pre-release commit

This commit is contained in:
koalazf.99
2025-07-22 19:59:07 +08:00
parent c5dee4bb17
commit a9d6965bef
485 changed files with 111444 additions and 2 deletions

View File

@@ -0,0 +1,75 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { AuthType } from '@qwen/qwen-code-core';
import { vi } from 'vitest';
import { validateAuthMethod } from './auth.js';
vi.mock('./settings.js', () => ({
loadEnvironment: vi.fn(),
}));
describe('validateAuthMethod', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
process.env = {};
});
afterEach(() => {
process.env = originalEnv;
});
it('should return null for LOGIN_WITH_GOOGLE', () => {
expect(validateAuthMethod(AuthType.LOGIN_WITH_GOOGLE)).toBeNull();
});
it('should return null for CLOUD_SHELL', () => {
expect(validateAuthMethod(AuthType.CLOUD_SHELL)).toBeNull();
});
describe('USE_GEMINI', () => {
it('should return null if GEMINI_API_KEY is set', () => {
process.env.GEMINI_API_KEY = 'test-key';
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
});
it('should return an error message if GEMINI_API_KEY is not set', () => {
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBe(
'GEMINI_API_KEY environment variable not found. Add that to your environment and try again (no reload needed if using .env)!',
);
});
});
describe('USE_VERTEX_AI', () => {
it('should return null if GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION are set', () => {
process.env.GOOGLE_CLOUD_PROJECT = 'test-project';
process.env.GOOGLE_CLOUD_LOCATION = 'test-location';
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
});
it('should return null if GOOGLE_API_KEY is set', () => {
process.env.GOOGLE_API_KEY = 'test-api-key';
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
});
it('should return an error message if no required environment variables are set', () => {
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBe(
'When using Vertex AI, you must specify either:\n' +
'• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' +
'• GOOGLE_API_KEY environment variable (if using express mode).\n' +
'Update your environment and try again (no reload needed if using .env)!',
);
});
});
it('should return an error message for an invalid auth method', () => {
expect(validateAuthMethod('invalid-method')).toBe(
'Invalid auth method selected.',
);
});
});

View File

@@ -0,0 +1,61 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { AuthType } from '@qwen/qwen-code-core';
import { loadEnvironment } from './settings.js';
export const validateAuthMethod = (authMethod: string): string | null => {
loadEnvironment();
if (
authMethod === AuthType.LOGIN_WITH_GOOGLE ||
authMethod === AuthType.CLOUD_SHELL
) {
return null;
}
if (authMethod === AuthType.USE_GEMINI) {
if (!process.env.GEMINI_API_KEY) {
return 'GEMINI_API_KEY environment variable not found. Add that to your environment and try again (no reload needed if using .env)!';
}
return null;
}
if (authMethod === AuthType.USE_VERTEX_AI) {
const hasVertexProjectLocationConfig =
!!process.env.GOOGLE_CLOUD_PROJECT && !!process.env.GOOGLE_CLOUD_LOCATION;
const hasGoogleApiKey = !!process.env.GOOGLE_API_KEY;
if (!hasVertexProjectLocationConfig && !hasGoogleApiKey) {
return (
'When using Vertex AI, you must specify either:\n' +
'• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' +
'• GOOGLE_API_KEY environment variable (if using express mode).\n' +
'Update your environment and try again (no reload needed if using .env)!'
);
}
return null;
}
if (authMethod === AuthType.USE_OPENAI) {
if (!process.env.OPENAI_API_KEY) {
return 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.';
}
return null;
}
return 'Invalid auth method selected.';
};
export const setOpenAIApiKey = (apiKey: string): void => {
process.env.OPENAI_API_KEY = apiKey;
};
export const setOpenAIBaseUrl = (baseUrl: string): void => {
process.env.OPENAI_BASE_URL = baseUrl;
};
export const setOpenAIModel = (model: string): void => {
process.env.OPENAI_MODEL = model;
};

View File

@@ -0,0 +1,243 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import {
Config,
ConfigParameters,
ContentGeneratorConfig,
} from '@qwen/qwen-code-core';
const TEST_CONTENT_GENERATOR_CONFIG: ContentGeneratorConfig = {
apiKey: 'test-key',
model: 'test-model',
userAgent: 'test-agent',
};
// Mock file discovery service and tool registry
vi.mock('@qwen/qwen-code-core', async () => {
const actual = await vi.importActual('@qwen/qwen-code-core');
return {
...actual,
FileDiscoveryService: vi.fn().mockImplementation(() => ({
initialize: vi.fn(),
})),
createToolRegistry: vi.fn().mockResolvedValue({}),
};
});
describe('Configuration Integration Tests', () => {
let tempDir: string;
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(tmpdir(), 'gemini-cli-test-'));
originalEnv = { ...process.env };
process.env.GEMINI_API_KEY = 'test-api-key';
vi.clearAllMocks();
});
afterEach(() => {
process.env = originalEnv;
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true });
}
});
describe('File Filtering Configuration', () => {
it('should load default file filtering settings', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
fileFilteringRespectGitIgnore: undefined, // Should default to true
};
const config = new Config(configParams);
expect(config.getFileFilteringRespectGitIgnore()).toBe(true);
});
it('should load custom file filtering settings from configuration', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
fileFiltering: {
respectGitIgnore: false,
},
};
const config = new Config(configParams);
expect(config.getFileFilteringRespectGitIgnore()).toBe(false);
});
it('should merge user and workspace file filtering settings', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
fileFilteringRespectGitIgnore: true,
};
const config = new Config(configParams);
expect(config.getFileFilteringRespectGitIgnore()).toBe(true);
});
});
describe('Configuration Integration', () => {
it('should handle partial configuration objects gracefully', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
fileFiltering: {
respectGitIgnore: false,
},
};
const config = new Config(configParams);
// Specified settings should be applied
expect(config.getFileFilteringRespectGitIgnore()).toBe(false);
});
it('should handle empty configuration objects gracefully', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
fileFilteringRespectGitIgnore: undefined,
};
const config = new Config(configParams);
// All settings should use defaults
expect(config.getFileFilteringRespectGitIgnore()).toBe(true);
});
it('should handle missing configuration sections gracefully', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
// Missing fileFiltering configuration
};
const config = new Config(configParams);
// All git-aware settings should use defaults
expect(config.getFileFilteringRespectGitIgnore()).toBe(true);
});
});
describe('Real-world Configuration Scenarios', () => {
it('should handle a security-focused configuration', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
fileFilteringRespectGitIgnore: true,
};
const config = new Config(configParams);
expect(config.getFileFilteringRespectGitIgnore()).toBe(true);
});
it('should handle a CI/CD environment configuration', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
fileFiltering: {
respectGitIgnore: false,
}, // CI might need to see all files
};
const config = new Config(configParams);
expect(config.getFileFilteringRespectGitIgnore()).toBe(false);
});
});
describe('Checkpointing Configuration', () => {
it('should enable checkpointing when the setting is true', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
checkpointing: true,
};
const config = new Config(configParams);
expect(config.getCheckpointingEnabled()).toBe(true);
});
});
describe('Extension Context Files', () => {
it('should have an empty array for extension context files by default', () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
};
const config = new Config(configParams);
expect(config.getExtensionContextFilePaths()).toEqual([]);
});
it('should correctly store and return extension context file paths', () => {
const contextFiles = ['/path/to/file1.txt', '/path/to/file2.js'];
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
extensionContextFilePaths: contextFiles,
};
const config = new Config(configParams);
expect(config.getExtensionContextFilePaths()).toEqual(contextFiles);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,429 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import {
Config,
loadServerHierarchicalMemory,
setGeminiMdFilename as setServerGeminiMdFilename,
getCurrentGeminiMdFilename,
ApprovalMode,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_EMBEDDING_MODEL,
FileDiscoveryService,
TelemetryTarget,
MCPServerConfig,
} from '@qwen/qwen-code-core';
import { Settings } from './settings.js';
import { Extension, filterActiveExtensions } from './extension.js';
import { getCliVersion } from '../utils/version.js';
import { loadSandboxConfig } from './sandboxConfig.js';
// Simple console logger for now - replace with actual logger if available
const logger = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
debug: (...args: any[]) => console.debug('[DEBUG]', ...args),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
warn: (...args: any[]) => console.warn('[WARN]', ...args),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (...args: any[]) => console.error('[ERROR]', ...args),
};
export interface CliArgs {
model: string | undefined;
sandbox: boolean | string | undefined;
sandboxImage: string | undefined;
debug: boolean | undefined;
prompt: string | undefined;
promptInteractive: string | undefined;
allFiles: boolean | undefined;
all_files: boolean | undefined;
showMemoryUsage: boolean | undefined;
show_memory_usage: boolean | undefined;
yolo: boolean | undefined;
telemetry: boolean | undefined;
checkpointing: boolean | undefined;
telemetryTarget: string | undefined;
telemetryOtlpEndpoint: string | undefined;
telemetryLogPrompts: boolean | undefined;
allowedMcpServerNames: string[] | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
ideMode: boolean | undefined;
openaiLogging: boolean | undefined;
openaiApiKey: string | undefined;
openaiBaseUrl: string | undefined;
}
export async function parseArguments(): Promise<CliArgs> {
const yargsInstance = yargs(hideBin(process.argv))
.scriptName('qwen')
.usage(
'$0 [options]',
'Qwen Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
)
.option('model', {
alias: 'm',
type: 'string',
description: `Model`,
default: process.env.GEMINI_MODEL || DEFAULT_GEMINI_MODEL,
})
.option('prompt', {
alias: 'p',
type: 'string',
description: 'Prompt. Appended to input on stdin (if any).',
})
.option('prompt-interactive', {
alias: 'i',
type: 'string',
description:
'Execute the provided prompt and continue in interactive mode',
})
.option('sandbox', {
alias: 's',
type: 'boolean',
description: 'Run in sandbox?',
})
.option('sandbox-image', {
type: 'string',
description: 'Sandbox image URI.',
})
.option('debug', {
alias: 'd',
type: 'boolean',
description: 'Run in debug mode?',
default: false,
})
.option('all-files', {
alias: ['a'],
type: 'boolean',
description: 'Include ALL files in context?',
default: false,
})
.option('all_files', {
type: 'boolean',
description: 'Include ALL files in context?',
default: false,
})
.deprecateOption(
'all_files',
'Use --all-files instead. We will be removing --all_files in the coming weeks.',
)
.option('show-memory-usage', {
type: 'boolean',
description: 'Show memory usage in status bar',
default: false,
})
.option('show_memory_usage', {
type: 'boolean',
description: 'Show memory usage in status bar',
default: false,
})
.deprecateOption(
'show_memory_usage',
'Use --show-memory-usage instead. We will be removing --show_memory_usage in the coming weeks.',
)
.option('yolo', {
alias: 'y',
type: 'boolean',
description:
'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?',
default: false,
})
.option('telemetry', {
type: 'boolean',
description:
'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.',
})
.option('telemetry-target', {
type: 'string',
choices: ['local', 'gcp'],
description:
'Set the telemetry target (local or gcp). Overrides settings files.',
})
.option('telemetry-otlp-endpoint', {
type: 'string',
description:
'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.',
})
.option('telemetry-log-prompts', {
type: 'boolean',
description:
'Enable or disable logging of user prompts for telemetry. Overrides settings files.',
})
.option('checkpointing', {
alias: 'c',
type: 'boolean',
description: 'Enables checkpointing of file edits',
default: false,
})
.option('allowed-mcp-server-names', {
type: 'array',
string: true,
description: 'Allowed MCP server names',
})
.option('extensions', {
alias: 'e',
type: 'array',
string: true,
description:
'A list of extensions to use. If not provided, all extensions are used.',
})
.option('list-extensions', {
alias: 'l',
type: 'boolean',
description: 'List all available extensions and exit.',
})
.option('ide-mode', {
type: 'boolean',
description: 'Run in IDE mode?',
})
.option('openai-logging', {
type: 'boolean',
description:
'Enable logging of OpenAI API calls for debugging and analysis',
})
.option('openai-api-key', {
type: 'string',
description: 'OpenAI API key to use for authentication',
})
.option('openai-base-url', {
type: 'string',
description: 'OpenAI base URL (for custom endpoints)',
})
.version(await getCliVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version')
.help()
.alias('h', 'help')
.strict()
.check((argv) => {
if (argv.prompt && argv.promptInteractive) {
throw new Error(
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
);
}
return true;
});
yargsInstance.wrap(yargsInstance.terminalWidth());
return yargsInstance.argv;
}
// This function is now a thin wrapper around the server's implementation.
// It's kept in the CLI for now as App.tsx directly calls it for memory refresh.
// TODO: Consider if App.tsx should get memory via a server call or if Config should refresh itself.
export async function loadHierarchicalGeminiMemory(
currentWorkingDirectory: string,
debugMode: boolean,
fileService: FileDiscoveryService,
extensionContextFilePaths: string[] = [],
): Promise<{ memoryContent: string; fileCount: number }> {
if (debugMode) {
logger.debug(
`CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory}`,
);
}
// Directly call the server function.
// The server function will use its own homedir() for the global path.
return loadServerHierarchicalMemory(
currentWorkingDirectory,
debugMode,
fileService,
extensionContextFilePaths,
);
}
export async function loadCliConfig(
settings: Settings,
extensions: Extension[],
sessionId: string,
argv: CliArgs,
): Promise<Config> {
const debugMode =
argv.debug ||
[process.env.DEBUG, process.env.DEBUG_MODE].some(
(v) => v === 'true' || v === '1',
);
const ideMode =
(argv.ideMode ?? settings.ideMode ?? false) &&
process.env.TERM_PROGRAM === 'vscode' &&
!process.env.SANDBOX;
const activeExtensions = filterActiveExtensions(
extensions,
argv.extensions || [],
);
// Handle OpenAI API key from command line
if (argv.openaiApiKey) {
process.env.OPENAI_API_KEY = argv.openaiApiKey;
}
// Handle OpenAI base URL from command line
if (argv.openaiBaseUrl) {
process.env.OPENAI_BASE_URL = argv.openaiBaseUrl;
}
// Set the context filename in the server's memoryTool module BEFORE loading memory
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
// However, loadHierarchicalGeminiMemory is called *before* createServerConfig.
if (settings.contextFileName) {
setServerGeminiMdFilename(settings.contextFileName);
} else {
// Reset to default if not provided in settings.
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
}
const extensionContextFilePaths = activeExtensions.flatMap(
(e) => e.contextFiles,
);
const fileService = new FileDiscoveryService(process.cwd());
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
process.cwd(),
debugMode,
fileService,
extensionContextFilePaths,
);
let mcpServers = mergeMcpServers(settings, activeExtensions);
const excludeTools = mergeExcludeTools(settings, activeExtensions);
if (argv.allowedMcpServerNames) {
const allowedNames = new Set(argv.allowedMcpServerNames.filter(Boolean));
if (allowedNames.size > 0) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(([key]) => allowedNames.has(key)),
);
} else {
mcpServers = {};
}
}
if (ideMode) {
mcpServers['_ide_server'] = new MCPServerConfig(
undefined, // command
undefined, // args
undefined, // env
undefined, // cwd
undefined, // url
'http://localhost:3000/mcp', // httpUrl
undefined, // headers
undefined, // tcp
undefined, // timeout
false, // trust
'IDE connection', // description
undefined, // includeTools
undefined, // excludeTools
);
}
const sandboxConfig = await loadSandboxConfig(settings, argv);
return new Config({
sessionId,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: sandboxConfig,
targetDir: process.cwd(),
debugMode,
question: argv.promptInteractive || argv.prompt || '',
fullContext: argv.allFiles || argv.all_files || false,
coreTools: settings.coreTools || undefined,
excludeTools,
toolDiscoveryCommand: settings.toolDiscoveryCommand,
toolCallCommand: settings.toolCallCommand,
mcpServerCommand: settings.mcpServerCommand,
mcpServers,
userMemory: memoryContent,
geminiMdFileCount: fileCount,
approvalMode: argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT,
showMemoryUsage:
argv.showMemoryUsage ||
argv.show_memory_usage ||
settings.showMemoryUsage ||
false,
accessibility: settings.accessibility,
telemetry: {
enabled: argv.telemetry ?? settings.telemetry?.enabled,
target: (argv.telemetryTarget ??
settings.telemetry?.target) as TelemetryTarget,
otlpEndpoint:
argv.telemetryOtlpEndpoint ??
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ??
settings.telemetry?.otlpEndpoint,
logPrompts: argv.telemetryLogPrompts ?? settings.telemetry?.logPrompts,
},
usageStatisticsEnabled: settings.usageStatisticsEnabled ?? true,
// Git-aware file filtering settings
fileFiltering: {
respectGitIgnore: settings.fileFiltering?.respectGitIgnore,
enableRecursiveFileSearch:
settings.fileFiltering?.enableRecursiveFileSearch,
},
checkpointing: argv.checkpointing || settings.checkpointing?.enabled,
proxy:
process.env.HTTPS_PROXY ||
process.env.https_proxy ||
process.env.HTTP_PROXY ||
process.env.http_proxy,
cwd: process.cwd(),
fileDiscoveryService: fileService,
bugCommand: settings.bugCommand,
model: argv.model!,
extensionContextFilePaths,
maxSessionTurns: settings.maxSessionTurns ?? -1,
listExtensions: argv.listExtensions || false,
activeExtensions: activeExtensions.map((e) => ({
name: e.config.name,
version: e.config.version,
})),
noBrowser: !!process.env.NO_BROWSER,
ideMode,
enableOpenAILogging:
(typeof argv.openaiLogging === 'undefined'
? settings.enableOpenAILogging
: argv.openaiLogging) ?? false,
sampling_params: settings.sampling_params,
});
}
function mergeMcpServers(settings: Settings, extensions: Extension[]) {
const mcpServers = { ...(settings.mcpServers || {}) };
for (const extension of extensions) {
Object.entries(extension.config.mcpServers || {}).forEach(
([key, server]) => {
if (mcpServers[key]) {
logger.warn(
`Skipping extension MCP config for server with key "${key}" as it already exists.`,
);
return;
}
mcpServers[key] = server;
},
);
}
return mcpServers;
}
function mergeExcludeTools(
settings: Settings,
extensions: Extension[],
): string[] {
const allExcludeTools = new Set(settings.excludeTools || []);
for (const extension of extensions) {
for (const tool of extension.config.excludeTools || []) {
allExcludeTools.add(tool);
}
}
return [...allExcludeTools];
}

View File

@@ -0,0 +1,151 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
EXTENSIONS_CONFIG_FILENAME,
EXTENSIONS_DIRECTORY_NAME,
filterActiveExtensions,
loadExtensions,
} from './extension.js';
vi.mock('os', async (importOriginal) => {
const os = await importOriginal<typeof import('os')>();
return {
...os,
homedir: vi.fn(),
};
});
describe('loadExtensions', () => {
let tempWorkspaceDir: string;
let tempHomeDir: string;
beforeEach(() => {
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
});
afterEach(() => {
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
fs.rmSync(tempHomeDir, { recursive: true, force: true });
});
it('should load context file path when GEMINI.md is present', () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
createExtension(workspaceExtensionsDir, 'ext1', '1.0.0', true);
createExtension(workspaceExtensionsDir, 'ext2', '2.0.0');
const extensions = loadExtensions(tempWorkspaceDir);
expect(extensions).toHaveLength(2);
const ext1 = extensions.find((e) => e.config.name === 'ext1');
const ext2 = extensions.find((e) => e.config.name === 'ext2');
expect(ext1?.contextFiles).toEqual([
path.join(workspaceExtensionsDir, 'ext1', 'QWEN.md'),
]);
expect(ext2?.contextFiles).toEqual([]);
});
it('should load context file path from the extension config', () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
createExtension(
workspaceExtensionsDir,
'ext1',
'1.0.0',
false,
'my-context-file.md',
);
const extensions = loadExtensions(tempWorkspaceDir);
expect(extensions).toHaveLength(1);
const ext1 = extensions.find((e) => e.config.name === 'ext1');
expect(ext1?.contextFiles).toEqual([
path.join(workspaceExtensionsDir, 'ext1', 'my-context-file.md'),
]);
});
});
describe('filterActiveExtensions', () => {
const extensions = [
{ config: { name: 'ext1', version: '1.0.0' }, contextFiles: [] },
{ config: { name: 'ext2', version: '1.0.0' }, contextFiles: [] },
{ config: { name: 'ext3', version: '1.0.0' }, contextFiles: [] },
];
it('should return all extensions if no enabled extensions are provided', () => {
const activeExtensions = filterActiveExtensions(extensions, []);
expect(activeExtensions).toHaveLength(3);
});
it('should return only the enabled extensions', () => {
const activeExtensions = filterActiveExtensions(extensions, [
'ext1',
'ext3',
]);
expect(activeExtensions).toHaveLength(2);
expect(activeExtensions.some((e) => e.config.name === 'ext1')).toBe(true);
expect(activeExtensions.some((e) => e.config.name === 'ext3')).toBe(true);
});
it('should return no extensions when "none" is provided', () => {
const activeExtensions = filterActiveExtensions(extensions, ['none']);
expect(activeExtensions).toHaveLength(0);
});
it('should handle case-insensitivity', () => {
const activeExtensions = filterActiveExtensions(extensions, ['EXT1']);
expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].config.name).toBe('ext1');
});
it('should log an error for unknown extensions', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
filterActiveExtensions(extensions, ['ext4']);
expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4');
consoleSpy.mockRestore();
});
});
function createExtension(
extensionsDir: string,
name: string,
version: string,
addContextFile = false,
contextFileName?: string,
): void {
const extDir = path.join(extensionsDir, name);
fs.mkdirSync(extDir);
fs.writeFileSync(
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name, version, contextFileName }),
);
if (addContextFile) {
fs.writeFileSync(path.join(extDir, 'QWEN.md'), 'context');
}
if (contextFileName) {
fs.writeFileSync(path.join(extDir, contextFileName), 'context');
}
}

View File

@@ -0,0 +1,159 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { MCPServerConfig } from '@qwen/qwen-code-core';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
export const EXTENSIONS_DIRECTORY_NAME = path.join('.qwen', 'extensions');
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
export interface Extension {
config: ExtensionConfig;
contextFiles: string[];
}
export interface ExtensionConfig {
name: string;
version: string;
mcpServers?: Record<string, MCPServerConfig>;
contextFileName?: string | string[];
excludeTools?: string[];
}
export function loadExtensions(workspaceDir: string): Extension[] {
const allExtensions = [
...loadExtensionsFromDir(workspaceDir),
...loadExtensionsFromDir(os.homedir()),
];
const uniqueExtensions = new Map<string, Extension>();
for (const extension of allExtensions) {
if (!uniqueExtensions.has(extension.config.name)) {
console.log(
`Loading extension: ${extension.config.name} (version: ${extension.config.version})`,
);
uniqueExtensions.set(extension.config.name, extension);
}
}
return Array.from(uniqueExtensions.values());
}
function loadExtensionsFromDir(dir: string): Extension[] {
const extensionsDir = path.join(dir, EXTENSIONS_DIRECTORY_NAME);
if (!fs.existsSync(extensionsDir)) {
return [];
}
const extensions: Extension[] = [];
for (const subdir of fs.readdirSync(extensionsDir)) {
const extensionDir = path.join(extensionsDir, subdir);
const extension = loadExtension(extensionDir);
if (extension != null) {
extensions.push(extension);
}
}
return extensions;
}
function loadExtension(extensionDir: string): Extension | null {
if (!fs.statSync(extensionDir).isDirectory()) {
console.error(
`Warning: unexpected file ${extensionDir} in extensions directory.`,
);
return null;
}
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
if (!fs.existsSync(configFilePath)) {
console.error(
`Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`,
);
return null;
}
try {
const configContent = fs.readFileSync(configFilePath, 'utf-8');
const config = JSON.parse(configContent) as ExtensionConfig;
if (!config.name || !config.version) {
console.error(
`Invalid extension config in ${configFilePath}: missing name or version.`,
);
return null;
}
const contextFiles = getContextFileNames(config)
.map((contextFileName) => path.join(extensionDir, contextFileName))
.filter((contextFilePath) => fs.existsSync(contextFilePath));
return {
config,
contextFiles,
};
} catch (e) {
console.error(
`Warning: error parsing extension config in ${configFilePath}: ${e}`,
);
return null;
}
}
function getContextFileNames(config: ExtensionConfig): string[] {
if (!config.contextFileName) {
return ['QWEN.md'];
} else if (!Array.isArray(config.contextFileName)) {
return [config.contextFileName];
}
return config.contextFileName;
}
export function filterActiveExtensions(
extensions: Extension[],
enabledExtensionNames: string[],
): Extension[] {
if (enabledExtensionNames.length === 0) {
return extensions;
}
const lowerCaseEnabledExtensions = new Set(
enabledExtensionNames.map((e) => e.trim().toLowerCase()),
);
if (
lowerCaseEnabledExtensions.size === 1 &&
lowerCaseEnabledExtensions.has('none')
) {
if (extensions.length > 0) {
console.log('All extensions are disabled.');
}
return [];
}
const activeExtensions: Extension[] = [];
const notFoundNames = new Set(lowerCaseEnabledExtensions);
for (const extension of extensions) {
const lowerCaseName = extension.config.name.toLowerCase();
if (lowerCaseEnabledExtensions.has(lowerCaseName)) {
console.log(
`Activated extension: ${extension.config.name} (version: ${extension.config.version})`,
);
activeExtensions.push(extension);
notFoundNames.delete(lowerCaseName);
} else {
console.log(`Disabled extension: ${extension.config.name}`);
}
}
for (const requestedName of notFoundNames) {
console.log(`Extension not found: ${requestedName}`);
}
return activeExtensions;
}

View File

@@ -0,0 +1,107 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { SandboxConfig } from '@qwen/qwen-code-core';
import commandExists from 'command-exists';
import * as os from 'node:os';
import { getPackageJson } from '../utils/package.js';
import { Settings } from './settings.js';
// This is a stripped-down version of the CliArgs interface from config.ts
// to avoid circular dependencies.
interface SandboxCliArgs {
sandbox?: boolean | string;
sandboxImage?: string;
}
const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
'docker',
'podman',
'sandbox-exec',
];
function isSandboxCommand(value: string): value is SandboxConfig['command'] {
return (VALID_SANDBOX_COMMANDS as readonly string[]).includes(value);
}
function getSandboxCommand(
sandbox?: boolean | string,
): SandboxConfig['command'] | '' {
// If the SANDBOX env var is set, we're already inside the sandbox.
if (process.env.SANDBOX) {
return '';
}
// note environment variable takes precedence over argument (from command line or settings)
const environmentConfiguredSandbox =
process.env.GEMINI_SANDBOX?.toLowerCase().trim() ?? '';
sandbox =
environmentConfiguredSandbox?.length > 0
? environmentConfiguredSandbox
: sandbox;
if (sandbox === '1' || sandbox === 'true') sandbox = true;
else if (sandbox === '0' || sandbox === 'false' || !sandbox) sandbox = false;
if (sandbox === false) {
return '';
}
if (typeof sandbox === 'string' && sandbox) {
if (!isSandboxCommand(sandbox)) {
console.error(
`ERROR: invalid sandbox command '${sandbox}'. Must be one of ${VALID_SANDBOX_COMMANDS.join(
', ',
)}`,
);
process.exit(1);
}
// confirm that specified command exists
if (commandExists.sync(sandbox)) {
return sandbox;
}
console.error(
`ERROR: missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`,
);
process.exit(1);
}
// look for seatbelt, docker, or podman, in that order
// for container-based sandboxing, require sandbox to be enabled explicitly
if (os.platform() === 'darwin' && commandExists.sync('sandbox-exec')) {
return 'sandbox-exec';
} else if (commandExists.sync('docker') && sandbox === true) {
return 'docker';
} else if (commandExists.sync('podman') && sandbox === true) {
return 'podman';
}
// throw an error if user requested sandbox but no command was found
if (sandbox === true) {
console.error(
'ERROR: GEMINI_SANDBOX is true but failed to determine command for sandbox; ' +
'install docker or podman or specify command in GEMINI_SANDBOX',
);
process.exit(1);
}
return '';
}
export async function loadSandboxConfig(
settings: Settings,
argv: SandboxCliArgs,
): Promise<SandboxConfig | undefined> {
const sandboxOption = argv.sandbox ?? settings.sandbox;
const command = getSandboxCommand(sandboxOption);
const packageJson = await getPackageJson();
const image =
argv.sandboxImage ??
process.env.GEMINI_SANDBOX_IMAGE ??
packageJson?.config?.sandboxImageUri;
return command && image ? { command, image } : undefined;
}

View File

@@ -0,0 +1,795 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/// <reference types="vitest/globals" />
// Mock 'os' first.
import * as osActual from 'os'; // Import for type info for the mock factory
vi.mock('os', async (importOriginal) => {
const actualOs = await importOriginal<typeof osActual>();
return {
...actualOs,
homedir: vi.fn(() => '/mock/home/user'),
platform: vi.fn(() => 'linux'),
};
});
// Mock './settings.js' to ensure it uses the mocked 'os.homedir()' for its internal constants.
vi.mock('./settings.js', async (importActual) => {
const originalModule = await importActual<typeof import('./settings.js')>();
return {
__esModule: true, // Ensure correct module shape
...originalModule, // Re-export all original members
// We are relying on originalModule's USER_SETTINGS_PATH being constructed with mocked os.homedir()
};
});
// NOW import everything else, including the (now effectively re-exported) settings.js
import * as pathActual from 'path'; // Restored for MOCK_WORKSPACE_SETTINGS_PATH
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mocked,
type Mock,
} from 'vitest';
import * as fs from 'fs'; // fs will be mocked separately
import stripJsonComments from 'strip-json-comments'; // Will be mocked separately
// These imports will get the versions from the vi.mock('./settings.js', ...) factory.
import {
loadSettings,
USER_SETTINGS_PATH, // This IS the mocked path.
SYSTEM_SETTINGS_PATH,
SETTINGS_DIRECTORY_NAME, // This is from the original module, but used by the mock.
SettingScope,
} from './settings.js';
const MOCK_WORKSPACE_DIR = '/mock/workspace';
// Use the (mocked) SETTINGS_DIRECTORY_NAME for consistency
const MOCK_WORKSPACE_SETTINGS_PATH = pathActual.join(
MOCK_WORKSPACE_DIR,
SETTINGS_DIRECTORY_NAME,
'settings.json',
);
vi.mock('fs');
vi.mock('strip-json-comments', () => ({
default: vi.fn((content) => content),
}));
describe('Settings Loading and Merging', () => {
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
let mockStripJsonComments: Mocked<typeof stripJsonComments>;
let mockFsMkdirSync: Mocked<typeof fs.mkdirSync>;
beforeEach(() => {
vi.resetAllMocks();
mockFsExistsSync = vi.mocked(fs.existsSync);
mockFsMkdirSync = vi.mocked(fs.mkdirSync);
mockStripJsonComments = vi.mocked(stripJsonComments);
vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user');
(mockStripJsonComments as unknown as Mock).mockImplementation(
(jsonString: string) => jsonString,
);
(mockFsExistsSync as Mock).mockReturnValue(false);
(fs.readFileSync as Mock).mockReturnValue('{}'); // Return valid empty JSON
(mockFsMkdirSync as Mock).mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('loadSettings', () => {
it('should load empty settings if no files exist', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.system.settings).toEqual({});
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual({});
expect(settings.merged).toEqual({});
expect(settings.errors.length).toBe(0);
});
it('should load system settings if only system file exists', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === SYSTEM_SETTINGS_PATH,
);
const systemSettingsContent = {
theme: 'system-default',
sandbox: false,
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === SYSTEM_SETTINGS_PATH)
return JSON.stringify(systemSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(fs.readFileSync).toHaveBeenCalledWith(
SYSTEM_SETTINGS_PATH,
'utf-8',
);
expect(settings.system.settings).toEqual(systemSettingsContent);
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual({});
expect(settings.merged).toEqual(systemSettingsContent);
});
it('should load user settings if only user file exists', () => {
const expectedUserSettingsPath = USER_SETTINGS_PATH; // Use the path actually resolved by the (mocked) module
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === expectedUserSettingsPath,
);
const userSettingsContent = {
theme: 'dark',
contextFileName: 'USER_CONTEXT.md',
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === expectedUserSettingsPath)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(fs.readFileSync).toHaveBeenCalledWith(
expectedUserSettingsPath,
'utf-8',
);
expect(settings.user.settings).toEqual(userSettingsContent);
expect(settings.workspace.settings).toEqual({});
expect(settings.merged).toEqual(userSettingsContent);
});
it('should load workspace settings if only workspace file exists', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
const workspaceSettingsContent = {
sandbox: true,
contextFileName: 'WORKSPACE_CONTEXT.md',
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(fs.readFileSync).toHaveBeenCalledWith(
MOCK_WORKSPACE_SETTINGS_PATH,
'utf-8',
);
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
expect(settings.merged).toEqual(workspaceSettingsContent);
});
it('should merge user and workspace settings, with workspace taking precedence', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
theme: 'dark',
sandbox: false,
contextFileName: 'USER_CONTEXT.md',
};
const workspaceSettingsContent = {
sandbox: true,
coreTools: ['tool1'],
contextFileName: 'WORKSPACE_CONTEXT.md',
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings).toEqual(userSettingsContent);
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
expect(settings.merged).toEqual({
theme: 'dark',
sandbox: true,
coreTools: ['tool1'],
contextFileName: 'WORKSPACE_CONTEXT.md',
});
});
it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const systemSettingsContent = {
theme: 'system-theme',
sandbox: false,
telemetry: { enabled: false },
};
const userSettingsContent = {
theme: 'dark',
sandbox: true,
contextFileName: 'USER_CONTEXT.md',
};
const workspaceSettingsContent = {
sandbox: false,
coreTools: ['tool1'],
contextFileName: 'WORKSPACE_CONTEXT.md',
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === SYSTEM_SETTINGS_PATH)
return JSON.stringify(systemSettingsContent);
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.system.settings).toEqual(systemSettingsContent);
expect(settings.user.settings).toEqual(userSettingsContent);
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
expect(settings.merged).toEqual({
theme: 'system-theme',
sandbox: false,
telemetry: { enabled: false },
coreTools: ['tool1'],
contextFileName: 'WORKSPACE_CONTEXT.md',
});
});
it('should handle contextFileName correctly when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = { contextFileName: 'CUSTOM.md' };
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.contextFileName).toBe('CUSTOM.md');
});
it('should handle contextFileName correctly when only in workspace settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
const workspaceSettingsContent = {
contextFileName: 'PROJECT_SPECIFIC.md',
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.contextFileName).toBe('PROJECT_SPECIFIC.md');
});
it('should default contextFileName to undefined if not in any settings file', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = { theme: 'dark' };
const workspaceSettingsContent = { sandbox: true };
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.contextFileName).toBeUndefined();
});
it('should load telemetry setting from user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = { telemetry: true };
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.telemetry).toBe(true);
});
it('should load telemetry setting from workspace settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
const workspaceSettingsContent = { telemetry: false };
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.telemetry).toBe(false);
});
it('should prioritize workspace telemetry setting over user setting', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = { telemetry: true };
const workspaceSettingsContent = { telemetry: false };
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.telemetry).toBe(false);
});
it('should have telemetry as undefined if not in any settings file', () => {
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
(fs.readFileSync as Mock).mockReturnValue('{}');
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.telemetry).toBeUndefined();
});
it('should handle JSON parsing errors gracefully', () => {
(mockFsExistsSync as Mock).mockReturnValue(true); // Both files "exist"
const invalidJsonContent = 'invalid json';
const userReadError = new SyntaxError(
"Expected ',' or '}' after property value in JSON at position 10",
);
const workspaceReadError = new SyntaxError(
'Unexpected token i in JSON at position 0',
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH) {
// Simulate JSON.parse throwing for user settings
vi.spyOn(JSON, 'parse').mockImplementationOnce(() => {
throw userReadError;
});
return invalidJsonContent; // Content that would cause JSON.parse to throw
}
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
// Simulate JSON.parse throwing for workspace settings
vi.spyOn(JSON, 'parse').mockImplementationOnce(() => {
throw workspaceReadError;
});
return invalidJsonContent;
}
return '{}'; // Default for other reads
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Check that settings are empty due to parsing errors
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual({});
expect(settings.merged).toEqual({});
// Check that error objects are populated in settings.errors
expect(settings.errors).toBeDefined();
// Assuming both user and workspace files cause errors and are added in order
expect(settings.errors.length).toEqual(2);
const userError = settings.errors.find(
(e) => e.path === USER_SETTINGS_PATH,
);
expect(userError).toBeDefined();
expect(userError?.message).toBe(userReadError.message);
const workspaceError = settings.errors.find(
(e) => e.path === MOCK_WORKSPACE_SETTINGS_PATH,
);
expect(workspaceError).toBeDefined();
expect(workspaceError?.message).toBe(workspaceReadError.message);
// Restore JSON.parse mock if it was spied on specifically for this test
vi.restoreAllMocks(); // Or more targeted restore if needed
});
it('should resolve environment variables in user settings', () => {
process.env.TEST_API_KEY = 'user_api_key_from_env';
const userSettingsContent = {
apiKey: '$TEST_API_KEY',
someUrl: 'https://test.com/${TEST_API_KEY}',
};
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings.apiKey).toBe('user_api_key_from_env');
expect(settings.user.settings.someUrl).toBe(
'https://test.com/user_api_key_from_env',
);
expect(settings.merged.apiKey).toBe('user_api_key_from_env');
delete process.env.TEST_API_KEY;
});
it('should resolve environment variables in workspace settings', () => {
process.env.WORKSPACE_ENDPOINT = 'workspace_endpoint_from_env';
const workspaceSettingsContent = {
endpoint: '${WORKSPACE_ENDPOINT}/api',
nested: { value: '$WORKSPACE_ENDPOINT' },
};
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.workspace.settings.endpoint).toBe(
'workspace_endpoint_from_env/api',
);
expect(settings.workspace.settings.nested.value).toBe(
'workspace_endpoint_from_env',
);
expect(settings.merged.endpoint).toBe('workspace_endpoint_from_env/api');
delete process.env.WORKSPACE_ENDPOINT;
});
it('should prioritize user env variables over workspace env variables if keys clash after resolution', () => {
const userSettingsContent = { configValue: '$SHARED_VAR' };
const workspaceSettingsContent = { configValue: '$SHARED_VAR' };
(mockFsExistsSync as Mock).mockReturnValue(true);
const originalSharedVar = process.env.SHARED_VAR;
// Temporarily delete to ensure a clean slate for the test's specific manipulations
delete process.env.SHARED_VAR;
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH) {
process.env.SHARED_VAR = 'user_value_for_user_read'; // Set for user settings read
return JSON.stringify(userSettingsContent);
}
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
process.env.SHARED_VAR = 'workspace_value_for_workspace_read'; // Set for workspace settings read
return JSON.stringify(workspaceSettingsContent);
}
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings.configValue).toBe(
'user_value_for_user_read',
);
expect(settings.workspace.settings.configValue).toBe(
'workspace_value_for_workspace_read',
);
// Merged should take workspace's resolved value
expect(settings.merged.configValue).toBe(
'workspace_value_for_workspace_read',
);
// Restore original environment variable state
if (originalSharedVar !== undefined) {
process.env.SHARED_VAR = originalSharedVar;
} else {
delete process.env.SHARED_VAR; // Ensure it's deleted if it wasn't there before
}
});
it('should prioritize workspace env variables over user env variables if keys clash after resolution', () => {
const userSettingsContent = { configValue: '$SHARED_VAR' };
const workspaceSettingsContent = { configValue: '$SHARED_VAR' };
(mockFsExistsSync as Mock).mockReturnValue(true);
const originalSharedVar = process.env.SHARED_VAR;
// Temporarily delete to ensure a clean slate for the test's specific manipulations
delete process.env.SHARED_VAR;
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH) {
process.env.SHARED_VAR = 'user_value_for_user_read'; // Set for user settings read
return JSON.stringify(userSettingsContent);
}
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
process.env.SHARED_VAR = 'workspace_value_for_workspace_read'; // Set for workspace settings read
return JSON.stringify(workspaceSettingsContent);
}
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings.configValue).toBe(
'user_value_for_user_read',
);
expect(settings.workspace.settings.configValue).toBe(
'workspace_value_for_workspace_read',
);
// Merged should take workspace's resolved value
expect(settings.merged.configValue).toBe(
'workspace_value_for_workspace_read',
);
// Restore original environment variable state
if (originalSharedVar !== undefined) {
process.env.SHARED_VAR = originalSharedVar;
} else {
delete process.env.SHARED_VAR; // Ensure it's deleted if it wasn't there before
}
});
it('should prioritize system env variables over workspace env variables if keys clash after resolution', () => {
const workspaceSettingsContent = { configValue: '$SHARED_VAR' };
const systemSettingsContent = { configValue: '$SHARED_VAR' };
(mockFsExistsSync as Mock).mockReturnValue(true);
const originalSharedVar = process.env.SHARED_VAR;
// Temporarily delete to ensure a clean slate for the test's specific manipulations
delete process.env.SHARED_VAR;
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === SYSTEM_SETTINGS_PATH) {
process.env.SHARED_VAR = 'system_value_for_system_read'; // Set for system settings read
return JSON.stringify(systemSettingsContent);
}
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
process.env.SHARED_VAR = 'workspace_value_for_workspace_read'; // Set for workspace settings read
return JSON.stringify(workspaceSettingsContent);
}
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.system.settings.configValue).toBe(
'system_value_for_system_read',
);
expect(settings.workspace.settings.configValue).toBe(
'workspace_value_for_workspace_read',
);
// Merged should take workspace's resolved value
expect(settings.merged.configValue).toBe('system_value_for_system_read');
// Restore original environment variable state
if (originalSharedVar !== undefined) {
process.env.SHARED_VAR = originalSharedVar;
} else {
delete process.env.SHARED_VAR; // Ensure it's deleted if it wasn't there before
}
});
it('should leave unresolved environment variables as is', () => {
const userSettingsContent = { apiKey: '$UNDEFINED_VAR' };
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings.apiKey).toBe('$UNDEFINED_VAR');
expect(settings.merged.apiKey).toBe('$UNDEFINED_VAR');
});
it('should resolve multiple environment variables in a single string', () => {
process.env.VAR_A = 'valueA';
process.env.VAR_B = 'valueB';
const userSettingsContent = { path: '/path/$VAR_A/${VAR_B}/end' };
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings.path).toBe('/path/valueA/valueB/end');
delete process.env.VAR_A;
delete process.env.VAR_B;
});
it('should resolve environment variables in arrays', () => {
process.env.ITEM_1 = 'item1_env';
process.env.ITEM_2 = 'item2_env';
const userSettingsContent = { list: ['$ITEM_1', '${ITEM_2}', 'literal'] };
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings.list).toEqual([
'item1_env',
'item2_env',
'literal',
]);
delete process.env.ITEM_1;
delete process.env.ITEM_2;
});
it('should correctly pass through null, boolean, and number types, and handle undefined properties', () => {
process.env.MY_ENV_STRING = 'env_string_value';
process.env.MY_ENV_STRING_NESTED = 'env_string_nested_value';
const userSettingsContent = {
nullVal: null,
trueVal: true,
falseVal: false,
numberVal: 123.45,
stringVal: '$MY_ENV_STRING',
nestedObj: {
nestedNull: null,
nestedBool: true,
nestedNum: 0,
nestedString: 'literal',
anotherEnv: '${MY_ENV_STRING_NESTED}',
},
};
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings.nullVal).toBeNull();
expect(settings.user.settings.trueVal).toBe(true);
expect(settings.user.settings.falseVal).toBe(false);
expect(settings.user.settings.numberVal).toBe(123.45);
expect(settings.user.settings.stringVal).toBe('env_string_value');
expect(settings.user.settings.undefinedVal).toBeUndefined();
expect(settings.user.settings.nestedObj.nestedNull).toBeNull();
expect(settings.user.settings.nestedObj.nestedBool).toBe(true);
expect(settings.user.settings.nestedObj.nestedNum).toBe(0);
expect(settings.user.settings.nestedObj.nestedString).toBe('literal');
expect(settings.user.settings.nestedObj.anotherEnv).toBe(
'env_string_nested_value',
);
delete process.env.MY_ENV_STRING;
delete process.env.MY_ENV_STRING_NESTED;
});
it('should resolve multiple concatenated environment variables in a single string value', () => {
process.env.TEST_HOST = 'myhost';
process.env.TEST_PORT = '9090';
const userSettingsContent = {
serverAddress: '${TEST_HOST}:${TEST_PORT}/api',
};
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings.serverAddress).toBe('myhost:9090/api');
delete process.env.TEST_HOST;
delete process.env.TEST_PORT;
});
});
describe('LoadedSettings class', () => {
it('setValue should update the correct scope and recompute merged settings', () => {
(mockFsExistsSync as Mock).mockReturnValue(false);
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
vi.mocked(fs.writeFileSync).mockImplementation(() => {});
// mkdirSync is mocked in beforeEach to return undefined, which is fine for void usage
loadedSettings.setValue(SettingScope.User, 'theme', 'matrix');
expect(loadedSettings.user.settings.theme).toBe('matrix');
expect(loadedSettings.merged.theme).toBe('matrix');
expect(fs.writeFileSync).toHaveBeenCalledWith(
USER_SETTINGS_PATH,
JSON.stringify({ theme: 'matrix' }, null, 2),
'utf-8',
);
loadedSettings.setValue(
SettingScope.Workspace,
'contextFileName',
'MY_AGENTS.md',
);
expect(loadedSettings.workspace.settings.contextFileName).toBe(
'MY_AGENTS.md',
);
expect(loadedSettings.merged.contextFileName).toBe('MY_AGENTS.md');
expect(loadedSettings.merged.theme).toBe('matrix'); // User setting should still be there
expect(fs.writeFileSync).toHaveBeenCalledWith(
MOCK_WORKSPACE_SETTINGS_PATH,
JSON.stringify({ contextFileName: 'MY_AGENTS.md' }, null, 2),
'utf-8',
);
// System theme overrides user and workspace themes
loadedSettings.setValue(SettingScope.System, 'theme', 'ocean');
expect(loadedSettings.system.settings.theme).toBe('ocean');
expect(loadedSettings.merged.theme).toBe('ocean');
});
});
});

View File

@@ -0,0 +1,388 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs';
import * as path from 'path';
import { homedir, platform } from 'os';
import * as dotenv from 'dotenv';
import {
MCPServerConfig,
GEMINI_CONFIG_DIR as GEMINI_DIR,
getErrorMessage,
BugCommandSettings,
TelemetrySettings,
AuthType,
} from '@qwen/qwen-code-core';
import stripJsonComments from 'strip-json-comments';
import { DefaultLight } from '../ui/themes/default-light.js';
import { DefaultDark } from '../ui/themes/default.js';
export const SETTINGS_DIRECTORY_NAME = '.qwen';
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
function getSystemSettingsPath(): string {
if (platform() === 'darwin') {
return '/Library/Application Support/QwenCode/settings.json';
} else if (platform() === 'win32') {
return 'C:\\ProgramData\\qwen-code\\settings.json';
} else {
return '/etc/qwen-code/settings.json';
}
}
export const SYSTEM_SETTINGS_PATH = getSystemSettingsPath();
export enum SettingScope {
User = 'User',
Workspace = 'Workspace',
System = 'System',
}
export interface CheckpointingSettings {
enabled?: boolean;
}
export interface AccessibilitySettings {
disableLoadingPhrases?: boolean;
}
export interface Settings {
theme?: string;
selectedAuthType?: AuthType;
sandbox?: boolean | string;
coreTools?: string[];
excludeTools?: string[];
toolDiscoveryCommand?: string;
toolCallCommand?: string;
mcpServerCommand?: string;
mcpServers?: Record<string, MCPServerConfig>;
showMemoryUsage?: boolean;
contextFileName?: string | string[];
accessibility?: AccessibilitySettings;
telemetry?: TelemetrySettings;
usageStatisticsEnabled?: boolean;
preferredEditor?: string;
bugCommand?: BugCommandSettings;
checkpointing?: CheckpointingSettings;
autoConfigureMaxOldSpaceSize?: boolean;
enableOpenAILogging?: boolean;
// Git-aware file filtering settings
fileFiltering?: {
respectGitIgnore?: boolean;
enableRecursiveFileSearch?: boolean;
};
// UI setting. Does not display the ANSI-controlled terminal title.
hideWindowTitle?: boolean;
hideTips?: boolean;
hideBanner?: boolean;
// Setting for setting maximum number of user/model/tool turns in a session.
maxSessionTurns?: number;
// Sampling parameters for content generation
sampling_params?: {
top_p?: number;
top_k?: number;
repetition_penalty?: number;
presence_penalty?: number;
frequency_penalty?: number;
temperature?: number;
max_tokens?: number;
};
// Add other settings here.
ideMode?: boolean;
}
export interface SettingsError {
message: string;
path: string;
}
export interface SettingsFile {
settings: Settings;
path: string;
}
export class LoadedSettings {
constructor(
system: SettingsFile,
user: SettingsFile,
workspace: SettingsFile,
errors: SettingsError[],
) {
this.system = system;
this.user = user;
this.workspace = workspace;
this.errors = errors;
this._merged = this.computeMergedSettings();
}
readonly system: SettingsFile;
readonly user: SettingsFile;
readonly workspace: SettingsFile;
readonly errors: SettingsError[];
private _merged: Settings;
get merged(): Settings {
return this._merged;
}
private computeMergedSettings(): Settings {
return {
...this.user.settings,
...this.workspace.settings,
...this.system.settings,
};
}
forScope(scope: SettingScope): SettingsFile {
switch (scope) {
case SettingScope.User:
return this.user;
case SettingScope.Workspace:
return this.workspace;
case SettingScope.System:
return this.system;
default:
throw new Error(`Invalid scope: ${scope}`);
}
}
setValue(
scope: SettingScope,
key: keyof Settings,
value: string | Record<string, MCPServerConfig> | undefined,
): void {
const settingsFile = this.forScope(scope);
// @ts-expect-error - value can be string | Record<string, MCPServerConfig>
settingsFile.settings[key] = value;
this._merged = this.computeMergedSettings();
saveSettings(settingsFile);
}
}
function resolveEnvVarsInString(value: string): string {
const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME}
return value.replace(envVarRegex, (match, varName1, varName2) => {
const varName = varName1 || varName2;
if (process && process.env && typeof process.env[varName] === 'string') {
return process.env[varName]!;
}
return match;
});
}
function resolveEnvVarsInObject<T>(obj: T): T {
if (
obj === null ||
obj === undefined ||
typeof obj === 'boolean' ||
typeof obj === 'number'
) {
return obj;
}
if (typeof obj === 'string') {
return resolveEnvVarsInString(obj) as unknown as T;
}
if (Array.isArray(obj)) {
return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T;
}
if (typeof obj === 'object') {
const newObj = { ...obj } as T;
for (const key in newObj) {
if (Object.prototype.hasOwnProperty.call(newObj, key)) {
newObj[key] = resolveEnvVarsInObject(newObj[key]);
}
}
return newObj;
}
return obj;
}
function findEnvFile(startDir: string): string | null {
let currentDir = path.resolve(startDir);
while (true) {
// prefer gemini-specific .env under GEMINI_DIR
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
if (fs.existsSync(geminiEnvPath)) {
return geminiEnvPath;
}
const envPath = path.join(currentDir, '.env');
if (fs.existsSync(envPath)) {
return envPath;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir || !parentDir) {
// check .env under home as fallback, again preferring gemini-specific .env
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
if (fs.existsSync(homeGeminiEnvPath)) {
return homeGeminiEnvPath;
}
const homeEnvPath = path.join(homedir(), '.env');
if (fs.existsSync(homeEnvPath)) {
return homeEnvPath;
}
return null;
}
currentDir = parentDir;
}
}
export function setUpCloudShellEnvironment(envFilePath: string | null): void {
// Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell:
// Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project
// set by the user using "gcloud config set project" we do not want to
// use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in
// one of the .env files, we set the Cloud Shell-specific default here.
if (envFilePath && fs.existsSync(envFilePath)) {
const envFileContent = fs.readFileSync(envFilePath);
const parsedEnv = dotenv.parse(envFileContent);
if (parsedEnv.GOOGLE_CLOUD_PROJECT) {
// .env file takes precedence in Cloud Shell
process.env.GOOGLE_CLOUD_PROJECT = parsedEnv.GOOGLE_CLOUD_PROJECT;
} else {
// If not in .env, set to default and override global
process.env.GOOGLE_CLOUD_PROJECT = 'cloudshell-gca';
}
} else {
// If no .env file, set to default and override global
process.env.GOOGLE_CLOUD_PROJECT = 'cloudshell-gca';
}
}
export function loadEnvironment(): void {
const envFilePath = findEnvFile(process.cwd());
if (process.env.CLOUD_SHELL === 'true') {
setUpCloudShellEnvironment(envFilePath);
}
if (envFilePath) {
dotenv.config({ path: envFilePath, quiet: true });
}
}
/**
* Loads settings from user and workspace directories.
* Project settings override user settings.
*/
export function loadSettings(workspaceDir: string): LoadedSettings {
loadEnvironment();
let systemSettings: Settings = {};
let userSettings: Settings = {};
let workspaceSettings: Settings = {};
const settingsErrors: SettingsError[] = [];
// Load system settings
try {
if (fs.existsSync(SYSTEM_SETTINGS_PATH)) {
const systemContent = fs.readFileSync(SYSTEM_SETTINGS_PATH, 'utf-8');
const parsedSystemSettings = JSON.parse(
stripJsonComments(systemContent),
) as Settings;
systemSettings = resolveEnvVarsInObject(parsedSystemSettings);
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: SYSTEM_SETTINGS_PATH,
});
}
// Load user settings
try {
if (fs.existsSync(USER_SETTINGS_PATH)) {
const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
const parsedUserSettings = JSON.parse(
stripJsonComments(userContent),
) as Settings;
userSettings = resolveEnvVarsInObject(parsedUserSettings);
// Support legacy theme names
if (userSettings.theme && userSettings.theme === 'VS') {
userSettings.theme = DefaultLight.name;
} else if (userSettings.theme && userSettings.theme === 'VS2015') {
userSettings.theme = DefaultDark.name;
}
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: USER_SETTINGS_PATH,
});
}
const workspaceSettingsPath = path.join(
workspaceDir,
SETTINGS_DIRECTORY_NAME,
'settings.json',
);
// Load workspace settings
try {
if (fs.existsSync(workspaceSettingsPath)) {
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
const parsedWorkspaceSettings = JSON.parse(
stripJsonComments(projectContent),
) as Settings;
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
if (workspaceSettings.theme && workspaceSettings.theme === 'VS') {
workspaceSettings.theme = DefaultLight.name;
} else if (
workspaceSettings.theme &&
workspaceSettings.theme === 'VS2015'
) {
workspaceSettings.theme = DefaultDark.name;
}
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: workspaceSettingsPath,
});
}
return new LoadedSettings(
{
path: SYSTEM_SETTINGS_PATH,
settings: systemSettings,
},
{
path: USER_SETTINGS_PATH,
settings: userSettings,
},
{
path: workspaceSettingsPath,
settings: workspaceSettings,
},
settingsErrors,
);
}
export function saveSettings(settingsFile: SettingsFile): void {
try {
// Ensure the directory exists
const dirPath = path.dirname(settingsFile.path);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
fs.writeFileSync(
settingsFile.path,
JSON.stringify(settingsFile.settings, null, 2),
'utf-8',
);
} catch (error) {
console.error('Error saving user settings file:', error);
}
}

View File

@@ -0,0 +1,151 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import stripAnsi from 'strip-ansi';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { main } from './gemini.js';
import {
LoadedSettings,
SettingsFile,
loadSettings,
} from './config/settings.js';
// Custom error to identify mock process.exit calls
class MockProcessExitError extends Error {
constructor(readonly code?: string | number | null | undefined) {
super('PROCESS_EXIT_MOCKED');
this.name = 'MockProcessExitError';
}
}
// Mock dependencies
vi.mock('./config/settings.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./config/settings.js')>();
return {
...actual,
loadSettings: vi.fn(),
};
});
vi.mock('./config/config.js', () => ({
loadCliConfig: vi.fn().mockResolvedValue({
config: {
getSandbox: vi.fn(() => false),
getQuestion: vi.fn(() => ''),
},
modelWasSwitched: false,
originalModelBeforeSwitch: null,
finalModel: 'test-model',
}),
}));
vi.mock('read-package-up', () => ({
readPackageUp: vi.fn().mockResolvedValue({
packageJson: { name: 'test-pkg', version: 'test-version' },
path: '/fake/path/package.json',
}),
}));
vi.mock('update-notifier', () => ({
default: vi.fn(() => ({
notify: vi.fn(),
})),
}));
vi.mock('./utils/sandbox.js', () => ({
sandbox_command: vi.fn(() => ''), // Default to no sandbox command
start_sandbox: vi.fn(() => Promise.resolve()), // Mock as an async function that resolves
}));
describe('gemini.tsx main function', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let loadSettingsMock: ReturnType<typeof vi.mocked<typeof loadSettings>>;
let originalEnvGeminiSandbox: string | undefined;
let originalEnvSandbox: string | undefined;
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
beforeEach(() => {
loadSettingsMock = vi.mocked(loadSettings);
// Store and clear sandbox-related env variables to ensure a consistent test environment
originalEnvGeminiSandbox = process.env.GEMINI_SANDBOX;
originalEnvSandbox = process.env.SANDBOX;
delete process.env.GEMINI_SANDBOX;
delete process.env.SANDBOX;
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
// Restore original env variables
if (originalEnvGeminiSandbox !== undefined) {
process.env.GEMINI_SANDBOX = originalEnvGeminiSandbox;
} else {
delete process.env.GEMINI_SANDBOX;
}
if (originalEnvSandbox !== undefined) {
process.env.SANDBOX = originalEnvSandbox;
} else {
delete process.env.SANDBOX;
}
vi.restoreAllMocks();
});
it('should call process.exit(1) if settings have errors', async () => {
const settingsError = {
message: 'Test settings error',
path: '/test/settings.json',
};
const userSettingsFile: SettingsFile = {
path: '/user/settings.json',
settings: {},
};
const workspaceSettingsFile: SettingsFile = {
path: '/workspace/.qwen/settings.json',
settings: {},
};
const systemSettingsFile: SettingsFile = {
path: '/system/settings.json',
settings: {},
};
const mockLoadedSettings = new LoadedSettings(
systemSettingsFile,
userSettingsFile,
workspaceSettingsFile,
[settingsError],
);
loadSettingsMock.mockReturnValue(mockLoadedSettings);
try {
await main();
// If main completes without throwing, the test should fail because process.exit was expected
expect.fail('main function did not exit as expected');
} catch (error) {
expect(error).toBeInstanceOf(MockProcessExitError);
if (error instanceof MockProcessExitError) {
expect(error.code).toBe(1);
}
}
// Verify console.error was called with the error message
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
expect(stripAnsi(String(consoleErrorSpy.mock.calls[0][0]))).toBe(
'Error in /test/settings.json: Test settings error',
);
expect(stripAnsi(String(consoleErrorSpy.mock.calls[1][0]))).toBe(
'Please fix /test/settings.json and try again.',
);
// Verify process.exit was called (indirectly, via the thrown error)
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});

344
packages/cli/src/gemini.tsx Normal file
View File

@@ -0,0 +1,344 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink';
import { AppWrapper } from './ui/App.js';
import { loadCliConfig, parseArguments, CliArgs } from './config/config.js';
import { readStdin } from './utils/readStdin.js';
import { basename } from 'node:path';
import v8 from 'node:v8';
import os from 'node:os';
import { spawn } from 'node:child_process';
import { start_sandbox } from './utils/sandbox.js';
import {
LoadedSettings,
loadSettings,
USER_SETTINGS_PATH,
SettingScope,
} from './config/settings.js';
import { themeManager } from './ui/themes/theme-manager.js';
import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { runNonInteractive } from './nonInteractiveCli.js';
import { loadExtensions, Extension } from './config/extension.js';
import { cleanupCheckpoints, registerCleanup } from './utils/cleanup.js';
import { getCliVersion } from './utils/version.js';
import {
ApprovalMode,
Config,
EditTool,
ShellTool,
WriteFileTool,
sessionId,
logUserPrompt,
AuthType,
getOauthClient,
} from '@qwen/qwen-code-core';
import { validateAuthMethod } from './config/auth.js';
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
function getNodeMemoryArgs(config: Config): string[] {
const totalMemoryMB = os.totalmem() / (1024 * 1024);
const heapStats = v8.getHeapStatistics();
const currentMaxOldSpaceSizeMb = Math.floor(
heapStats.heap_size_limit / 1024 / 1024,
);
// Set target to 50% of total memory
const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5);
if (config.getDebugMode()) {
console.debug(
`Current heap size ${currentMaxOldSpaceSizeMb.toFixed(2)} MB`,
);
}
if (process.env.GEMINI_CLI_NO_RELAUNCH) {
return [];
}
if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) {
if (config.getDebugMode()) {
console.debug(
`Need to relaunch with more memory: ${targetMaxOldSpaceSizeInMB.toFixed(2)} MB`,
);
}
return [`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`];
}
return [];
}
async function relaunchWithAdditionalArgs(additionalArgs: string[]) {
const nodeArgs = [...additionalArgs, ...process.argv.slice(1)];
const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' };
const child = spawn(process.execPath, nodeArgs, {
stdio: 'inherit',
env: newEnv,
});
await new Promise((resolve) => child.on('close', resolve));
process.exit(0);
}
export async function main() {
const workspaceRoot = process.cwd();
const settings = loadSettings(workspaceRoot);
await cleanupCheckpoints();
if (settings.errors.length > 0) {
for (const error of settings.errors) {
let errorMessage = `Error in ${error.path}: ${error.message}`;
if (!process.env.NO_COLOR) {
errorMessage = `\x1b[31m${errorMessage}\x1b[0m`;
}
console.error(errorMessage);
console.error(`Please fix ${error.path} and try again.`);
}
process.exit(1);
}
const argv = await parseArguments();
const extensions = loadExtensions(workspaceRoot);
const config = await loadCliConfig(
settings.merged,
extensions,
sessionId,
argv,
);
if (argv.promptInteractive && !process.stdin.isTTY) {
console.error(
'Error: The --prompt-interactive flag is not supported when piping input from stdin.',
);
process.exit(1);
}
if (config.getListExtensions()) {
console.log('Installed extensions:');
for (const extension of extensions) {
console.log(`- ${extension.config.name}`);
}
process.exit(0);
}
// Set a default auth type if one isn't set.
if (!settings.merged.selectedAuthType) {
if (process.env.CLOUD_SHELL === 'true') {
settings.setValue(
SettingScope.User,
'selectedAuthType',
AuthType.CLOUD_SHELL,
);
}
}
setMaxSizedBoxDebugging(config.getDebugMode());
await config.initialize();
if (settings.merged.theme) {
if (!themeManager.setActiveTheme(settings.merged.theme)) {
// If the theme is not found during initial load, log a warning and continue.
// The useThemeCommand hook in App.tsx will handle opening the dialog.
console.warn(`Warning: Theme "${settings.merged.theme}" not found.`);
}
}
// hop into sandbox if we are outside and sandboxing is enabled
if (!process.env.SANDBOX) {
const memoryArgs = settings.merged.autoConfigureMaxOldSpaceSize
? getNodeMemoryArgs(config)
: [];
const sandboxConfig = config.getSandbox();
if (sandboxConfig) {
if (settings.merged.selectedAuthType) {
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
try {
const err = validateAuthMethod(settings.merged.selectedAuthType);
if (err) {
throw new Error(err);
}
await config.refreshAuth(settings.merged.selectedAuthType);
} catch (err) {
console.error('Error authenticating:', err);
process.exit(1);
}
}
await start_sandbox(sandboxConfig, memoryArgs);
process.exit(0);
} else {
// Not in a sandbox and not entering one, so relaunch with additional
// arguments to control memory usage if needed.
if (memoryArgs.length > 0) {
await relaunchWithAdditionalArgs(memoryArgs);
process.exit(0);
}
}
}
if (
settings.merged.selectedAuthType === AuthType.LOGIN_WITH_GOOGLE &&
config.getNoBrowser()
) {
// Do oauth before app renders to make copying the link possible.
await getOauthClient(settings.merged.selectedAuthType, config);
}
let input = config.getQuestion();
const startupWarnings = [
...(await getStartupWarnings()),
...(await getUserStartupWarnings(workspaceRoot)),
];
const shouldBeInteractive =
!!argv.promptInteractive || (process.stdin.isTTY && input?.length === 0);
// Render UI, passing necessary config values. Check that there is no command line question.
if (shouldBeInteractive) {
const version = await getCliVersion();
setWindowTitle(basename(workspaceRoot), settings);
const instance = render(
<React.StrictMode>
<AppWrapper
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
/>
</React.StrictMode>,
{ exitOnCtrlC: false },
);
registerCleanup(() => instance.unmount());
return;
}
// If not a TTY, read from stdin
// This is for cases where the user pipes input directly into the command
if (!process.stdin.isTTY && !input) {
input += await readStdin();
}
if (!input) {
console.error('No input provided via stdin.');
process.exit(1);
}
const prompt_id = Math.random().toString(16).slice(2);
logUserPrompt(config, {
'event.name': 'user_prompt',
'event.timestamp': new Date().toISOString(),
prompt: input,
prompt_id,
auth_type: config.getContentGeneratorConfig()?.authType,
prompt_length: input.length,
});
// Non-interactive mode handled by runNonInteractive
const nonInteractiveConfig = await loadNonInteractiveConfig(
config,
extensions,
settings,
argv,
);
await runNonInteractive(nonInteractiveConfig, input, prompt_id);
process.exit(0);
}
function setWindowTitle(title: string, settings: LoadedSettings) {
if (!settings.merged.hideWindowTitle) {
const windowTitle = (process.env.CLI_TITLE || `Qwen - ${title}`).replace(
// eslint-disable-next-line no-control-regex
/[\x00-\x1F\x7F]/g,
'',
);
process.stdout.write(`\x1b]2;${windowTitle}\x07`);
process.on('exit', () => {
process.stdout.write(`\x1b]2;\x07`);
});
}
}
// --- Global Unhandled Rejection Handler ---
process.on('unhandledRejection', (reason, _promise) => {
// Log other unexpected unhandled rejections as critical errors
console.error('=========================================');
console.error('CRITICAL: Unhandled Promise Rejection!');
console.error('=========================================');
console.error('Reason:', reason);
console.error('Stack trace may follow:');
if (!(reason instanceof Error)) {
console.error(reason);
}
// Exit for genuinely unhandled errors
process.exit(1);
});
async function loadNonInteractiveConfig(
config: Config,
extensions: Extension[],
settings: LoadedSettings,
argv: CliArgs,
) {
let finalConfig = config;
if (config.getApprovalMode() !== ApprovalMode.YOLO) {
// Everything is not allowed, ensure that only read-only tools are configured.
const existingExcludeTools = settings.merged.excludeTools || [];
const interactiveTools = [
ShellTool.Name,
EditTool.Name,
WriteFileTool.Name,
];
const newExcludeTools = [
...new Set([...existingExcludeTools, ...interactiveTools]),
];
const nonInteractiveSettings = {
...settings.merged,
excludeTools: newExcludeTools,
};
finalConfig = await loadCliConfig(
nonInteractiveSettings,
extensions,
config.getSessionId(),
argv,
);
await finalConfig.initialize();
}
return await validateNonInterActiveAuth(
settings.merged.selectedAuthType,
finalConfig,
);
}
async function validateNonInterActiveAuth(
selectedAuthType: AuthType | undefined,
nonInteractiveConfig: Config,
) {
// making a special case for the cli. many headless environments might not have a settings.json set
// so if GEMINI_API_KEY is set, we'll use that. However since the oauth things are interactive anyway, we'll
// still expect that exists
if (!selectedAuthType && !process.env.GEMINI_API_KEY) {
console.error(
`Please set an Auth method in your ${USER_SETTINGS_PATH} OR specify GEMINI_API_KEY env variable file before running`,
);
process.exit(1);
}
selectedAuthType = selectedAuthType || AuthType.USE_GEMINI;
const err = validateAuthMethod(selectedAuthType);
if (err != null) {
console.error(err);
process.exit(1);
}
await nonInteractiveConfig.refreshAuth(selectedAuthType);
return nonInteractiveConfig;
}

View File

@@ -0,0 +1,344 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { runNonInteractive } from './nonInteractiveCli.js';
import { Config, GeminiClient, ToolRegistry } from '@qwen/qwen-code-core';
import { GenerateContentResponse, Part, FunctionCall } from '@google/genai';
// Mock dependencies
vi.mock('@qwen/qwen-code-core', async () => {
const actualCore = await vi.importActual<
typeof import('@qwen/qwen-code-core')
>('@qwen/qwen-code-core');
return {
...actualCore,
GeminiClient: vi.fn(),
ToolRegistry: vi.fn(),
executeToolCall: vi.fn(),
};
});
describe('runNonInteractive', () => {
let mockConfig: Config;
let mockGeminiClient: GeminiClient;
let mockToolRegistry: ToolRegistry;
let mockChat: {
sendMessageStream: ReturnType<typeof vi.fn>;
};
let mockProcessStdoutWrite: ReturnType<typeof vi.fn>;
let mockProcessExit: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.resetAllMocks();
mockChat = {
sendMessageStream: vi.fn(),
};
mockGeminiClient = {
getChat: vi.fn().mockResolvedValue(mockChat),
} as unknown as GeminiClient;
mockToolRegistry = {
getFunctionDeclarations: vi.fn().mockReturnValue([]),
getTool: vi.fn(),
} as unknown as ToolRegistry;
vi.mocked(GeminiClient).mockImplementation(() => mockGeminiClient);
vi.mocked(ToolRegistry).mockImplementation(() => mockToolRegistry);
mockConfig = {
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getMaxSessionTurns: vi.fn().mockReturnValue(10),
initialize: vi.fn(),
} as unknown as Config;
mockProcessStdoutWrite = vi.fn().mockImplementation(() => true);
process.stdout.write = mockProcessStdoutWrite as any; // Use any to bypass strict signature matching for mock
mockProcessExit = vi
.fn()
.mockImplementation((_code?: number) => undefined as never);
process.exit = mockProcessExit as any; // Use any for process.exit mock
});
afterEach(() => {
vi.restoreAllMocks();
// Restore original process methods if they were globally patched
// This might require storing the original methods before patching them in beforeEach
});
it('should process input and write text output', async () => {
const inputStream = (async function* () {
yield {
candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
} as GenerateContentResponse;
yield {
candidates: [{ content: { parts: [{ text: ' World' }] } }],
} as GenerateContentResponse;
})();
mockChat.sendMessageStream.mockResolvedValue(inputStream);
await runNonInteractive(mockConfig, 'Test input', 'prompt-id-1');
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
{
message: [{ text: 'Test input' }],
config: {
abortSignal: expect.any(AbortSignal),
tools: [{ functionDeclarations: [] }],
},
},
expect.any(String),
);
expect(mockProcessStdoutWrite).toHaveBeenCalledWith('Hello');
expect(mockProcessStdoutWrite).toHaveBeenCalledWith(' World');
expect(mockProcessStdoutWrite).toHaveBeenCalledWith('\n');
});
it('should handle a single tool call and respond', async () => {
const functionCall: FunctionCall = {
id: 'fc1',
name: 'testTool',
args: { p: 'v' },
};
const toolResponsePart: Part = {
functionResponse: {
name: 'testTool',
id: 'fc1',
response: { result: 'tool success' },
},
};
const { executeToolCall: mockCoreExecuteToolCall } = await import(
'@qwen/qwen-code-core'
);
vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({
callId: 'fc1',
responseParts: [toolResponsePart],
resultDisplay: 'Tool success display',
error: undefined,
});
const stream1 = (async function* () {
yield { functionCalls: [functionCall] } as GenerateContentResponse;
})();
const stream2 = (async function* () {
yield {
candidates: [{ content: { parts: [{ text: 'Final answer' }] } }],
} as GenerateContentResponse;
})();
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
await runNonInteractive(mockConfig, 'Use a tool', 'prompt-id-2');
expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2);
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({ callId: 'fc1', name: 'testTool' }),
mockToolRegistry,
expect.any(AbortSignal),
);
expect(mockChat.sendMessageStream).toHaveBeenLastCalledWith(
expect.objectContaining({
message: [toolResponsePart],
}),
expect.any(String),
);
expect(mockProcessStdoutWrite).toHaveBeenCalledWith('Final answer');
});
it('should handle error during tool execution', async () => {
const functionCall: FunctionCall = {
id: 'fcError',
name: 'errorTool',
args: {},
};
const errorResponsePart: Part = {
functionResponse: {
name: 'errorTool',
id: 'fcError',
response: { error: 'Tool failed' },
},
};
const { executeToolCall: mockCoreExecuteToolCall } = await import(
'@qwen/qwen-code-core'
);
vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({
callId: 'fcError',
responseParts: [errorResponsePart],
resultDisplay: 'Tool execution failed badly',
error: new Error('Tool failed'),
});
const stream1 = (async function* () {
yield { functionCalls: [functionCall] } as GenerateContentResponse;
})();
const stream2 = (async function* () {
yield {
candidates: [
{ content: { parts: [{ text: 'Could not complete request.' }] } },
],
} as GenerateContentResponse;
})();
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await runNonInteractive(mockConfig, 'Trigger tool error', 'prompt-id-3');
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error executing tool errorTool: Tool execution failed badly',
);
expect(mockChat.sendMessageStream).toHaveBeenLastCalledWith(
expect.objectContaining({
message: [errorResponsePart],
}),
expect.any(String),
);
expect(mockProcessStdoutWrite).toHaveBeenCalledWith(
'Could not complete request.',
);
});
it('should exit with error if sendMessageStream throws initially', async () => {
const apiError = new Error('API connection failed');
mockChat.sendMessageStream.mockRejectedValue(apiError);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await runNonInteractive(mockConfig, 'Initial fail', 'prompt-id-4');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[API Error: API connection failed]',
);
});
it('should not exit if a tool is not found, and should send error back to model', async () => {
const functionCall: FunctionCall = {
id: 'fcNotFound',
name: 'nonExistentTool',
args: {},
};
const errorResponsePart: Part = {
functionResponse: {
name: 'nonExistentTool',
id: 'fcNotFound',
response: { error: 'Tool "nonExistentTool" not found in registry.' },
},
};
const { executeToolCall: mockCoreExecuteToolCall } = await import(
'@qwen/qwen-code-core'
);
vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({
callId: 'fcNotFound',
responseParts: [errorResponsePart],
resultDisplay: 'Tool "nonExistentTool" not found in registry.',
error: new Error('Tool "nonExistentTool" not found in registry.'),
});
const stream1 = (async function* () {
yield { functionCalls: [functionCall] } as GenerateContentResponse;
})();
const stream2 = (async function* () {
yield {
candidates: [
{
content: {
parts: [{ text: 'Unfortunately the tool does not exist.' }],
},
},
],
} as GenerateContentResponse;
})();
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await runNonInteractive(
mockConfig,
'Trigger tool not found',
'prompt-id-5',
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error executing tool nonExistentTool: Tool "nonExistentTool" not found in registry.',
);
expect(mockProcessExit).not.toHaveBeenCalled();
expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2);
expect(mockChat.sendMessageStream).toHaveBeenLastCalledWith(
expect.objectContaining({
message: [errorResponsePart],
}),
expect.any(String),
);
expect(mockProcessStdoutWrite).toHaveBeenCalledWith(
'Unfortunately the tool does not exist.',
);
});
it('should exit when max session turns are exceeded', async () => {
const functionCall: FunctionCall = {
id: 'fcLoop',
name: 'loopTool',
args: {},
};
const toolResponsePart: Part = {
functionResponse: {
name: 'loopTool',
id: 'fcLoop',
response: { result: 'still looping' },
},
};
// Config with a max turn of 1
vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(1);
const { executeToolCall: mockCoreExecuteToolCall } = await import(
'@qwen/qwen-code-core'
);
vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({
callId: 'fcLoop',
responseParts: [toolResponsePart],
resultDisplay: 'Still looping',
error: undefined,
});
const stream = (async function* () {
yield { functionCalls: [functionCall] } as GenerateContentResponse;
})();
mockChat.sendMessageStream.mockResolvedValue(stream);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await runNonInteractive(mockConfig, 'Trigger loop');
expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy).toHaveBeenCalledWith(
`
Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.`,
);
expect(mockProcessExit).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,275 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
Config,
ToolCallRequestInfo,
executeToolCall,
ToolRegistry,
shutdownTelemetry,
isTelemetrySdkInitialized,
ToolResultDisplay,
} from '@qwen/qwen-code-core';
import {
Content,
Part,
FunctionCall,
GenerateContentResponse,
} from '@google/genai';
import { parseAndFormatApiError } from './ui/utils/errorParsing.js';
function getResponseText(response: GenerateContentResponse): string | null {
if (response.candidates && response.candidates.length > 0) {
const candidate = response.candidates[0];
if (
candidate.content &&
candidate.content.parts &&
candidate.content.parts.length > 0
) {
// We are running in headless mode so we don't need to return thoughts to STDOUT.
const thoughtPart = candidate.content.parts[0];
if (thoughtPart?.thought) {
return null;
}
return candidate.content.parts
.filter((part) => part.text)
.map((part) => part.text)
.join('');
}
}
return null;
}
// Helper function to format tool call arguments for display
function formatToolArgs(args: Record<string, unknown>): string {
if (!args || Object.keys(args).length === 0) {
return '(no arguments)';
}
const formattedArgs = Object.entries(args)
.map(([key, value]) => {
if (typeof value === 'string') {
return `${key}: "${value}"`;
} else if (typeof value === 'object' && value !== null) {
return `${key}: ${JSON.stringify(value)}`;
} else {
return `${key}: ${value}`;
}
})
.join(', ');
return `(${formattedArgs})`;
}
// Helper function to display tool call information
function displayToolCallInfo(
toolName: string,
args: Record<string, unknown>,
status: 'start' | 'success' | 'error',
resultDisplay?: ToolResultDisplay,
errorMessage?: string,
): void {
const timestamp = new Date().toLocaleTimeString();
const argsStr = formatToolArgs(args);
switch (status) {
case 'start':
process.stdout.write(
`\n[${timestamp}] 🔧 Executing tool: ${toolName} ${argsStr}\n`,
);
break;
case 'success':
if (resultDisplay) {
if (typeof resultDisplay === 'string' && resultDisplay.trim()) {
process.stdout.write(
`[${timestamp}] ✅ Tool ${toolName} completed successfully\n`,
);
process.stdout.write(`📋 Result:\n${resultDisplay}\n`);
} else if (
typeof resultDisplay === 'object' &&
'fileDiff' in resultDisplay
) {
process.stdout.write(
`[${timestamp}] ✅ Tool ${toolName} completed successfully\n`,
);
process.stdout.write(`📋 File: ${resultDisplay.fileName}\n`);
process.stdout.write(`📋 Diff:\n${resultDisplay.fileDiff}\n`);
} else {
process.stdout.write(
`[${timestamp}] ✅ Tool ${toolName} completed successfully (no output)\n`,
);
}
} else {
process.stdout.write(
`[${timestamp}] ✅ Tool ${toolName} completed successfully (no output)\n`,
);
}
break;
case 'error':
process.stdout.write(
`[${timestamp}] ❌ Tool ${toolName} failed: ${errorMessage}\n`,
);
break;
default:
process.stdout.write(
`[${timestamp}] ⚠️ Tool ${toolName} reported unknown status: ${status}\n`,
);
break;
}
}
export async function runNonInteractive(
config: Config,
input: string,
prompt_id: string,
): Promise<void> {
await config.initialize();
// 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.
process.exit(0);
}
});
const geminiClient = config.getGeminiClient();
const toolRegistry: ToolRegistry = await config.getToolRegistry();
const chat = await geminiClient.getChat();
const abortController = new AbortController();
let currentMessages: Content[] = [{ role: 'user', parts: [{ text: input }] }];
let turnCount = 0;
try {
while (true) {
turnCount++;
if (
config.getMaxSessionTurns() > 0 &&
turnCount > config.getMaxSessionTurns()
) {
console.error(
'\n Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
);
return;
}
const functionCalls: FunctionCall[] = [];
const responseStream = await chat.sendMessageStream(
{
message: currentMessages[0]?.parts || [], // Ensure parts are always provided
config: {
abortSignal: abortController.signal,
tools: [
{ functionDeclarations: toolRegistry.getFunctionDeclarations() },
],
},
},
prompt_id,
);
for await (const resp of responseStream) {
if (abortController.signal.aborted) {
console.error('Operation cancelled.');
return;
}
const textPart = getResponseText(resp);
if (textPart) {
process.stdout.write(textPart);
}
if (resp.functionCalls) {
functionCalls.push(...resp.functionCalls);
}
}
if (functionCalls.length > 0) {
const toolResponseParts: Part[] = [];
for (const fc of functionCalls) {
const callId = fc.id ?? `${fc.name}-${Date.now()}`;
const requestInfo: ToolCallRequestInfo = {
callId,
name: fc.name as string,
args: (fc.args ?? {}) as Record<string, unknown>,
isClientInitiated: false,
prompt_id,
};
//Display tool call start information
displayToolCallInfo(fc.name as string, fc.args ?? {}, 'start');
const toolResponse = await executeToolCall(
config,
requestInfo,
toolRegistry,
abortController.signal,
);
if (toolResponse.error) {
// Display tool call error information
const errorMessage =
typeof toolResponse.resultDisplay === 'string'
? toolResponse.resultDisplay
: toolResponse.error?.message;
displayToolCallInfo(
fc.name as string,
fc.args ?? {},
'error',
undefined,
errorMessage,
);
const isToolNotFound = toolResponse.error.message.includes(
'not found in registry',
);
console.error(
`Error executing tool ${fc.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`,
);
if (!isToolNotFound) {
process.exit(1);
}
} else {
// Display tool call success information
displayToolCallInfo(
fc.name as string,
fc.args ?? {},
'success',
toolResponse.resultDisplay,
);
}
if (toolResponse.responseParts) {
const parts = Array.isArray(toolResponse.responseParts)
? toolResponse.responseParts
: [toolResponse.responseParts];
for (const part of parts) {
if (typeof part === 'string') {
toolResponseParts.push({ text: part });
} else if (part) {
toolResponseParts.push(part);
}
}
}
}
currentMessages = [{ role: 'user', parts: toolResponseParts }];
} else {
process.stdout.write('\n'); // Ensure a final newline
return;
}
}
} catch (error) {
console.error(
parseAndFormatApiError(
error,
config.getContentGeneratorConfig()?.authType,
),
);
process.exit(1);
} finally {
if (isTelemetrySdkInitialized()) {
await shutdownTelemetry();
}
}
}

View File

@@ -0,0 +1,138 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { CommandService } from './CommandService.js';
import { type SlashCommand } from '../ui/commands/types.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { themeCommand } from '../ui/commands/themeCommand.js';
import { privacyCommand } from '../ui/commands/privacyCommand.js';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
// Mock the command modules to isolate the service from the command implementations.
vi.mock('../ui/commands/memoryCommand.js', () => ({
memoryCommand: { name: 'memory', description: 'Mock Memory' },
}));
vi.mock('../ui/commands/helpCommand.js', () => ({
helpCommand: { name: 'help', description: 'Mock Help' },
}));
vi.mock('../ui/commands/clearCommand.js', () => ({
clearCommand: { name: 'clear', description: 'Mock Clear' },
}));
vi.mock('../ui/commands/authCommand.js', () => ({
authCommand: { name: 'auth', description: 'Mock Auth' },
}));
vi.mock('../ui/commands/themeCommand.js', () => ({
themeCommand: { name: 'theme', description: 'Mock Theme' },
}));
vi.mock('../ui/commands/privacyCommand.js', () => ({
privacyCommand: { name: 'privacy', description: 'Mock Privacy' },
}));
vi.mock('../ui/commands/aboutCommand.js', () => ({
aboutCommand: { name: 'about', description: 'Mock About' },
}));
describe('CommandService', () => {
describe('when using default production loader', () => {
let commandService: CommandService;
beforeEach(() => {
commandService = new CommandService();
});
it('should initialize with an empty command tree', () => {
const tree = commandService.getCommands();
expect(tree).toBeInstanceOf(Array);
expect(tree.length).toBe(0);
});
describe('loadCommands', () => {
it('should load the built-in commands into the command tree', async () => {
// Pre-condition check
expect(commandService.getCommands().length).toBe(0);
// Action
await commandService.loadCommands();
const tree = commandService.getCommands();
// Post-condition assertions
expect(tree.length).toBe(7);
const commandNames = tree.map((cmd) => cmd.name);
expect(commandNames).toContain('auth');
expect(commandNames).toContain('memory');
expect(commandNames).toContain('help');
expect(commandNames).toContain('clear');
expect(commandNames).toContain('theme');
expect(commandNames).toContain('privacy');
expect(commandNames).toContain('about');
});
it('should overwrite any existing commands when called again', async () => {
// Load once
await commandService.loadCommands();
expect(commandService.getCommands().length).toBe(7);
// Load again
await commandService.loadCommands();
const tree = commandService.getCommands();
// Should not append, but overwrite
expect(tree.length).toBe(7);
});
});
describe('getCommandTree', () => {
it('should return the current command tree', async () => {
const initialTree = commandService.getCommands();
expect(initialTree).toEqual([]);
await commandService.loadCommands();
const loadedTree = commandService.getCommands();
expect(loadedTree.length).toBe(7);
expect(loadedTree).toEqual([
aboutCommand,
authCommand,
clearCommand,
helpCommand,
memoryCommand,
privacyCommand,
themeCommand,
]);
});
});
});
describe('when initialized with an injected loader function', () => {
it('should use the provided loader instead of the built-in one', async () => {
// Arrange: Create a set of mock commands.
const mockCommands: SlashCommand[] = [
{ name: 'injected-test-1', description: 'injected 1' },
{ name: 'injected-test-2', description: 'injected 2' },
];
// Arrange: Create a mock loader FUNCTION that resolves with our mock commands.
const mockLoader = vi.fn().mockResolvedValue(mockCommands);
// Act: Instantiate the service WITH the injected loader function.
const commandService = new CommandService(mockLoader);
await commandService.loadCommands();
const tree = commandService.getCommands();
// Assert: The tree should contain ONLY our injected commands.
expect(mockLoader).toHaveBeenCalled(); // Verify our mock loader was actually called.
expect(tree.length).toBe(2);
expect(tree).toEqual(mockCommands);
const commandNames = tree.map((cmd) => cmd.name);
expect(commandNames).not.toContain('memory'); // Verify it didn't load production commands.
});
});
});

View File

@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { SlashCommand } from '../ui/commands/types.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { themeCommand } from '../ui/commands/themeCommand.js';
import { privacyCommand } from '../ui/commands/privacyCommand.js';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
const loadBuiltInCommands = async (): Promise<SlashCommand[]> => [
aboutCommand,
authCommand,
clearCommand,
helpCommand,
memoryCommand,
privacyCommand,
themeCommand,
];
export class CommandService {
private commands: SlashCommand[] = [];
constructor(
private commandLoader: () => Promise<SlashCommand[]> = loadBuiltInCommands,
) {
// The constructor can be used for dependency injection in the future.
}
async loadCommands(): Promise<void> {
// For now, we only load the built-in commands.
// File-based and remote commands will be added later.
this.commands = await this.commandLoader();
}
getCommands(): SlashCommand[] {
return this.commands;
}
}

View File

@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect } from 'vitest';
import { createMockCommandContext } from './mockCommandContext.js';
describe('createMockCommandContext', () => {
it('should return a valid CommandContext object with default mocks', () => {
const context = createMockCommandContext();
// Just a few spot checks to ensure the structure is correct
// and functions are mocks.
expect(context).toBeDefined();
expect(context.ui.addItem).toBeInstanceOf(Function);
expect(vi.isMockFunction(context.ui.addItem)).toBe(true);
});
it('should apply top-level overrides correctly', () => {
const mockClear = vi.fn();
const overrides = {
ui: {
clear: mockClear,
},
};
const context = createMockCommandContext(overrides);
// Call the function to see if the override was used
context.ui.clear();
// Assert that our specific mock was called, not the default
expect(mockClear).toHaveBeenCalled();
// And that other defaults are still in place
expect(vi.isMockFunction(context.ui.addItem)).toBe(true);
});
it('should apply deeply nested overrides correctly', () => {
// This is the most important test for factory's logic.
const mockConfig = {
getProjectRoot: () => '/test/project',
getModel: () => 'gemini-pro',
};
const overrides = {
services: {
config: mockConfig,
},
};
const context = createMockCommandContext(overrides);
expect(context.services.config).toBeDefined();
expect(context.services.config?.getModel()).toBe('gemini-pro');
expect(context.services.config?.getProjectRoot()).toBe('/test/project');
// Verify a default property on the same nested object is still there
expect(context.services.logger).toBeDefined();
});
});

View File

@@ -0,0 +1,94 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import { CommandContext } from '../ui/commands/types.js';
import { LoadedSettings } from '../config/settings.js';
import { GitService } from '@google/gemini-cli-core';
import { SessionStatsState } from '../ui/contexts/SessionContext.js';
// A utility type to make all properties of an object, and its nested objects, partial.
type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
/**
* Creates a deep, fully-typed mock of the CommandContext for use in tests.
* All functions are pre-mocked with `vi.fn()`.
*
* @param overrides - A deep partial object to override any default mock values.
* @returns A complete, mocked CommandContext object.
*/
export const createMockCommandContext = (
overrides: DeepPartial<CommandContext> = {},
): CommandContext => {
const defaultMocks: CommandContext = {
services: {
config: null,
settings: { merged: {} } as LoadedSettings,
git: undefined as GitService | undefined,
logger: {
log: vi.fn(),
logMessage: vi.fn(),
saveCheckpoint: vi.fn(),
loadCheckpoint: vi.fn().mockResolvedValue([]),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any, // Cast because Logger is a class.
},
ui: {
addItem: vi.fn(),
clear: vi.fn(),
setDebugMessage: vi.fn(),
},
session: {
stats: {
sessionStartTime: new Date(),
lastPromptTokenCount: 0,
metrics: {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
},
} as SessionStatsState,
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const merge = (target: any, source: any): any => {
const output = { ...target };
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const sourceValue = source[key];
const targetValue = output[key];
if (
sourceValue &&
typeof sourceValue === 'object' &&
!Array.isArray(sourceValue) &&
targetValue &&
typeof targetValue === 'object' &&
!Array.isArray(targetValue)
) {
output[key] = merge(targetValue, sourceValue);
} else {
output[key] = sourceValue;
}
}
}
return output;
};
return merge(defaultMocks, overrides);
};

View File

@@ -0,0 +1,595 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { render } from 'ink-testing-library';
import { AppWrapper as App } from './App.js';
import {
Config as ServerConfig,
MCPServerConfig,
ApprovalMode,
ToolRegistry,
AccessibilitySettings,
SandboxConfig,
GeminiClient,
} from '@qwen/qwen-code-core';
import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js';
import process from 'node:process';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { StreamingState } from './types.js';
import { Tips } from './components/Tips.js';
// Define a more complete mock server config based on actual Config
interface MockServerConfig {
apiKey: string;
model: string;
sandbox?: SandboxConfig;
targetDir: string;
debugMode: boolean;
question?: string;
fullContext: boolean;
coreTools?: string[];
toolDiscoveryCommand?: string;
toolCallCommand?: string;
mcpServerCommand?: string;
mcpServers?: Record<string, MCPServerConfig>; // Use imported MCPServerConfig
userAgent: string;
userMemory: string;
geminiMdFileCount: number;
approvalMode: ApprovalMode;
vertexai?: boolean;
showMemoryUsage?: boolean;
accessibility?: AccessibilitySettings;
embeddingModel: string;
getApiKey: Mock<() => string>;
getModel: Mock<() => string>;
getSandbox: Mock<() => SandboxConfig | undefined>;
getTargetDir: Mock<() => string>;
getToolRegistry: Mock<() => ToolRegistry>; // Use imported ToolRegistry type
getDebugMode: Mock<() => boolean>;
getQuestion: Mock<() => string | undefined>;
getFullContext: Mock<() => boolean>;
getCoreTools: Mock<() => string[] | undefined>;
getToolDiscoveryCommand: Mock<() => string | undefined>;
getToolCallCommand: Mock<() => string | undefined>;
getMcpServerCommand: Mock<() => string | undefined>;
getMcpServers: Mock<() => Record<string, MCPServerConfig> | undefined>;
getUserAgent: Mock<() => string>;
getUserMemory: Mock<() => string>;
setUserMemory: Mock<(newUserMemory: string) => void>;
getGeminiMdFileCount: Mock<() => number>;
setGeminiMdFileCount: Mock<(count: number) => void>;
getApprovalMode: Mock<() => ApprovalMode>;
setApprovalMode: Mock<(skip: ApprovalMode) => void>;
getVertexAI: Mock<() => boolean | undefined>;
getShowMemoryUsage: Mock<() => boolean>;
getAccessibility: Mock<() => AccessibilitySettings>;
getProjectRoot: Mock<() => string | undefined>;
getAllGeminiMdFilenames: Mock<() => string[]>;
getGeminiClient: Mock<() => GeminiClient | undefined>;
getUserTier: Mock<() => Promise<string | undefined>>;
}
// Mock @qwen/qwen-code-core and its Config class
vi.mock('@qwen/qwen-code-core', async (importOriginal) => {
const actualCore =
await importOriginal<typeof import('@qwen/qwen-code-core')>();
const ConfigClassMock = vi
.fn()
.mockImplementation((optionsPassedToConstructor) => {
const opts = { ...optionsPassedToConstructor }; // Clone
// Basic mock structure, will be extended by the instance in tests
return {
apiKey: opts.apiKey || 'test-key',
model: opts.model || 'test-model-in-mock-factory',
sandbox: opts.sandbox,
targetDir: opts.targetDir || '/test/dir',
debugMode: opts.debugMode || false,
question: opts.question,
fullContext: opts.fullContext ?? false,
coreTools: opts.coreTools,
toolDiscoveryCommand: opts.toolDiscoveryCommand,
toolCallCommand: opts.toolCallCommand,
mcpServerCommand: opts.mcpServerCommand,
mcpServers: opts.mcpServers,
userAgent: opts.userAgent || 'test-agent',
userMemory: opts.userMemory || '',
geminiMdFileCount: opts.geminiMdFileCount || 0,
approvalMode: opts.approvalMode ?? ApprovalMode.DEFAULT,
vertexai: opts.vertexai,
showMemoryUsage: opts.showMemoryUsage ?? false,
accessibility: opts.accessibility ?? {},
embeddingModel: opts.embeddingModel || 'test-embedding-model',
getApiKey: vi.fn(() => opts.apiKey || 'test-key'),
getModel: vi.fn(() => opts.model || 'test-model-in-mock-factory'),
getSandbox: vi.fn(() => opts.sandbox),
getTargetDir: vi.fn(() => opts.targetDir || '/test/dir'),
getToolRegistry: vi.fn(() => ({}) as ToolRegistry), // Simple mock
getDebugMode: vi.fn(() => opts.debugMode || false),
getQuestion: vi.fn(() => opts.question),
getFullContext: vi.fn(() => opts.fullContext ?? false),
getCoreTools: vi.fn(() => opts.coreTools),
getToolDiscoveryCommand: vi.fn(() => opts.toolDiscoveryCommand),
getToolCallCommand: vi.fn(() => opts.toolCallCommand),
getMcpServerCommand: vi.fn(() => opts.mcpServerCommand),
getMcpServers: vi.fn(() => opts.mcpServers),
getUserAgent: vi.fn(() => opts.userAgent || 'test-agent'),
getUserMemory: vi.fn(() => opts.userMemory || ''),
setUserMemory: vi.fn(),
getGeminiMdFileCount: vi.fn(() => opts.geminiMdFileCount || 0),
setGeminiMdFileCount: vi.fn(),
getApprovalMode: vi.fn(() => opts.approvalMode ?? ApprovalMode.DEFAULT),
setApprovalMode: vi.fn(),
getVertexAI: vi.fn(() => opts.vertexai),
getShowMemoryUsage: vi.fn(() => opts.showMemoryUsage ?? false),
getAccessibility: vi.fn(() => opts.accessibility ?? {}),
getProjectRoot: vi.fn(() => opts.targetDir),
getGeminiClient: vi.fn(() => ({})),
getCheckpointingEnabled: vi.fn(() => opts.checkpointing ?? true),
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
setFlashFallbackHandler: vi.fn(),
getSessionId: vi.fn(() => 'test-session-id'),
getUserTier: vi.fn().mockResolvedValue(undefined),
};
});
return {
...actualCore,
Config: ConfigClassMock,
MCPServerConfig: actualCore.MCPServerConfig,
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
};
});
// Mock heavy dependencies or those with side effects
vi.mock('./hooks/useGeminiStream', () => ({
useGeminiStream: vi.fn(() => ({
streamingState: 'Idle',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
})),
}));
vi.mock('./hooks/useAuthCommand', () => ({
useAuthCommand: vi.fn(() => ({
isAuthDialogOpen: false,
openAuthDialog: vi.fn(),
handleAuthSelect: vi.fn(),
handleAuthHighlight: vi.fn(),
isAuthenticating: false,
cancelAuthentication: vi.fn(),
})),
}));
vi.mock('./hooks/useLogger', () => ({
useLogger: vi.fn(() => ({
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
})),
}));
vi.mock('../config/config.js', async (importOriginal) => {
const actual = await importOriginal();
return {
// @ts-expect-error - this is fine
...actual,
loadHierarchicalGeminiMemory: vi
.fn()
.mockResolvedValue({ memoryContent: '', fileCount: 0 }),
};
});
vi.mock('./components/Tips.js', () => ({
Tips: vi.fn(() => null),
}));
vi.mock('./components/Header.js', () => ({
Header: vi.fn(() => null),
}));
describe('App UI', () => {
let mockConfig: MockServerConfig;
let mockSettings: LoadedSettings;
let mockVersion: string;
let currentUnmount: (() => void) | undefined;
const createMockSettings = (
settings: {
system?: Partial<Settings>;
user?: Partial<Settings>;
workspace?: Partial<Settings>;
} = {},
): LoadedSettings => {
const systemSettingsFile: SettingsFile = {
path: '/system/settings.json',
settings: settings.system || {},
};
const userSettingsFile: SettingsFile = {
path: '/user/settings.json',
settings: settings.user || {},
};
const workspaceSettingsFile: SettingsFile = {
path: '/workspace/.qwen/settings.json',
settings: settings.workspace || {},
};
return new LoadedSettings(
systemSettingsFile,
userSettingsFile,
workspaceSettingsFile,
[],
);
};
beforeEach(() => {
const ServerConfigMocked = vi.mocked(ServerConfig, true);
mockConfig = new ServerConfigMocked({
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: '/test/dir',
debugMode: false,
userMemory: '',
geminiMdFileCount: 0,
showMemoryUsage: false,
sessionId: 'test-session-id',
cwd: '/tmp',
model: 'model',
}) as unknown as MockServerConfig;
mockVersion = '0.0.0-test';
// Ensure the getShowMemoryUsage mock function is specifically set up if not covered by constructor mock
if (!mockConfig.getShowMemoryUsage) {
mockConfig.getShowMemoryUsage = vi.fn(() => false);
}
mockConfig.getShowMemoryUsage.mockReturnValue(false); // Default for most tests
// Ensure a theme is set so the theme dialog does not appear.
mockSettings = createMockSettings({ workspace: { theme: 'Default' } });
});
afterEach(() => {
if (currentUnmount) {
currentUnmount();
currentUnmount = undefined;
}
vi.clearAllMocks(); // Clear mocks after each test
});
it('should display default "GEMINI.md" in footer when contextFileName is not set and count is 1', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
// For this test, ensure showMemoryUsage is false or debugMode is false if it relies on that
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve(); // Wait for any async updates
expect(lastFrame()).toContain('Using 1 GEMINI.md file');
});
it('should display default "GEMINI.md" with plural when contextFileName is not set and count is > 1', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('Using 2 GEMINI.md files');
});
it('should display custom contextFileName in footer when set and count is 1', async () => {
mockSettings = createMockSettings({
workspace: { contextFileName: 'AGENTS.md', theme: 'Default' },
});
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('Using 1 AGENTS.md file');
});
it('should display a generic message when multiple context files with different names are provided', async () => {
mockSettings = createMockSettings({
workspace: {
contextFileName: ['AGENTS.md', 'CONTEXT.md'],
theme: 'Default',
},
});
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('Using 2 context files');
});
it('should display custom contextFileName with plural when set and count is > 1', async () => {
mockSettings = createMockSettings({
workspace: { contextFileName: 'MY_NOTES.TXT', theme: 'Default' },
});
mockConfig.getGeminiMdFileCount.mockReturnValue(3);
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('Using 3 MY_NOTES.TXT files');
});
it('should not display context file message if count is 0, even if contextFileName is set', async () => {
mockSettings = createMockSettings({
workspace: { contextFileName: 'ANY_FILE.MD', theme: 'Default' },
});
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).not.toContain('ANY_FILE.MD');
});
it('should display GEMINI.md and MCP server count when both are present', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
mockConfig.getMcpServers.mockReturnValue({
server1: {} as MCPServerConfig,
});
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('server');
});
it('should display only MCP server count when GEMINI.md count is 0', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
mockConfig.getMcpServers.mockReturnValue({
server1: {} as MCPServerConfig,
server2: {} as MCPServerConfig,
});
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('Using 2 MCP servers');
});
it('should display Tips component by default', async () => {
const { unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(vi.mocked(Tips)).toHaveBeenCalled();
});
it('should not display Tips component when hideTips is true', async () => {
mockSettings = createMockSettings({
workspace: {
hideTips: true,
},
});
const { unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(vi.mocked(Tips)).not.toHaveBeenCalled();
});
it('should display Header component by default', async () => {
const { Header } = await import('./components/Header.js');
const { unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(vi.mocked(Header)).toHaveBeenCalled();
});
it('should not display Header component when hideBanner is true', async () => {
const { Header } = await import('./components/Header.js');
mockSettings = createMockSettings({
user: { hideBanner: true },
});
const { unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(vi.mocked(Header)).not.toHaveBeenCalled();
});
it('should show tips if system says show, but workspace and user settings say hide', async () => {
mockSettings = createMockSettings({
system: { hideTips: false },
user: { hideTips: true },
workspace: { hideTips: true },
});
const { unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(vi.mocked(Tips)).toHaveBeenCalled();
});
describe('when no theme is set', () => {
let originalNoColor: string | undefined;
beforeEach(() => {
originalNoColor = process.env.NO_COLOR;
// Ensure no theme is set for these tests
mockSettings = createMockSettings({});
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
});
afterEach(() => {
process.env.NO_COLOR = originalNoColor;
});
it('should display theme dialog if NO_COLOR is not set', async () => {
delete process.env.NO_COLOR;
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
expect(lastFrame()).toContain('Select Theme');
});
it('should display a message if NO_COLOR is set', async () => {
process.env.NO_COLOR = 'true';
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
expect(lastFrame()).toContain(
'Theme configuration unavailable due to NO_COLOR env variable.',
);
expect(lastFrame()).not.toContain('Select Theme');
});
});
describe('with initial prompt from --prompt-interactive', () => {
it('should submit the initial prompt automatically', async () => {
const mockSubmitQuery = vi.fn();
mockConfig.getQuestion = vi.fn(() => 'hello from prompt-interactive');
vi.mocked(useGeminiStream).mockReturnValue({
streamingState: StreamingState.Idle,
submitQuery: mockSubmitQuery,
initError: null,
pendingHistoryItems: [],
thought: null,
});
mockConfig.getGeminiClient.mockReturnValue({
isInitialized: vi.fn(() => true),
} as unknown as GeminiClient);
const { unmount, rerender } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
// Force a re-render to trigger useEffect
rerender(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mockSubmitQuery).toHaveBeenCalledWith(
'hello from prompt-interactive',
);
});
});
});

989
packages/cli/src/ui/App.tsx Normal file
View File

@@ -0,0 +1,989 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import {
Box,
DOMElement,
measureElement,
Static,
Text,
useStdin,
useStdout,
useInput,
type Key as InkKeyType,
} from 'ink';
import { StreamingState, type HistoryItem, MessageType } from './types.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useAuthCommand } from './hooks/useAuthCommand.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
import { Header } from './components/Header.js';
import { LoadingIndicator } from './components/LoadingIndicator.js';
import { AutoAcceptIndicator } from './components/AutoAcceptIndicator.js';
import { ShellModeIndicator } from './components/ShellModeIndicator.js';
import { InputPrompt } from './components/InputPrompt.js';
import { Footer } from './components/Footer.js';
import { ThemeDialog } from './components/ThemeDialog.js';
import { AuthDialog } from './components/AuthDialog.js';
import { AuthInProgress } from './components/AuthInProgress.js';
import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
import { Colors } from './colors.js';
import { Help } from './components/Help.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js';
import { LoadedSettings } from '../config/settings.js';
import { Tips } from './components/Tips.js';
import { ConsolePatcher } from './utils/ConsolePatcher.js';
import { registerCleanup } from '../utils/cleanup.js';
import { DetailedMessagesDisplay } from './components/DetailedMessagesDisplay.js';
import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js';
import { useHistory } from './hooks/useHistoryManager.js';
import process from 'node:process';
import {
getErrorMessage,
type Config,
getAllGeminiMdFilenames,
ApprovalMode,
isEditorAvailable,
EditorType,
FlashFallbackEvent,
logFlashFallback,
} from '@qwen/qwen-code-core';
import { validateAuthMethod } from '../config/auth.js';
import { useLogger } from './hooks/useLogger.js';
import { StreamingContext } from './contexts/StreamingContext.js';
import {
SessionStatsProvider,
useSessionStats,
} from './contexts/SessionContext.js';
import { useGitBranchName } from './hooks/useGitBranchName.js';
import { useBracketedPaste } from './hooks/useBracketedPaste.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import * as fs from 'fs';
import { UpdateNotification } from './components/UpdateNotification.js';
import {
isProQuotaExceededError,
isGenericQuotaExceededError,
UserTierId,
} from '@qwen/qwen-code-core';
import { checkForUpdates } from './utils/updateCheck.js';
import ansiEscapes from 'ansi-escapes';
import { OverflowProvider } from './contexts/OverflowContext.js';
import { ShowMoreLines } from './components/ShowMoreLines.js';
import { PrivacyNotice } from './privacy/PrivacyNotice.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
interface AppProps {
config: Config;
settings: LoadedSettings;
startupWarnings?: string[];
version: string;
}
export const AppWrapper = (props: AppProps) => (
<SessionStatsProvider>
<App {...props} />
</SessionStatsProvider>
);
const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
useBracketedPaste();
const [updateMessage, setUpdateMessage] = useState<string | null>(null);
const { stdout } = useStdout();
const nightly = version.includes('nightly');
useEffect(() => {
checkForUpdates().then(setUpdateMessage);
}, []);
const { history, addItem, clearItems, loadHistory } = useHistory();
const {
consoleMessages,
handleNewMessage,
clearConsoleMessages: clearConsoleMessagesState,
} = useConsoleMessages();
useEffect(() => {
const consolePatcher = new ConsolePatcher({
onNewMessage: handleNewMessage,
debugMode: config.getDebugMode(),
});
consolePatcher.patch();
registerCleanup(consolePatcher.cleanup);
}, [handleNewMessage, config]);
const { stats: sessionStats } = useSessionStats();
const [staticNeedsRefresh, setStaticNeedsRefresh] = useState(false);
const [staticKey, setStaticKey] = useState(0);
const refreshStatic = useCallback(() => {
stdout.write(ansiEscapes.clearTerminal);
setStaticKey((prev) => prev + 1);
}, [setStaticKey, stdout]);
const [geminiMdFileCount, setGeminiMdFileCount] = useState<number>(0);
const [debugMessage, setDebugMessage] = useState<string>('');
const [showHelp, setShowHelp] = useState<boolean>(false);
const [themeError, setThemeError] = useState<string | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [editorError, setEditorError] = useState<string | null>(null);
const [footerHeight, setFooterHeight] = useState<number>(0);
const [corgiMode, setCorgiMode] = useState(false);
const [currentModel, setCurrentModel] = useState(config.getModel());
const [shellModeActive, setShellModeActive] = useState(false);
const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false);
const [showToolDescriptions, setShowToolDescriptions] =
useState<boolean>(false);
const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false);
const [quittingMessages, setQuittingMessages] = useState<
HistoryItem[] | null
>(null);
const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null);
const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false);
const ctrlDTimerRef = useRef<NodeJS.Timeout | null>(null);
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [showPrivacyNotice, setShowPrivacyNotice] = useState<boolean>(false);
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
useState<boolean>(false);
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
const openPrivacyNotice = useCallback(() => {
setShowPrivacyNotice(true);
}, []);
const initialPromptSubmitted = useRef(false);
const errorCount = useMemo(
() => consoleMessages.filter((msg) => msg.type === 'error').length,
[consoleMessages],
);
const {
isThemeDialogOpen,
openThemeDialog,
handleThemeSelect,
handleThemeHighlight,
} = useThemeCommand(settings, setThemeError, addItem);
const {
isAuthDialogOpen,
openAuthDialog,
handleAuthSelect,
isAuthenticating,
cancelAuthentication,
} = useAuthCommand(settings, setAuthError, config);
useEffect(() => {
if (settings.merged.selectedAuthType) {
const error = validateAuthMethod(settings.merged.selectedAuthType);
if (error) {
setAuthError(error);
openAuthDialog();
}
}
}, [settings.merged.selectedAuthType, openAuthDialog, setAuthError]);
// Sync user tier from config when authentication changes
useEffect(() => {
const syncUserTier = async () => {
try {
const configUserTier = await config.getUserTier();
if (configUserTier !== userTier) {
setUserTier(configUserTier);
}
} catch (error) {
// Silently fail - this is not critical functionality
// Only log in debug mode to avoid cluttering the console
if (config.getDebugMode()) {
console.debug('Failed to sync user tier:', error);
}
}
};
// Only sync when not currently authenticating
if (!isAuthenticating) {
syncUserTier();
}
}, [config, userTier, isAuthenticating]);
const {
isEditorDialogOpen,
openEditorDialog,
handleEditorSelect,
exitEditorDialog,
} = useEditorSettings(settings, setEditorError, addItem);
const toggleCorgiMode = useCallback(() => {
setCorgiMode((prev) => !prev);
}, []);
const performMemoryRefresh = useCallback(async () => {
addItem(
{
type: MessageType.INFO,
text: 'Refreshing hierarchical memory (QWEN.md or other context files)...',
},
Date.now(),
);
try {
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
process.cwd(),
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
);
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);
setGeminiMdFileCount(fileCount);
addItem(
{
type: MessageType.INFO,
text: `Memory refreshed successfully. ${memoryContent.length > 0 ? `Loaded ${memoryContent.length} characters from ${fileCount} file(s).` : 'No memory content found.'}`,
},
Date.now(),
);
if (config.getDebugMode()) {
console.log(
`[DEBUG] Refreshed memory content in config: ${memoryContent.substring(0, 200)}...`,
);
}
} catch (error) {
const errorMessage = getErrorMessage(error);
addItem(
{
type: MessageType.ERROR,
text: `Error refreshing memory: ${errorMessage}`,
},
Date.now(),
);
console.error('Error refreshing memory:', error);
}
}, [config, addItem]);
// Watch for model changes (e.g., from Flash fallback)
useEffect(() => {
const checkModelChange = () => {
const configModel = config.getModel();
if (configModel !== currentModel) {
setCurrentModel(configModel);
}
};
// Check immediately and then periodically
checkModelChange();
const interval = setInterval(checkModelChange, 1000); // Check every second
return () => clearInterval(interval);
}, [config, currentModel]);
// Set up Flash fallback handler
useEffect(() => {
const flashFallbackHandler = async (
currentModel: string,
fallbackModel: string,
error?: unknown,
): Promise<boolean> => {
let message: string;
// Use actual user tier if available, otherwise default to FREE tier behavior (safe default)
const isPaidTier =
userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
// Check if this is a Pro quota exceeded error
if (error && isProQuotaExceededError(error)) {
if (isPaidTier) {
message = `⚡ You have reached your daily ${currentModel} quota limit.
⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session.
⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
} else {
message = `⚡ You have reached your daily ${currentModel} quota limit.
⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session.
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
⚡ You can switch authentication methods by typing /auth`;
}
} else if (error && isGenericQuotaExceededError(error)) {
if (isPaidTier) {
message = `⚡ You have reached your daily quota limit.
⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session.
⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
} else {
message = `⚡ You have reached your daily quota limit.
⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session.
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
⚡ You can switch authentication methods by typing /auth`;
}
} else {
if (isPaidTier) {
// Default fallback message for other cases (like consecutive 429s)
message = `⚡ Automatically switching from ${currentModel} to ${fallbackModel} for faster responses for the remainder of this session.
⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${currentModel} quota limit
⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
} else {
// Default fallback message for other cases (like consecutive 429s)
message = `⚡ Automatically switching from ${currentModel} to ${fallbackModel} for faster responses for the remainder of this session.
⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${currentModel} quota limit
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
⚡ You can switch authentication methods by typing /auth`;
}
}
// Add message to UI history
addItem(
{
type: MessageType.INFO,
text: message,
},
Date.now(),
);
// Set the flag to prevent tool continuation
setModelSwitchedFromQuotaError(true);
// Set global quota error flag to prevent Flash model calls
config.setQuotaErrorOccurred(true);
// Switch model for future use but return false to stop current retry
config.setModel(fallbackModel);
logFlashFallback(
config,
new FlashFallbackEvent(config.getContentGeneratorConfig().authType!),
);
return false; // Don't continue with current prompt
};
config.setFlashFallbackHandler(flashFallbackHandler);
}, [config, addItem, userTier]);
const {
handleSlashCommand,
slashCommands,
pendingHistoryItems: pendingSlashCommandHistoryItems,
commandContext,
} = useSlashCommandProcessor(
config,
settings,
history,
addItem,
clearItems,
loadHistory,
refreshStatic,
setShowHelp,
setDebugMessage,
openThemeDialog,
openAuthDialog,
openEditorDialog,
toggleCorgiMode,
showToolDescriptions,
setQuittingMessages,
openPrivacyNotice,
);
const pendingHistoryItems = [...pendingSlashCommandHistoryItems];
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
const isInitialMount = useRef(true);
const { stdin, setRawMode } = useStdin();
const isValidPath = useCallback((filePath: string): boolean => {
try {
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
} catch (_e) {
return false;
}
}, []);
const widthFraction = 0.9;
const inputWidth = Math.max(
20,
Math.floor(terminalWidth * widthFraction) - 3,
);
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
const buffer = useTextBuffer({
initialText: '',
viewport: { height: 10, width: inputWidth },
stdin,
setRawMode,
isValidPath,
shellModeActive,
});
const handleExit = useCallback(
(
pressedOnce: boolean,
setPressedOnce: (value: boolean) => void,
timerRef: React.MutableRefObject<NodeJS.Timeout | null>,
) => {
if (pressedOnce) {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
const quitCommand = slashCommands.find(
(cmd) => cmd.name === 'quit' || cmd.altName === 'exit',
);
if (quitCommand && quitCommand.action) {
quitCommand.action(commandContext, '');
} else {
// This is unlikely to be needed but added for an additional fallback.
process.exit(0);
}
} else {
setPressedOnce(true);
timerRef.current = setTimeout(() => {
setPressedOnce(false);
timerRef.current = null;
}, CTRL_EXIT_PROMPT_DURATION_MS);
}
},
// Add commandContext to the dependency array here!
[slashCommands, commandContext],
);
useInput((input: string, key: InkKeyType) => {
let enteringConstrainHeightMode = false;
if (!constrainHeight) {
// Automatically re-enter constrain height mode if the user types
// anything. When constrainHeight==false, the user will experience
// significant flickering so it is best to disable it immediately when
// the user starts interacting with the app.
enteringConstrainHeightMode = true;
setConstrainHeight(true);
}
if (key.ctrl && input === 'o') {
setShowErrorDetails((prev) => !prev);
} else if (key.ctrl && input === 't') {
const newValue = !showToolDescriptions;
setShowToolDescriptions(newValue);
const mcpServers = config.getMcpServers();
if (Object.keys(mcpServers || {}).length > 0) {
handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
}
} else if (key.ctrl && (input === 'c' || input === 'C')) {
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
} else if (key.ctrl && (input === 'd' || input === 'D')) {
if (buffer.text.length > 0) {
// Do nothing if there is text in the input.
return;
}
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
} else if (key.ctrl && input === 's' && !enteringConstrainHeightMode) {
setConstrainHeight(false);
}
});
useEffect(() => {
if (config) {
setGeminiMdFileCount(config.getGeminiMdFileCount());
}
}, [config]);
const getPreferredEditor = useCallback(() => {
const editorType = settings.merged.preferredEditor;
const isValidEditor = isEditorAvailable(editorType);
if (!isValidEditor) {
openEditorDialog();
return;
}
return editorType as EditorType;
}, [settings, openEditorDialog]);
const onAuthError = useCallback(() => {
setAuthError('reauth required');
openAuthDialog();
}, [openAuthDialog, setAuthError]);
const {
streamingState,
submitQuery,
initError,
pendingHistoryItems: pendingGeminiHistoryItems,
thought,
} = useGeminiStream(
config.getGeminiClient(),
history,
addItem,
setShowHelp,
config,
setDebugMessage,
handleSlashCommand,
shellModeActive,
getPreferredEditor,
onAuthError,
performMemoryRefresh,
modelSwitchedFromQuotaError,
setModelSwitchedFromQuotaError,
);
pendingHistoryItems.push(...pendingGeminiHistoryItems);
const { elapsedTime, currentLoadingPhrase } =
useLoadingIndicator(streamingState);
const showAutoAcceptIndicator = useAutoAcceptIndicator({ config });
const handleFinalSubmit = useCallback(
(submittedValue: string) => {
const trimmedValue = submittedValue.trim();
if (trimmedValue.length > 0) {
submitQuery(trimmedValue);
}
},
[submitQuery],
);
const logger = useLogger();
const [userMessages, setUserMessages] = useState<string[]>([]);
useEffect(() => {
const fetchUserMessages = async () => {
const pastMessagesRaw = (await logger?.getPreviousUserMessages()) || []; // Newest first
const currentSessionUserMessages = history
.filter(
(item): item is HistoryItem & { type: 'user'; text: string } =>
item.type === 'user' &&
typeof item.text === 'string' &&
item.text.trim() !== '',
)
.map((item) => item.text)
.reverse(); // Newest first, to match pastMessagesRaw sorting
// Combine, with current session messages being more recent
const combinedMessages = [
...currentSessionUserMessages,
...pastMessagesRaw,
];
// Deduplicate consecutive identical messages from the combined list (still newest first)
const deduplicatedMessages: string[] = [];
if (combinedMessages.length > 0) {
deduplicatedMessages.push(combinedMessages[0]); // Add the newest one unconditionally
for (let i = 1; i < combinedMessages.length; i++) {
if (combinedMessages[i] !== combinedMessages[i - 1]) {
deduplicatedMessages.push(combinedMessages[i]);
}
}
}
// Reverse to oldest first for useInputHistory
setUserMessages(deduplicatedMessages.reverse());
};
fetchUserMessages();
}, [history, logger]);
const isInputActive = streamingState === StreamingState.Idle && !initError;
const handleClearScreen = useCallback(() => {
clearItems();
clearConsoleMessagesState();
console.clear();
refreshStatic();
}, [clearItems, clearConsoleMessagesState, refreshStatic]);
const mainControlsRef = useRef<DOMElement>(null);
const pendingHistoryItemRef = useRef<DOMElement>(null);
useEffect(() => {
if (mainControlsRef.current) {
const fullFooterMeasurement = measureElement(mainControlsRef.current);
setFooterHeight(fullFooterMeasurement.height);
}
}, [terminalHeight, consoleMessages, showErrorDetails]);
const staticExtraHeight = /* margins and padding */ 3;
const availableTerminalHeight = useMemo(
() => terminalHeight - footerHeight - staticExtraHeight,
[terminalHeight, footerHeight],
);
useEffect(() => {
// skip refreshing Static during first mount
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
// debounce so it doesn't fire up too often during resize
const handler = setTimeout(() => {
setStaticNeedsRefresh(false);
refreshStatic();
}, 300);
return () => {
clearTimeout(handler);
};
}, [terminalWidth, terminalHeight, refreshStatic]);
useEffect(() => {
if (streamingState === StreamingState.Idle && staticNeedsRefresh) {
setStaticNeedsRefresh(false);
refreshStatic();
}
}, [streamingState, refreshStatic, staticNeedsRefresh]);
const filteredConsoleMessages = useMemo(() => {
if (config.getDebugMode()) {
return consoleMessages;
}
return consoleMessages.filter((msg) => msg.type !== 'debug');
}, [consoleMessages, config]);
const branchName = useGitBranchName(config.getTargetDir());
const contextFileNames = useMemo(() => {
const fromSettings = settings.merged.contextFileName;
if (fromSettings) {
return Array.isArray(fromSettings) ? fromSettings : [fromSettings];
}
return getAllGeminiMdFilenames();
}, [settings.merged.contextFileName]);
const initialPrompt = useMemo(() => config.getQuestion(), [config]);
const geminiClient = config.getGeminiClient();
useEffect(() => {
if (
initialPrompt &&
!initialPromptSubmitted.current &&
!isAuthenticating &&
!isAuthDialogOpen &&
!isThemeDialogOpen &&
!isEditorDialogOpen &&
!showPrivacyNotice &&
geminiClient?.isInitialized?.()
) {
submitQuery(initialPrompt);
initialPromptSubmitted.current = true;
}
}, [
initialPrompt,
submitQuery,
isAuthenticating,
isAuthDialogOpen,
isThemeDialogOpen,
isEditorDialogOpen,
showPrivacyNotice,
geminiClient,
]);
if (quittingMessages) {
return (
<Box flexDirection="column" marginBottom={1}>
{quittingMessages.map((item) => (
<HistoryItemDisplay
key={item.id}
availableTerminalHeight={
constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={terminalWidth}
item={item}
isPending={false}
config={config}
/>
))}
</Box>
);
}
const mainAreaWidth = Math.floor(terminalWidth * 0.9);
const debugConsoleMaxHeight = Math.floor(Math.max(terminalHeight * 0.2, 5));
// Arbitrary threshold to ensure that items in the static area are large
// enough but not too large to make the terminal hard to use.
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
return (
<StreamingContext.Provider value={streamingState}>
<Box flexDirection="column" marginBottom={1} width="90%">
{/* Move UpdateNotification outside Static so it can re-render when updateMessage changes */}
{updateMessage && <UpdateNotification message={updateMessage} />}
{/*
* The Static component is an Ink intrinsic in which there can only be 1 per application.
* Because of this restriction we're hacking it slightly by having a 'header' item here to
* ensure that it's statically rendered.
*
* Background on the Static Item: Anything in the Static component is written a single time
* to the console. Think of it like doing a console.log and then never using ANSI codes to
* clear that content ever again. Effectively it has a moving frame that every time new static
* content is set it'll flush content to the terminal and move the area which it's "clearing"
* down a notch. Without Static the area which gets erased and redrawn continuously grows.
*/}
<Static
key={staticKey}
items={[
<Box flexDirection="column" key="header">
{!settings.merged.hideBanner && (
<Header
terminalWidth={terminalWidth}
version={version}
nightly={nightly}
/>
)}
{!settings.merged.hideTips && <Tips config={config} />}
</Box>,
...history.map((h) => (
<HistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
key={h.id}
item={h}
isPending={false}
config={config}
/>
)),
]}
>
{(item) => item}
</Static>
<OverflowProvider>
<Box ref={pendingHistoryItemRef} flexDirection="column">
{pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={mainAreaWidth}
// TODO(taehykim): It seems like references to ids aren't necessary in
// HistoryItemDisplay. Refactor later. Use a fake id for now.
item={{ ...item, id: 0 }}
isPending={true}
config={config}
isFocused={!isEditorDialogOpen}
/>
))}
<ShowMoreLines constrainHeight={constrainHeight} />
</Box>
</OverflowProvider>
{showHelp && <Help commands={slashCommands} />}
<Box flexDirection="column" ref={mainControlsRef}>
{startupWarnings.length > 0 && (
<Box
borderStyle="round"
borderColor={Colors.AccentYellow}
paddingX={1}
marginY={1}
flexDirection="column"
>
{startupWarnings.map((warning, index) => (
<Text key={index} color={Colors.AccentYellow}>
{warning}
</Text>
))}
</Box>
)}
{isThemeDialogOpen ? (
<Box flexDirection="column">
{themeError && (
<Box marginBottom={1}>
<Text color={Colors.AccentRed}>{themeError}</Text>
</Box>
)}
<ThemeDialog
onSelect={handleThemeSelect}
onHighlight={handleThemeHighlight}
settings={settings}
availableTerminalHeight={
constrainHeight
? terminalHeight - staticExtraHeight
: undefined
}
terminalWidth={mainAreaWidth}
/>
</Box>
) : isAuthenticating ? (
<>
<AuthInProgress
onTimeout={() => {
setAuthError('Authentication timed out. Please try again.');
cancelAuthentication();
openAuthDialog();
}}
/>
{showErrorDetails && (
<OverflowProvider>
<Box flexDirection="column">
<DetailedMessagesDisplay
messages={filteredConsoleMessages}
maxHeight={
constrainHeight ? debugConsoleMaxHeight : undefined
}
width={inputWidth}
/>
<ShowMoreLines constrainHeight={constrainHeight} />
</Box>
</OverflowProvider>
)}
</>
) : isAuthDialogOpen ? (
<Box flexDirection="column">
<AuthDialog
onSelect={handleAuthSelect}
settings={settings}
initialErrorMessage={authError}
/>
</Box>
) : isEditorDialogOpen ? (
<Box flexDirection="column">
{editorError && (
<Box marginBottom={1}>
<Text color={Colors.AccentRed}>{editorError}</Text>
</Box>
)}
<EditorSettingsDialog
onSelect={handleEditorSelect}
settings={settings}
onExit={exitEditorDialog}
/>
</Box>
) : showPrivacyNotice ? (
<PrivacyNotice
onExit={() => setShowPrivacyNotice(false)}
config={config}
/>
) : (
<>
<LoadingIndicator
thought={
streamingState === StreamingState.WaitingForConfirmation ||
config.getAccessibility()?.disableLoadingPhrases
? undefined
: thought
}
currentLoadingPhrase={
config.getAccessibility()?.disableLoadingPhrases
? undefined
: currentLoadingPhrase
}
elapsedTime={elapsedTime}
/>
<Box
marginTop={1}
display="flex"
justifyContent="space-between"
width="100%"
>
<Box>
{process.env.GEMINI_SYSTEM_MD && (
<Text color={Colors.AccentRed}>|_| </Text>
)}
{ctrlCPressedOnce ? (
<Text color={Colors.AccentYellow}>
Press Ctrl+C again to exit.
</Text>
) : ctrlDPressedOnce ? (
<Text color={Colors.AccentYellow}>
Press Ctrl+D again to exit.
</Text>
) : (
<ContextSummaryDisplay
geminiMdFileCount={geminiMdFileCount}
contextFileNames={contextFileNames}
mcpServers={config.getMcpServers()}
showToolDescriptions={showToolDescriptions}
/>
)}
</Box>
<Box>
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
!shellModeActive && (
<AutoAcceptIndicator
approvalMode={showAutoAcceptIndicator}
/>
)}
{shellModeActive && <ShellModeIndicator />}
</Box>
</Box>
{showErrorDetails && (
<OverflowProvider>
<Box flexDirection="column">
<DetailedMessagesDisplay
messages={filteredConsoleMessages}
maxHeight={
constrainHeight ? debugConsoleMaxHeight : undefined
}
width={inputWidth}
/>
<ShowMoreLines constrainHeight={constrainHeight} />
</Box>
</OverflowProvider>
)}
{isInputActive && (
<InputPrompt
buffer={buffer}
inputWidth={inputWidth}
suggestionsWidth={suggestionsWidth}
onSubmit={handleFinalSubmit}
userMessages={userMessages}
onClearScreen={handleClearScreen}
config={config}
slashCommands={slashCommands}
commandContext={commandContext}
shellModeActive={shellModeActive}
setShellModeActive={setShellModeActive}
/>
)}
</>
)}
{initError && streamingState !== StreamingState.Responding && (
<Box
borderStyle="round"
borderColor={Colors.AccentRed}
paddingX={1}
marginBottom={1}
>
{history.find(
(item) =>
item.type === 'error' && item.text?.includes(initError),
)?.text ? (
<Text color={Colors.AccentRed}>
{
history.find(
(item) =>
item.type === 'error' && item.text?.includes(initError),
)?.text
}
</Text>
) : (
<>
<Text color={Colors.AccentRed}>
Initialization Error: {initError}
</Text>
<Text color={Colors.AccentRed}>
{' '}
Please check API key and configuration.
</Text>
</>
)}
</Box>
)}
<Footer
model={currentModel}
targetDir={config.getTargetDir()}
debugMode={config.getDebugMode()}
branchName={branchName}
debugMessage={debugMessage}
corgiMode={corgiMode}
errorCount={errorCount}
showErrorDetails={showErrorDetails}
showMemoryUsage={
config.getDebugMode() || config.getShowMemoryUsage()
}
promptTokenCount={sessionStats.lastPromptTokenCount}
nightly={nightly}
/>
</Box>
</Box>
</StreamingContext.Provider>
);
};

View File

@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { themeManager } from './themes/theme-manager.js';
import { ColorsTheme } from './themes/theme.js';
export const Colors: ColorsTheme = {
get type() {
return themeManager.getActiveTheme().colors.type;
},
get Foreground() {
return themeManager.getActiveTheme().colors.Foreground;
},
get Background() {
return themeManager.getActiveTheme().colors.Background;
},
get LightBlue() {
return themeManager.getActiveTheme().colors.LightBlue;
},
get AccentBlue() {
return themeManager.getActiveTheme().colors.AccentBlue;
},
get AccentPurple() {
return themeManager.getActiveTheme().colors.AccentPurple;
},
get AccentCyan() {
return themeManager.getActiveTheme().colors.AccentCyan;
},
get AccentGreen() {
return themeManager.getActiveTheme().colors.AccentGreen;
},
get AccentYellow() {
return themeManager.getActiveTheme().colors.AccentYellow;
},
get AccentRed() {
return themeManager.getActiveTheme().colors.AccentRed;
},
get Comment() {
return themeManager.getActiveTheme().colors.Comment;
},
get Gray() {
return themeManager.getActiveTheme().colors.Gray;
},
get GradientColors() {
return themeManager.getActiveTheme().colors.GradientColors;
},
};

View File

@@ -0,0 +1,117 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { aboutCommand } from './aboutCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import * as versionUtils from '../../utils/version.js';
import { MessageType } from '../types.js';
vi.mock('../../utils/version.js', () => ({
getCliVersion: vi.fn(),
}));
describe('aboutCommand', () => {
let mockContext: CommandContext;
const originalPlatform = process.platform;
const originalEnv = { ...process.env };
beforeEach(() => {
mockContext = createMockCommandContext({
services: {
config: {
getModel: vi.fn(),
},
settings: {
merged: {
selectedAuthType: 'test-auth',
},
},
},
ui: {
addItem: vi.fn(),
},
} as unknown as CommandContext);
vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version');
vi.spyOn(mockContext.services.config!, 'getModel').mockReturnValue(
'test-model',
);
process.env.GOOGLE_CLOUD_PROJECT = 'test-gcp-project';
Object.defineProperty(process, 'platform', {
value: 'test-os',
});
});
afterEach(() => {
vi.unstubAllEnvs();
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
process.env = originalEnv;
vi.clearAllMocks();
});
it('should have the correct name and description', () => {
expect(aboutCommand.name).toBe('about');
expect(aboutCommand.description).toBe('show version info');
});
it('should call addItem with all version info', async () => {
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
await aboutCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ABOUT,
cliVersion: 'test-version',
osVersion: 'test-os',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
gcpProject: 'test-gcp-project',
},
expect.any(Number),
);
});
it('should show the correct sandbox environment variable', async () => {
process.env.SANDBOX = 'gemini-sandbox';
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
await aboutCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
sandboxEnv: 'gemini-sandbox',
}),
expect.any(Number),
);
});
it('should show sandbox-exec profile when applicable', async () => {
process.env.SANDBOX = 'sandbox-exec';
process.env.SEATBELT_PROFILE = 'test-profile';
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
await aboutCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
sandboxEnv: 'sandbox-exec (test-profile)',
}),
expect.any(Number),
);
});
});

View File

@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { getCliVersion } from '../../utils/version.js';
import { SlashCommand } from './types.js';
import process from 'node:process';
import { MessageType, type HistoryItemAbout } from '../types.js';
export const aboutCommand: SlashCommand = {
name: 'about',
description: 'show version info',
action: async (context) => {
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.services.config?.getModel() || 'Unknown';
const cliVersion = await getCliVersion();
const selectedAuthType =
context.services.settings.merged.selectedAuthType || '';
const gcpProject = process.env.GOOGLE_CLOUD_PROJECT || '';
const aboutItem: Omit<HistoryItemAbout, 'id'> = {
type: MessageType.ABOUT,
cliVersion,
osVersion,
sandboxEnv,
modelVersion,
selectedAuthType,
gcpProject,
};
context.ui.addItem(aboutItem, Date.now());
},
};

View File

@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { authCommand } from './authCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('authCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should return a dialog action to open the auth dialog', () => {
if (!authCommand.action) {
throw new Error('The auth command must have an action.');
}
const result = authCommand.action(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'auth',
});
});
it('should have the correct name and description', () => {
expect(authCommand.name).toBe('auth');
expect(authCommand.description).toBe('change the auth method');
});
});

View File

@@ -0,0 +1,16 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { OpenDialogActionReturn, SlashCommand } from './types.js';
export const authCommand: SlashCommand = {
name: 'auth',
description: 'change the auth method',
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'auth',
}),
};

View File

@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
import { clearCommand } from './clearCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { GeminiClient } from '@google/gemini-cli-core';
describe('clearCommand', () => {
let mockContext: CommandContext;
let mockResetChat: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockResetChat = vi.fn().mockResolvedValue(undefined);
mockContext = createMockCommandContext({
services: {
config: {
getGeminiClient: () =>
({
resetChat: mockResetChat,
}) as unknown as GeminiClient,
},
},
});
});
it('should set debug message, reset chat, and clear UI when config is available', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');
}
await clearCommand.action(mockContext, '');
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledWith(
'Clearing terminal and resetting chat.',
);
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1);
expect(mockResetChat).toHaveBeenCalledTimes(1);
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
// Check the order of operations.
const setDebugMessageOrder = (mockContext.ui.setDebugMessage as Mock).mock
.invocationCallOrder[0];
const resetChatOrder = mockResetChat.mock.invocationCallOrder[0];
const clearOrder = (mockContext.ui.clear as Mock).mock
.invocationCallOrder[0];
expect(setDebugMessageOrder).toBeLessThan(resetChatOrder);
expect(resetChatOrder).toBeLessThan(clearOrder);
});
it('should not attempt to reset chat if config service is not available', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');
}
const nullConfigContext = createMockCommandContext({
services: {
config: null,
},
});
await clearCommand.action(nullConfigContext, '');
expect(nullConfigContext.ui.setDebugMessage).toHaveBeenCalledWith(
'Clearing terminal and resetting chat.',
);
expect(mockResetChat).not.toHaveBeenCalled();
expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,17 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { SlashCommand } from './types.js';
export const clearCommand: SlashCommand = {
name: 'clear',
description: 'clear the screen and conversation history',
action: async (context, _args) => {
context.ui.setDebugMessage('Clearing terminal and resetting chat.');
await context.services.config?.getGeminiClient()?.resetChat();
context.ui.clear();
},
};

View File

@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { helpCommand } from './helpCommand.js';
import { type CommandContext } from './types.js';
describe('helpCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = {} as unknown as CommandContext;
});
it("should return a dialog action and log a debug message for '/help'", () => {
const consoleDebugSpy = vi
.spyOn(console, 'debug')
.mockImplementation(() => {});
if (!helpCommand.action) {
throw new Error('Help command has no action');
}
const result = helpCommand.action(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'help',
});
expect(consoleDebugSpy).toHaveBeenCalledWith('Opening help UI ...');
});
it("should also be triggered by its alternative name '?'", () => {
// This test is more conceptual. The routing of altName to the command
// is handled by the slash command processor, but we can assert the
// altName is correctly defined on the command object itself.
expect(helpCommand.altName).toBe('?');
});
});

View File

@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { OpenDialogActionReturn, SlashCommand } from './types.js';
export const helpCommand: SlashCommand = {
name: 'help',
altName: '?',
description: 'for help on qwen code',
action: (_context, _args): OpenDialogActionReturn => {
console.debug('Opening help UI ...');
return {
type: 'dialog',
dialog: 'help',
};
},
};

View File

@@ -0,0 +1,248 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
import { memoryCommand } from './memoryCommand.js';
import { type CommandContext, SlashCommand } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import { getErrorMessage } from '@qwen/qwen-code-core';
vi.mock('@qwen/qwen-code-core', async (importOriginal) => {
const original = await importOriginal<typeof import('@qwen/qwen-code-core')>();
return {
...original,
getErrorMessage: vi.fn((error: unknown) => {
if (error instanceof Error) return error.message;
return String(error);
}),
};
});
describe('memoryCommand', () => {
let mockContext: CommandContext;
const getSubCommand = (name: 'show' | 'add' | 'refresh'): SlashCommand => {
const subCommand = memoryCommand.subCommands?.find(
(cmd) => cmd.name === name,
);
if (!subCommand) {
throw new Error(`/memory ${name} command not found.`);
}
return subCommand;
};
describe('/memory show', () => {
let showCommand: SlashCommand;
let mockGetUserMemory: Mock;
let mockGetGeminiMdFileCount: Mock;
beforeEach(() => {
showCommand = getSubCommand('show');
mockGetUserMemory = vi.fn();
mockGetGeminiMdFileCount = vi.fn();
mockContext = createMockCommandContext({
services: {
config: {
getUserMemory: mockGetUserMemory,
getGeminiMdFileCount: mockGetGeminiMdFileCount,
},
},
});
});
it('should display a message if memory is empty', async () => {
if (!showCommand.action) throw new Error('Command has no action');
mockGetUserMemory.mockReturnValue('');
mockGetGeminiMdFileCount.mockReturnValue(0);
await showCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Memory is currently empty.',
},
expect.any(Number),
);
});
it('should display the memory content and file count if it exists', async () => {
if (!showCommand.action) throw new Error('Command has no action');
const memoryContent = 'This is a test memory.';
mockGetUserMemory.mockReturnValue(memoryContent);
mockGetGeminiMdFileCount.mockReturnValue(1);
await showCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Current memory content from 1 file(s):\n\n---\n${memoryContent}\n---`,
},
expect.any(Number),
);
});
});
describe('/memory add', () => {
let addCommand: SlashCommand;
beforeEach(() => {
addCommand = getSubCommand('add');
mockContext = createMockCommandContext();
});
it('should return an error message if no arguments are provided', () => {
if (!addCommand.action) throw new Error('Command has no action');
const result = addCommand.action(mockContext, ' ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Usage: /memory add <text to remember>',
});
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
});
it('should return a tool action and add an info message when arguments are provided', () => {
if (!addCommand.action) throw new Error('Command has no action');
const fact = 'remember this';
const result = addCommand.action(mockContext, ` ${fact} `);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Attempting to save to memory: "${fact}"`,
},
expect.any(Number),
);
expect(result).toEqual({
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact },
});
});
});
describe('/memory refresh', () => {
let refreshCommand: SlashCommand;
let mockRefreshMemory: Mock;
beforeEach(() => {
refreshCommand = getSubCommand('refresh');
mockRefreshMemory = vi.fn();
mockContext = createMockCommandContext({
services: {
config: {
refreshMemory: mockRefreshMemory,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
},
});
});
it('should display success message when memory is refreshed with content', async () => {
if (!refreshCommand.action) throw new Error('Command has no action');
const refreshResult = {
memoryContent: 'new memory content',
fileCount: 2,
};
mockRefreshMemory.mockResolvedValue(refreshResult);
await refreshCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Refreshing memory from source files...',
},
expect.any(Number),
);
expect(mockRefreshMemory).toHaveBeenCalledOnce();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).',
},
expect.any(Number),
);
});
it('should display success message when memory is refreshed with no content', async () => {
if (!refreshCommand.action) throw new Error('Command has no action');
const refreshResult = { memoryContent: '', fileCount: 0 };
mockRefreshMemory.mockResolvedValue(refreshResult);
await refreshCommand.action(mockContext, '');
expect(mockRefreshMemory).toHaveBeenCalledOnce();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Memory refreshed successfully. No memory content found.',
},
expect.any(Number),
);
});
it('should display an error message if refreshing fails', async () => {
if (!refreshCommand.action) throw new Error('Command has no action');
const error = new Error('Failed to read memory files.');
mockRefreshMemory.mockRejectedValue(error);
await refreshCommand.action(mockContext, '');
expect(mockRefreshMemory).toHaveBeenCalledOnce();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: `Error refreshing memory: ${error.message}`,
},
expect.any(Number),
);
expect(getErrorMessage).toHaveBeenCalledWith(error);
});
it('should not throw if config service is unavailable', async () => {
if (!refreshCommand.action) throw new Error('Command has no action');
const nullConfigContext = createMockCommandContext({
services: { config: null },
});
await expect(
refreshCommand.action(nullConfigContext, ''),
).resolves.toBeUndefined();
expect(nullConfigContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Refreshing memory from source files...',
},
expect.any(Number),
);
expect(mockRefreshMemory).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { getErrorMessage } from '@qwen/qwen-code-core';
import { MessageType } from '../types.js';
import { SlashCommand, SlashCommandActionReturn } from './types.js';
export const memoryCommand: SlashCommand = {
name: 'memory',
description: 'Commands for interacting with memory.',
subCommands: [
{
name: 'show',
description: 'Show the current memory contents.',
action: async (context) => {
const memoryContent = context.services.config?.getUserMemory() || '';
const fileCount = context.services.config?.getGeminiMdFileCount() || 0;
const messageContent =
memoryContent.length > 0
? `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---`
: 'Memory is currently empty.';
context.ui.addItem(
{
type: MessageType.INFO,
text: messageContent,
},
Date.now(),
);
},
},
{
name: 'add',
description: 'Add content to the memory.',
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: 'Usage: /memory add <text to remember>',
};
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Attempting to save to memory: "${args.trim()}"`,
},
Date.now(),
);
return {
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact: args.trim() },
};
},
},
{
name: 'refresh',
description: 'Refresh the memory from the source.',
action: async (context) => {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'Refreshing memory from source files...',
},
Date.now(),
);
try {
const result = await context.services.config?.refreshMemory();
if (result) {
const { memoryContent, fileCount } = result;
const successMessage =
memoryContent.length > 0
? `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`
: 'Memory refreshed successfully. No memory content found.';
context.ui.addItem(
{
type: MessageType.INFO,
text: successMessage,
},
Date.now(),
);
}
} catch (error) {
const errorMessage = getErrorMessage(error);
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Error refreshing memory: ${errorMessage}`,
},
Date.now(),
);
}
},
},
],
};

View File

@@ -0,0 +1,38 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { privacyCommand } from './privacyCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('privacyCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should return a dialog action to open the privacy dialog', () => {
// Ensure the command has an action to test.
if (!privacyCommand.action) {
throw new Error('The privacy command must have an action.');
}
const result = privacyCommand.action(mockContext, '');
// Assert that the action returns the correct object to trigger the privacy dialog.
expect(result).toEqual({
type: 'dialog',
dialog: 'privacy',
});
});
it('should have the correct name and description', () => {
expect(privacyCommand.name).toBe('privacy');
expect(privacyCommand.description).toBe('display the privacy notice');
});
});

View File

@@ -0,0 +1,16 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { OpenDialogActionReturn, SlashCommand } from './types.js';
export const privacyCommand: SlashCommand = {
name: 'privacy',
description: 'display the privacy notice',
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'privacy',
}),
};

View File

@@ -0,0 +1,38 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { themeCommand } from './themeCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('themeCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should return a dialog action to open the theme dialog', () => {
// Ensure the command has an action to test.
if (!themeCommand.action) {
throw new Error('The theme command must have an action.');
}
const result = themeCommand.action(mockContext, '');
// Assert that the action returns the correct object to trigger the theme dialog.
expect(result).toEqual({
type: 'dialog',
dialog: 'theme',
});
});
it('should have the correct name and description', () => {
expect(themeCommand.name).toBe('theme');
expect(themeCommand.description).toBe('change the theme');
});
});

View File

@@ -0,0 +1,16 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { OpenDialogActionReturn, SlashCommand } from './types.js';
export const themeCommand: SlashCommand = {
name: 'theme',
description: 'change the theme',
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'theme',
}),
};

View File

@@ -0,0 +1,98 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Config, GitService, Logger } from '@qwen/qwen-code-core';
import { LoadedSettings } from '../../config/settings.js';
import { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { SessionStatsState } from '../contexts/SessionContext.js';
// Grouped dependencies for clarity and easier mocking
export interface CommandContext {
// Core services and configuration
services: {
// TODO(abhipatel12): Ensure that config is never null.
config: Config | null;
settings: LoadedSettings;
git: GitService | undefined;
logger: Logger;
};
// UI state and history management
ui: {
// TODO - As more commands are add some additions may be needed or reworked using this new context.
// Ex.
// history: HistoryItem[];
// pendingHistoryItems: HistoryItemWithoutId[];
/** Adds a new item to the history display. */
addItem: UseHistoryManagerReturn['addItem'];
/** Clears all history items and the console screen. */
clear: () => void;
/**
* Sets the transient debug message displayed in the application footer in debug mode.
*/
setDebugMessage: (message: string) => void;
};
// Session-specific data
session: {
stats: SessionStatsState;
};
}
/**
* The return type for a command action that results in scheduling a tool call.
*/
export interface ToolActionReturn {
type: 'tool';
toolName: string;
toolArgs: Record<string, unknown>;
}
/**
* The return type for a command action that results in a simple message
* being displayed to the user.
*/
export interface MessageActionReturn {
type: 'message';
messageType: 'info' | 'error';
content: string;
}
/**
* The return type for a command action that needs to open a dialog.
*/
export interface OpenDialogActionReturn {
type: 'dialog';
// TODO: Add 'theme' | 'auth' | 'editor' | 'privacy' as migration happens.
dialog: 'help' | 'auth' | 'theme' | 'privacy';
}
export type SlashCommandActionReturn =
| ToolActionReturn
| MessageActionReturn
| OpenDialogActionReturn;
// The standardized contract for any command in the system.
export interface SlashCommand {
name: string;
altName?: string;
description?: string;
// The action to run. Optional for parent commands that only group sub-commands.
action?: (
context: CommandContext,
args: string,
) =>
| void
| SlashCommandActionReturn
| Promise<void | SlashCommandActionReturn>;
// Provides argument completion (e.g., completing a tag for `/chat resume <tag>`).
completion?: (
context: CommandContext,
partialArg: string,
) => Promise<string[]>;
subCommands?: SlashCommand[];
}

View File

@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
interface AboutBoxProps {
cliVersion: string;
osVersion: string;
sandboxEnv: string;
modelVersion: string;
selectedAuthType: string;
gcpProject: string;
}
export const AboutBox: React.FC<AboutBoxProps> = ({
cliVersion,
osVersion,
sandboxEnv,
modelVersion,
selectedAuthType,
gcpProject,
}) => (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
padding={1}
marginY={1}
width="100%"
>
<Box marginBottom={1}>
<Text bold color={Colors.AccentPurple}>
About Gemini CLI
</Text>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
CLI Version
</Text>
</Box>
<Box>
<Text>{cliVersion}</Text>
</Box>
</Box>
{GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
Git Commit
</Text>
</Box>
<Box>
<Text>{GIT_COMMIT_INFO}</Text>
</Box>
</Box>
)}
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
Model
</Text>
</Box>
<Box>
<Text>{modelVersion}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
Sandbox
</Text>
</Box>
<Box>
<Text>{sandboxEnv}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
OS
</Text>
</Box>
<Box>
<Text>{osVersion}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
Auth Method
</Text>
</Box>
<Box>
<Text>
{selectedAuthType.startsWith('oauth') ? 'OAuth' : selectedAuthType}
</Text>
</Box>
</Box>
{gcpProject && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
GCP Project
</Text>
</Box>
<Box>
<Text>{gcpProject}</Text>
</Box>
</Box>
)}
</Box>
);

View File

@@ -0,0 +1,22 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const shortAsciiLogo = `
██████╗ ██╗ ██╗███████╗███╗ ██╗
██╔═══██╗██║ ██║██╔════╝████╗ ██║
██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║
██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║
╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
`;
export const longAsciiLogo = `
██╗ ██████╗ ██╗ ██╗███████╗███╗ ██╗
╚██╗ ██╔═══██╗██║ ██║██╔════╝████╗ ██║
╚██╗ ██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║
██╔╝ ██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║
██╔╝ ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
╚═╝ ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
`;

View File

@@ -0,0 +1,325 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthDialog } from './AuthDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { AuthType } from '@qwen/qwen-code-core';
describe('AuthDialog', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalEnv = { ...process.env };
process.env.GEMINI_API_KEY = '';
process.env.GEMINI_DEFAULT_AUTH_TYPE = '';
vi.clearAllMocks();
});
afterEach(() => {
process.env = originalEnv;
});
it('should show an error if the initial auth type is invalid', () => {
process.env.GEMINI_API_KEY = '';
const settings: LoadedSettings = new LoadedSettings(
{
settings: {},
path: '',
},
{
settings: {
selectedAuthType: AuthType.USE_GEMINI,
},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { lastFrame } = render(
<AuthDialog
onSelect={() => {}}
settings={settings}
initialErrorMessage="GEMINI_API_KEY environment variable not found"
/>,
);
expect(lastFrame()).toContain(
'GEMINI_API_KEY environment variable not found',
);
});
describe('GEMINI_API_KEY environment variable', () => {
it('should detect GEMINI_API_KEY environment variable', () => {
process.env.GEMINI_API_KEY = 'foobar';
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { lastFrame } = render(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog only shows OpenAI option now,
// it won't show GEMINI_API_KEY messages
expect(lastFrame()).toContain('OpenAI');
});
it('should not show the GEMINI_API_KEY message if GEMINI_DEFAULT_AUTH_TYPE is set to something else', () => {
process.env.GEMINI_API_KEY = 'foobar';
process.env.GEMINI_DEFAULT_AUTH_TYPE = AuthType.LOGIN_WITH_GOOGLE;
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { lastFrame } = render(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
expect(lastFrame()).not.toContain(
'Existing API key detected (GEMINI_API_KEY)',
);
});
it('should show the GEMINI_API_KEY message if GEMINI_DEFAULT_AUTH_TYPE is set to use api key', () => {
process.env.GEMINI_API_KEY = 'foobar';
process.env.GEMINI_DEFAULT_AUTH_TYPE = AuthType.USE_GEMINI;
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { lastFrame } = render(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog only shows OpenAI option now,
// it won't show GEMINI_API_KEY messages
expect(lastFrame()).toContain('OpenAI');
});
});
describe('GEMINI_DEFAULT_AUTH_TYPE environment variable', () => {
it('should select the auth type specified by GEMINI_DEFAULT_AUTH_TYPE', () => {
process.env.GEMINI_DEFAULT_AUTH_TYPE = AuthType.LOGIN_WITH_GOOGLE;
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { lastFrame } = render(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since only OpenAI is available, it should be selected by default
expect(lastFrame()).toContain('○ OpenAI');
});
it('should fall back to default if GEMINI_DEFAULT_AUTH_TYPE is not set', () => {
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { lastFrame } = render(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Default is OpenAI (the only option)
expect(lastFrame()).toContain('○ OpenAI');
});
it('should show an error and fall back to default if GEMINI_DEFAULT_AUTH_TYPE is invalid', () => {
process.env.GEMINI_DEFAULT_AUTH_TYPE = 'invalid-auth-type';
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { lastFrame } = render(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog doesn't show GEMINI_DEFAULT_AUTH_TYPE errors anymore,
// it will just show the default OpenAI option
expect(lastFrame()).toContain('○ OpenAI');
});
});
// it('should prevent exiting when no auth method is selected and show error message', async () => {
// const onSelect = vi.fn();
// const settings: LoadedSettings = new LoadedSettings(
// {
// settings: {},
// path: '',
// },
// {
// settings: {
// selectedAuthType: undefined,
// },
// path: '',
// },
// {
// settings: {},
// path: '',
// },
// [],
// );
// const { lastFrame, stdin, unmount } = render(
// <AuthDialog onSelect={onSelect} settings={settings} />,
// );
// await wait();
// // Simulate pressing escape key
// stdin.write('\u001b'); // ESC key
// await wait(100); // Increased wait time for CI environment
// // Should show error message instead of calling onSelect
// expect(lastFrame()).toContain(
// 'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
// );
// expect(onSelect).not.toHaveBeenCalled();
// unmount();
// });
it('should not exit if there is already an error message', async () => {
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: {},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { lastFrame, stdin, unmount } = render(
<AuthDialog
onSelect={onSelect}
settings={settings}
initialErrorMessage="Initial error"
/>,
);
await wait();
expect(lastFrame()).toContain('Initial error');
// Simulate pressing escape key
stdin.write('\u001b'); // ESC key
await wait();
// Should not call onSelect
expect(onSelect).not.toHaveBeenCalled();
unmount();
});
it('should allow exiting when auth method is already selected', async () => {
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: {},
path: '',
},
{
settings: {
selectedAuthType: AuthType.USE_GEMINI,
},
path: '',
},
{
settings: {},
path: '',
},
[],
);
const { stdin, unmount } = render(
<AuthDialog onSelect={onSelect} settings={settings} />,
);
await wait();
// Simulate pressing escape key
stdin.write('\u001b'); // ESC key
await wait();
// Should call onSelect with undefined to exit
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
unmount();
});
});

View File

@@ -0,0 +1,171 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { AuthType } from '@qwen/qwen-code-core';
import {
validateAuthMethod,
setOpenAIApiKey,
setOpenAIBaseUrl,
setOpenAIModel,
} from '../../config/auth.js';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
interface AuthDialogProps {
onSelect: (authMethod: AuthType | undefined, scope: SettingScope) => void;
settings: LoadedSettings;
initialErrorMessage?: string | null;
}
function parseDefaultAuthType(
defaultAuthType: string | undefined,
): AuthType | null {
if (
defaultAuthType &&
Object.values(AuthType).includes(defaultAuthType as AuthType)
) {
return defaultAuthType as AuthType;
}
return null;
}
export function AuthDialog({
onSelect,
settings,
initialErrorMessage,
}: AuthDialogProps): React.JSX.Element {
const [errorMessage, setErrorMessage] = useState<string | null>(
initialErrorMessage || null,
);
const [showOpenAIKeyPrompt, setShowOpenAIKeyPrompt] = useState(false);
const items = [{ label: 'OpenAI', value: AuthType.USE_OPENAI }];
const initialAuthIndex = items.findIndex((item) => {
if (settings.merged.selectedAuthType) {
return item.value === settings.merged.selectedAuthType;
}
const defaultAuthType = parseDefaultAuthType(
process.env.GEMINI_DEFAULT_AUTH_TYPE,
);
if (defaultAuthType) {
return item.value === defaultAuthType;
}
if (process.env.GEMINI_API_KEY) {
return item.value === AuthType.USE_GEMINI;
}
return item.value === AuthType.LOGIN_WITH_GOOGLE;
});
const handleAuthSelect = (authMethod: AuthType) => {
const error = validateAuthMethod(authMethod);
if (error) {
if (authMethod === AuthType.USE_OPENAI && !process.env.OPENAI_API_KEY) {
setShowOpenAIKeyPrompt(true);
setErrorMessage(null);
} else {
setErrorMessage(error);
}
} else {
setErrorMessage(null);
onSelect(authMethod, SettingScope.User);
}
};
const handleOpenAIKeySubmit = (
apiKey: string,
baseUrl: string,
model: string,
) => {
setOpenAIApiKey(apiKey);
setOpenAIBaseUrl(baseUrl);
setOpenAIModel(model);
setShowOpenAIKeyPrompt(false);
onSelect(AuthType.USE_OPENAI, SettingScope.User);
};
const handleOpenAIKeyCancel = () => {
setShowOpenAIKeyPrompt(false);
setErrorMessage('OpenAI API key is required to use OpenAI authentication.');
};
useInput((_input, key) => {
// 当显示 OpenAIKeyPrompt 时,不处理输入事件
if (showOpenAIKeyPrompt) {
return;
}
if (key.escape) {
// Prevent exit if there is an error message.
// This means they user is not authenticated yet.
if (errorMessage) {
return;
}
if (settings.merged.selectedAuthType === undefined) {
// Prevent exiting if no auth method is set
setErrorMessage(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
);
return;
}
onSelect(undefined, SettingScope.User);
}
});
if (showOpenAIKeyPrompt) {
return (
<OpenAIKeyPrompt
onSubmit={handleOpenAIKeySubmit}
onCancel={handleOpenAIKeyCancel}
/>
);
}
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>Get started</Text>
<Box marginTop={1}>
<Text>How would you like to authenticate for this project?</Text>
</Box>
<Box marginTop={1}>
<RadioButtonSelect
items={items}
initialIndex={initialAuthIndex}
onSelect={handleAuthSelect}
isFocused={true}
/>
</Box>
{errorMessage && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{errorMessage}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={Colors.AccentPurple}>(Use Enter to Set Auth)</Text>
</Box>
<Box marginTop={1}>
<Text>Terms of Services and Privacy Notice for Qwen Code</Text>
</Box>
<Box marginTop={1}>
<Text color={Colors.AccentBlue}>
{'https://github.com/QwenLM/Qwen3-Coder/blob/main/README.md'}
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,57 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import Spinner from 'ink-spinner';
import { Colors } from '../colors.js';
interface AuthInProgressProps {
onTimeout: () => void;
}
export function AuthInProgress({
onTimeout,
}: AuthInProgressProps): React.JSX.Element {
const [timedOut, setTimedOut] = useState(false);
useInput((_, key) => {
if (key.escape) {
onTimeout();
}
});
useEffect(() => {
const timer = setTimeout(() => {
setTimedOut(true);
onTimeout();
}, 180000);
return () => clearTimeout(timer);
}, [onTimeout]);
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
padding={1}
width="100%"
>
{timedOut ? (
<Text color={Colors.AccentRed}>
Authentication timed out. Please try again.
</Text>
) : (
<Box>
<Text>
<Spinner type="dots" /> Waiting for auth... (Press ESC to cancel)
</Text>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { ApprovalMode } from '@qwen/qwen-code-core';
interface AutoAcceptIndicatorProps {
approvalMode: ApprovalMode;
}
export const AutoAcceptIndicator: React.FC<AutoAcceptIndicatorProps> = ({
approvalMode,
}) => {
let textColor = '';
let textContent = '';
let subText = '';
switch (approvalMode) {
case ApprovalMode.AUTO_EDIT:
textColor = Colors.AccentGreen;
textContent = 'accepting edits';
subText = ' (shift + tab to toggle)';
break;
case ApprovalMode.YOLO:
textColor = Colors.AccentRed;
textContent = 'YOLO mode';
subText = ' (ctrl + y to toggle)';
break;
case ApprovalMode.DEFAULT:
default:
break;
}
return (
<Box>
<Text color={textColor}>
{textContent}
{subText && <Text color={Colors.Gray}>{subText}</Text>}
</Text>
</Box>
);
};

View File

@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
interface ConsoleSummaryDisplayProps {
errorCount: number;
// logCount is not currently in the plan to be displayed in summary
}
export const ConsoleSummaryDisplay: React.FC<ConsoleSummaryDisplayProps> = ({
errorCount,
}) => {
if (errorCount === 0) {
return null;
}
const errorIcon = '\u2716'; // Heavy multiplication x (✖)
return (
<Box>
{errorCount > 0 && (
<Text color={Colors.AccentRed}>
{errorIcon} {errorCount} error{errorCount > 1 ? 's' : ''}{' '}
<Text color={Colors.Gray}>(ctrl+o for details)</Text>
</Text>
)}
</Box>
);
};

View File

@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
import { type MCPServerConfig } from '@qwen/qwen-code-core';
interface ContextSummaryDisplayProps {
geminiMdFileCount: number;
contextFileNames: string[];
mcpServers?: Record<string, MCPServerConfig>;
showToolDescriptions?: boolean;
}
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
geminiMdFileCount,
contextFileNames,
mcpServers,
showToolDescriptions,
}) => {
const mcpServerCount = Object.keys(mcpServers || {}).length;
if (geminiMdFileCount === 0 && mcpServerCount === 0) {
return <Text> </Text>; // Render an empty space to reserve height
}
const geminiMdText = (() => {
if (geminiMdFileCount === 0) {
return '';
}
const allNamesTheSame = new Set(contextFileNames).size < 2;
const name = allNamesTheSame ? contextFileNames[0] : 'context';
return `${geminiMdFileCount} ${name} file${
geminiMdFileCount > 1 ? 's' : ''
}`;
})();
const mcpText =
mcpServerCount > 0
? `${mcpServerCount} MCP server${mcpServerCount > 1 ? 's' : ''}`
: '';
let summaryText = 'Using ';
if (geminiMdText) {
summaryText += geminiMdText;
}
if (geminiMdText && mcpText) {
summaryText += ' and ';
}
if (mcpText) {
summaryText += mcpText;
// Add ctrl+t hint when MCP servers are available
if (mcpServers && Object.keys(mcpServers).length > 0) {
if (showToolDescriptions) {
summaryText += ' (ctrl+t to toggle)';
} else {
summaryText += ' (ctrl+t to view)';
}
}
}
return <Text color={Colors.Gray}>{summaryText}</Text>;
};

View File

@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { ConsoleMessageItem } from '../types.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
interface DetailedMessagesDisplayProps {
messages: ConsoleMessageItem[];
maxHeight: number | undefined;
width: number;
// debugMode is not needed here if App.tsx filters debug messages before passing them.
// If DetailedMessagesDisplay should handle filtering, add debugMode prop.
}
export const DetailedMessagesDisplay: React.FC<
DetailedMessagesDisplayProps
> = ({ messages, maxHeight, width }) => {
if (messages.length === 0) {
return null; // Don't render anything if there are no messages
}
const borderAndPadding = 4;
return (
<Box
flexDirection="column"
marginTop={1}
borderStyle="round"
borderColor={Colors.Gray}
paddingX={1}
width={width}
>
<Box marginBottom={1}>
<Text bold color={Colors.Foreground}>
Debug Console <Text color={Colors.Gray}>(ctrl+o to close)</Text>
</Text>
</Box>
<MaxSizedBox maxHeight={maxHeight} maxWidth={width - borderAndPadding}>
{messages.map((msg, index) => {
let textColor = Colors.Foreground;
let icon = '\u2139'; // Information source ()
switch (msg.type) {
case 'warn':
textColor = Colors.AccentYellow;
icon = '\u26A0'; // Warning sign (⚠)
break;
case 'error':
textColor = Colors.AccentRed;
icon = '\u2716'; // Heavy multiplication x (✖)
break;
case 'debug':
textColor = Colors.Gray; // Or Colors.Gray
icon = '\u1F50D'; // Left-pointing magnifying glass (????)
break;
case 'log':
default:
// Default textColor and icon are already set
break;
}
return (
<Box key={index} flexDirection="row">
<Text color={textColor}>{icon} </Text>
<Text color={textColor} wrap="wrap">
{msg.content}
{msg.count && msg.count > 1 && (
<Text color={Colors.Gray}> (x{msg.count})</Text>
)}
</Text>
</Box>
);
})}
</MaxSizedBox>
</Box>
);
};

View File

@@ -0,0 +1,168 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
import {
EDITOR_DISPLAY_NAMES,
editorSettingsManager,
type EditorDisplay,
} from '../editors/editorSettingsManager.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { EditorType, isEditorAvailable } from '@qwen/qwen-code-core';
interface EditorDialogProps {
onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void;
settings: LoadedSettings;
onExit: () => void;
}
export function EditorSettingsDialog({
onSelect,
settings,
onExit,
}: EditorDialogProps): React.JSX.Element {
const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.User,
);
const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>(
'editor',
);
useInput((_, key) => {
if (key.tab) {
setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
}
if (key.escape) {
onExit();
}
});
const editorItems: EditorDisplay[] =
editorSettingsManager.getAvailableEditorDisplays();
const currentPreference =
settings.forScope(selectedScope).settings.preferredEditor;
let editorIndex = currentPreference
? editorItems.findIndex(
(item: EditorDisplay) => item.type === currentPreference,
)
: 0;
if (editorIndex === -1) {
console.error(`Editor is not supported: ${currentPreference}`);
editorIndex = 0;
}
const scopeItems = [
{ label: 'User Settings', value: SettingScope.User },
{ label: 'Workspace Settings', value: SettingScope.Workspace },
];
const handleEditorSelect = (editorType: EditorType | 'not_set') => {
if (editorType === 'not_set') {
onSelect(undefined, selectedScope);
return;
}
onSelect(editorType, selectedScope);
};
const handleScopeSelect = (scope: SettingScope) => {
setSelectedScope(scope);
setFocusedSection('editor');
};
let otherScopeModifiedMessage = '';
const otherScope =
selectedScope === SettingScope.User
? SettingScope.Workspace
: SettingScope.User;
if (settings.forScope(otherScope).settings.preferredEditor !== undefined) {
otherScopeModifiedMessage =
settings.forScope(selectedScope).settings.preferredEditor !== undefined
? `(Also modified in ${otherScope})`
: `(Modified in ${otherScope})`;
}
let mergedEditorName = 'None';
if (
settings.merged.preferredEditor &&
isEditorAvailable(settings.merged.preferredEditor)
) {
mergedEditorName =
EDITOR_DISPLAY_NAMES[settings.merged.preferredEditor as EditorType];
}
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="row"
padding={1}
width="100%"
>
<Box flexDirection="column" width="45%" paddingRight={2}>
<Text bold={focusedSection === 'editor'}>
{focusedSection === 'editor' ? '> ' : ' '}Select Editor{' '}
<Text color={Colors.Gray}>{otherScopeModifiedMessage}</Text>
</Text>
<RadioButtonSelect
items={editorItems.map((item) => ({
label: item.name,
value: item.type,
disabled: item.disabled,
}))}
initialIndex={editorIndex}
onSelect={handleEditorSelect}
isFocused={focusedSection === 'editor'}
key={selectedScope}
/>
<Box marginTop={1} flexDirection="column">
<Text bold={focusedSection === 'scope'}>
{focusedSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={0}
onSelect={handleScopeSelect}
isFocused={focusedSection === 'scope'}
/>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
(Use Enter to select, Tab to change focus)
</Text>
</Box>
</Box>
<Box flexDirection="column" width="55%" paddingLeft={2}>
<Text bold>Editor Preference</Text>
<Box flexDirection="column" gap={1} marginTop={1}>
<Text color={Colors.Gray}>
These editors are currently supported. Please note that some editors
cannot be used in sandbox mode.
</Text>
<Text color={Colors.Gray}>
Your preferred editor is:{' '}
<Text
color={
mergedEditorName === 'None'
? Colors.AccentRed
: Colors.AccentCyan
}
bold
>
{mergedEditorName}
</Text>
.
</Text>
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,121 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { shortenPath, tildeifyPath, tokenLimit } from '@qwen/qwen-code-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
import Gradient from 'ink-gradient';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
interface FooterProps {
model: string;
targetDir: string;
branchName?: string;
debugMode: boolean;
debugMessage: string;
corgiMode: boolean;
errorCount: number;
showErrorDetails: boolean;
showMemoryUsage?: boolean;
promptTokenCount: number;
nightly: boolean;
}
export const Footer: React.FC<FooterProps> = ({
model,
targetDir,
branchName,
debugMode,
debugMessage,
corgiMode,
errorCount,
showErrorDetails,
showMemoryUsage,
promptTokenCount,
nightly,
}) => {
const limit = tokenLimit(model);
const percentage = promptTokenCount / limit;
return (
<Box marginTop={1} justifyContent="space-between" width="100%">
<Box>
{nightly ? (
<Gradient colors={Colors.GradientColors}>
<Text>
{shortenPath(tildeifyPath(targetDir), 70)}
{branchName && <Text> ({branchName}*)</Text>}
</Text>
</Gradient>
) : (
<Text color={Colors.LightBlue}>
{shortenPath(tildeifyPath(targetDir), 70)}
{branchName && <Text color={Colors.Gray}> ({branchName}*)</Text>}
</Text>
)}
{debugMode && (
<Text color={Colors.AccentRed}>
{' ' + (debugMessage || '--debug')}
</Text>
)}
</Box>
{/* Middle Section: Centered Sandbox Info */}
<Box
flexGrow={1}
alignItems="center"
justifyContent="center"
display="flex"
>
{process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? (
<Text color="green">
{process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')}
</Text>
) : process.env.SANDBOX === 'sandbox-exec' ? (
<Text color={Colors.AccentYellow}>
MacOS Seatbelt{' '}
<Text color={Colors.Gray}>({process.env.SEATBELT_PROFILE})</Text>
</Text>
) : (
<Text color={Colors.AccentRed}>
no sandbox <Text color={Colors.Gray}>(see /docs)</Text>
</Text>
)}
</Box>
{/* Right Section: Gemini Label and Console Summary */}
<Box alignItems="center">
<Text color={Colors.AccentBlue}>
{' '}
{model}{' '}
<Text color={Colors.Gray}>
({((1 - percentage) * 100).toFixed(0)}% context left)
</Text>
</Text>
{corgiMode && (
<Text>
<Text color={Colors.Gray}>| </Text>
<Text color={Colors.AccentRed}></Text>
<Text color={Colors.Foreground}>(´</Text>
<Text color={Colors.AccentRed}></Text>
<Text color={Colors.Foreground}>`)</Text>
<Text color={Colors.AccentRed}>▼ </Text>
</Text>
)}
{!showErrorDetails && errorCount > 0 && (
<Box>
<Text color={Colors.Gray}>| </Text>
<ConsoleSummaryDisplay errorCount={errorCount} />
</Box>
)}
{showMemoryUsage && <MemoryUsageDisplay />}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text } from 'ink';
import Spinner from 'ink-spinner';
import type { SpinnerName } from 'cli-spinners';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
interface GeminiRespondingSpinnerProps {
/**
* Optional string to display when not in Responding state.
* If not provided and not Responding, renders null.
*/
nonRespondingDisplay?: string;
spinnerType?: SpinnerName;
}
export const GeminiRespondingSpinner: React.FC<
GeminiRespondingSpinnerProps
> = ({ nonRespondingDisplay, spinnerType = 'dots' }) => {
const streamingState = useStreamingContext();
if (streamingState === StreamingState.Responding) {
return <Spinner type={spinnerType} />;
} else if (nonRespondingDisplay) {
return <Text>{nonRespondingDisplay}</Text>;
}
return null;
};

View File

@@ -0,0 +1,63 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { Colors } from '../colors.js';
import { shortAsciiLogo, longAsciiLogo } from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js';
interface HeaderProps {
customAsciiArt?: string; // For user-defined ASCII art
terminalWidth: number; // For responsive logo
version: string;
nightly: boolean;
}
export const Header: React.FC<HeaderProps> = ({
customAsciiArt,
terminalWidth,
version,
nightly,
}) => {
let displayTitle;
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
if (customAsciiArt) {
displayTitle = customAsciiArt;
} else {
displayTitle =
terminalWidth >= widthOfLongLogo ? longAsciiLogo : shortAsciiLogo;
}
const artWidth = getAsciiArtWidth(displayTitle);
return (
<Box
marginBottom={1}
alignItems="flex-start"
width={artWidth}
flexShrink={0}
flexDirection="column"
>
{Colors.GradientColors ? (
<Gradient colors={Colors.GradientColors}>
<Text>{displayTitle}</Text>
</Gradient>
) : (
<Text>{displayTitle}</Text>
)}
{nightly && (
<Box width="100%" flexDirection="row" justifyContent="flex-end">
<Gradient colors={Colors.GradientColors}>
<Text>v{version}</Text>
</Gradient>
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,155 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { SlashCommand } from '../commands/types.js';
interface Help {
commands: SlashCommand[];
}
export const Help: React.FC<Help> = ({ commands }) => (
<Box
flexDirection="column"
marginBottom={1}
borderColor={Colors.Gray}
borderStyle="round"
padding={1}
>
{/* Basics */}
<Text bold color={Colors.Foreground}>
Basics:
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Add context
</Text>
: Use{' '}
<Text bold color={Colors.AccentPurple}>
@
</Text>{' '}
to specify files for context (e.g.,{' '}
<Text bold color={Colors.AccentPurple}>
@src/myFile.ts
</Text>
) to target specific files or folders.
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Shell mode
</Text>
: Execute shell commands via{' '}
<Text bold color={Colors.AccentPurple}>
!
</Text>{' '}
(e.g.,{' '}
<Text bold color={Colors.AccentPurple}>
!npm run start
</Text>
) or use natural language (e.g.{' '}
<Text bold color={Colors.AccentPurple}>
start server
</Text>
).
</Text>
<Box height={1} />
{/* Commands */}
<Text bold color={Colors.Foreground}>
Commands:
</Text>
{commands
.filter((command) => command.description)
.map((command: SlashCommand) => (
<Box key={command.name} flexDirection="column">
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
{' '}
/{command.name}
</Text>
{command.description && ' - ' + command.description}
</Text>
{command.subCommands &&
command.subCommands.map((subCommand) => (
<Text key={subCommand.name} color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
{' '}
{subCommand.name}
</Text>
{subCommand.description && ' - ' + subCommand.description}
</Text>
))}
</Box>
))}
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
{' '}
!{' '}
</Text>
- shell command
</Text>
<Box height={1} />
{/* Shortcuts */}
<Text bold color={Colors.Foreground}>
Keyboard Shortcuts:
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Enter
</Text>{' '}
- Send message
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
{process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J'}
</Text>{' '}
{process.platform === 'linux'
? '- New line (Alt+Enter works for certain linux distros)'
: '- New line'}
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Up/Down
</Text>{' '}
- Cycle through your prompt history
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Alt+Left/Right
</Text>{' '}
- Jump through words in the input
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Shift+Tab
</Text>{' '}
- Toggle auto-accepting edits
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Ctrl+Y
</Text>{' '}
- Toggle YOLO mode
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Esc
</Text>{' '}
- Cancel operation
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Ctrl+C
</Text>{' '}
- Quit application
</Text>
</Box>
);

View File

@@ -0,0 +1,112 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { HistoryItem, MessageType } from '../types.js';
import { SessionStatsProvider } from '../contexts/SessionContext.js';
// Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({
ToolGroupMessage: () => <div />,
}));
describe('<HistoryItemDisplay />', () => {
const baseItem = {
id: 1,
timestamp: 12345,
isPending: false,
terminalWidth: 80,
};
it('renders UserMessage for "user" type', () => {
const item: HistoryItem = {
...baseItem,
type: MessageType.USER,
text: 'Hello',
};
const { lastFrame } = render(
<HistoryItemDisplay {...baseItem} item={item} />,
);
expect(lastFrame()).toContain('Hello');
});
it('renders StatsDisplay for "stats" type', () => {
const item: HistoryItem = {
...baseItem,
type: MessageType.STATS,
duration: '1s',
};
const { lastFrame } = render(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
expect(lastFrame()).toContain('Stats');
});
it('renders AboutBox for "about" type', () => {
const item: HistoryItem = {
...baseItem,
type: MessageType.ABOUT,
cliVersion: '1.0.0',
osVersion: 'test-os',
sandboxEnv: 'test-env',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
gcpProject: 'test-project',
};
const { lastFrame } = render(
<HistoryItemDisplay {...baseItem} item={item} />,
);
expect(lastFrame()).toContain('About Gemini CLI');
});
it('renders ModelStatsDisplay for "model_stats" type', () => {
const item: HistoryItem = {
...baseItem,
type: 'model_stats',
};
const { lastFrame } = render(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
expect(lastFrame()).toContain(
'No API calls have been made in this session.',
);
});
it('renders ToolStatsDisplay for "tool_stats" type', () => {
const item: HistoryItem = {
...baseItem,
type: 'tool_stats',
};
const { lastFrame } = render(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
expect(lastFrame()).toContain(
'No tool calls have been made in this session.',
);
});
it('renders SessionSummaryDisplay for "quit" type', () => {
const item: HistoryItem = {
...baseItem,
type: 'quit',
duration: '1s',
};
const { lastFrame } = render(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
expect(lastFrame()).toContain('Agent powering down. Goodbye!');
});
});

View File

@@ -0,0 +1,92 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type { HistoryItem } from '../types.js';
import { UserMessage } from './messages/UserMessage.js';
import { UserShellMessage } from './messages/UserShellMessage.js';
import { GeminiMessage } from './messages/GeminiMessage.js';
import { InfoMessage } from './messages/InfoMessage.js';
import { ErrorMessage } from './messages/ErrorMessage.js';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
import { CompressionMessage } from './messages/CompressionMessage.js';
import { Box } from 'ink';
import { AboutBox } from './AboutBox.js';
import { StatsDisplay } from './StatsDisplay.js';
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import { Config } from '@qwen/qwen-code-core';
interface HistoryItemDisplayProps {
item: HistoryItem;
availableTerminalHeight?: number;
terminalWidth: number;
isPending: boolean;
config?: Config;
isFocused?: boolean;
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
item,
availableTerminalHeight,
terminalWidth,
isPending,
config,
isFocused = true,
}) => (
<Box flexDirection="column" key={item.id}>
{/* Render standard message types */}
{item.type === 'user' && <UserMessage text={item.text} />}
{item.type === 'user_shell' && <UserShellMessage text={item.text} />}
{item.type === 'gemini' && (
<GeminiMessage
text={item.text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/>
)}
{item.type === 'gemini_content' && (
<GeminiMessageContent
text={item.text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/>
)}
{item.type === 'info' && <InfoMessage text={item.text} />}
{item.type === 'error' && <ErrorMessage text={item.text} />}
{item.type === 'about' && (
<AboutBox
cliVersion={item.cliVersion}
osVersion={item.osVersion}
sandboxEnv={item.sandboxEnv}
modelVersion={item.modelVersion}
selectedAuthType={item.selectedAuthType}
gcpProject={item.gcpProject}
/>
)}
{item.type === 'stats' && <StatsDisplay duration={item.duration} />}
{item.type === 'model_stats' && <ModelStatsDisplay />}
{item.type === 'tool_stats' && <ToolStatsDisplay />}
{item.type === 'quit' && <SessionSummaryDisplay duration={item.duration} />}
{item.type === 'tool_group' && (
<ToolGroupMessage
toolCalls={item.tools}
groupId={item.id}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
config={config}
isFocused={isFocused}
/>
)}
{item.type === 'compression' && (
<CompressionMessage compression={item.compression} />
)}
</Box>
);

View File

@@ -0,0 +1,546 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { InputPrompt, InputPromptProps } from './InputPrompt.js';
import type { TextBuffer } from './shared/text-buffer.js';
import { Config } from '@qwen/qwen-code-core';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { vi } from 'vitest';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useCompletion } from '../hooks/useCompletion.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import * as clipboardUtils from '../utils/clipboardUtils.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCompletion.js');
vi.mock('../hooks/useInputHistory.js');
vi.mock('../utils/clipboardUtils.js');
type MockedUseShellHistory = ReturnType<typeof useShellHistory>;
type MockedUseCompletion = ReturnType<typeof useCompletion>;
type MockedUseInputHistory = ReturnType<typeof useInputHistory>;
const mockSlashCommands: SlashCommand[] = [
{ name: 'clear', description: 'Clear screen', action: vi.fn() },
{
name: 'memory',
description: 'Manage memory',
subCommands: [
{ name: 'show', description: 'Show memory', action: vi.fn() },
{ name: 'add', description: 'Add to memory', action: vi.fn() },
{ name: 'refresh', description: 'Refresh memory', action: vi.fn() },
],
},
{
name: 'chat',
description: 'Manage chats',
subCommands: [
{
name: 'resume',
description: 'Resume a chat',
action: vi.fn(),
completion: async () => ['fix-foo', 'fix-bar'],
},
],
},
];
describe('InputPrompt', () => {
let props: InputPromptProps;
let mockShellHistory: MockedUseShellHistory;
let mockCompletion: MockedUseCompletion;
let mockInputHistory: MockedUseInputHistory;
let mockBuffer: TextBuffer;
let mockCommandContext: CommandContext;
const mockedUseShellHistory = vi.mocked(useShellHistory);
const mockedUseCompletion = vi.mocked(useCompletion);
const mockedUseInputHistory = vi.mocked(useInputHistory);
beforeEach(() => {
vi.resetAllMocks();
mockCommandContext = createMockCommandContext();
mockBuffer = {
text: '',
cursor: [0, 0],
lines: [''],
setText: vi.fn((newText: string) => {
mockBuffer.text = newText;
mockBuffer.lines = [newText];
mockBuffer.cursor = [0, newText.length];
mockBuffer.viewportVisualLines = [newText];
mockBuffer.allVisualLines = [newText];
}),
replaceRangeByOffset: vi.fn(),
viewportVisualLines: [''],
allVisualLines: [''],
visualCursor: [0, 0],
visualScrollRow: 0,
handleInput: vi.fn(),
move: vi.fn(),
moveToOffset: vi.fn(),
killLineRight: vi.fn(),
killLineLeft: vi.fn(),
openInExternalEditor: vi.fn(),
newline: vi.fn(),
backspace: vi.fn(),
} as unknown as TextBuffer;
mockShellHistory = {
addCommandToHistory: vi.fn(),
getPreviousCommand: vi.fn().mockReturnValue(null),
getNextCommand: vi.fn().mockReturnValue(null),
resetHistoryPosition: vi.fn(),
};
mockedUseShellHistory.mockReturnValue(mockShellHistory);
mockCompletion = {
suggestions: [],
activeSuggestionIndex: -1,
isLoadingSuggestions: false,
showSuggestions: false,
visibleStartIndex: 0,
navigateUp: vi.fn(),
navigateDown: vi.fn(),
resetCompletionState: vi.fn(),
setActiveSuggestionIndex: vi.fn(),
setShowSuggestions: vi.fn(),
};
mockedUseCompletion.mockReturnValue(mockCompletion);
mockInputHistory = {
navigateUp: vi.fn(),
navigateDown: vi.fn(),
handleSubmit: vi.fn(),
};
mockedUseInputHistory.mockReturnValue(mockInputHistory);
props = {
buffer: mockBuffer,
onSubmit: vi.fn(),
userMessages: [],
onClearScreen: vi.fn(),
config: {
getProjectRoot: () => '/test/project',
getTargetDir: () => '/test/project/src',
} as unknown as Config,
slashCommands: [],
commandContext: mockCommandContext,
shellModeActive: false,
setShellModeActive: vi.fn(),
inputWidth: 80,
suggestionsWidth: 80,
focus: true,
};
props.slashCommands = mockSlashCommands;
});
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
it('should call shellHistory.getPreviousCommand on up arrow in shell mode', async () => {
props.shellModeActive = true;
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait(100); // Increased wait time for CI environment
stdin.write('\u001B[A');
await wait(100); // Increased wait time to ensure input is processed
expect(mockShellHistory.getPreviousCommand).toHaveBeenCalled();
unmount();
});
it('should call shellHistory.getNextCommand on down arrow in shell mode', async () => {
props.shellModeActive = true;
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait(100); // Increased wait time for CI environment
stdin.write('\u001B[B');
await wait(100); // Increased wait time to ensure input is processed
expect(mockShellHistory.getNextCommand).toHaveBeenCalled();
unmount();
});
it('should set the buffer text when a shell history command is retrieved', async () => {
props.shellModeActive = true;
vi.mocked(mockShellHistory.getPreviousCommand).mockReturnValue(
'previous command',
);
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait(100); // Increased wait time for CI environment
stdin.write('\u001B[A');
await wait(100); // Increased wait time to ensure input is processed
expect(mockShellHistory.getPreviousCommand).toHaveBeenCalled();
expect(props.buffer.setText).toHaveBeenCalledWith('previous command');
unmount();
});
it('should call shellHistory.addCommandToHistory on submit in shell mode', async () => {
props.shellModeActive = true;
props.buffer.setText('ls -l');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
await wait();
expect(mockShellHistory.addCommandToHistory).toHaveBeenCalledWith('ls -l');
expect(props.onSubmit).toHaveBeenCalledWith('ls -l');
unmount();
});
it('should NOT call shell history methods when not in shell mode', async () => {
props.buffer.setText('some text');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\u001B[A'); // Up arrow
await wait();
stdin.write('\u001B[B'); // Down arrow
await wait();
stdin.write('\r'); // Enter
await wait();
expect(mockShellHistory.getPreviousCommand).not.toHaveBeenCalled();
expect(mockShellHistory.getNextCommand).not.toHaveBeenCalled();
expect(mockShellHistory.addCommandToHistory).not.toHaveBeenCalled();
expect(mockInputHistory.navigateUp).toHaveBeenCalled();
expect(mockInputHistory.navigateDown).toHaveBeenCalled();
expect(props.onSubmit).toHaveBeenCalledWith('some text');
unmount();
});
describe('clipboard image paste', () => {
beforeEach(() => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
vi.mocked(clipboardUtils.cleanupOldClipboardImages).mockResolvedValue(
undefined,
);
});
it('should handle Ctrl+V when clipboard has an image', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
'/test/.gemini-clipboard/clipboard-123.png',
);
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
// Send Ctrl+V
stdin.write('\x16'); // Ctrl+V
await wait();
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith(
props.config.getTargetDir(),
);
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith(
props.config.getTargetDir(),
);
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
unmount();
});
it('should not insert anything when clipboard has no image', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x16'); // Ctrl+V
await wait();
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
expect(clipboardUtils.saveClipboardImage).not.toHaveBeenCalled();
expect(mockBuffer.setText).not.toHaveBeenCalled();
unmount();
});
it('should handle image save failure gracefully', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x16'); // Ctrl+V
await wait();
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
expect(mockBuffer.setText).not.toHaveBeenCalled();
unmount();
});
it('should insert image path at cursor position with proper spacing', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
'/test/.gemini-clipboard/clipboard-456.png',
);
// Set initial text and cursor position
mockBuffer.text = 'Hello world';
mockBuffer.cursor = [0, 5]; // Cursor after "Hello"
mockBuffer.lines = ['Hello world'];
mockBuffer.replaceRangeByOffset = vi.fn();
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x16'); // Ctrl+V
await wait();
// Should insert at cursor position with spaces
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
// Get the actual call to see what path was used
const actualCall = vi.mocked(mockBuffer.replaceRangeByOffset).mock
.calls[0];
expect(actualCall[0]).toBe(5); // start offset
expect(actualCall[1]).toBe(5); // end offset
expect(actualCall[2]).toMatch(
/@.*\.gemini-clipboard\/clipboard-456\.png/,
); // flexible path match
unmount();
});
it('should handle errors during clipboard operations', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
vi.mocked(clipboardUtils.clipboardHasImage).mockRejectedValue(
new Error('Clipboard error'),
);
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x16'); // Ctrl+V
await wait();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error handling clipboard image:',
expect.any(Error),
);
expect(mockBuffer.setText).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
unmount();
});
});
it('should complete a partial parent command and add a space', async () => {
// SCENARIO: /mem -> Tab
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [{ label: 'memory', value: 'memory', description: '...' }],
activeSuggestionIndex: 0,
});
props.buffer.setText('/mem');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
unmount();
});
it('should append a sub-command when the parent command is already complete with a space', async () => {
// SCENARIO: /memory -> Tab (to accept 'add')
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [
{ label: 'show', value: 'show' },
{ label: 'add', value: 'add' },
],
activeSuggestionIndex: 1, // 'add' is highlighted
});
props.buffer.setText('/memory ');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('/memory add ');
unmount();
});
it('should handle the "backspace" edge case correctly', async () => {
// SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
// This is the critical bug we fixed.
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [
{ label: 'show', value: 'show' },
{ label: 'add', value: 'add' },
],
activeSuggestionIndex: 0, // 'show' is highlighted
});
// The user has backspaced, so the query is now just '/memory'
props.buffer.setText('/memory');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
await wait();
// It should NOT become '/show '. It should correctly become '/memory show '.
expect(props.buffer.setText).toHaveBeenCalledWith('/memory show ');
unmount();
});
it('should complete a partial argument for a command', async () => {
// SCENARIO: /chat resume fi- -> Tab
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [{ label: 'fix-foo', value: 'fix-foo' }],
activeSuggestionIndex: 0,
});
props.buffer.setText('/chat resume fi-');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo ');
unmount();
});
it('should autocomplete on Enter when suggestions are active, without submitting', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [{ label: 'memory', value: 'memory' }],
activeSuggestionIndex: 0,
});
props.buffer.setText('/mem');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
await wait();
// The app should autocomplete the text, NOT submit.
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
});
it('should complete a command based on its altName', async () => {
// Add a command with an altName to our mock for this test
props.slashCommands.push({
name: 'help',
altName: '?',
description: '...',
});
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [{ label: 'help', value: 'help' }],
activeSuggestionIndex: 0,
});
props.buffer.setText('/?');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('/help ');
unmount();
});
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
props.buffer.setText(' '); // Set buffer to whitespace
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\r'); // Press Enter
await wait();
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
});
it('should submit directly on Enter when a complete leaf command is typed', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: false,
});
props.buffer.setText('/clear');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
await wait();
expect(props.onSubmit).toHaveBeenCalledWith('/clear');
expect(props.buffer.setText).not.toHaveBeenCalledWith('/clear ');
unmount();
});
it('should autocomplete an @-path on Enter without submitting', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
suggestions: [{ label: 'index.ts', value: 'index.ts' }],
activeSuggestionIndex: 0,
});
props.buffer.setText('@src/components/');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
await wait();
expect(props.buffer.replaceRangeByOffset).toHaveBeenCalled();
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
});
it('should add a newline on enter when the line ends with a backslash', async () => {
props.buffer.setText('first line\\');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
await wait();
expect(props.onSubmit).not.toHaveBeenCalled();
expect(props.buffer.backspace).toHaveBeenCalled();
expect(props.buffer.newline).toHaveBeenCalled();
unmount();
});
});

View File

@@ -0,0 +1,486 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { TextBuffer } from './shared/text-buffer.js';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import chalk from 'chalk';
import stringWidth from 'string-width';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useCompletion } from '../hooks/useCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js';
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@qwen/qwen-code-core';
import {
clipboardHasImage,
saveClipboardImage,
cleanupOldClipboardImages,
} from '../utils/clipboardUtils.js';
import * as path from 'path';
export interface InputPromptProps {
buffer: TextBuffer;
onSubmit: (value: string) => void;
userMessages: readonly string[];
onClearScreen: () => void;
config: Config;
slashCommands: SlashCommand[];
commandContext: CommandContext;
placeholder?: string;
focus?: boolean;
inputWidth: number;
suggestionsWidth: number;
shellModeActive: boolean;
setShellModeActive: (value: boolean) => void;
}
export const InputPrompt: React.FC<InputPromptProps> = ({
buffer,
onSubmit,
userMessages,
onClearScreen,
config,
slashCommands,
commandContext,
placeholder = ' Type your message or @path/to/file',
focus = true,
inputWidth,
suggestionsWidth,
shellModeActive,
setShellModeActive,
}) => {
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const completion = useCompletion(
buffer.text,
config.getTargetDir(),
isAtCommand(buffer.text) || isSlashCommand(buffer.text),
slashCommands,
commandContext,
config,
);
const resetCompletionState = completion.resetCompletionState;
const shellHistory = useShellHistory(config.getProjectRoot());
const handleSubmitAndClear = useCallback(
(submittedValue: string) => {
if (shellModeActive) {
shellHistory.addCommandToHistory(submittedValue);
}
// Clear the buffer *before* calling onSubmit to prevent potential re-submission
// if onSubmit triggers a re-render while the buffer still holds the old value.
buffer.setText('');
onSubmit(submittedValue);
resetCompletionState();
},
[onSubmit, buffer, resetCompletionState, shellModeActive, shellHistory],
);
const customSetTextAndResetCompletionSignal = useCallback(
(newText: string) => {
buffer.setText(newText);
setJustNavigatedHistory(true);
},
[buffer, setJustNavigatedHistory],
);
const inputHistory = useInputHistory({
userMessages,
onSubmit: handleSubmitAndClear,
isActive: !completion.showSuggestions && !shellModeActive,
currentQuery: buffer.text,
onChange: customSetTextAndResetCompletionSignal,
});
// Effect to reset completion if history navigation just occurred and set the text
useEffect(() => {
if (justNavigatedHistory) {
resetCompletionState();
setJustNavigatedHistory(false);
}
}, [
justNavigatedHistory,
buffer.text,
resetCompletionState,
setJustNavigatedHistory,
]);
const completionSuggestions = completion.suggestions;
const handleAutocomplete = useCallback(
(indexToUse: number) => {
if (indexToUse < 0 || indexToUse >= completionSuggestions.length) {
return;
}
const query = buffer.text;
const suggestion = completionSuggestions[indexToUse].value;
if (query.trimStart().startsWith('/')) {
const hasTrailingSpace = query.endsWith(' ');
const parts = query
.trimStart()
.substring(1)
.split(/\s+/)
.filter(Boolean);
let isParentPath = false;
// If there's no trailing space, we need to check if the current query
// is already a complete path to a parent command.
if (!hasTrailingSpace) {
let currentLevel: SlashCommand[] | undefined = slashCommands;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const found: SlashCommand | undefined = currentLevel?.find(
(cmd) => cmd.name === part || cmd.altName === part,
);
if (found) {
if (i === parts.length - 1 && found.subCommands) {
isParentPath = true;
}
currentLevel = found.subCommands;
} else {
// Path is invalid, so it can't be a parent path.
currentLevel = undefined;
break;
}
}
}
// Determine the base path of the command.
// - If there's a trailing space, the whole command is the base.
// - If it's a known parent path, the whole command is the base.
// - Otherwise, the base is everything EXCEPT the last partial part.
const basePath =
hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1);
const newValue = `/${[...basePath, suggestion].join(' ')} `;
buffer.setText(newValue);
} else {
const atIndex = query.lastIndexOf('@');
if (atIndex === -1) return;
const pathPart = query.substring(atIndex + 1);
const lastSlashIndexInPath = pathPart.lastIndexOf('/');
let autoCompleteStartIndex = atIndex + 1;
if (lastSlashIndexInPath !== -1) {
autoCompleteStartIndex += lastSlashIndexInPath + 1;
}
buffer.replaceRangeByOffset(
autoCompleteStartIndex,
buffer.text.length,
suggestion,
);
}
resetCompletionState();
},
[resetCompletionState, buffer, completionSuggestions, slashCommands],
);
// Handle clipboard image pasting with Ctrl+V
const handleClipboardImage = useCallback(async () => {
try {
if (await clipboardHasImage()) {
const imagePath = await saveClipboardImage(config.getTargetDir());
if (imagePath) {
// Clean up old images
cleanupOldClipboardImages(config.getTargetDir()).catch(() => {
// Ignore cleanup errors
});
// Get relative path from current directory
const relativePath = path.relative(config.getTargetDir(), imagePath);
// Insert @path reference at cursor position
const insertText = `@${relativePath}`;
const currentText = buffer.text;
const [row, col] = buffer.cursor;
// Calculate offset from row/col
let offset = 0;
for (let i = 0; i < row; i++) {
offset += buffer.lines[i].length + 1; // +1 for newline
}
offset += col;
// Add spaces around the path if needed
let textToInsert = insertText;
const charBefore = offset > 0 ? currentText[offset - 1] : '';
const charAfter =
offset < currentText.length ? currentText[offset] : '';
if (charBefore && charBefore !== ' ' && charBefore !== '\n') {
textToInsert = ' ' + textToInsert;
}
if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) {
textToInsert = textToInsert + ' ';
}
// Insert at cursor position
buffer.replaceRangeByOffset(offset, offset, textToInsert);
}
}
} catch (error) {
console.error('Error handling clipboard image:', error);
}
}, [buffer, config]);
const handleInput = useCallback(
(key: Key) => {
if (!focus) {
return;
}
if (
key.sequence === '!' &&
buffer.text === '' &&
!completion.showSuggestions
) {
setShellModeActive(!shellModeActive);
buffer.setText(''); // Clear the '!' from input
return;
}
if (key.name === 'escape') {
if (shellModeActive) {
setShellModeActive(false);
return;
}
if (completion.showSuggestions) {
completion.resetCompletionState();
return;
}
}
if (key.ctrl && key.name === 'l') {
onClearScreen();
return;
}
if (completion.showSuggestions) {
if (key.name === 'up') {
completion.navigateUp();
return;
}
if (key.name === 'down') {
completion.navigateDown();
return;
}
if (key.name === 'tab' || (key.name === 'return' && !key.ctrl)) {
if (completion.suggestions.length > 0) {
const targetIndex =
completion.activeSuggestionIndex === -1
? 0 // Default to the first if none is active
: completion.activeSuggestionIndex;
if (targetIndex < completion.suggestions.length) {
handleAutocomplete(targetIndex);
}
}
return;
}
} else {
if (!shellModeActive) {
if (key.ctrl && key.name === 'p') {
inputHistory.navigateUp();
return;
}
if (key.ctrl && key.name === 'n') {
inputHistory.navigateDown();
return;
}
// Handle arrow-up/down for history on single-line or at edges
if (
key.name === 'up' &&
(buffer.allVisualLines.length === 1 ||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
) {
inputHistory.navigateUp();
return;
}
if (
key.name === 'down' &&
(buffer.allVisualLines.length === 1 ||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
) {
inputHistory.navigateDown();
return;
}
} else {
// Shell History Navigation
if (key.name === 'up') {
const prevCommand = shellHistory.getPreviousCommand();
if (prevCommand !== null) buffer.setText(prevCommand);
return;
}
if (key.name === 'down') {
const nextCommand = shellHistory.getNextCommand();
if (nextCommand !== null) buffer.setText(nextCommand);
return;
}
}
if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
if (buffer.text.trim()) {
const [row, col] = buffer.cursor;
const line = buffer.lines[row];
const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
if (charBefore === '\\') {
buffer.backspace();
buffer.newline();
} else {
handleSubmitAndClear(buffer.text);
}
}
return;
}
}
// Newline insertion
if (key.name === 'return' && (key.ctrl || key.meta || key.paste)) {
buffer.newline();
return;
}
// Ctrl+A (Home) / Ctrl+E (End)
if (key.ctrl && key.name === 'a') {
buffer.move('home');
return;
}
if (key.ctrl && key.name === 'e') {
buffer.move('end');
return;
}
// Kill line commands
if (key.ctrl && key.name === 'k') {
buffer.killLineRight();
return;
}
if (key.ctrl && key.name === 'u') {
buffer.killLineLeft();
return;
}
// External editor
const isCtrlX = key.ctrl && (key.name === 'x' || key.sequence === '\x18');
if (isCtrlX) {
buffer.openInExternalEditor();
return;
}
// Ctrl+V for clipboard image paste
if (key.ctrl && key.name === 'v') {
handleClipboardImage();
return;
}
// Fallback to the text buffer's default input handling for all other keys
buffer.handleInput(key);
},
[
focus,
buffer,
completion,
shellModeActive,
setShellModeActive,
onClearScreen,
inputHistory,
handleAutocomplete,
handleSubmitAndClear,
shellHistory,
handleClipboardImage,
],
);
useKeypress(handleInput, { isActive: focus });
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow;
return (
<>
<Box
borderStyle="round"
borderColor={shellModeActive ? Colors.AccentYellow : Colors.AccentBlue}
paddingX={1}
>
<Text
color={shellModeActive ? Colors.AccentYellow : Colors.AccentPurple}
>
{shellModeActive ? '! ' : '> '}
</Text>
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
focus ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={Colors.Gray}>{placeholder.slice(1)}</Text>
</Text>
) : (
<Text color={Colors.Gray}>{placeholder}</Text>
)
) : (
linesToRender.map((lineText, visualIdxInRenderedSet) => {
const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
let display = cpSlice(lineText, 0, inputWidth);
const currentVisualWidth = stringWidth(display);
if (currentVisualWidth < inputWidth) {
display = display + ' '.repeat(inputWidth - currentVisualWidth);
}
if (visualIdxInRenderedSet === cursorVisualRow) {
const relativeVisualColForHighlight = cursorVisualColAbsolute;
if (relativeVisualColForHighlight >= 0) {
if (relativeVisualColForHighlight < cpLen(display)) {
const charToHighlight =
cpSlice(
display,
relativeVisualColForHighlight,
relativeVisualColForHighlight + 1,
) || ' ';
const highlighted = chalk.inverse(charToHighlight);
display =
cpSlice(display, 0, relativeVisualColForHighlight) +
highlighted +
cpSlice(display, relativeVisualColForHighlight + 1);
} else if (
relativeVisualColForHighlight === cpLen(display) &&
cpLen(display) === inputWidth
) {
display = display + chalk.inverse(' ');
}
}
}
return (
<Text key={`line-${visualIdxInRenderedSet}`}>{display}</Text>
);
})
)}
</Box>
</Box>
{completion.showSuggestions && (
<Box>
<SuggestionsDisplay
suggestions={completion.suggestions}
activeIndex={completion.activeSuggestionIndex}
isLoading={completion.isLoadingSuggestions}
width={suggestionsWidth}
scrollOffset={completion.visibleStartIndex}
userInput={buffer.text}
/>
</Box>
)}
</>
);
};

View File

@@ -0,0 +1,226 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink-testing-library';
import { Text } from 'ink';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { vi } from 'vitest';
// Mock GeminiRespondingSpinner
vi.mock('./GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: ({
nonRespondingDisplay,
}: {
nonRespondingDisplay?: string;
}) => {
const streamingState = React.useContext(StreamingContext)!;
if (streamingState === StreamingState.Responding) {
return <Text>MockRespondingSpinner</Text>;
} else if (nonRespondingDisplay) {
return <Text>{nonRespondingDisplay}</Text>;
}
return null;
},
}));
const renderWithContext = (
ui: React.ReactElement,
streamingStateValue: StreamingState,
) => {
const contextValue: StreamingState = streamingStateValue;
return render(
<StreamingContext.Provider value={contextValue}>
{ui}
</StreamingContext.Provider>,
);
};
describe('<LoadingIndicator />', () => {
const defaultProps = {
currentLoadingPhrase: 'Loading...',
elapsedTime: 5,
};
it('should not render when streamingState is Idle', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Idle,
);
expect(lastFrame()).toBe('');
});
it('should render spinner, phrase, and time when streamingState is Responding', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
});
it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', () => {
const props = {
currentLoadingPhrase: 'Confirm action',
elapsedTime: 10,
};
const { lastFrame } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.WaitingForConfirmation,
);
const output = lastFrame();
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
expect(output).toContain('Confirm action');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 10s');
});
it('should display the currentLoadingPhrase correctly', () => {
const props = {
currentLoadingPhrase: 'Processing data...',
elapsedTime: 3,
};
const { lastFrame } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('Processing data...');
});
it('should display the elapsedTime correctly when Responding', () => {
const props = {
currentLoadingPhrase: 'Working...',
elapsedTime: 60,
};
const { lastFrame } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('(esc to cancel, 1m)');
});
it('should display the elapsedTime correctly in human-readable format', () => {
const props = {
currentLoadingPhrase: 'Working...',
elapsedTime: 125,
};
const { lastFrame } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
});
it('should render rightContent when provided', () => {
const rightContent = <Text>Extra Info</Text>;
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} rightContent={rightContent} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('Extra Info');
});
it('should transition correctly between states using rerender', () => {
const { lastFrame, rerender } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Idle,
);
expect(lastFrame()).toBe(''); // Initial: Idle
// Transition to Responding
rerender(
<StreamingContext.Provider value={StreamingState.Responding}>
<LoadingIndicator
currentLoadingPhrase="Now Responding"
elapsedTime={2}
/>
</StreamingContext.Provider>,
);
let output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Now Responding');
expect(output).toContain('(esc to cancel, 2s)');
// Transition to WaitingForConfirmation
rerender(
<StreamingContext.Provider value={StreamingState.WaitingForConfirmation}>
<LoadingIndicator
currentLoadingPhrase="Please Confirm"
elapsedTime={15}
/>
</StreamingContext.Provider>,
);
output = lastFrame();
expect(output).toContain('⠏');
expect(output).toContain('Please Confirm');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 15s');
// Transition back to Idle
rerender(
<StreamingContext.Provider value={StreamingState.Idle}>
<LoadingIndicator {...defaultProps} />
</StreamingContext.Provider>,
);
expect(lastFrame()).toBe('');
});
it('should display fallback phrase if thought is empty', () => {
const props = {
thought: null,
currentLoadingPhrase: 'Loading...',
elapsedTime: 5,
};
const { lastFrame } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).toContain('Loading...');
});
it('should display the subject of a thought', () => {
const props = {
thought: {
subject: 'Thinking about something...',
description: 'and other stuff.',
},
elapsedTime: 5,
};
const { lastFrame } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).toBeDefined();
if (output) {
expect(output).toContain('Thinking about something...');
expect(output).not.toContain('and other stuff.');
}
});
it('should prioritize thought.subject over currentLoadingPhrase', () => {
const props = {
thought: {
subject: 'This should be displayed',
description: 'A description',
},
currentLoadingPhrase: 'This should not be displayed',
elapsedTime: 5,
};
const { lastFrame } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).toContain('This should be displayed');
expect(output).not.toContain('This should not be displayed');
});
});

View File

@@ -0,0 +1,61 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { ThoughtSummary } from '@qwen/qwen-code-core';
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { formatDuration } from '../utils/formatters.js';
interface LoadingIndicatorProps {
currentLoadingPhrase?: string;
elapsedTime: number;
rightContent?: React.ReactNode;
thought?: ThoughtSummary | null;
}
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
currentLoadingPhrase,
elapsedTime,
rightContent,
thought,
}) => {
const streamingState = useStreamingContext();
if (streamingState === StreamingState.Idle) {
return null;
}
const primaryText = thought?.subject || currentLoadingPhrase;
return (
<Box marginTop={1} paddingLeft={0} flexDirection="column">
{/* Main loading line */}
<Box>
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
}
/>
</Box>
{primaryText && <Text color={Colors.AccentPurple}>{primaryText}</Text>}
<Text color={Colors.Gray}>
{streamingState === StreamingState.WaitingForConfirmation
? ''
: ` (esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`}
</Text>
<Box flexGrow={1}>{/* Spacer */}</Box>
{rightContent && <Box>{rightContent}</Box>}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useEffect, useState } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import process from 'node:process';
import { formatMemoryUsage } from '../utils/formatters.js';
export const MemoryUsageDisplay: React.FC = () => {
const [memoryUsage, setMemoryUsage] = useState<string>('');
const [memoryUsageColor, setMemoryUsageColor] = useState<string>(Colors.Gray);
useEffect(() => {
const updateMemory = () => {
const usage = process.memoryUsage().rss;
setMemoryUsage(formatMemoryUsage(usage));
setMemoryUsageColor(
usage >= 2 * 1024 * 1024 * 1024 ? Colors.AccentRed : Colors.Gray,
);
};
const intervalId = setInterval(updateMemory, 2000);
updateMemory(); // Initial update
return () => clearInterval(intervalId);
}, []);
return (
<Box>
<Text color={Colors.Gray}>| </Text>
<Text color={memoryUsageColor}>{memoryUsage}</Text>
</Box>
);
};

View File

@@ -0,0 +1,239 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
return {
...actual,
useSessionStats: vi.fn(),
};
});
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount: 5,
},
getPromptCount: () => 5,
startNewPrompt: vi.fn(),
});
return render(<ModelStatsDisplay />);
};
describe('<ModelStatsDisplay />', () => {
it('should render "no API calls" message when there are no active models', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
expect(lastFrame()).toContain(
'No API calls have been made in this session.',
);
expect(lastFrame()).toMatchSnapshot();
});
it('should not display conditional rows if no model has data for them', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 10,
candidates: 20,
total: 30,
cached: 0,
thoughts: 0,
tool: 0,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
const output = lastFrame();
expect(output).not.toContain('Cached');
expect(output).not.toContain('Thoughts');
expect(output).not.toContain('Tool');
expect(output).toMatchSnapshot();
});
it('should display conditional rows if at least one model has data', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 10,
candidates: 20,
total: 30,
cached: 5,
thoughts: 2,
tool: 0,
},
},
'gemini-2.5-flash': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 50 },
tokens: {
prompt: 5,
candidates: 10,
total: 15,
cached: 0,
thoughts: 0,
tool: 3,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
const output = lastFrame();
expect(output).toContain('Cached');
expect(output).toContain('Thoughts');
expect(output).toContain('Tool');
expect(output).toMatchSnapshot();
});
it('should display stats for multiple models correctly', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 1000 },
tokens: {
prompt: 100,
candidates: 200,
total: 300,
cached: 50,
thoughts: 10,
tool: 5,
},
},
'gemini-2.5-flash': {
api: { totalRequests: 20, totalErrors: 2, totalLatencyMs: 500 },
tokens: {
prompt: 200,
candidates: 400,
total: 600,
cached: 100,
thoughts: 20,
tool: 10,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
const output = lastFrame();
expect(output).toContain('gemini-2.5-pro');
expect(output).toContain('gemini-2.5-flash');
expect(output).toMatchSnapshot();
});
it('should handle large values without wrapping or overlapping', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: {
totalRequests: 999999999,
totalErrors: 123456789,
totalLatencyMs: 9876,
},
tokens: {
prompt: 987654321,
candidates: 123456789,
total: 999999999,
cached: 123456789,
thoughts: 111111111,
tool: 222222222,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
expect(lastFrame()).toMatchSnapshot();
});
it('should display a single model correctly', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 10,
candidates: 20,
total: 30,
cached: 5,
thoughts: 2,
tool: 1,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
const output = lastFrame();
expect(output).toContain('gemini-2.5-pro');
expect(output).not.toContain('gemini-2.5-flash');
expect(output).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,197 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { formatDuration } from '../utils/formatters.js';
import {
calculateAverageLatency,
calculateCacheHitRate,
calculateErrorRate,
} from '../utils/computeStats.js';
import { useSessionStats, ModelMetrics } from '../contexts/SessionContext.js';
const METRIC_COL_WIDTH = 28;
const MODEL_COL_WIDTH = 22;
interface StatRowProps {
title: string;
values: Array<string | React.ReactElement>;
isSubtle?: boolean;
isSection?: boolean;
}
const StatRow: React.FC<StatRowProps> = ({
title,
values,
isSubtle = false,
isSection = false,
}) => (
<Box>
<Box width={METRIC_COL_WIDTH}>
<Text bold={isSection} color={isSection ? undefined : Colors.LightBlue}>
{isSubtle ? `${title}` : title}
</Text>
</Box>
{values.map((value, index) => (
<Box width={MODEL_COL_WIDTH} key={index}>
<Text>{value}</Text>
</Box>
))}
</Box>
);
export const ModelStatsDisplay: React.FC = () => {
const { stats } = useSessionStats();
const { models } = stats.metrics;
const activeModels = Object.entries(models).filter(
([, metrics]) => metrics.api.totalRequests > 0,
);
if (activeModels.length === 0) {
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
paddingY={1}
paddingX={2}
>
<Text>No API calls have been made in this session.</Text>
</Box>
);
}
const modelNames = activeModels.map(([name]) => name);
const getModelValues = (
getter: (metrics: ModelMetrics) => string | React.ReactElement,
) => activeModels.map(([, metrics]) => getter(metrics));
const hasThoughts = activeModels.some(
([, metrics]) => metrics.tokens.thoughts > 0,
);
const hasTool = activeModels.some(([, metrics]) => metrics.tokens.tool > 0);
const hasCached = activeModels.some(
([, metrics]) => metrics.tokens.cached > 0,
);
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
paddingY={1}
paddingX={2}
>
<Text bold color={Colors.AccentPurple}>
Model Stats For Nerds
</Text>
<Box height={1} />
{/* Header */}
<Box>
<Box width={METRIC_COL_WIDTH}>
<Text bold>Metric</Text>
</Box>
{modelNames.map((name) => (
<Box width={MODEL_COL_WIDTH} key={name}>
<Text bold>{name}</Text>
</Box>
))}
</Box>
{/* Divider */}
<Box
borderStyle="single"
borderBottom={true}
borderTop={false}
borderLeft={false}
borderRight={false}
/>
{/* API Section */}
<StatRow title="API" values={[]} isSection />
<StatRow
title="Requests"
values={getModelValues((m) => m.api.totalRequests.toLocaleString())}
/>
<StatRow
title="Errors"
values={getModelValues((m) => {
const errorRate = calculateErrorRate(m);
return (
<Text
color={
m.api.totalErrors > 0 ? Colors.AccentRed : Colors.Foreground
}
>
{m.api.totalErrors.toLocaleString()} ({errorRate.toFixed(1)}%)
</Text>
);
})}
/>
<StatRow
title="Avg Latency"
values={getModelValues((m) => {
const avgLatency = calculateAverageLatency(m);
return formatDuration(avgLatency);
})}
/>
<Box height={1} />
{/* Tokens Section */}
<StatRow title="Tokens" values={[]} isSection />
<StatRow
title="Total"
values={getModelValues((m) => (
<Text color={Colors.AccentYellow}>
{m.tokens.total.toLocaleString()}
</Text>
))}
/>
<StatRow
title="Prompt"
isSubtle
values={getModelValues((m) => m.tokens.prompt.toLocaleString())}
/>
{hasCached && (
<StatRow
title="Cached"
isSubtle
values={getModelValues((m) => {
const cacheHitRate = calculateCacheHitRate(m);
return (
<Text color={Colors.AccentGreen}>
{m.tokens.cached.toLocaleString()} ({cacheHitRate.toFixed(1)}%)
</Text>
);
})}
/>
)}
{hasThoughts && (
<StatRow
title="Thoughts"
isSubtle
values={getModelValues((m) => m.tokens.thoughts.toLocaleString())}
/>
)}
{hasTool && (
<StatRow
title="Tool"
isSubtle
values={getModelValues((m) => m.tokens.tool.toLocaleString())}
/>
)}
<StatRow
title="Output"
isSubtle
values={getModelValues((m) => m.tokens.candidates.toLocaleString())}
/>
</Box>
);
};

View File

@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
describe('OpenAIKeyPrompt', () => {
it('should render the prompt correctly', () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
);
expect(lastFrame()).toContain('OpenAI Configuration Required');
expect(lastFrame()).toContain('https://platform.openai.com/api-keys');
expect(lastFrame()).toContain(
'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel',
);
});
it('should show the component with proper styling', () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
);
const output = lastFrame();
expect(output).toContain('OpenAI Configuration Required');
expect(output).toContain('API Key:');
expect(output).toContain('Base URL:');
expect(output).toContain('Model:');
expect(output).toContain(
'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel',
);
});
it('should handle paste with control characters', async () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const { stdin } = render(
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
);
// Simulate paste with control characters
const pasteWithControlChars = '\x1b[200~sk-test123\x1b[201~';
stdin.write(pasteWithControlChars);
// Wait a bit for processing
await new Promise((resolve) => setTimeout(resolve, 50));
// The component should have filtered out the control characters
// and only kept 'sk-test123'
expect(onSubmit).not.toHaveBeenCalled(); // Should not submit yet
});
});

View File

@@ -0,0 +1,197 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
interface OpenAIKeyPromptProps {
onSubmit: (apiKey: string, baseUrl: string, model: string) => void;
onCancel: () => void;
}
export function OpenAIKeyPrompt({
onSubmit,
onCancel,
}: OpenAIKeyPromptProps): React.JSX.Element {
const [apiKey, setApiKey] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [model, setModel] = useState('');
const [currentField, setCurrentField] = useState<
'apiKey' | 'baseUrl' | 'model'
>('apiKey');
useInput((input, key) => {
// 过滤粘贴相关的控制序列
let cleanInput = (input || '')
// 过滤 ESC 开头的控制序列(如 \u001b[200~、\u001b[201~ 等)
.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '') // eslint-disable-line no-control-regex
// 过滤粘贴开始标记 [200~
.replace(/\[200~/g, '')
// 过滤粘贴结束标记 [201~
.replace(/\[201~/g, '')
// 过滤单独的 [ 和 ~ 字符(可能是粘贴标记的残留)
.replace(/^\[|~$/g, '');
// 再过滤所有不可见字符ASCII < 32除了回车换行
cleanInput = cleanInput
.split('')
.filter((ch) => ch.charCodeAt(0) >= 32)
.join('');
if (cleanInput.length > 0) {
if (currentField === 'apiKey') {
setApiKey((prev) => prev + cleanInput);
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev + cleanInput);
} else if (currentField === 'model') {
setModel((prev) => prev + cleanInput);
}
return;
}
// 检查是否是 Enter 键(通过检查输入是否包含换行符)
if (input.includes('\n') || input.includes('\r')) {
if (currentField === 'apiKey') {
// 允许空 API key 跳转到下一个字段,让用户稍后可以返回修改
setCurrentField('baseUrl');
return;
} else if (currentField === 'baseUrl') {
setCurrentField('model');
return;
} else if (currentField === 'model') {
// 只有在提交时才检查 API key 是否为空
if (apiKey.trim()) {
onSubmit(apiKey.trim(), baseUrl.trim(), model.trim());
} else {
// 如果 API key 为空,回到 API key 字段
setCurrentField('apiKey');
}
}
return;
}
if (key.escape) {
onCancel();
return;
}
// Handle Tab key for field navigation
if (key.tab) {
if (currentField === 'apiKey') {
setCurrentField('baseUrl');
} else if (currentField === 'baseUrl') {
setCurrentField('model');
} else if (currentField === 'model') {
setCurrentField('apiKey');
}
return;
}
// Handle arrow keys for field navigation
if (key.upArrow) {
if (currentField === 'baseUrl') {
setCurrentField('apiKey');
} else if (currentField === 'model') {
setCurrentField('baseUrl');
}
return;
}
if (key.downArrow) {
if (currentField === 'apiKey') {
setCurrentField('baseUrl');
} else if (currentField === 'baseUrl') {
setCurrentField('model');
}
return;
}
// Handle backspace - check both key.backspace and delete key
if (key.backspace || key.delete) {
if (currentField === 'apiKey') {
setApiKey((prev) => prev.slice(0, -1));
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev.slice(0, -1));
} else if (currentField === 'model') {
setModel((prev) => prev.slice(0, -1));
}
return;
}
});
return (
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={Colors.AccentBlue}>
OpenAI Configuration Required
</Text>
<Box marginTop={1}>
<Text>
Please enter your OpenAI configuration. You can get an API key from{' '}
<Text color={Colors.AccentBlue}>
https://platform.openai.com/api-keys
</Text>
</Text>
</Box>
<Box marginTop={1} flexDirection="row">
<Box width={12}>
<Text
color={currentField === 'apiKey' ? Colors.AccentBlue : Colors.Gray}
>
API Key:
</Text>
</Box>
<Box flexGrow={1}>
<Text>
{currentField === 'apiKey' ? '> ' : ' '}
{apiKey || ' '}
</Text>
</Box>
</Box>
<Box marginTop={1} flexDirection="row">
<Box width={12}>
<Text
color={currentField === 'baseUrl' ? Colors.AccentBlue : Colors.Gray}
>
Base URL:
</Text>
</Box>
<Box flexGrow={1}>
<Text>
{currentField === 'baseUrl' ? '> ' : ' '}
{baseUrl}
</Text>
</Box>
</Box>
<Box marginTop={1} flexDirection="row">
<Box width={12}>
<Text
color={currentField === 'model' ? Colors.AccentBlue : Colors.Gray}
>
Model:
</Text>
</Box>
<Box flexGrow={1}>
<Text>
{currentField === 'model' ? '> ' : ' '}
{model}
</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
Press Enter to continue, Tab/ to navigate, Esc to cancel
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,71 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
return {
...actual,
useSessionStats: vi.fn(),
};
});
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount: 5,
},
getPromptCount: () => 5,
startNewPrompt: vi.fn(),
});
return render(<SessionSummaryDisplay duration="1h 23m 45s" />);
};
describe('<SessionSummaryDisplay />', () => {
it('renders the summary display with a title', () => {
const metrics: SessionMetrics = {
models: {
'gemini-2.5-pro': {
api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 50234 },
tokens: {
prompt: 1000,
candidates: 2000,
total: 3500,
cached: 500,
thoughts: 300,
tool: 200,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { StatsDisplay } from './StatsDisplay.js';
interface SessionSummaryDisplayProps {
duration: string;
}
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
duration,
}) => (
<StatsDisplay title="Agent powering down. Goodbye!" duration={duration} />
);

View File

@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
export const ShellModeIndicator: React.FC = () => (
<Box>
<Text color={Colors.AccentYellow}>
shell mode enabled
<Text color={Colors.Gray}> (esc to disable)</Text>
</Text>
</Box>
);

View File

@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useOverflowState } from '../contexts/OverflowContext.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { Colors } from '../colors.js';
interface ShowMoreLinesProps {
constrainHeight: boolean;
}
export const ShowMoreLines = ({ constrainHeight }: ShowMoreLinesProps) => {
const overflowState = useOverflowState();
const streamingState = useStreamingContext();
if (
overflowState === undefined ||
overflowState.overflowingIds.size === 0 ||
!constrainHeight ||
!(
streamingState === StreamingState.Idle ||
streamingState === StreamingState.WaitingForConfirmation
)
) {
return null;
}
return (
<Box>
<Text color={Colors.Gray} wrap="truncate">
Press ctrl-s to show more lines
</Text>
</Box>
);
};

View File

@@ -0,0 +1,311 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { StatsDisplay } from './StatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
return {
...actual,
useSessionStats: vi.fn(),
};
});
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount: 5,
},
getPromptCount: () => 5,
startNewPrompt: vi.fn(),
});
return render(<StatsDisplay duration="1s" />);
};
describe('<StatsDisplay />', () => {
it('renders only the Performance section in its zero state', () => {
const zeroMetrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const { lastFrame } = renderWithMockedStats(zeroMetrics);
const output = lastFrame();
expect(output).toContain('Performance');
expect(output).not.toContain('Interaction Summary');
expect(output).not.toContain('Efficiency & Optimizations');
expect(output).not.toContain('Model'); // The table header
expect(output).toMatchSnapshot();
});
it('renders a table with two models correctly', () => {
const metrics: SessionMetrics = {
models: {
'gemini-2.5-pro': {
api: { totalRequests: 3, totalErrors: 0, totalLatencyMs: 15000 },
tokens: {
prompt: 1000,
candidates: 2000,
total: 43234,
cached: 500,
thoughts: 100,
tool: 50,
},
},
'gemini-2.5-flash': {
api: { totalRequests: 5, totalErrors: 1, totalLatencyMs: 4500 },
tokens: {
prompt: 25000,
candidates: 15000,
total: 150000000,
cached: 10000,
thoughts: 2000,
tool: 1000,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('gemini-2.5-pro');
expect(output).toContain('gemini-2.5-flash');
expect(output).toContain('1,000');
expect(output).toContain('25,000');
expect(output).toMatchSnapshot();
});
it('renders all sections when all data is present', () => {
const metrics: SessionMetrics = {
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 100,
candidates: 100,
total: 250,
cached: 50,
thoughts: 0,
tool: 0,
},
},
},
tools: {
totalCalls: 2,
totalSuccess: 1,
totalFail: 1,
totalDurationMs: 123,
totalDecisions: { accept: 1, reject: 0, modify: 0 },
byName: {
'test-tool': {
count: 2,
success: 1,
fail: 1,
durationMs: 123,
decisions: { accept: 1, reject: 0, modify: 0 },
},
},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('Performance');
expect(output).toContain('Interaction Summary');
expect(output).toContain('User Agreement');
expect(output).toContain('Savings Highlight');
expect(output).toContain('gemini-2.5-pro');
expect(output).toMatchSnapshot();
});
describe('Conditional Rendering Tests', () => {
it('hides User Agreement when no decisions are made', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 2,
totalSuccess: 1,
totalFail: 1,
totalDurationMs: 123,
totalDecisions: { accept: 0, reject: 0, modify: 0 }, // No decisions
byName: {
'test-tool': {
count: 2,
success: 1,
fail: 1,
durationMs: 123,
decisions: { accept: 0, reject: 0, modify: 0 },
},
},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('Interaction Summary');
expect(output).toContain('Success Rate');
expect(output).not.toContain('User Agreement');
expect(output).toMatchSnapshot();
});
it('hides Efficiency section when cache is not used', () => {
const metrics: SessionMetrics = {
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 100,
candidates: 100,
total: 200,
cached: 0,
thoughts: 0,
tool: 0,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).not.toContain('Efficiency & Optimizations');
expect(output).toMatchSnapshot();
});
});
describe('Conditional Color Tests', () => {
it('renders success rate in green for high values', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 10,
totalSuccess: 10,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
expect(lastFrame()).toMatchSnapshot();
});
it('renders success rate in yellow for medium values', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 10,
totalSuccess: 9,
totalFail: 1,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
expect(lastFrame()).toMatchSnapshot();
});
it('renders success rate in red for low values', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 10,
totalSuccess: 5,
totalFail: 5,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
expect(lastFrame()).toMatchSnapshot();
});
});
describe('Title Rendering', () => {
const zeroMetrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
it('renders the default title when no title prop is provided', () => {
const { lastFrame } = renderWithMockedStats(zeroMetrics);
const output = lastFrame();
expect(output).toContain('Session Stats');
expect(output).not.toContain('Agent powering down');
expect(output).toMatchSnapshot();
});
it('renders the custom title when a title prop is provided', () => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionStartTime: new Date(),
metrics: zeroMetrics,
lastPromptTokenCount: 0,
promptCount: 5,
},
getPromptCount: () => 5,
startNewPrompt: vi.fn(),
});
const { lastFrame } = render(
<StatsDisplay duration="1s" title="Agent powering down. Goodbye!" />,
);
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).not.toContain('Session Stats');
expect(output).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,259 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { Colors } from '../colors.js';
import { formatDuration } from '../utils/formatters.js';
import { useSessionStats, ModelMetrics } from '../contexts/SessionContext.js';
import {
getStatusColor,
TOOL_SUCCESS_RATE_HIGH,
TOOL_SUCCESS_RATE_MEDIUM,
USER_AGREEMENT_RATE_HIGH,
USER_AGREEMENT_RATE_MEDIUM,
} from '../utils/displayUtils.js';
import { computeSessionStats } from '../utils/computeStats.js';
// A more flexible and powerful StatRow component
interface StatRowProps {
title: string;
children: React.ReactNode; // Use children to allow for complex, colored values
}
const StatRow: React.FC<StatRowProps> = ({ title, children }) => (
<Box>
{/* Fixed width for the label creates a clean "gutter" for alignment */}
<Box width={28}>
<Text color={Colors.LightBlue}>{title}</Text>
</Box>
{children}
</Box>
);
// A SubStatRow for indented, secondary information
interface SubStatRowProps {
title: string;
children: React.ReactNode;
}
const SubStatRow: React.FC<SubStatRowProps> = ({ title, children }) => (
<Box paddingLeft={2}>
{/* Adjust width for the "» " prefix */}
<Box width={26}>
<Text>» {title}</Text>
</Box>
{children}
</Box>
);
// A Section component to group related stats
interface SectionProps {
title: string;
children: React.ReactNode;
}
const Section: React.FC<SectionProps> = ({ title, children }) => (
<Box flexDirection="column" width="100%" marginBottom={1}>
<Text bold>{title}</Text>
{children}
</Box>
);
const ModelUsageTable: React.FC<{
models: Record<string, ModelMetrics>;
totalCachedTokens: number;
cacheEfficiency: number;
}> = ({ models, totalCachedTokens, cacheEfficiency }) => {
const nameWidth = 25;
const requestsWidth = 8;
const inputTokensWidth = 15;
const outputTokensWidth = 15;
return (
<Box flexDirection="column" marginTop={1}>
{/* Header */}
<Box>
<Box width={nameWidth}>
<Text bold>Model Usage</Text>
</Box>
<Box width={requestsWidth} justifyContent="flex-end">
<Text bold>Reqs</Text>
</Box>
<Box width={inputTokensWidth} justifyContent="flex-end">
<Text bold>Input Tokens</Text>
</Box>
<Box width={outputTokensWidth} justifyContent="flex-end">
<Text bold>Output Tokens</Text>
</Box>
</Box>
{/* Divider */}
<Box
borderStyle="round"
borderBottom={true}
borderTop={false}
borderLeft={false}
borderRight={false}
width={nameWidth + requestsWidth + inputTokensWidth + outputTokensWidth}
></Box>
{/* Rows */}
{Object.entries(models).map(([name, modelMetrics]) => (
<Box key={name}>
<Box width={nameWidth}>
<Text>{name.replace('-001', '')}</Text>
</Box>
<Box width={requestsWidth} justifyContent="flex-end">
<Text>{modelMetrics.api.totalRequests}</Text>
</Box>
<Box width={inputTokensWidth} justifyContent="flex-end">
<Text color={Colors.AccentYellow}>
{modelMetrics.tokens.prompt.toLocaleString()}
</Text>
</Box>
<Box width={outputTokensWidth} justifyContent="flex-end">
<Text color={Colors.AccentYellow}>
{modelMetrics.tokens.candidates.toLocaleString()}
</Text>
</Box>
</Box>
))}
{cacheEfficiency > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text>
<Text color={Colors.AccentGreen}>Savings Highlight:</Text>{' '}
{totalCachedTokens.toLocaleString()} ({cacheEfficiency.toFixed(1)}
%) of input tokens were served from the cache, reducing costs.
</Text>
<Box height={1} />
<Text color={Colors.Gray}>
» Tip: For a full token breakdown, run `/stats model`.
</Text>
</Box>
)}
</Box>
);
};
interface StatsDisplayProps {
duration: string;
title?: string;
}
export const StatsDisplay: React.FC<StatsDisplayProps> = ({
duration,
title,
}) => {
const { stats } = useSessionStats();
const { metrics } = stats;
const { models, tools } = metrics;
const computed = computeSessionStats(metrics);
const successThresholds = {
green: TOOL_SUCCESS_RATE_HIGH,
yellow: TOOL_SUCCESS_RATE_MEDIUM,
};
const agreementThresholds = {
green: USER_AGREEMENT_RATE_HIGH,
yellow: USER_AGREEMENT_RATE_MEDIUM,
};
const successColor = getStatusColor(computed.successRate, successThresholds);
const agreementColor = getStatusColor(
computed.agreementRate,
agreementThresholds,
);
const renderTitle = () => {
if (title) {
return Colors.GradientColors && Colors.GradientColors.length > 0 ? (
<Gradient colors={Colors.GradientColors}>
<Text bold>{title}</Text>
</Gradient>
) : (
<Text bold color={Colors.AccentPurple}>
{title}
</Text>
);
}
return (
<Text bold color={Colors.AccentPurple}>
Session Stats
</Text>
);
};
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
paddingY={1}
paddingX={2}
>
{renderTitle()}
<Box height={1} />
{tools.totalCalls > 0 && (
<Section title="Interaction Summary">
<StatRow title="Tool Calls:">
<Text>
{tools.totalCalls} ({' '}
<Text color={Colors.AccentGreen}> {tools.totalSuccess}</Text>{' '}
<Text color={Colors.AccentRed}> {tools.totalFail}</Text> )
</Text>
</StatRow>
<StatRow title="Success Rate:">
<Text color={successColor}>{computed.successRate.toFixed(1)}%</Text>
</StatRow>
{computed.totalDecisions > 0 && (
<StatRow title="User Agreement:">
<Text color={agreementColor}>
{computed.agreementRate.toFixed(1)}%{' '}
<Text color={Colors.Gray}>
({computed.totalDecisions} reviewed)
</Text>
</Text>
</StatRow>
)}
</Section>
)}
<Section title="Performance">
<StatRow title="Wall Time:">
<Text>{duration}</Text>
</StatRow>
<StatRow title="Agent Active:">
<Text>{formatDuration(computed.agentActiveTime)}</Text>
</StatRow>
<SubStatRow title="API Time:">
<Text>
{formatDuration(computed.totalApiTime)}{' '}
<Text color={Colors.Gray}>
({computed.apiTimePercent.toFixed(1)}%)
</Text>
</Text>
</SubStatRow>
<SubStatRow title="Tool Time:">
<Text>
{formatDuration(computed.totalToolTime)}{' '}
<Text color={Colors.Gray}>
({computed.toolTimePercent.toFixed(1)}%)
</Text>
</Text>
</SubStatRow>
</Section>
{Object.keys(models).length > 0 && (
<ModelUsageTable
models={models}
totalCachedTokens={computed.totalCachedTokens}
cacheEfficiency={computed.cacheEfficiency}
/>
)}
</Box>
);
};

View File

@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
export interface Suggestion {
label: string;
value: string;
description?: string;
}
interface SuggestionsDisplayProps {
suggestions: Suggestion[];
activeIndex: number;
isLoading: boolean;
width: number;
scrollOffset: number;
userInput: string;
}
export const MAX_SUGGESTIONS_TO_SHOW = 8;
export function SuggestionsDisplay({
suggestions,
activeIndex,
isLoading,
width,
scrollOffset,
userInput,
}: SuggestionsDisplayProps) {
if (isLoading) {
return (
<Box paddingX={1} width={width}>
<Text color="gray">Loading suggestions...</Text>
</Box>
);
}
if (suggestions.length === 0) {
return null; // Don't render anything if there are no suggestions
}
// Calculate the visible slice based on scrollOffset
const startIndex = scrollOffset;
const endIndex = Math.min(
scrollOffset + MAX_SUGGESTIONS_TO_SHOW,
suggestions.length,
);
const visibleSuggestions = suggestions.slice(startIndex, endIndex);
return (
<Box flexDirection="column" paddingX={1} width={width}>
{scrollOffset > 0 && <Text color={Colors.Foreground}></Text>}
{visibleSuggestions.map((suggestion, index) => {
const originalIndex = startIndex + index;
const isActive = originalIndex === activeIndex;
const textColor = isActive ? Colors.AccentPurple : Colors.Gray;
return (
<Box key={`${suggestion}-${originalIndex}`} width={width}>
<Box flexDirection="row">
{userInput.startsWith('/') ? (
// only use box model for (/) command mode
<Box width={20} flexShrink={0}>
<Text color={textColor}>{suggestion.label}</Text>
</Box>
) : (
// use regular text for other modes (@ context)
<Text color={textColor}>{suggestion.label}</Text>
)}
{suggestion.description ? (
<Box flexGrow={1}>
<Text color={textColor} wrap="wrap">
{suggestion.description}
</Text>
</Box>
) : null}
</Box>
</Box>
);
})}
{endIndex < suggestions.length && <Text color="gray"></Text>}
{suggestions.length > MAX_SUGGESTIONS_TO_SHOW && (
<Text color="gray">
({activeIndex + 1}/{suggestions.length})
</Text>
)}
</Box>
);
}

View File

@@ -0,0 +1,272 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useCallback, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { DiffRenderer } from './messages/DiffRenderer.js';
import { colorizeCode } from '../utils/CodeColorizer.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
interface ThemeDialogProps {
/** Callback function when a theme is selected */
onSelect: (themeName: string | undefined, scope: SettingScope) => void;
/** Callback function when a theme is highlighted */
onHighlight: (themeName: string | undefined) => void;
/** The settings object */
settings: LoadedSettings;
availableTerminalHeight?: number;
terminalWidth: number;
}
export function ThemeDialog({
onSelect,
onHighlight,
settings,
availableTerminalHeight,
terminalWidth,
}: ThemeDialogProps): React.JSX.Element {
const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.User,
);
// Generate theme items
const themeItems = themeManager.getAvailableThemes().map((theme) => {
const typeString = theme.type.charAt(0).toUpperCase() + theme.type.slice(1);
return {
label: theme.name,
value: theme.name,
themeNameDisplay: theme.name,
themeTypeDisplay: typeString,
};
});
const [selectInputKey, setSelectInputKey] = useState(Date.now());
// Determine which radio button should be initially selected in the theme list
// This should reflect the theme *saved* for the selected scope, or the default
const initialThemeIndex = themeItems.findIndex(
(item) => item.value === (settings.merged.theme || DEFAULT_THEME.name),
);
const scopeItems = [
{ label: 'User Settings', value: SettingScope.User },
{ label: 'Workspace Settings', value: SettingScope.Workspace },
{ label: 'System Settings', value: SettingScope.System },
];
const handleThemeSelect = useCallback(
(themeName: string) => {
onSelect(themeName, selectedScope);
},
[onSelect, selectedScope],
);
const handleScopeHighlight = useCallback((scope: SettingScope) => {
setSelectedScope(scope);
setSelectInputKey(Date.now());
}, []);
const handleScopeSelect = useCallback(
(scope: SettingScope) => {
handleScopeHighlight(scope);
setFocusedSection('theme'); // Reset focus to theme section
},
[handleScopeHighlight],
);
const [focusedSection, setFocusedSection] = useState<'theme' | 'scope'>(
'theme',
);
useInput((input, key) => {
if (key.tab) {
setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme'));
}
if (key.escape) {
onSelect(undefined, selectedScope);
}
});
const otherScopes = Object.values(SettingScope).filter(
(scope) => scope !== selectedScope,
);
const modifiedInOtherScopes = otherScopes.filter(
(scope) => settings.forScope(scope).settings.theme !== undefined,
);
let otherScopeModifiedMessage = '';
if (modifiedInOtherScopes.length > 0) {
const modifiedScopesStr = modifiedInOtherScopes.join(', ');
otherScopeModifiedMessage =
settings.forScope(selectedScope).settings.theme !== undefined
? `(Also modified in ${modifiedScopesStr})`
: `(Modified in ${modifiedScopesStr})`;
}
// Constants for calculating preview pane layout.
// These values are based on the JSX structure below.
const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;
// A safety margin to prevent text from touching the border.
// This is a complete hack unrelated to the 0.9 used in App.tsx
const PREVIEW_PANE_WIDTH_SAFETY_MARGIN = 0.9;
// Combined horizontal padding from the dialog and preview pane.
const TOTAL_HORIZONTAL_PADDING = 4;
const colorizeCodeWidth = Math.max(
Math.floor(
(terminalWidth - TOTAL_HORIZONTAL_PADDING) *
PREVIEW_PANE_WIDTH_PERCENTAGE *
PREVIEW_PANE_WIDTH_SAFETY_MARGIN,
),
1,
);
const DIALOG_PADDING = 2;
const selectThemeHeight = themeItems.length + 1;
const SCOPE_SELECTION_HEIGHT = 4; // Height for the scope selection section + margin.
const SPACE_BETWEEN_THEME_SELECTION_AND_APPLY_TO = 1;
const TAB_TO_SELECT_HEIGHT = 2;
availableTerminalHeight = availableTerminalHeight ?? Number.MAX_SAFE_INTEGER;
availableTerminalHeight -= 2; // Top and bottom borders.
availableTerminalHeight -= TAB_TO_SELECT_HEIGHT;
let totalLeftHandSideHeight =
DIALOG_PADDING +
selectThemeHeight +
SCOPE_SELECTION_HEIGHT +
SPACE_BETWEEN_THEME_SELECTION_AND_APPLY_TO;
let showScopeSelection = true;
let includePadding = true;
// Remove content from the LHS that can be omitted if it exceeds the available height.
if (totalLeftHandSideHeight > availableTerminalHeight) {
includePadding = false;
totalLeftHandSideHeight -= DIALOG_PADDING;
}
if (totalLeftHandSideHeight > availableTerminalHeight) {
// First, try hiding the scope selection
totalLeftHandSideHeight -= SCOPE_SELECTION_HEIGHT;
showScopeSelection = false;
}
// Don't focus the scope selection if it is hidden due to height constraints.
const currenFocusedSection = !showScopeSelection ? 'theme' : focusedSection;
// Vertical space taken by elements other than the two code blocks in the preview pane.
// Includes "Preview" title, borders, and margin between blocks.
const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8;
// The right column doesn't need to ever be shorter than the left column.
availableTerminalHeight = Math.max(
availableTerminalHeight,
totalLeftHandSideHeight,
);
const availableTerminalHeightCodeBlock =
availableTerminalHeight -
PREVIEW_PANE_FIXED_VERTICAL_SPACE -
(includePadding ? 2 : 0) * 2;
// Give slightly more space to the code block as it is 3 lines longer.
const diffHeight = Math.floor(availableTerminalHeightCodeBlock / 2) - 1;
const codeBlockHeight = Math.ceil(availableTerminalHeightCodeBlock / 2) + 1;
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
paddingTop={includePadding ? 1 : 0}
paddingBottom={includePadding ? 1 : 0}
paddingLeft={1}
paddingRight={1}
width="100%"
>
<Box flexDirection="row">
{/* Left Column: Selection */}
<Box flexDirection="column" width="45%" paddingRight={2}>
<Text bold={currenFocusedSection === 'theme'} wrap="truncate">
{currenFocusedSection === 'theme' ? '> ' : ' '}Select Theme{' '}
<Text color={Colors.Gray}>{otherScopeModifiedMessage}</Text>
</Text>
<RadioButtonSelect
key={selectInputKey}
items={themeItems}
initialIndex={initialThemeIndex}
onSelect={handleThemeSelect}
onHighlight={onHighlight}
isFocused={currenFocusedSection === 'theme'}
maxItemsToShow={8}
showScrollArrows={true}
/>
{/* Scope Selection */}
{showScopeSelection && (
<Box marginTop={1} flexDirection="column">
<Text bold={currenFocusedSection === 'scope'} wrap="truncate">
{currenFocusedSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={0} // Default to User Settings
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={currenFocusedSection === 'scope'}
/>
</Box>
)}
</Box>
{/* Right Column: Preview */}
<Box flexDirection="column" width="55%" paddingLeft={2}>
<Text bold>Preview</Text>
<Box
borderStyle="single"
borderColor={Colors.Gray}
paddingTop={includePadding ? 1 : 0}
paddingBottom={includePadding ? 1 : 0}
paddingLeft={1}
paddingRight={1}
flexDirection="column"
>
{colorizeCode(
`# function
-def fibonacci(n):
- a, b = 0, 1
- for _ in range(n):
- a, b = b, a + b
- return a`,
'python',
codeBlockHeight,
colorizeCodeWidth,
)}
<Box marginTop={1} />
<DiffRenderer
diffContent={`--- a/old_file.txt
-+++ b/new_file.txt
-@@ -1,4 +1,5 @@
- This is a context line.
--This line was deleted.
-+This line was added.
-`}
availableTerminalHeight={diffHeight}
terminalWidth={colorizeCodeWidth}
/>
</Box>
</Box>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray} wrap="truncate">
(Use Enter to select
{showScopeSelection ? ', Tab to change focus' : ''})
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { type Config } from '@qwen/qwen-code-core';
interface TipsProps {
config: Config;
}
export const Tips: React.FC<TipsProps> = ({ config }) => {
const geminiMdFileCount = config.getGeminiMdFileCount();
return (
<Box flexDirection="column" marginBottom={1}>
<Text color={Colors.Foreground}>Tips for getting started:</Text>
<Text color={Colors.Foreground}>
1. Ask questions, edit files, or run commands.
</Text>
<Text color={Colors.Foreground}>
2. Be specific for the best results.
</Text>
{geminiMdFileCount === 0 && (
<Text color={Colors.Foreground}>
3. Create{' '}
<Text bold color={Colors.AccentPurple}>
QWEN.md
</Text>{' '}
files to customize your interactions with Qwen Code.
</Text>
)}
<Text color={Colors.Foreground}>
{geminiMdFileCount === 0 ? '4.' : '3.'}{' '}
<Text bold color={Colors.AccentPurple}>
/help
</Text>{' '}
for more information.
</Text>
</Box>
);
};

View File

@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
return {
...actual,
useSessionStats: vi.fn(),
};
});
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount: 5,
},
getPromptCount: () => 5,
startNewPrompt: vi.fn(),
});
return render(<ToolStatsDisplay />);
};
describe('<ToolStatsDisplay />', () => {
it('should render "no tool calls" message when there are no active tools', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
expect(lastFrame()).toContain(
'No tool calls have been made in this session.',
);
expect(lastFrame()).toMatchSnapshot();
});
it('should display stats for a single tool correctly', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 1,
totalSuccess: 1,
totalFail: 0,
totalDurationMs: 100,
totalDecisions: { accept: 1, reject: 0, modify: 0 },
byName: {
'test-tool': {
count: 1,
success: 1,
fail: 0,
durationMs: 100,
decisions: { accept: 1, reject: 0, modify: 0 },
},
},
},
});
const output = lastFrame();
expect(output).toContain('test-tool');
expect(output).toMatchSnapshot();
});
it('should display stats for multiple tools correctly', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 3,
totalSuccess: 2,
totalFail: 1,
totalDurationMs: 300,
totalDecisions: { accept: 1, reject: 1, modify: 1 },
byName: {
'tool-a': {
count: 2,
success: 1,
fail: 1,
durationMs: 200,
decisions: { accept: 1, reject: 1, modify: 0 },
},
'tool-b': {
count: 1,
success: 1,
fail: 0,
durationMs: 100,
decisions: { accept: 0, reject: 0, modify: 1 },
},
},
},
});
const output = lastFrame();
expect(output).toContain('tool-a');
expect(output).toContain('tool-b');
expect(output).toMatchSnapshot();
});
it('should handle large values without wrapping or overlapping', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 999999999,
totalSuccess: 888888888,
totalFail: 111111111,
totalDurationMs: 987654321,
totalDecisions: {
accept: 123456789,
reject: 98765432,
modify: 12345,
},
byName: {
'long-named-tool-for-testing-wrapping-and-such': {
count: 999999999,
success: 888888888,
fail: 111111111,
durationMs: 987654321,
decisions: {
accept: 123456789,
reject: 98765432,
modify: 12345,
},
},
},
},
});
expect(lastFrame()).toMatchSnapshot();
});
it('should handle zero decisions gracefully', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 1,
totalSuccess: 1,
totalFail: 0,
totalDurationMs: 100,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {
'test-tool': {
count: 1,
success: 1,
fail: 0,
durationMs: 100,
decisions: { accept: 0, reject: 0, modify: 0 },
},
},
},
});
const output = lastFrame();
expect(output).toContain('Total Reviewed Suggestions:');
expect(output).toContain('0');
expect(output).toContain('Overall Agreement Rate:');
expect(output).toContain('--');
expect(output).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,208 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { formatDuration } from '../utils/formatters.js';
import {
getStatusColor,
TOOL_SUCCESS_RATE_HIGH,
TOOL_SUCCESS_RATE_MEDIUM,
USER_AGREEMENT_RATE_HIGH,
USER_AGREEMENT_RATE_MEDIUM,
} from '../utils/displayUtils.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { ToolCallStats } from '@qwen/qwen-code-core';
const TOOL_NAME_COL_WIDTH = 25;
const CALLS_COL_WIDTH = 8;
const SUCCESS_RATE_COL_WIDTH = 15;
const AVG_DURATION_COL_WIDTH = 15;
const StatRow: React.FC<{
name: string;
stats: ToolCallStats;
}> = ({ name, stats }) => {
const successRate = stats.count > 0 ? (stats.success / stats.count) * 100 : 0;
const avgDuration = stats.count > 0 ? stats.durationMs / stats.count : 0;
const successColor = getStatusColor(successRate, {
green: TOOL_SUCCESS_RATE_HIGH,
yellow: TOOL_SUCCESS_RATE_MEDIUM,
});
return (
<Box>
<Box width={TOOL_NAME_COL_WIDTH}>
<Text color={Colors.LightBlue}>{name}</Text>
</Box>
<Box width={CALLS_COL_WIDTH} justifyContent="flex-end">
<Text>{stats.count}</Text>
</Box>
<Box width={SUCCESS_RATE_COL_WIDTH} justifyContent="flex-end">
<Text color={successColor}>{successRate.toFixed(1)}%</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text>{formatDuration(avgDuration)}</Text>
</Box>
</Box>
);
};
export const ToolStatsDisplay: React.FC = () => {
const { stats } = useSessionStats();
const { tools } = stats.metrics;
const activeTools = Object.entries(tools.byName).filter(
([, metrics]) => metrics.count > 0,
);
if (activeTools.length === 0) {
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
paddingY={1}
paddingX={2}
>
<Text>No tool calls have been made in this session.</Text>
</Box>
);
}
const totalDecisions = Object.values(tools.byName).reduce(
(acc, tool) => {
acc.accept += tool.decisions.accept;
acc.reject += tool.decisions.reject;
acc.modify += tool.decisions.modify;
return acc;
},
{ accept: 0, reject: 0, modify: 0 },
);
const totalReviewed =
totalDecisions.accept + totalDecisions.reject + totalDecisions.modify;
const agreementRate =
totalReviewed > 0 ? (totalDecisions.accept / totalReviewed) * 100 : 0;
const agreementColor = getStatusColor(agreementRate, {
green: USER_AGREEMENT_RATE_HIGH,
yellow: USER_AGREEMENT_RATE_MEDIUM,
});
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
paddingY={1}
paddingX={2}
width={70}
>
<Text bold color={Colors.AccentPurple}>
Tool Stats For Nerds
</Text>
<Box height={1} />
{/* Header */}
<Box>
<Box width={TOOL_NAME_COL_WIDTH}>
<Text bold>Tool Name</Text>
</Box>
<Box width={CALLS_COL_WIDTH} justifyContent="flex-end">
<Text bold>Calls</Text>
</Box>
<Box width={SUCCESS_RATE_COL_WIDTH} justifyContent="flex-end">
<Text bold>Success Rate</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text bold>Avg Duration</Text>
</Box>
</Box>
{/* Divider */}
<Box
borderStyle="single"
borderBottom={true}
borderTop={false}
borderLeft={false}
borderRight={false}
width="100%"
/>
{/* Tool Rows */}
{activeTools.map(([name, stats]) => (
<StatRow key={name} name={name} stats={stats as ToolCallStats} />
))}
<Box height={1} />
{/* User Decision Summary */}
<Text bold>User Decision Summary</Text>
<Box>
<Box
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
>
<Text color={Colors.LightBlue}>Total Reviewed Suggestions:</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text>{totalReviewed}</Text>
</Box>
</Box>
<Box>
<Box
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
>
<Text> » Accepted:</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text color={Colors.AccentGreen}>{totalDecisions.accept}</Text>
</Box>
</Box>
<Box>
<Box
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
>
<Text> » Rejected:</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text color={Colors.AccentRed}>{totalDecisions.reject}</Text>
</Box>
</Box>
<Box>
<Box
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
>
<Text> » Modified:</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text color={Colors.AccentYellow}>{totalDecisions.modify}</Text>
</Box>
</Box>
{/* Divider */}
<Box
borderStyle="single"
borderBottom={true}
borderTop={false}
borderLeft={false}
borderRight={false}
width="100%"
/>
<Box>
<Box
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
>
<Text> Overall Agreement Rate:</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text bold color={totalReviewed > 0 ? agreementColor : undefined}>
{totalReviewed > 0 ? `${agreementRate.toFixed(1)}%` : '--'}
</Text>
</Box>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,23 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
interface UpdateNotificationProps {
message: string;
}
export const UpdateNotification = ({ message }: UpdateNotificationProps) => (
<Box
borderStyle="round"
borderColor={Colors.AccentYellow}
paddingX={1}
marginY={1}
>
<Text color={Colors.AccentYellow}>{message}</Text>
</Box>
);

View File

@@ -0,0 +1,121 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ModelStatsDisplay /> > should display a single model correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 1 │
│ Errors 0 (0.0%) │
│ Avg Latency 100ms │
│ │
│ Tokens │
│ Total 30 │
│ ↳ Prompt 10 │
│ ↳ Cached 5 (50.0%) │
│ ↳ Thoughts 2 │
│ ↳ Tool 1 │
│ ↳ Output 20 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should display conditional rows if at least one model has data 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro gemini-2.5-flash │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 1 1 │
│ Errors 0 (0.0%) 0 (0.0%) │
│ Avg Latency 100ms 50ms │
│ │
│ Tokens │
│ Total 30 15 │
│ ↳ Prompt 10 5 │
│ ↳ Cached 5 (50.0%) 0 (0.0%) │
│ ↳ Thoughts 2 0 │
│ ↳ Tool 0 3 │
│ ↳ Output 20 10 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should display stats for multiple models correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro gemini-2.5-flash │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 10 20 │
│ Errors 1 (10.0%) 2 (10.0%) │
│ Avg Latency 100ms 25ms │
│ │
│ Tokens │
│ Total 300 600 │
│ ↳ Prompt 100 200 │
│ ↳ Cached 50 (50.0%) 100 (50.0%) │
│ ↳ Thoughts 10 20 │
│ ↳ Tool 5 10 │
│ ↳ Output 200 400 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should handle large values without wrapping or overlapping 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 999,999,999 │
│ Errors 123,456,789 (12.3%) │
│ Avg Latency 0ms │
│ │
│ Tokens │
│ Total 999,999,999 │
│ ↳ Prompt 987,654,321 │
│ ↳ Cached 123,456,789 (12.5%) │
│ ↳ Thoughts 111,111,111 │
│ ↳ Tool 222,222,222 │
│ ↳ Output 123,456,789 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should not display conditional rows if no model has data for them 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 1 │
│ Errors 0 (0.0%) │
│ Avg Latency 100ms │
│ │
│ Tokens │
│ Total 30 │
│ ↳ Prompt 10 │
│ ↳ Output 20 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should render "no API calls" message when there are no active models 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ No API calls have been made in this session. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -0,0 +1,24 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Agent powering down. Goodbye! │
│ │
│ Performance │
│ Wall Time: 1h 23m 45s │
│ Agent Active: 50.2s │
│ » API Time: 50.2s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens │
│ ─────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 10 1,000 2,000 │
│ │
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -0,0 +1,193 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in green for high values 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Tool Calls: 10 ( ✔ 10 ✖ 0 ) │
│ Success Rate: 100.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in red for low values 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Tool Calls: 10 ( ✔ 5 ✖ 5 ) │
│ Success Rate: 50.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in yellow for medium values 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Tool Calls: 10 ( ✔ 9 ✖ 1 ) │
│ Success Rate: 90.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency section when cache is not used 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 100ms │
│ » API Time: 100ms (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens │
│ ─────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 1 100 100 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Conditional Rendering Tests > hides User Agreement when no decisions are made 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Tool Calls: 2 ( ✔ 1 ✖ 1 ) │
│ Success Rate: 50.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 123ms │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 123ms (100.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Title Rendering > renders the custom title when a title prop is provided 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Agent powering down. Goodbye! │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Title Rendering > renders the default title when no title prop is provided 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 19.5s │
│ » API Time: 19.5s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens │
│ ─────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 3 1,000 2,000 │
│ gemini-2.5-flash 5 25,000 15,000 │
│ │
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > renders all sections when all data is present 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Tool Calls: 2 ( ✔ 1 ✖ 1 ) │
│ Success Rate: 50.0% │
│ User Agreement: 100.0% (1 reviewed) │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 223ms │
│ » API Time: 100ms (44.8%) │
│ » Tool Time: 123ms (55.2%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens │
│ ─────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 1 100 100 │
│ │
│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > renders only the Performance section in its zero state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -0,0 +1,91 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolStatsDisplay /> > should display stats for a single tool correctly 1`] = `
"╭────────────────────────────────────────────────────────────────────╮
│ │
│ Tool Stats For Nerds │
│ │
│ Tool Name Calls Success Rate Avg Duration │
│ ──────────────────────────────────────────────────────────────── │
│ test-tool 1 100.0% 100ms │
│ │
│ User Decision Summary │
│ Total Reviewed Suggestions: 1 │
│ » Accepted: 1 │
│ » Rejected: 0 │
│ » Modified: 0 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: 100.0% │
│ │
╰────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolStatsDisplay /> > should display stats for multiple tools correctly 1`] = `
"╭────────────────────────────────────────────────────────────────────╮
│ │
│ Tool Stats For Nerds │
│ │
│ Tool Name Calls Success Rate Avg Duration │
│ ──────────────────────────────────────────────────────────────── │
│ tool-a 2 50.0% 100ms │
│ tool-b 1 100.0% 100ms │
│ │
│ User Decision Summary │
│ Total Reviewed Suggestions: 3 │
│ » Accepted: 1 │
│ » Rejected: 1 │
│ » Modified: 1 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: 33.3% │
│ │
╰────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolStatsDisplay /> > should handle large values without wrapping or overlapping 1`] = `
"╭────────────────────────────────────────────────────────────────────╮
│ │
│ Tool Stats For Nerds │
│ │
│ Tool Name Calls Success Rate Avg Duration │
│ ──────────────────────────────────────────────────────────────── │
│ long-named-tool-for-testi99999999 88.9% 1ms │
│ ng-wrapping-and-such 9 │
│ │
│ User Decision Summary │
│ Total Reviewed Suggestions: 222234566 │
│ » Accepted: 123456789 │
│ » Rejected: 98765432 │
│ » Modified: 12345 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: 55.6% │
│ │
╰────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolStatsDisplay /> > should handle zero decisions gracefully 1`] = `
"╭────────────────────────────────────────────────────────────────────╮
│ │
│ Tool Stats For Nerds │
│ │
│ Tool Name Calls Success Rate Avg Duration │
│ ──────────────────────────────────────────────────────────────── │
│ test-tool 1 100.0% 100ms │
│ │
│ User Decision Summary │
│ Total Reviewed Suggestions: 0 │
│ » Accepted: 0 │
│ » Rejected: 0 │
│ » Modified: 0 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: -- │
│ │
╰────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolStatsDisplay /> > should render "no tool calls" message when there are no active tools 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ No tool calls have been made in this session. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { CompressionProps } from '../../types.js';
import Spinner from 'ink-spinner';
import { Colors } from '../../colors.js';
export interface CompressionDisplayProps {
compression: CompressionProps;
}
/*
* Compression messages appear when the /compress command is run, and show a loading spinner
* while compression is in progress, followed up by some compression stats.
*/
export const CompressionMessage: React.FC<CompressionDisplayProps> = ({
compression,
}) => {
const text = compression.isPending
? 'Compressing chat history'
: `Chat history compressed from ${compression.originalTokenCount ?? 'unknown'}` +
` to ${compression.newTokenCount ?? 'unknown'} tokens.`;
return (
<Box flexDirection="row">
<Box marginRight={1}>
{compression.isPending ? (
<Spinner type="dots" />
) : (
<Text color={Colors.AccentPurple}></Text>
)}
</Box>
<Box>
<Text
color={
compression.isPending ? Colors.AccentPurple : Colors.AccentGreen
}
>
{text}
</Text>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,362 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { render } from 'ink-testing-library';
import { DiffRenderer } from './DiffRenderer.js';
import * as CodeColorizer from '../../utils/CodeColorizer.js';
import { vi } from 'vitest';
describe('<OverflowProvider><DiffRenderer /></OverflowProvider>', () => {
const mockColorizeCode = vi.spyOn(CodeColorizer, 'colorizeCode');
beforeEach(() => {
mockColorizeCode.mockClear();
});
const sanitizeOutput = (output: string | undefined, terminalWidth: number) =>
output?.replace(/GAP_INDICATOR/g, '═'.repeat(terminalWidth));
it('should call colorizeCode with correct language for new file with known extension', () => {
const newFileDiffContent = `
diff --git a/test.py b/test.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test.py
@@ -0,0 +1 @@
+print("hello world")
`;
render(
<OverflowProvider>
<DiffRenderer
diffContent={newFileDiffContent}
filename="test.py"
terminalWidth={80}
/>
</OverflowProvider>,
);
expect(mockColorizeCode).toHaveBeenCalledWith(
'print("hello world")',
'python',
undefined,
80,
);
});
it('should call colorizeCode with null language for new file with unknown extension', () => {
const newFileDiffContent = `
diff --git a/test.unknown b/test.unknown
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test.unknown
@@ -0,0 +1 @@
+some content
`;
render(
<OverflowProvider>
<DiffRenderer
diffContent={newFileDiffContent}
filename="test.unknown"
terminalWidth={80}
/>
</OverflowProvider>,
);
expect(mockColorizeCode).toHaveBeenCalledWith(
'some content',
null,
undefined,
80,
);
});
it('should call colorizeCode with null language for new file if no filename is provided', () => {
const newFileDiffContent = `
diff --git a/test.txt b/test.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test.txt
@@ -0,0 +1 @@
+some text content
`;
render(
<OverflowProvider>
<DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />
</OverflowProvider>,
);
expect(mockColorizeCode).toHaveBeenCalledWith(
'some text content',
null,
undefined,
80,
);
});
it('should render diff content for existing file (not calling colorizeCode directly for the whole block)', () => {
const existingFileDiffContent = `
diff --git a/test.txt b/test.txt
index 0000001..0000002 100644
--- a/test.txt
+++ b/test.txt
@@ -1 +1 @@
-old line
+new line
`;
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={existingFileDiffContent}
filename="test.txt"
terminalWidth={80}
/>
</OverflowProvider>,
);
// colorizeCode is used internally by the line-by-line rendering, not for the whole block
expect(mockColorizeCode).not.toHaveBeenCalledWith(
expect.stringContaining('old line'),
expect.anything(),
);
expect(mockColorizeCode).not.toHaveBeenCalledWith(
expect.stringContaining('new line'),
expect.anything(),
);
const output = lastFrame();
const lines = output!.split('\n');
expect(lines[0]).toBe('1 - old line');
expect(lines[1]).toBe('1 + new line');
});
it('should handle diff with only header and no changes', () => {
const noChangeDiff = `diff --git a/file.txt b/file.txt
index 1234567..1234567 100644
--- a/file.txt
+++ b/file.txt
`;
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={noChangeDiff}
filename="file.txt"
terminalWidth={80}
/>
</OverflowProvider>,
);
expect(lastFrame()).toContain('No changes detected');
expect(mockColorizeCode).not.toHaveBeenCalled();
});
it('should handle empty diff content', () => {
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer diffContent="" terminalWidth={80} />
</OverflowProvider>,
);
expect(lastFrame()).toContain('No diff content');
expect(mockColorizeCode).not.toHaveBeenCalled();
});
it('should render a gap indicator for skipped lines', () => {
const diffWithGap = `
diff --git a/file.txt b/file.txt
index 123..456 100644
--- a/file.txt
+++ b/file.txt
@@ -1,2 +1,2 @@
context line 1
-deleted line
+added line
@@ -10,2 +10,2 @@
context line 10
context line 11
`;
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={diffWithGap}
filename="file.txt"
terminalWidth={80}
/>
</OverflowProvider>,
);
const output = lastFrame();
expect(output).toContain('═'); // Check for the border character used in the gap
// Verify that lines before and after the gap are rendered
expect(output).toContain('context line 1');
expect(output).toContain('added line');
expect(output).toContain('context line 10');
});
it('should not render a gap indicator for small gaps (<= MAX_CONTEXT_LINES_WITHOUT_GAP)', () => {
const diffWithSmallGap = `
diff --git a/file.txt b/file.txt
index abc..def 100644
--- a/file.txt
+++ b/file.txt
@@ -1,5 +1,5 @@
context line 1
context line 2
context line 3
context line 4
context line 5
@@ -11,5 +11,5 @@
context line 11
context line 12
context line 13
context line 14
context line 15
`;
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={diffWithSmallGap}
filename="file.txt"
terminalWidth={80}
/>
</OverflowProvider>,
);
const output = lastFrame();
expect(output).not.toContain('═'); // Ensure no separator is rendered
// Verify that lines before and after the gap are rendered
expect(output).toContain('context line 5');
expect(output).toContain('context line 11');
});
describe('should correctly render a diff with multiple hunks and a gap indicator', () => {
const diffWithMultipleHunks = `
diff --git a/multi.js b/multi.js
index 123..789 100644
--- a/multi.js
+++ b/multi.js
@@ -1,3 +1,3 @@
console.log('first hunk');
-const oldVar = 1;
+const newVar = 1;
console.log('end of first hunk');
@@ -20,3 +20,3 @@
console.log('second hunk');
-const anotherOld = 'test';
+const anotherNew = 'test';
console.log('end of second hunk');
`;
it.each([
{
terminalWidth: 80,
height: undefined,
expected: `1 console.log('first hunk');
2 - const oldVar = 1;
2 + const newVar = 1;
3 console.log('end of first hunk');
════════════════════════════════════════════════════════════════════════════════
20 console.log('second hunk');
21 - const anotherOld = 'test';
21 + const anotherNew = 'test';
22 console.log('end of second hunk');`,
},
{
terminalWidth: 80,
height: 6,
expected: `... first 4 lines hidden ...
════════════════════════════════════════════════════════════════════════════════
20 console.log('second hunk');
21 - const anotherOld = 'test';
21 + const anotherNew = 'test';
22 console.log('end of second hunk');`,
},
{
terminalWidth: 30,
height: 6,
expected: `... first 10 lines hidden ...
'test';
21 + const anotherNew =
'test';
22 console.log('end of
second hunk');`,
},
])(
'with terminalWidth $terminalWidth and height $height',
({ terminalWidth, height, expected }) => {
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={diffWithMultipleHunks}
filename="multi.js"
terminalWidth={terminalWidth}
availableTerminalHeight={height}
/>
</OverflowProvider>,
);
const output = lastFrame();
expect(sanitizeOutput(output, terminalWidth)).toEqual(expected);
},
);
});
it('should correctly render a diff with a SVN diff format', () => {
const newFileDiff = `
fileDiff Index: file.txt
===================================================================
--- a/file.txt Current
+++ b/file.txt Proposed
--- a/multi.js
+++ b/multi.js
@@ -1,1 +1,1 @@
-const oldVar = 1;
+const newVar = 1;
@@ -20,1 +20,1 @@
-const anotherOld = 'test';
+const anotherNew = 'test';
\\ No newline at end of file
`;
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={newFileDiff}
filename="TEST"
terminalWidth={80}
/>
</OverflowProvider>,
);
const output = lastFrame();
expect(output).toEqual(`1 - const oldVar = 1;
1 + const newVar = 1;
════════════════════════════════════════════════════════════════════════════════
20 - const anotherOld = 'test';
20 + const anotherNew = 'test';`);
});
it('should correctly render a new file with no file extension correctly', () => {
const newFileDiff = `
fileDiff Index: Dockerfile
===================================================================
--- Dockerfile Current
+++ Dockerfile Proposed
@@ -0,0 +1,3 @@
+FROM node:14
+RUN npm install
+RUN npm run build
\\ No newline at end of file
`;
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={newFileDiff}
filename="Dockerfile"
terminalWidth={80}
/>
</OverflowProvider>,
);
const output = lastFrame();
expect(output).toEqual(`1 FROM node:14
2 RUN npm install
3 RUN npm run build`);
});
});

View File

@@ -0,0 +1,312 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import crypto from 'crypto';
import { colorizeCode } from '../../utils/CodeColorizer.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
interface DiffLine {
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
oldLine?: number;
newLine?: number;
content: string;
}
function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
const lines = diffContent.split('\n');
const result: DiffLine[] = [];
let currentOldLine = 0;
let currentNewLine = 0;
let inHunk = false;
const hunkHeaderRegex = /^@@ -(\d+),?\d* \+(\d+),?\d* @@/;
for (const line of lines) {
const hunkMatch = line.match(hunkHeaderRegex);
if (hunkMatch) {
currentOldLine = parseInt(hunkMatch[1], 10);
currentNewLine = parseInt(hunkMatch[2], 10);
inHunk = true;
result.push({ type: 'hunk', content: line });
// We need to adjust the starting point because the first line number applies to the *first* actual line change/context,
// but we increment *before* pushing that line. So decrement here.
currentOldLine--;
currentNewLine--;
continue;
}
if (!inHunk) {
// Skip standard Git header lines more robustly
if (
line.startsWith('--- ') ||
line.startsWith('+++ ') ||
line.startsWith('diff --git') ||
line.startsWith('index ') ||
line.startsWith('similarity index') ||
line.startsWith('rename from') ||
line.startsWith('rename to') ||
line.startsWith('new file mode') ||
line.startsWith('deleted file mode')
)
continue;
// If it's not a hunk or header, skip (or handle as 'other' if needed)
continue;
}
if (line.startsWith('+')) {
currentNewLine++; // Increment before pushing
result.push({
type: 'add',
newLine: currentNewLine,
content: line.substring(1),
});
} else if (line.startsWith('-')) {
currentOldLine++; // Increment before pushing
result.push({
type: 'del',
oldLine: currentOldLine,
content: line.substring(1),
});
} else if (line.startsWith(' ')) {
currentOldLine++; // Increment before pushing
currentNewLine++;
result.push({
type: 'context',
oldLine: currentOldLine,
newLine: currentNewLine,
content: line.substring(1),
});
} else if (line.startsWith('\\')) {
// Handle "\ No newline at end of file"
result.push({ type: 'other', content: line });
}
}
return result;
}
interface DiffRendererProps {
diffContent: string;
filename?: string;
tabWidth?: number;
availableTerminalHeight?: number;
terminalWidth: number;
}
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
export const DiffRenderer: React.FC<DiffRendererProps> = ({
diffContent,
filename,
tabWidth = DEFAULT_TAB_WIDTH,
availableTerminalHeight,
terminalWidth,
}) => {
if (!diffContent || typeof diffContent !== 'string') {
return <Text color={Colors.AccentYellow}>No diff content.</Text>;
}
const parsedLines = parseDiffWithLineNumbers(diffContent);
if (parsedLines.length === 0) {
return (
<Box borderStyle="round" borderColor={Colors.Gray} padding={1}>
<Text dimColor>No changes detected.</Text>
</Box>
);
}
// Check if the diff represents a new file (only additions and header lines)
const isNewFile = parsedLines.every(
(line) =>
line.type === 'add' ||
line.type === 'hunk' ||
line.type === 'other' ||
line.content.startsWith('diff --git') ||
line.content.startsWith('new file mode'),
);
let renderedOutput;
if (isNewFile) {
// Extract only the added lines' content
const addedContent = parsedLines
.filter((line) => line.type === 'add')
.map((line) => line.content)
.join('\n');
// Attempt to infer language from filename, default to plain text if no filename
const fileExtension = filename?.split('.').pop() || null;
const language = fileExtension
? getLanguageFromExtension(fileExtension)
: null;
renderedOutput = colorizeCode(
addedContent,
language,
availableTerminalHeight,
terminalWidth,
);
} else {
renderedOutput = renderDiffContent(
parsedLines,
filename,
tabWidth,
availableTerminalHeight,
terminalWidth,
);
}
return renderedOutput;
};
const renderDiffContent = (
parsedLines: DiffLine[],
filename: string | undefined,
tabWidth = DEFAULT_TAB_WIDTH,
availableTerminalHeight: number | undefined,
terminalWidth: number,
) => {
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
const normalizedLines = parsedLines.map((line) => ({
...line,
content: line.content.replace(/\t/g, ' '.repeat(tabWidth)),
}));
// Filter out non-displayable lines (hunks, potentially 'other') using the normalized list
const displayableLines = normalizedLines.filter(
(l) => l.type !== 'hunk' && l.type !== 'other',
);
if (displayableLines.length === 0) {
return (
<Box borderStyle="round" borderColor={Colors.Gray} padding={1}>
<Text dimColor>No changes detected.</Text>
</Box>
);
}
// Calculate the minimum indentation across all displayable lines
let baseIndentation = Infinity; // Start high to find the minimum
for (const line of displayableLines) {
// Only consider lines with actual content for indentation calculation
if (line.content.trim() === '') continue;
const firstCharIndex = line.content.search(/\S/); // Find index of first non-whitespace char
const currentIndent = firstCharIndex === -1 ? 0 : firstCharIndex; // Indent is 0 if no non-whitespace found
baseIndentation = Math.min(baseIndentation, currentIndent);
}
// If baseIndentation remained Infinity (e.g., no displayable lines with content), default to 0
if (!isFinite(baseIndentation)) {
baseIndentation = 0;
}
const key = filename
? `diff-box-${filename}`
: `diff-box-${crypto.createHash('sha1').update(JSON.stringify(parsedLines)).digest('hex')}`;
let lastLineNumber: number | null = null;
const MAX_CONTEXT_LINES_WITHOUT_GAP = 5;
return (
<MaxSizedBox
maxHeight={availableTerminalHeight}
maxWidth={terminalWidth}
key={key}
>
{displayableLines.reduce<React.ReactNode[]>((acc, line, index) => {
// Determine the relevant line number for gap calculation based on type
let relevantLineNumberForGapCalc: number | null = null;
if (line.type === 'add' || line.type === 'context') {
relevantLineNumberForGapCalc = line.newLine ?? null;
} else if (line.type === 'del') {
// For deletions, the gap is typically in relation to the original file's line numbering
relevantLineNumberForGapCalc = line.oldLine ?? null;
}
if (
lastLineNumber !== null &&
relevantLineNumberForGapCalc !== null &&
relevantLineNumberForGapCalc >
lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1
) {
acc.push(
<Box key={`gap-${index}`}>
<Text wrap="truncate">{'═'.repeat(terminalWidth)}</Text>
</Box>,
);
}
const lineKey = `diff-line-${index}`;
let gutterNumStr = '';
let color: string | undefined = undefined;
let prefixSymbol = ' ';
let dim = false;
switch (line.type) {
case 'add':
gutterNumStr = (line.newLine ?? '').toString();
color = 'green';
prefixSymbol = '+';
lastLineNumber = line.newLine ?? null;
break;
case 'del':
gutterNumStr = (line.oldLine ?? '').toString();
color = 'red';
prefixSymbol = '-';
// For deletions, update lastLineNumber based on oldLine if it's advancing.
// This helps manage gaps correctly if there are multiple consecutive deletions
// or if a deletion is followed by a context line far away in the original file.
if (line.oldLine !== undefined) {
lastLineNumber = line.oldLine;
}
break;
case 'context':
gutterNumStr = (line.newLine ?? '').toString();
dim = true;
prefixSymbol = ' ';
lastLineNumber = line.newLine ?? null;
break;
default:
return acc;
}
const displayContent = line.content.substring(baseIndentation);
acc.push(
<Box key={lineKey} flexDirection="row">
<Text color={Colors.Gray}>{gutterNumStr.padEnd(4)} </Text>
<Text color={color} dimColor={dim}>
{prefixSymbol}{' '}
</Text>
<Text color={color} dimColor={dim} wrap="wrap">
{displayContent}
</Text>
</Box>,
);
return acc;
}, [])}
</MaxSizedBox>
);
};
const getLanguageFromExtension = (extension: string): string | null => {
const languageMap: { [key: string]: string } = {
js: 'javascript',
ts: 'typescript',
py: 'python',
json: 'json',
css: 'css',
html: 'html',
sh: 'bash',
md: 'markdown',
yaml: 'yaml',
yml: 'yaml',
txt: 'plaintext',
java: 'java',
c: 'c',
cpp: 'cpp',
rb: 'ruby',
};
return languageMap[extension] || null; // Return null if extension not found
};

View File

@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';
interface ErrorMessageProps {
text: string;
}
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => {
const prefix = '✕ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row" marginBottom={1}>
<Box width={prefixWidth}>
<Text color={Colors.AccentRed}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={Colors.AccentRed}>
{text}
</Text>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { Colors } from '../../colors.js';
interface GeminiMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
}
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
text,
isPending,
availableTerminalHeight,
terminalWidth,
}) => {
const prefix = '✦ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={Colors.AccentPurple}>{prefix}</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
interface GeminiMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
}
/*
* Gemini message content is a semi-hacked component. The intention is to represent a partial
* of GeminiMessage and is only used when a response gets too long. In that instance messages
* are split into multiple GeminiMessageContent's to enable the root <Static> component in
* App.tsx to be as performant as humanly possible.
*/
export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
text,
isPending,
availableTerminalHeight,
terminalWidth,
}) => {
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
return (
<Box flexDirection="column" paddingLeft={prefixWidth}>
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/>
</Box>
);
};

View File

@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';
interface InfoMessageProps {
text: string;
}
export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
const prefix = ' ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row" marginTop={1}>
<Box width={prefixWidth}>
<Text color={Colors.AccentYellow}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={Colors.AccentYellow}>
{text}
</Text>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,58 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { ToolCallConfirmationDetails } from '@qwen/qwen-code-core';
describe('ToolConfirmationMessage', () => {
it('should not display urls if prompt and url are the same', () => {
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'info',
title: 'Confirm Web Fetch',
prompt: 'https://example.com',
urls: ['https://example.com'],
onConfirm: vi.fn(),
};
const { lastFrame } = render(
<ToolConfirmationMessage
confirmationDetails={confirmationDetails}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
expect(lastFrame()).not.toContain('URLs to fetch:');
});
it('should display urls if prompt and url are different', () => {
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'info',
title: 'Confirm Web Fetch',
prompt:
'fetch https://github.com/google/gemini-react/blob/main/README.md',
urls: [
'https://raw.githubusercontent.com/google/gemini-react/main/README.md',
],
onConfirm: vi.fn(),
};
const { lastFrame } = render(
<ToolConfirmationMessage
confirmationDetails={confirmationDetails}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('URLs to fetch:');
expect(lastFrame()).toContain(
'- https://raw.githubusercontent.com/google/gemini-react/main/README.md',
);
});
});

View File

@@ -0,0 +1,250 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text, useInput } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js';
import {
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
ToolExecuteConfirmationDetails,
ToolMcpConfirmationDetails,
Config,
} from '@qwen/qwen-code-core';
import {
RadioButtonSelect,
RadioSelectItem,
} from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails;
config?: Config;
isFocused?: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
}
export const ToolConfirmationMessage: React.FC<
ToolConfirmationMessageProps
> = ({
confirmationDetails,
isFocused = true,
availableTerminalHeight,
terminalWidth,
}) => {
const { onConfirm } = confirmationDetails;
const childWidth = terminalWidth - 2; // 2 for padding
useInput((_, key) => {
if (!isFocused) return;
if (key.escape) {
onConfirm(ToolConfirmationOutcome.Cancel);
}
});
const handleSelect = (item: ToolConfirmationOutcome) => onConfirm(item);
let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here
let question: string;
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = new Array<
RadioSelectItem<ToolConfirmationOutcome>
>();
// Body content is now the DiffRenderer, passing filename to it
// The bordered box is removed from here and handled within DiffRenderer
function availableBodyContentHeight() {
if (options.length === 0) {
// This should not happen in practice as options are always added before this is called.
throw new Error('Options not provided for confirmation message');
}
if (availableTerminalHeight === undefined) {
return undefined;
}
// Calculate the vertical space (in lines) consumed by UI elements
// surrounding the main body content.
const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom).
const MARGIN_BODY_BOTTOM = 1; // margin on the body container.
const HEIGHT_QUESTION = 1; // The question text is one line.
const MARGIN_QUESTION_BOTTOM = 1; // Margin on the question container.
const HEIGHT_OPTIONS = options.length; // Each option in the radio select takes one line.
const surroundingElementsHeight =
PADDING_OUTER_Y +
MARGIN_BODY_BOTTOM +
HEIGHT_QUESTION +
MARGIN_QUESTION_BOTTOM +
HEIGHT_OPTIONS;
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
}
if (confirmationDetails.type === 'edit') {
if (confirmationDetails.isModifying) {
return (
<Box
minWidth="90%"
borderStyle="round"
borderColor={Colors.Gray}
justifyContent="space-around"
padding={1}
overflow="hidden"
>
<Text>Modify in progress: </Text>
<Text color={Colors.AccentGreen}>
Save and close external editor to continue
</Text>
</Box>
);
}
question = `Apply this change?`;
options.push(
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
},
{
label: 'Yes, allow always',
value: ToolConfirmationOutcome.ProceedAlways,
},
{
label: 'Modify with external editor',
value: ToolConfirmationOutcome.ModifyWithEditor,
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
);
bodyContent = (
<DiffRenderer
diffContent={confirmationDetails.fileDiff}
filename={confirmationDetails.fileName}
availableTerminalHeight={availableBodyContentHeight()}
terminalWidth={childWidth}
/>
);
} else if (confirmationDetails.type === 'exec') {
const executionProps =
confirmationDetails as ToolExecuteConfirmationDetails;
question = `Allow execution?`;
options.push(
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
},
{
label: `Yes, allow always "${executionProps.rootCommand} ..."`,
value: ToolConfirmationOutcome.ProceedAlways,
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
);
let bodyContentHeight = availableBodyContentHeight();
if (bodyContentHeight !== undefined) {
bodyContentHeight -= 2; // Account for padding;
}
bodyContent = (
<Box flexDirection="column">
<Box paddingX={1} marginLeft={1}>
<MaxSizedBox
maxHeight={bodyContentHeight}
maxWidth={Math.max(childWidth - 4, 1)}
>
<Box>
<Text color={Colors.AccentCyan}>{executionProps.command}</Text>
</Box>
</MaxSizedBox>
</Box>
</Box>
);
} else if (confirmationDetails.type === 'info') {
const infoProps = confirmationDetails;
const displayUrls =
infoProps.urls &&
!(infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt);
question = `Do you want to proceed?`;
options.push(
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
},
{
label: 'Yes, allow always',
value: ToolConfirmationOutcome.ProceedAlways,
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
);
bodyContent = (
<Box flexDirection="column" paddingX={1} marginLeft={1}>
<Text color={Colors.AccentCyan}>{infoProps.prompt}</Text>
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text>URLs to fetch:</Text>
{infoProps.urls.map((url) => (
<Text key={url}> - {url}</Text>
))}
</Box>
)}
</Box>
);
} else {
// mcp tool confirmation
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
bodyContent = (
<Box flexDirection="column" paddingX={1} marginLeft={1}>
<Text color={Colors.AccentCyan}>MCP Server: {mcpProps.serverName}</Text>
<Text color={Colors.AccentCyan}>Tool: {mcpProps.toolName}</Text>
</Box>
);
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
options.push(
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
},
{
label: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated
},
{
label: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
value: ToolConfirmationOutcome.ProceedAlwaysServer,
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
);
}
return (
<Box flexDirection="column" padding={1} width={childWidth}>
{/* Body Content (Diff Renderer or Command Info) */}
{/* No separate context display here anymore for edits */}
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
{bodyContent}
</Box>
{/* Confirmation Question */}
<Box marginBottom={1} flexShrink={0}>
<Text wrap="truncate">{question}</Text>
</Box>
{/* Select Input for Options */}
<Box flexShrink={0}>
<RadioButtonSelect
items={options}
onSelect={handleSelect}
isFocused={isFocused}
/>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,123 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useMemo } from 'react';
import { Box } from 'ink';
import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { Colors } from '../../colors.js';
import { Config } from '@qwen/qwen-code-core';
interface ToolGroupMessageProps {
groupId: number;
toolCalls: IndividualToolCallDisplay[];
availableTerminalHeight?: number;
terminalWidth: number;
config?: Config;
isFocused?: boolean;
}
// Main component renders the border and maps the tools using ToolMessage
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
toolCalls,
availableTerminalHeight,
terminalWidth,
config,
isFocused = true,
}) => {
const hasPending = !toolCalls.every(
(t) => t.status === ToolCallStatus.Success,
);
const borderColor = hasPending ? Colors.AccentYellow : Colors.Gray;
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
// This is a bit of a magic number, but it accounts for the border and
// marginLeft.
const innerWidth = terminalWidth - 4;
// only prompt for tool approval on the first 'confirming' tool in the list
// note, after the CTA, this automatically moves over to the next 'confirming' tool
const toolAwaitingApproval = useMemo(
() => toolCalls.find((tc) => tc.status === ToolCallStatus.Confirming),
[toolCalls],
);
let countToolCallsWithResults = 0;
for (const tool of toolCalls) {
if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
countToolCallsWithResults++;
}
}
const countOneLineToolCalls = toolCalls.length - countToolCallsWithResults;
const availableTerminalHeightPerToolMessage = availableTerminalHeight
? Math.max(
Math.floor(
(availableTerminalHeight - staticHeight - countOneLineToolCalls) /
Math.max(1, countToolCallsWithResults),
),
1,
)
: undefined;
return (
<Box
flexDirection="column"
borderStyle="round"
/*
This width constraint is highly important and protects us from an Ink rendering bug.
Since the ToolGroup can typically change rendering states frequently, it can cause
Ink to render the border of the box incorrectly and span multiple lines and even
cause tearing.
*/
width="100%"
marginLeft={1}
borderDimColor={hasPending}
borderColor={borderColor}
>
{toolCalls.map((tool) => {
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
return (
<Box key={tool.callId} flexDirection="column" minHeight={1}>
<Box flexDirection="row" alignItems="center">
<ToolMessage
callId={tool.callId}
name={tool.name}
description={tool.description}
resultDisplay={tool.resultDisplay}
status={tool.status}
confirmationDetails={tool.confirmationDetails}
availableTerminalHeight={availableTerminalHeightPerToolMessage}
terminalWidth={innerWidth}
emphasis={
isConfirming
? 'high'
: toolAwaitingApproval
? 'low'
: 'medium'
}
renderOutputAsMarkdown={tool.renderOutputAsMarkdown}
/>
</Box>
{tool.status === ToolCallStatus.Confirming &&
isConfirming &&
tool.confirmationDetails && (
<ToolConfirmationMessage
confirmationDetails={tool.confirmationDetails}
config={config}
isFocused={isFocused}
availableTerminalHeight={
availableTerminalHeightPerToolMessage
}
terminalWidth={innerWidth}
/>
)}
</Box>
);
})}
</Box>
);
};

View File

@@ -0,0 +1,181 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink-testing-library';
import { ToolMessage, ToolMessageProps } from './ToolMessage.js';
import { StreamingState, ToolCallStatus } from '../../types.js';
import { Text } from 'ink';
import { StreamingContext } from '../../contexts/StreamingContext.js';
// Mock child components or utilities if they are complex or have side effects
vi.mock('../GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: ({
nonRespondingDisplay,
}: {
nonRespondingDisplay?: string;
}) => {
const streamingState = React.useContext(StreamingContext)!;
if (streamingState === StreamingState.Responding) {
return <Text>MockRespondingSpinner</Text>;
}
return nonRespondingDisplay ? <Text>{nonRespondingDisplay}</Text> : null;
},
}));
vi.mock('./DiffRenderer.js', () => ({
DiffRenderer: function MockDiffRenderer({
diffContent,
}: {
diffContent: string;
}) {
return <Text>MockDiff:{diffContent}</Text>;
},
}));
vi.mock('../../utils/MarkdownDisplay.js', () => ({
MarkdownDisplay: function MockMarkdownDisplay({ text }: { text: string }) {
return <Text>MockMarkdown:{text}</Text>;
},
}));
// Helper to render with context
const renderWithContext = (
ui: React.ReactElement,
streamingState: StreamingState,
) => {
const contextValue: StreamingState = streamingState;
return render(
<StreamingContext.Provider value={contextValue}>
{ui}
</StreamingContext.Provider>,
);
};
describe('<ToolMessage />', () => {
const baseProps: ToolMessageProps = {
callId: 'tool-123',
name: 'test-tool',
description: 'A tool for testing',
resultDisplay: 'Test result',
status: ToolCallStatus.Success,
terminalWidth: 80,
confirmationDetails: undefined,
emphasis: 'medium',
};
it('renders basic tool information', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} />,
StreamingState.Idle,
);
const output = lastFrame();
expect(output).toContain('✔'); // Success indicator
expect(output).toContain('test-tool');
expect(output).toContain('A tool for testing');
expect(output).toContain('MockMarkdown:Test result');
});
describe('ToolStatusIndicator rendering', () => {
it('shows ✔ for Success status', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Success} />,
StreamingState.Idle,
);
expect(lastFrame()).toContain('✔');
});
it('shows o for Pending status', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Pending} />,
StreamingState.Idle,
);
expect(lastFrame()).toContain('o');
});
it('shows ? for Confirming status', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Confirming} />,
StreamingState.Idle,
);
expect(lastFrame()).toContain('?');
});
it('shows - for Canceled status', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Canceled} />,
StreamingState.Idle,
);
expect(lastFrame()).toContain('-');
});
it('shows x for Error status', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Error} />,
StreamingState.Idle,
);
expect(lastFrame()).toContain('x');
});
it('shows paused spinner for Executing status when streamingState is Idle', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Executing} />,
StreamingState.Idle,
);
expect(lastFrame()).toContain('⊷');
expect(lastFrame()).not.toContain('MockRespondingSpinner');
expect(lastFrame()).not.toContain('✔');
});
it('shows paused spinner for Executing status when streamingState is WaitingForConfirmation', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Executing} />,
StreamingState.WaitingForConfirmation,
);
expect(lastFrame()).toContain('⊷');
expect(lastFrame()).not.toContain('MockRespondingSpinner');
expect(lastFrame()).not.toContain('✔');
});
it('shows MockRespondingSpinner for Executing status when streamingState is Responding', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Executing} />,
StreamingState.Responding, // Simulate app still responding
);
expect(lastFrame()).toContain('MockRespondingSpinner');
expect(lastFrame()).not.toContain('✔');
});
});
it('renders DiffRenderer for diff results', () => {
const diffResult = {
fileDiff: '--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new',
fileName: 'file.txt',
};
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} resultDisplay={diffResult} />,
StreamingState.Idle,
);
// Check that the output contains the MockDiff content as part of the whole message
expect(lastFrame()).toMatch(/MockDiff:--- a\/file\.txt/);
});
it('renders emphasis correctly', () => {
const { lastFrame: highEmphasisFrame } = renderWithContext(
<ToolMessage {...baseProps} emphasis="high" />,
StreamingState.Idle,
);
// Check for trailing indicator or specific color if applicable (Colors are not easily testable here)
expect(highEmphasisFrame()).toContain('←'); // Trailing indicator for high emphasis
const { lastFrame: lowEmphasisFrame } = renderWithContext(
<ToolMessage {...baseProps} emphasis="low" />,
StreamingState.Idle,
);
// For low emphasis, the name and description might be dimmed (check for dimColor if possible)
// This is harder to assert directly in text output without color checks.
// We can at least ensure it doesn't have the high emphasis indicator.
expect(lowEmphasisFrame()).not.toContain('←');
});
});

View File

@@ -0,0 +1,194 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
const STATUS_INDICATOR_WIDTH = 3;
const MIN_LINES_SHOWN = 2; // show at least this many lines
// Large threshold to ensure we don't cause performance issues for very large
// outputs that will get truncated further MaxSizedBox anyway.
const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 1000000;
export type TextEmphasis = 'high' | 'medium' | 'low';
export interface ToolMessageProps extends IndividualToolCallDisplay {
availableTerminalHeight?: number;
terminalWidth: number;
emphasis?: TextEmphasis;
renderOutputAsMarkdown?: boolean;
}
export const ToolMessage: React.FC<ToolMessageProps> = ({
name,
description,
resultDisplay,
status,
availableTerminalHeight,
terminalWidth,
emphasis = 'medium',
renderOutputAsMarkdown = true,
}) => {
const availableHeight = availableTerminalHeight
? Math.max(
availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT,
MIN_LINES_SHOWN + 1, // enforce minimum lines shown
)
: undefined;
// Long tool call response in MarkdownDisplay doesn't respect availableTerminalHeight properly,
// we're forcing it to not render as markdown when the response is too long, it will fallback
// to render as plain text, which is contained within the terminal using MaxSizedBox
if (availableHeight) {
renderOutputAsMarkdown = false;
}
const childWidth = terminalWidth - 3; // account for padding.
if (typeof resultDisplay === 'string') {
if (resultDisplay.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
// Truncate the result display to fit within the available width.
resultDisplay =
'...' + resultDisplay.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
}
}
return (
<Box paddingX={1} paddingY={0} flexDirection="column">
<Box minHeight={1}>
<ToolStatusIndicator status={status} />
<ToolInfo
name={name}
status={status}
description={description}
emphasis={emphasis}
/>
{emphasis === 'high' && <TrailingIndicator />}
</Box>
{resultDisplay && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%" marginTop={1}>
<Box flexDirection="column">
{typeof resultDisplay === 'string' && renderOutputAsMarkdown && (
<Box flexDirection="column">
<MarkdownDisplay
text={resultDisplay}
isPending={false}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
</Box>
)}
{typeof resultDisplay === 'string' && !renderOutputAsMarkdown && (
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
<Box>
<Text wrap="wrap">{resultDisplay}</Text>
</Box>
</MaxSizedBox>
)}
{typeof resultDisplay !== 'string' && (
<DiffRenderer
diffContent={resultDisplay.fileDiff}
filename={resultDisplay.fileName}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
)}
</Box>
</Box>
)}
</Box>
);
};
type ToolStatusIndicatorProps = {
status: ToolCallStatus;
};
const ToolStatusIndicator: React.FC<ToolStatusIndicatorProps> = ({
status,
}) => (
<Box minWidth={STATUS_INDICATOR_WIDTH}>
{status === ToolCallStatus.Pending && (
<Text color={Colors.AccentGreen}>o</Text>
)}
{status === ToolCallStatus.Executing && (
<GeminiRespondingSpinner
spinnerType="toggle"
nonRespondingDisplay={'⊷'}
/>
)}
{status === ToolCallStatus.Success && (
<Text color={Colors.AccentGreen}></Text>
)}
{status === ToolCallStatus.Confirming && (
<Text color={Colors.AccentYellow}>?</Text>
)}
{status === ToolCallStatus.Canceled && (
<Text color={Colors.AccentYellow} bold>
-
</Text>
)}
{status === ToolCallStatus.Error && (
<Text color={Colors.AccentRed} bold>
x
</Text>
)}
</Box>
);
type ToolInfo = {
name: string;
description: string;
status: ToolCallStatus;
emphasis: TextEmphasis;
};
const ToolInfo: React.FC<ToolInfo> = ({
name,
description,
status,
emphasis,
}) => {
const nameColor = React.useMemo<string>(() => {
switch (emphasis) {
case 'high':
return Colors.Foreground;
case 'medium':
return Colors.Foreground;
case 'low':
return Colors.Gray;
default: {
const exhaustiveCheck: never = emphasis;
return exhaustiveCheck;
}
}
}, [emphasis]);
return (
<Box>
<Text
wrap="truncate-end"
strikethrough={status === ToolCallStatus.Canceled}
>
<Text color={nameColor} bold>
{name}
</Text>{' '}
<Text color={Colors.Gray}>{description}</Text>
</Text>
</Box>
);
};
const TrailingIndicator: React.FC = () => (
<Text color={Colors.Foreground} wrap="truncate">
{' '}
</Text>
);

View File

@@ -0,0 +1,39 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';
interface UserMessageProps {
text: string;
}
export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
const prefix = '> ';
const prefixWidth = prefix.length;
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="row"
paddingX={2}
paddingY={0}
marginY={1}
alignSelf="flex-start"
>
<Box width={prefixWidth}>
<Text color={Colors.Gray}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={Colors.Gray}>
{text}
</Text>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
interface UserShellMessageProps {
text: string;
}
export const UserShellMessage: React.FC<UserShellMessageProps> = ({ text }) => {
// Remove leading '!' if present, as App.tsx adds it for the processor.
const commandToDisplay = text.startsWith('!') ? text.substring(1) : text;
return (
<Box>
<Text color={Colors.AccentCyan}>$ </Text>
<Text>{commandToDisplay}</Text>
</Box>
);
};

View File

@@ -0,0 +1,342 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { MaxSizedBox, setMaxSizedBoxDebugging } from './MaxSizedBox.js';
import { Box, Text } from 'ink';
import { describe, it, expect } from 'vitest';
describe('<MaxSizedBox />', () => {
// Make sure MaxSizedBox logs errors on invalid configurations.
// This is useful for debugging issues with the component.
// It should be set to false in production for performance and to avoid
// cluttering the console if there are ignorable issues.
setMaxSizedBoxDebugging(true);
it('renders children without truncation when they fit', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}>
<Box>
<Text>Hello, World!</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals('Hello, World!');
});
it('hides lines when content exceeds maxHeight', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2}>
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`... first 2 lines hidden ...
Line 3`);
});
it('hides lines at the end when content exceeds maxHeight and overflowDirection is bottom', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`Line 1
... last 2 lines hidden ...`);
});
it('wraps text that exceeds maxWidth', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={10} maxHeight={5}>
<Box>
<Text wrap="wrap">This is a long line of text</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`This is a
long line
of text`);
});
it('handles mixed wrapping and non-wrapping segments', () => {
const multilineText = `This part will wrap around.
And has a line break.
Leading spaces preserved.`;
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={20} maxHeight={20}>
<Box>
<Text>Example</Text>
</Box>
<Box>
<Text>No Wrap: </Text>
<Text wrap="wrap">{multilineText}</Text>
</Box>
<Box>
<Text>Longer No Wrap: </Text>
<Text wrap="wrap">This part will wrap around.</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(
`Example
No Wrap: This part
will wrap
around.
And has a
line break.
Leading
spaces
preserved.
Longer No Wrap: This
part
will
wrap
arou
nd.`,
);
});
it('handles words longer than maxWidth by splitting them', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={5} maxHeight={5}>
<Box>
<Text wrap="wrap">Supercalifragilisticexpialidocious</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`... …
istic
expia
lidoc
ious`);
});
it('does not truncate when maxHeight is undefined', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={undefined}>
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`Line 1
Line 2`);
});
it('shows plural "lines" when more than one line is hidden', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2}>
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`... first 2 lines hidden ...
Line 3`);
});
it('shows plural "lines" when more than one line is hidden and overflowDirection is bottom', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`Line 1
... last 2 lines hidden ...`);
});
it('renders an empty box for empty children', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}></MaxSizedBox>
</OverflowProvider>,
);
// Expect an empty string or a box with nothing in it.
// Ink renders an empty box as an empty string.
expect(lastFrame()).equals('');
});
it('wraps text with multi-byte unicode characters correctly', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={5} maxHeight={5}>
<Box>
<Text wrap="wrap"></Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
// "你好" has a visual width of 4. "世界" has a visual width of 4.
// With maxWidth=5, it should wrap after the second character.
expect(lastFrame()).equals(`你好
世界`);
});
it('wraps text with multi-byte emoji characters correctly', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={5} maxHeight={5}>
<Box>
<Text wrap="wrap">🐶🐶🐶🐶🐶</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
// Each "🐶" has a visual width of 2.
// With maxWidth=5, it should wrap every 2 emojis.
expect(lastFrame()).equals(`🐶🐶
🐶🐶
🐶`);
});
it('accounts for additionalHiddenLinesCount', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={5}>
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
// 1 line is hidden by overflow, 5 are additionally hidden.
expect(lastFrame()).equals(`... first 7 lines hidden ...
Line 3`);
});
it('handles React.Fragment as a child', () => {
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}>
<>
<Box>
<Text>Line 1 from Fragment</Text>
</Box>
<Box>
<Text>Line 2 from Fragment</Text>
</Box>
</>
<Box>
<Text>Line 3 direct child</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`Line 1 from Fragment
Line 2 from Fragment
Line 3 direct child`);
});
it('clips a long single text child from the top', () => {
const THIRTY_LINES = Array.from(
{ length: 30 },
(_, i) => `Line ${i + 1}`,
).join('\n');
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}>
<Box>
<Text>{THIRTY_LINES}</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
const expected = [
'... first 21 lines hidden ...',
...Array.from({ length: 9 }, (_, i) => `Line ${22 + i}`),
].join('\n');
expect(lastFrame()).equals(expected);
});
it('clips a long single text child from the bottom', () => {
const THIRTY_LINES = Array.from(
{ length: 30 },
(_, i) => `Line ${i + 1}`,
).join('\n');
const { lastFrame } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="bottom">
<Box>
<Text>{THIRTY_LINES}</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
const expected = [
...Array.from({ length: 9 }, (_, i) => `Line ${i + 1}`),
'... last 21 lines hidden ...',
].join('\n');
expect(lastFrame()).equals(expected);
});
});

View File

@@ -0,0 +1,547 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { Fragment, useEffect, useId } from 'react';
import { Box, Text } from 'ink';
import stringWidth from 'string-width';
import { Colors } from '../../colors.js';
import { toCodePoints } from '../../utils/textUtils.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
let enableDebugLog = false;
/**
* Minimum height for the MaxSizedBox component.
* This ensures there is room for at least one line of content as well as the
* message that content was truncated.
*/
export const MINIMUM_MAX_HEIGHT = 2;
export function setMaxSizedBoxDebugging(value: boolean) {
enableDebugLog = value;
}
function debugReportError(message: string, element: React.ReactNode) {
if (!enableDebugLog) return;
if (!React.isValidElement(element)) {
console.error(
message,
`Invalid element: '${String(element)}' typeof=${typeof element}`,
);
return;
}
let sourceMessage = '<Unknown file>';
try {
const elementWithSource = element as {
_source?: { fileName?: string; lineNumber?: number };
};
const fileName = elementWithSource._source?.fileName;
const lineNumber = elementWithSource._source?.lineNumber;
sourceMessage = fileName ? `${fileName}:${lineNumber}` : '<Unknown file>';
} catch (error) {
console.error('Error while trying to get file name:', error);
}
console.error(message, `${String(element.type)}. Source: ${sourceMessage}`);
}
interface MaxSizedBoxProps {
children?: React.ReactNode;
maxWidth?: number;
maxHeight: number | undefined;
overflowDirection?: 'top' | 'bottom';
additionalHiddenLinesCount?: number;
}
/**
* A React component that constrains the size of its children and provides
* content-aware truncation when the content exceeds the specified `maxHeight`.
*
* `MaxSizedBox` requires a specific structure for its children to correctly
* measure and render the content:
*
* 1. **Direct children must be `<Box>` elements.** Each `<Box>` represents a
* single row of content.
* 2. **Row `<Box>` elements must contain only `<Text>` elements.** These
* `<Text>` elements can be nested and there are no restrictions to Text
* element styling other than that non-wrapping text elements must be
* before wrapping text elements.
*
* **Constraints:**
* - **Box Properties:** Custom properties on the child `<Box>` elements are
* ignored. In debug mode, runtime checks will report errors for any
* unsupported properties.
* - **Text Wrapping:** Within a single row, `<Text>` elements with no wrapping
* (e.g., headers, labels) must appear before any `<Text>` elements that wrap.
* - **Element Types:** Runtime checks will warn if unsupported element types
* are used as children.
*
* @example
* <MaxSizedBox maxWidth={80} maxHeight={10}>
* <Box>
* <Text>This is the first line.</Text>
* </Box>
* <Box>
* <Text color="cyan" wrap="truncate">Non-wrapping Header: </Text>
* <Text>This is the rest of the line which will wrap if it's too long.</Text>
* </Box>
* <Box>
* <Text>
* Line 3 with <Text color="yellow">nested styled text</Text> inside of it.
* </Text>
* </Box>
* </MaxSizedBox>
*/
export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
children,
maxWidth,
maxHeight,
overflowDirection = 'top',
additionalHiddenLinesCount = 0,
}) => {
const id = useId();
const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {};
const laidOutStyledText: StyledText[][] = [];
const targetMaxHeight = Math.max(
Math.round(maxHeight ?? Number.MAX_SAFE_INTEGER),
MINIMUM_MAX_HEIGHT,
);
if (maxWidth === undefined) {
throw new Error('maxWidth must be defined when maxHeight is set.');
}
function visitRows(element: React.ReactNode) {
if (!React.isValidElement<{ children?: React.ReactNode }>(element)) {
return;
}
if (element.type === Fragment) {
React.Children.forEach(element.props.children, visitRows);
return;
}
if (element.type === Box) {
layoutInkElementAsStyledText(element, maxWidth!, laidOutStyledText);
return;
}
debugReportError('MaxSizedBox children must be <Box> elements', element);
}
React.Children.forEach(children, visitRows);
const contentWillOverflow =
(targetMaxHeight !== undefined &&
laidOutStyledText.length > targetMaxHeight) ||
additionalHiddenLinesCount > 0;
const visibleContentHeight =
contentWillOverflow && targetMaxHeight !== undefined
? targetMaxHeight - 1
: targetMaxHeight;
const hiddenLinesCount =
visibleContentHeight !== undefined
? Math.max(0, laidOutStyledText.length - visibleContentHeight)
: 0;
const totalHiddenLines = hiddenLinesCount + additionalHiddenLinesCount;
useEffect(() => {
if (totalHiddenLines > 0) {
addOverflowingId?.(id);
} else {
removeOverflowingId?.(id);
}
return () => {
removeOverflowingId?.(id);
};
}, [id, totalHiddenLines, addOverflowingId, removeOverflowingId]);
const visibleStyledText =
hiddenLinesCount > 0
? overflowDirection === 'top'
? laidOutStyledText.slice(hiddenLinesCount, laidOutStyledText.length)
: laidOutStyledText.slice(0, visibleContentHeight)
: laidOutStyledText;
const visibleLines = visibleStyledText.map((line, index) => (
<Box key={index}>
{line.length > 0 ? (
line.map((segment, segIndex) => (
<Text key={segIndex} {...segment.props}>
{segment.text}
</Text>
))
) : (
<Text> </Text>
)}
</Box>
));
return (
<Box flexDirection="column" width={maxWidth} flexShrink={0}>
{totalHiddenLines > 0 && overflowDirection === 'top' && (
<Text color={Colors.Gray} wrap="truncate">
... first {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
hidden ...
</Text>
)}
{visibleLines}
{totalHiddenLines > 0 && overflowDirection === 'bottom' && (
<Text color={Colors.Gray} wrap="truncate">
... last {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
hidden ...
</Text>
)}
</Box>
);
};
// Define a type for styled text segments
interface StyledText {
text: string;
props: Record<string, unknown>;
}
/**
* Single row of content within the MaxSizedBox.
*
* A row can contain segments that are not wrapped, followed by segments that
* are. This is a minimal implementation that only supports the functionality
* needed today.
*/
interface Row {
noWrapSegments: StyledText[];
segments: StyledText[];
}
/**
* Flattens the child elements of MaxSizedBox into an array of `Row` objects.
*
* This function expects a specific child structure to function correctly:
* 1. The top-level child of `MaxSizedBox` should be a single `<Box>`. This
* outer box is primarily for structure and is not directly rendered.
* 2. Inside the outer `<Box>`, there should be one or more children. Each of
* these children must be a `<Box>` that represents a row.
* 3. Inside each "row" `<Box>`, the children must be `<Text>` components.
*
* The structure should look like this:
* <MaxSizedBox>
* <Box> // Row 1
* <Text>...</Text>
* <Text>...</Text>
* </Box>
* <Box> // Row 2
* <Text>...</Text>
* </Box>
* </MaxSizedBox>
*
* It is an error for a <Text> child without wrapping to appear after a
* <Text> child with wrapping within the same row Box.
*
* @param element The React node to flatten.
* @returns An array of `Row` objects.
*/
function visitBoxRow(element: React.ReactNode): Row {
if (
!React.isValidElement<{ children?: React.ReactNode }>(element) ||
element.type !== Box
) {
debugReportError(
`All children of MaxSizedBox must be <Box> elements`,
element,
);
return {
noWrapSegments: [{ text: '<ERROR>', props: {} }],
segments: [],
};
}
if (enableDebugLog) {
const boxProps = element.props as {
children?: React.ReactNode | undefined;
readonly flexDirection?:
| 'row'
| 'column'
| 'row-reverse'
| 'column-reverse'
| undefined;
};
// Ensure the Box has no props other than the default ones and key.
let maxExpectedProps = 4;
if (boxProps.children !== undefined) {
// Allow the key prop, which is automatically added by React.
maxExpectedProps += 1;
}
if (
boxProps.flexDirection !== undefined &&
boxProps.flexDirection !== 'row'
) {
debugReportError(
'MaxSizedBox children must have flexDirection="row".',
element,
);
}
if (Object.keys(boxProps).length > maxExpectedProps) {
debugReportError(
`Boxes inside MaxSizedBox must not have additional props. ${Object.keys(
boxProps,
).join(', ')}`,
element,
);
}
}
const row: Row = {
noWrapSegments: [],
segments: [],
};
let hasSeenWrapped = false;
function visitRowChild(
element: React.ReactNode,
parentProps: Record<string, unknown> | undefined,
) {
if (element === null) {
return;
}
if (typeof element === 'string' || typeof element === 'number') {
const text = String(element);
// Ignore empty strings as they don't need to be rendered.
if (!text) {
return;
}
const segment: StyledText = { text, props: parentProps ?? {} };
// Check the 'wrap' property from the merged props to decide the segment type.
if (parentProps === undefined || parentProps.wrap === 'wrap') {
hasSeenWrapped = true;
row.segments.push(segment);
} else {
if (!hasSeenWrapped) {
row.noWrapSegments.push(segment);
} else {
// put in the wrapped segment as the row is already stuck in wrapped mode.
row.segments.push(segment);
debugReportError(
'Text elements without wrapping cannot appear after elements with wrapping in the same row.',
element,
);
}
}
return;
}
if (!React.isValidElement<{ children?: React.ReactNode }>(element)) {
debugReportError('Invalid element.', element);
return;
}
if (element.type === Fragment) {
React.Children.forEach(element.props.children, (child) =>
visitRowChild(child, parentProps),
);
return;
}
if (element.type !== Text) {
debugReportError(
'Children of a row Box must be <Text> elements.',
element,
);
return;
}
// Merge props from parent <Text> elements. Child props take precedence.
const { children, ...currentProps } = element.props;
const mergedProps =
parentProps === undefined
? currentProps
: { ...parentProps, ...currentProps };
React.Children.forEach(children, (child) =>
visitRowChild(child, mergedProps),
);
}
React.Children.forEach(element.props.children, (child) =>
visitRowChild(child, undefined),
);
return row;
}
function layoutInkElementAsStyledText(
element: React.ReactElement,
maxWidth: number,
output: StyledText[][],
) {
const row = visitBoxRow(element);
if (row.segments.length === 0 && row.noWrapSegments.length === 0) {
// Return a single empty line if there are no segments to display
output.push([]);
return;
}
const lines: StyledText[][] = [];
const nonWrappingContent: StyledText[] = [];
let noWrappingWidth = 0;
// First, lay out the non-wrapping segments
row.noWrapSegments.forEach((segment) => {
nonWrappingContent.push(segment);
noWrappingWidth += stringWidth(segment.text);
});
if (row.segments.length === 0) {
// This is a bit of a special case when there are no segments that allow
// wrapping. It would be ideal to unify.
const lines: StyledText[][] = [];
let currentLine: StyledText[] = [];
nonWrappingContent.forEach((segment) => {
const textLines = segment.text.split('\n');
textLines.forEach((text, index) => {
if (index > 0) {
lines.push(currentLine);
currentLine = [];
}
if (text) {
currentLine.push({ text, props: segment.props });
}
});
});
if (
currentLine.length > 0 ||
(nonWrappingContent.length > 0 &&
nonWrappingContent[nonWrappingContent.length - 1].text.endsWith('\n'))
) {
lines.push(currentLine);
}
for (const line of lines) {
output.push(line);
}
return;
}
const availableWidth = maxWidth - noWrappingWidth;
if (availableWidth < 1) {
// No room to render the wrapping segments. TODO(jacob314): consider an alternative fallback strategy.
output.push(nonWrappingContent);
return;
}
// Now, lay out the wrapping segments
let wrappingPart: StyledText[] = [];
let wrappingPartWidth = 0;
function addWrappingPartToLines() {
if (lines.length === 0) {
lines.push([...nonWrappingContent, ...wrappingPart]);
} else {
if (noWrappingWidth > 0) {
lines.push([
...[{ text: ' '.repeat(noWrappingWidth), props: {} }],
...wrappingPart,
]);
} else {
lines.push(wrappingPart);
}
}
wrappingPart = [];
wrappingPartWidth = 0;
}
function addToWrappingPart(text: string, props: Record<string, unknown>) {
if (
wrappingPart.length > 0 &&
wrappingPart[wrappingPart.length - 1].props === props
) {
wrappingPart[wrappingPart.length - 1].text += text;
} else {
wrappingPart.push({ text, props });
}
}
row.segments.forEach((segment) => {
const linesFromSegment = segment.text.split('\n');
linesFromSegment.forEach((lineText, lineIndex) => {
if (lineIndex > 0) {
addWrappingPartToLines();
}
const words = lineText.split(/(\s+)/); // Split by whitespace
words.forEach((word) => {
if (!word) return;
const wordWidth = stringWidth(word);
if (
wrappingPartWidth + wordWidth > availableWidth &&
wrappingPartWidth > 0
) {
addWrappingPartToLines();
if (/^\s+$/.test(word)) {
return;
}
}
if (wordWidth > availableWidth) {
// Word is too long, needs to be split across lines
const wordAsCodePoints = toCodePoints(word);
let remainingWordAsCodePoints = wordAsCodePoints;
while (remainingWordAsCodePoints.length > 0) {
let splitIndex = 0;
let currentSplitWidth = 0;
for (const char of remainingWordAsCodePoints) {
const charWidth = stringWidth(char);
if (
wrappingPartWidth + currentSplitWidth + charWidth >
availableWidth
) {
break;
}
currentSplitWidth += charWidth;
splitIndex++;
}
if (splitIndex > 0) {
const part = remainingWordAsCodePoints
.slice(0, splitIndex)
.join('');
addToWrappingPart(part, segment.props);
wrappingPartWidth += stringWidth(part);
remainingWordAsCodePoints =
remainingWordAsCodePoints.slice(splitIndex);
}
if (remainingWordAsCodePoints.length > 0) {
addWrappingPartToLines();
}
}
} else {
addToWrappingPart(word, segment.props);
wrappingPartWidth += wordWidth;
}
});
});
// Split omits a trailing newline, so we need to handle it here
if (segment.text.endsWith('\n')) {
addWrappingPartToLines();
}
});
if (wrappingPart.length > 0) {
addWrappingPartToLines();
}
for (const line of lines) {
output.push(line);
}
}

View File

@@ -0,0 +1,157 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useEffect, useState } from 'react';
import { Text, Box, useInput } from 'ink';
import { Colors } from '../../colors.js';
/**
* Represents a single option for the RadioButtonSelect.
* Requires a label for display and a value to be returned on selection.
*/
export interface RadioSelectItem<T> {
label: string;
value: T;
disabled?: boolean;
themeNameDisplay?: string;
themeTypeDisplay?: string;
}
/**
* Props for the RadioButtonSelect component.
* @template T The type of the value associated with each radio item.
*/
export interface RadioButtonSelectProps<T> {
/** An array of items to display as radio options. */
items: Array<RadioSelectItem<T>>;
/** The initial index selected */
initialIndex?: number;
/** Function called when an item is selected. Receives the `value` of the selected item. */
onSelect: (value: T) => void;
/** Function called when an item is highlighted. Receives the `value` of the selected item. */
onHighlight?: (value: T) => void;
/** Whether this select input is currently focused and should respond to input. */
isFocused?: boolean;
/** Whether to show the scroll arrows. */
showScrollArrows?: boolean;
/** The maximum number of items to show at once. */
maxItemsToShow?: number;
}
/**
* A custom component that displays a list of items with radio buttons,
* supporting scrolling and keyboard navigation.
*
* @template T The type of the value associated with each radio item.
*/
export function RadioButtonSelect<T>({
items,
initialIndex = 0,
onSelect,
onHighlight,
isFocused,
showScrollArrows = false,
maxItemsToShow = 10,
}: RadioButtonSelectProps<T>): React.JSX.Element {
const [activeIndex, setActiveIndex] = useState(initialIndex);
const [scrollOffset, setScrollOffset] = useState(0);
useEffect(() => {
const newScrollOffset = Math.max(
0,
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
);
if (activeIndex < scrollOffset) {
setScrollOffset(activeIndex);
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newScrollOffset);
}
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
useInput(
(input, key) => {
if (input === 'k' || key.upArrow) {
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value);
}
if (input === 'j' || key.downArrow) {
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value);
}
if (key.return) {
onSelect(items[activeIndex]!.value);
}
// Enable selection directly from number keys.
if (/^[1-9]$/.test(input)) {
const targetIndex = Number.parseInt(input, 10) - 1;
if (targetIndex >= 0 && targetIndex < visibleItems.length) {
const selectedItem = visibleItems[targetIndex];
if (selectedItem) {
onSelect?.(selectedItem.value);
}
}
}
},
{ isActive: isFocused && items.length > 0 },
);
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
return (
<Box flexDirection="column">
{showScrollArrows && (
<Text color={scrollOffset > 0 ? Colors.Foreground : Colors.Gray}>
</Text>
)}
{visibleItems.map((item, index) => {
const itemIndex = scrollOffset + index;
const isSelected = activeIndex === itemIndex;
let textColor = Colors.Foreground;
if (isSelected) {
textColor = Colors.AccentGreen;
} else if (item.disabled) {
textColor = Colors.Gray;
}
return (
<Box key={item.label}>
<Box minWidth={2} flexShrink={0}>
<Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
{isSelected ? '●' : '○'}
</Text>
</Box>
{item.themeNameDisplay && item.themeTypeDisplay ? (
<Text color={textColor} wrap="truncate">
{item.themeNameDisplay}{' '}
<Text color={Colors.Gray}>{item.themeTypeDisplay}</Text>
</Text>
) : (
<Text color={textColor} wrap="truncate">
{item.label}
</Text>
)}
</Box>
);
})}
{showScrollArrows && (
<Text
color={
scrollOffset + maxItemsToShow < items.length
? Colors.Foreground
: Colors.Gray
}
>
</Text>
)}
</Box>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const EstimatedArtWidth = 59;
const BoxBorderWidth = 1;
export const BOX_PADDING_X = 1;
// Calculate width based on art, padding, and border
export const UI_WIDTH =
EstimatedArtWidth + BOX_PADDING_X * 2 + BoxBorderWidth * 2; // ~63
export const STREAM_DEBOUNCE_MS = 100;

View File

@@ -0,0 +1,87 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, {
createContext,
useContext,
useState,
useCallback,
useMemo,
} from 'react';
interface OverflowState {
overflowingIds: ReadonlySet<string>;
}
interface OverflowActions {
addOverflowingId: (id: string) => void;
removeOverflowingId: (id: string) => void;
}
const OverflowStateContext = createContext<OverflowState | undefined>(
undefined,
);
const OverflowActionsContext = createContext<OverflowActions | undefined>(
undefined,
);
export const useOverflowState = (): OverflowState | undefined =>
useContext(OverflowStateContext);
export const useOverflowActions = (): OverflowActions | undefined =>
useContext(OverflowActionsContext);
export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [overflowingIds, setOverflowingIds] = useState(new Set<string>());
const addOverflowingId = useCallback((id: string) => {
setOverflowingIds((prevIds) => {
if (prevIds.has(id)) {
return prevIds;
}
const newIds = new Set(prevIds);
newIds.add(id);
return newIds;
});
}, []);
const removeOverflowingId = useCallback((id: string) => {
setOverflowingIds((prevIds) => {
if (!prevIds.has(id)) {
return prevIds;
}
const newIds = new Set(prevIds);
newIds.delete(id);
return newIds;
});
}, []);
const stateValue = useMemo(
() => ({
overflowingIds,
}),
[overflowingIds],
);
const actionsValue = useMemo(
() => ({
addOverflowingId,
removeOverflowingId,
}),
[addOverflowingId, removeOverflowingId],
);
return (
<OverflowStateContext.Provider value={stateValue}>
<OverflowActionsContext.Provider value={actionsValue}>
{children}
</OverflowActionsContext.Provider>
</OverflowStateContext.Provider>
);
};

View File

@@ -0,0 +1,132 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type MutableRefObject } from 'react';
import { render } from 'ink-testing-library';
import { renderHook } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import {
SessionStatsProvider,
useSessionStats,
SessionMetrics,
} from './SessionContext.js';
import { describe, it, expect, vi } from 'vitest';
import { uiTelemetryService } from '@qwen/qwen-code-core';
/**
* A test harness component that uses the hook and exposes the context value
* via a mutable ref. This allows us to interact with the context's functions
* and assert against its state directly in our tests.
*/
const TestHarness = ({
contextRef,
}: {
contextRef: MutableRefObject<ReturnType<typeof useSessionStats> | undefined>;
}) => {
contextRef.current = useSessionStats();
return null;
};
describe('SessionStatsContext', () => {
it('should provide the correct initial state', () => {
const contextRef: MutableRefObject<
ReturnType<typeof useSessionStats> | undefined
> = { current: undefined };
render(
<SessionStatsProvider>
<TestHarness contextRef={contextRef} />
</SessionStatsProvider>,
);
const stats = contextRef.current?.stats;
expect(stats?.sessionStartTime).toBeInstanceOf(Date);
expect(stats?.metrics).toBeDefined();
expect(stats?.metrics.models).toEqual({});
});
it('should update metrics when the uiTelemetryService emits an update', () => {
const contextRef: MutableRefObject<
ReturnType<typeof useSessionStats> | undefined
> = { current: undefined };
render(
<SessionStatsProvider>
<TestHarness contextRef={contextRef} />
</SessionStatsProvider>,
);
const newMetrics: SessionMetrics = {
models: {
'gemini-pro': {
api: {
totalRequests: 1,
totalErrors: 0,
totalLatencyMs: 123,
},
tokens: {
prompt: 100,
candidates: 200,
total: 300,
cached: 50,
thoughts: 20,
tool: 10,
},
},
},
tools: {
totalCalls: 1,
totalSuccess: 1,
totalFail: 0,
totalDurationMs: 456,
totalDecisions: {
accept: 1,
reject: 0,
modify: 0,
},
byName: {
'test-tool': {
count: 1,
success: 1,
fail: 0,
durationMs: 456,
decisions: {
accept: 1,
reject: 0,
modify: 0,
},
},
},
},
};
act(() => {
uiTelemetryService.emit('update', {
metrics: newMetrics,
lastPromptTokenCount: 100,
});
});
const stats = contextRef.current?.stats;
expect(stats?.metrics).toEqual(newMetrics);
expect(stats?.lastPromptTokenCount).toBe(100);
});
it('should throw an error when useSessionStats is used outside of a provider', () => {
// Suppress console.error for this test since we expect an error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
// Expect renderHook itself to throw when the hook is used outside a provider
expect(() => {
renderHook(() => useSessionStats());
}).toThrow('useSessionStats must be used within a SessionStatsProvider');
} finally {
consoleSpy.mockRestore();
}
});
});

View File

@@ -0,0 +1,138 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, {
createContext,
useCallback,
useContext,
useState,
useMemo,
useEffect,
} from 'react';
import {
uiTelemetryService,
SessionMetrics,
ModelMetrics,
} from '@qwen/qwen-code-core';
// --- Interface Definitions ---
export type { SessionMetrics, ModelMetrics };
export interface SessionStatsState {
sessionStartTime: Date;
metrics: SessionMetrics;
lastPromptTokenCount: number;
promptCount: number;
}
export interface ComputedSessionStats {
totalApiTime: number;
totalToolTime: number;
agentActiveTime: number;
apiTimePercent: number;
toolTimePercent: number;
cacheEfficiency: number;
totalDecisions: number;
successRate: number;
agreementRate: number;
totalCachedTokens: number;
totalPromptTokens: number;
}
// Defines the final "value" of our context, including the state
// and the functions to update it.
interface SessionStatsContextValue {
stats: SessionStatsState;
startNewPrompt: () => void;
getPromptCount: () => number;
}
// --- Context Definition ---
const SessionStatsContext = createContext<SessionStatsContextValue | undefined>(
undefined,
);
// --- Provider Component ---
export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [stats, setStats] = useState<SessionStatsState>({
sessionStartTime: new Date(),
metrics: uiTelemetryService.getMetrics(),
lastPromptTokenCount: 0,
promptCount: 0,
});
useEffect(() => {
const handleUpdate = ({
metrics,
lastPromptTokenCount,
}: {
metrics: SessionMetrics;
lastPromptTokenCount: number;
}) => {
setStats((prevState) => ({
...prevState,
metrics,
lastPromptTokenCount,
}));
};
uiTelemetryService.on('update', handleUpdate);
// Set initial state
handleUpdate({
metrics: uiTelemetryService.getMetrics(),
lastPromptTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
return () => {
uiTelemetryService.off('update', handleUpdate);
};
}, []);
const startNewPrompt = useCallback(() => {
setStats((prevState) => ({
...prevState,
promptCount: prevState.promptCount + 1,
}));
}, []);
const getPromptCount = useCallback(
() => stats.promptCount,
[stats.promptCount],
);
const value = useMemo(
() => ({
stats,
startNewPrompt,
getPromptCount,
}),
[stats, startNewPrompt, getPromptCount],
);
return (
<SessionStatsContext.Provider value={value}>
{children}
</SessionStatsContext.Provider>
);
};
// --- Consumer Hook ---
export const useSessionStats = () => {
const context = useContext(SessionStatsContext);
if (context === undefined) {
throw new Error(
'useSessionStats must be used within a SessionStatsProvider',
);
}
return context;
};

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