mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
pre-release commit
This commit is contained in:
75
packages/cli/src/config/auth.test.ts
Normal file
75
packages/cli/src/config/auth.test.ts
Normal 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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
61
packages/cli/src/config/auth.ts
Normal file
61
packages/cli/src/config/auth.ts
Normal 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;
|
||||
};
|
||||
243
packages/cli/src/config/config.integration.test.ts
Normal file
243
packages/cli/src/config/config.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
1009
packages/cli/src/config/config.test.ts
Normal file
1009
packages/cli/src/config/config.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
429
packages/cli/src/config/config.ts
Normal file
429
packages/cli/src/config/config.ts
Normal 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];
|
||||
}
|
||||
151
packages/cli/src/config/extension.test.ts
Normal file
151
packages/cli/src/config/extension.test.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
159
packages/cli/src/config/extension.ts
Normal file
159
packages/cli/src/config/extension.ts
Normal 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;
|
||||
}
|
||||
107
packages/cli/src/config/sandboxConfig.ts
Normal file
107
packages/cli/src/config/sandboxConfig.ts
Normal 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;
|
||||
}
|
||||
795
packages/cli/src/config/settings.test.ts
Normal file
795
packages/cli/src/config/settings.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
388
packages/cli/src/config/settings.ts
Normal file
388
packages/cli/src/config/settings.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
151
packages/cli/src/gemini.test.tsx
Normal file
151
packages/cli/src/gemini.test.tsx
Normal 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
344
packages/cli/src/gemini.tsx
Normal 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;
|
||||
}
|
||||
344
packages/cli/src/nonInteractiveCli.test.ts
Normal file
344
packages/cli/src/nonInteractiveCli.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
275
packages/cli/src/nonInteractiveCli.ts
Normal file
275
packages/cli/src/nonInteractiveCli.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
138
packages/cli/src/services/CommandService.test.ts
Normal file
138
packages/cli/src/services/CommandService.test.ts
Normal 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.
|
||||
});
|
||||
});
|
||||
});
|
||||
44
packages/cli/src/services/CommandService.ts
Normal file
44
packages/cli/src/services/CommandService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
62
packages/cli/src/test-utils/mockCommandContext.test.ts
Normal file
62
packages/cli/src/test-utils/mockCommandContext.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
94
packages/cli/src/test-utils/mockCommandContext.ts
Normal file
94
packages/cli/src/test-utils/mockCommandContext.ts
Normal 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);
|
||||
};
|
||||
595
packages/cli/src/ui/App.test.tsx
Normal file
595
packages/cli/src/ui/App.test.tsx
Normal 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
989
packages/cli/src/ui/App.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
50
packages/cli/src/ui/colors.ts
Normal file
50
packages/cli/src/ui/colors.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
117
packages/cli/src/ui/commands/aboutCommand.test.ts
Normal file
117
packages/cli/src/ui/commands/aboutCommand.test.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
43
packages/cli/src/ui/commands/aboutCommand.ts
Normal file
43
packages/cli/src/ui/commands/aboutCommand.ts
Normal 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());
|
||||
},
|
||||
};
|
||||
36
packages/cli/src/ui/commands/authCommand.test.ts
Normal file
36
packages/cli/src/ui/commands/authCommand.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
16
packages/cli/src/ui/commands/authCommand.ts
Normal file
16
packages/cli/src/ui/commands/authCommand.ts
Normal 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',
|
||||
}),
|
||||
};
|
||||
78
packages/cli/src/ui/commands/clearCommand.test.ts
Normal file
78
packages/cli/src/ui/commands/clearCommand.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
17
packages/cli/src/ui/commands/clearCommand.ts
Normal file
17
packages/cli/src/ui/commands/clearCommand.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
40
packages/cli/src/ui/commands/helpCommand.test.ts
Normal file
40
packages/cli/src/ui/commands/helpCommand.test.ts
Normal 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('?');
|
||||
});
|
||||
});
|
||||
20
packages/cli/src/ui/commands/helpCommand.ts
Normal file
20
packages/cli/src/ui/commands/helpCommand.ts
Normal 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',
|
||||
};
|
||||
},
|
||||
};
|
||||
248
packages/cli/src/ui/commands/memoryCommand.test.ts
Normal file
248
packages/cli/src/ui/commands/memoryCommand.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
106
packages/cli/src/ui/commands/memoryCommand.ts
Normal file
106
packages/cli/src/ui/commands/memoryCommand.ts
Normal 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(),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
38
packages/cli/src/ui/commands/privacyCommand.test.ts
Normal file
38
packages/cli/src/ui/commands/privacyCommand.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
16
packages/cli/src/ui/commands/privacyCommand.ts
Normal file
16
packages/cli/src/ui/commands/privacyCommand.ts
Normal 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',
|
||||
}),
|
||||
};
|
||||
38
packages/cli/src/ui/commands/themeCommand.test.ts
Normal file
38
packages/cli/src/ui/commands/themeCommand.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
16
packages/cli/src/ui/commands/themeCommand.ts
Normal file
16
packages/cli/src/ui/commands/themeCommand.ts
Normal 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',
|
||||
}),
|
||||
};
|
||||
98
packages/cli/src/ui/commands/types.ts
Normal file
98
packages/cli/src/ui/commands/types.ts
Normal 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[];
|
||||
}
|
||||
119
packages/cli/src/ui/components/AboutBox.tsx
Normal file
119
packages/cli/src/ui/components/AboutBox.tsx
Normal 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>
|
||||
);
|
||||
22
packages/cli/src/ui/components/AsciiArt.ts
Normal file
22
packages/cli/src/ui/components/AsciiArt.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const shortAsciiLogo = `
|
||||
██████╗ ██╗ ██╗███████╗███╗ ██╗
|
||||
██╔═══██╗██║ ██║██╔════╝████╗ ██║
|
||||
██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║
|
||||
██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║
|
||||
╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
|
||||
╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
|
||||
`;
|
||||
export const longAsciiLogo = `
|
||||
██╗ ██████╗ ██╗ ██╗███████╗███╗ ██╗
|
||||
╚██╗ ██╔═══██╗██║ ██║██╔════╝████╗ ██║
|
||||
╚██╗ ██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║
|
||||
██╔╝ ██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║
|
||||
██╔╝ ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
|
||||
╚═╝ ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
|
||||
`;
|
||||
325
packages/cli/src/ui/components/AuthDialog.test.tsx
Normal file
325
packages/cli/src/ui/components/AuthDialog.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
171
packages/cli/src/ui/components/AuthDialog.tsx
Normal file
171
packages/cli/src/ui/components/AuthDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
packages/cli/src/ui/components/AuthInProgress.tsx
Normal file
57
packages/cli/src/ui/components/AuthInProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
packages/cli/src/ui/components/AutoAcceptIndicator.tsx
Normal file
47
packages/cli/src/ui/components/AutoAcceptIndicator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
35
packages/cli/src/ui/components/ConsoleSummaryDisplay.tsx
Normal file
35
packages/cli/src/ui/components/ConsoleSummaryDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
67
packages/cli/src/ui/components/ContextSummaryDisplay.tsx
Normal file
67
packages/cli/src/ui/components/ContextSummaryDisplay.tsx
Normal 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>;
|
||||
};
|
||||
82
packages/cli/src/ui/components/DetailedMessagesDisplay.tsx
Normal file
82
packages/cli/src/ui/components/DetailedMessagesDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
168
packages/cli/src/ui/components/EditorSettingsDialog.tsx
Normal file
168
packages/cli/src/ui/components/EditorSettingsDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
packages/cli/src/ui/components/Footer.tsx
Normal file
121
packages/cli/src/ui/components/Footer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
34
packages/cli/src/ui/components/GeminiRespondingSpinner.tsx
Normal file
34
packages/cli/src/ui/components/GeminiRespondingSpinner.tsx
Normal 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;
|
||||
};
|
||||
63
packages/cli/src/ui/components/Header.tsx
Normal file
63
packages/cli/src/ui/components/Header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
155
packages/cli/src/ui/components/Help.tsx
Normal file
155
packages/cli/src/ui/components/Help.tsx
Normal 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>
|
||||
);
|
||||
112
packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
Normal file
112
packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
Normal 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!');
|
||||
});
|
||||
});
|
||||
92
packages/cli/src/ui/components/HistoryItemDisplay.tsx
Normal file
92
packages/cli/src/ui/components/HistoryItemDisplay.tsx
Normal 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>
|
||||
);
|
||||
546
packages/cli/src/ui/components/InputPrompt.test.tsx
Normal file
546
packages/cli/src/ui/components/InputPrompt.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
486
packages/cli/src/ui/components/InputPrompt.tsx
Normal file
486
packages/cli/src/ui/components/InputPrompt.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
226
packages/cli/src/ui/components/LoadingIndicator.test.tsx
Normal file
226
packages/cli/src/ui/components/LoadingIndicator.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
61
packages/cli/src/ui/components/LoadingIndicator.tsx
Normal file
61
packages/cli/src/ui/components/LoadingIndicator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
36
packages/cli/src/ui/components/MemoryUsageDisplay.tsx
Normal file
36
packages/cli/src/ui/components/MemoryUsageDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
239
packages/cli/src/ui/components/ModelStatsDisplay.test.tsx
Normal file
239
packages/cli/src/ui/components/ModelStatsDisplay.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
197
packages/cli/src/ui/components/ModelStatsDisplay.tsx
Normal file
197
packages/cli/src/ui/components/ModelStatsDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
64
packages/cli/src/ui/components/OpenAIKeyPrompt.test.tsx
Normal file
64
packages/cli/src/ui/components/OpenAIKeyPrompt.test.tsx
Normal 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
|
||||
});
|
||||
});
|
||||
197
packages/cli/src/ui/components/OpenAIKeyPrompt.tsx
Normal file
197
packages/cli/src/ui/components/OpenAIKeyPrompt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
18
packages/cli/src/ui/components/SessionSummaryDisplay.tsx
Normal file
18
packages/cli/src/ui/components/SessionSummaryDisplay.tsx
Normal 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} />
|
||||
);
|
||||
18
packages/cli/src/ui/components/ShellModeIndicator.tsx
Normal file
18
packages/cli/src/ui/components/ShellModeIndicator.tsx
Normal 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>
|
||||
);
|
||||
40
packages/cli/src/ui/components/ShowMoreLines.tsx
Normal file
40
packages/cli/src/ui/components/ShowMoreLines.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
311
packages/cli/src/ui/components/StatsDisplay.test.tsx
Normal file
311
packages/cli/src/ui/components/StatsDisplay.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
259
packages/cli/src/ui/components/StatsDisplay.tsx
Normal file
259
packages/cli/src/ui/components/StatsDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
93
packages/cli/src/ui/components/SuggestionsDisplay.tsx
Normal file
93
packages/cli/src/ui/components/SuggestionsDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
272
packages/cli/src/ui/components/ThemeDialog.tsx
Normal file
272
packages/cli/src/ui/components/ThemeDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
packages/cli/src/ui/components/Tips.tsx
Normal file
45
packages/cli/src/ui/components/Tips.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
180
packages/cli/src/ui/components/ToolStatsDisplay.test.tsx
Normal file
180
packages/cli/src/ui/components/ToolStatsDisplay.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
208
packages/cli/src/ui/components/ToolStatsDisplay.tsx
Normal file
208
packages/cli/src/ui/components/ToolStatsDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
packages/cli/src/ui/components/UpdateNotification.tsx
Normal file
23
packages/cli/src/ui/components/UpdateNotification.tsx
Normal 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>
|
||||
);
|
||||
@@ -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. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -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\`. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -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%) │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -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. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
362
packages/cli/src/ui/components/messages/DiffRenderer.test.tsx
Normal file
362
packages/cli/src/ui/components/messages/DiffRenderer.test.tsx
Normal 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`);
|
||||
});
|
||||
});
|
||||
312
packages/cli/src/ui/components/messages/DiffRenderer.tsx
Normal file
312
packages/cli/src/ui/components/messages/DiffRenderer.tsx
Normal 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
|
||||
};
|
||||
31
packages/cli/src/ui/components/messages/ErrorMessage.tsx
Normal file
31
packages/cli/src/ui/components/messages/ErrorMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
43
packages/cli/src/ui/components/messages/GeminiMessage.tsx
Normal file
43
packages/cli/src/ui/components/messages/GeminiMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
31
packages/cli/src/ui/components/messages/InfoMessage.tsx
Normal file
31
packages/cli/src/ui/components/messages/InfoMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
123
packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
Normal file
123
packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
181
packages/cli/src/ui/components/messages/ToolMessage.test.tsx
Normal file
181
packages/cli/src/ui/components/messages/ToolMessage.test.tsx
Normal 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('←');
|
||||
});
|
||||
});
|
||||
194
packages/cli/src/ui/components/messages/ToolMessage.tsx
Normal file
194
packages/cli/src/ui/components/messages/ToolMessage.tsx
Normal 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>
|
||||
);
|
||||
39
packages/cli/src/ui/components/messages/UserMessage.tsx
Normal file
39
packages/cli/src/ui/components/messages/UserMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
25
packages/cli/src/ui/components/messages/UserShellMessage.tsx
Normal file
25
packages/cli/src/ui/components/messages/UserShellMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
342
packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
Normal file
342
packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
547
packages/cli/src/ui/components/shared/MaxSizedBox.tsx
Normal file
547
packages/cli/src/ui/components/shared/MaxSizedBox.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
157
packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
Normal file
157
packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1340
packages/cli/src/ui/components/shared/text-buffer.test.ts
Normal file
1340
packages/cli/src/ui/components/shared/text-buffer.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1389
packages/cli/src/ui/components/shared/text-buffer.ts
Normal file
1389
packages/cli/src/ui/components/shared/text-buffer.ts
Normal file
File diff suppressed because it is too large
Load Diff
15
packages/cli/src/ui/constants.ts
Normal file
15
packages/cli/src/ui/constants.ts
Normal 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;
|
||||
87
packages/cli/src/ui/contexts/OverflowContext.tsx
Normal file
87
packages/cli/src/ui/contexts/OverflowContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
132
packages/cli/src/ui/contexts/SessionContext.test.tsx
Normal file
132
packages/cli/src/ui/contexts/SessionContext.test.tsx
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
138
packages/cli/src/ui/contexts/SessionContext.tsx
Normal file
138
packages/cli/src/ui/contexts/SessionContext.tsx
Normal 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
Reference in New Issue
Block a user