diff --git a/packages/cli/src/ui/commands/aboutCommand.test.ts b/packages/cli/src/ui/commands/aboutCommand.test.ts index 414c06ad..8a1daaeb 100644 --- a/packages/cli/src/ui/commands/aboutCommand.test.ts +++ b/packages/cli/src/ui/commands/aboutCommand.test.ts @@ -8,38 +8,22 @@ 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'; -import { IdeClient } from '@qwen-code/qwen-code-core'; +import * as systemInfoUtils from '../../utils/systemInfo.js'; -vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - IdeClient: { - getInstance: vi.fn().mockResolvedValue({ - getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'), - }), - }, - }; -}); - -vi.mock('../../utils/version.js', () => ({ - getCliVersion: vi.fn(), -})); +vi.mock('../../utils/systemInfo.js'); describe('aboutCommand', () => { let mockContext: CommandContext; - const originalPlatform = process.platform; const originalEnv = { ...process.env }; beforeEach(() => { mockContext = createMockCommandContext({ services: { config: { - getModel: vi.fn(), + getModel: vi.fn().mockReturnValue('test-model'), getIdeMode: vi.fn().mockReturnValue(true), + getSessionId: vi.fn().mockReturnValue('test-session-id'), }, settings: { merged: { @@ -56,21 +40,25 @@ describe('aboutCommand', () => { }, } 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', + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: 'test-ide', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: undefined, }); }); afterEach(() => { vi.unstubAllEnvs(); - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }); process.env = originalEnv; vi.clearAllMocks(); }); @@ -81,30 +69,55 @@ describe('aboutCommand', () => { }); it('should call addItem with all version info', async () => { - process.env['SANDBOX'] = ''; if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } await aboutCommand.action(mockContext, ''); + expect(systemInfoUtils.getExtendedSystemInfo).toHaveBeenCalledWith( + mockContext, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { + expect.objectContaining({ type: MessageType.ABOUT, - cliVersion: 'test-version', - osVersion: 'test-os', - sandboxEnv: 'no sandbox', - modelVersion: 'test-model', - selectedAuthType: 'test-auth', - gcpProject: 'test-gcp-project', - ideClient: 'test-ide', - }, + systemInfo: expect.objectContaining({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: 'test-ide', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: undefined, + }), + }), expect.any(Number), ); }); it('should show the correct sandbox environment variable', async () => { - process.env['SANDBOX'] = 'gemini-sandbox'; + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'gemini-sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: 'test-ide', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: undefined, + }); + if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } @@ -113,15 +126,32 @@ describe('aboutCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ - sandboxEnv: 'gemini-sandbox', + type: MessageType.ABOUT, + systemInfo: 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'; + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'sandbox-exec (test-profile)', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: 'test-ide', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: undefined, + }); + if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } @@ -130,18 +160,31 @@ describe('aboutCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ - sandboxEnv: 'sandbox-exec (test-profile)', + systemInfo: expect.objectContaining({ + sandboxEnv: 'sandbox-exec (test-profile)', + }), }), expect.any(Number), ); }); it('should not show ide client when it is not detected', async () => { - vi.mocked(IdeClient.getInstance).mockResolvedValue({ - getDetectedIdeDisplayName: vi.fn().mockReturnValue(undefined), - } as unknown as IdeClient); + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: '', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: undefined, + }); - process.env['SANDBOX'] = ''; if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } @@ -151,13 +194,87 @@ describe('aboutCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ABOUT, - cliVersion: 'test-version', - osVersion: 'test-os', - sandboxEnv: 'no sandbox', - modelVersion: 'test-model', - selectedAuthType: 'test-auth', - gcpProject: 'test-gcp-project', - ideClient: '', + systemInfo: expect.objectContaining({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: '', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: undefined, + }), + }), + expect.any(Number), + ); + }); + + it('should show unknown npmVersion when npm command fails', async () => { + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: 'unknown', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: 'test-ide', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: undefined, + }); + + if (!aboutCommand.action) { + throw new Error('The about command must have an action.'); + } + + await aboutCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + systemInfo: expect.objectContaining({ + npmVersion: 'unknown', + }), + }), + expect.any(Number), + ); + }); + + it('should show unknown sessionId when config is not available', async () => { + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'no sandbox', + modelVersion: 'Unknown', + selectedAuthType: 'test-auth', + ideClient: '', + sessionId: 'unknown', + memoryUsage: '100 MB', + baseUrl: undefined, + }); + + if (!aboutCommand.action) { + throw new Error('The about command must have an action.'); + } + + await aboutCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + systemInfo: expect.objectContaining({ + sessionId: 'unknown', + }), }), expect.any(Number), ); diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index 36bfbdff..0f35db92 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -4,53 +4,23 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { getCliVersion } from '../../utils/version.js'; -import type { CommandContext, SlashCommand } from './types.js'; +import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; -import process from 'node:process'; import { MessageType, type HistoryItemAbout } from '../types.js'; -import { IdeClient } from '@qwen-code/qwen-code-core'; +import { getExtendedSystemInfo } from '../../utils/systemInfo.js'; export const aboutCommand: SlashCommand = { name: 'about', description: 'show version info', kind: CommandKind.BUILT_IN, 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.security?.auth?.selectedType || ''; - const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || ''; - const ideClient = await getIdeClientName(context); + const systemInfo = await getExtendedSystemInfo(context); const aboutItem: Omit = { type: MessageType.ABOUT, - cliVersion, - osVersion, - sandboxEnv, - modelVersion, - selectedAuthType, - gcpProject, - ideClient, + systemInfo, }; context.ui.addItem(aboutItem, Date.now()); }, }; - -async function getIdeClientName(context: CommandContext) { - if (!context.services.config?.getIdeMode()) { - return ''; - } - const ideClient = await IdeClient.getInstance(); - return ideClient?.getDetectedIdeDisplayName() ?? ''; -} diff --git a/packages/cli/src/ui/commands/bugCommand.test.ts b/packages/cli/src/ui/commands/bugCommand.test.ts index 9d668055..09c28aad 100644 --- a/packages/cli/src/ui/commands/bugCommand.test.ts +++ b/packages/cli/src/ui/commands/bugCommand.test.ts @@ -8,41 +8,34 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import open from 'open'; import { bugCommand } from './bugCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import { getCliVersion } from '../../utils/version.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; -import { formatMemoryUsage } from '../utils/formatters.js'; import { AuthType } from '@qwen-code/qwen-code-core'; +import * as systemInfoUtils from '../../utils/systemInfo.js'; // Mock dependencies vi.mock('open'); -vi.mock('../../utils/version.js'); -vi.mock('../utils/formatters.js'); -vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - IdeClient: { - getInstance: () => ({ - getDetectedIdeDisplayName: vi.fn().mockReturnValue('VSCode'), - }), - }, - }; -}); -vi.mock('node:process', () => ({ - default: { - platform: 'test-platform', - version: 'v20.0.0', - // Keep other necessary process properties if needed by other parts of the code - env: process.env, - memoryUsage: () => ({ rss: 0 }), - }, -})); +vi.mock('../../utils/systemInfo.js'); describe('bugCommand', () => { beforeEach(() => { - vi.mocked(getCliVersion).mockResolvedValue('0.1.0'); - vi.mocked(formatMemoryUsage).mockReturnValue('100 MB'); + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: '0.1.0', + osPlatform: 'test-platform', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'test', + modelVersion: 'qwen3-coder-plus', + selectedAuthType: '', + ideClient: 'VSCode', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + gitCommit: + GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) + ? GIT_COMMIT_INFO + : undefined, + }); vi.stubEnv('SANDBOX', 'qwen-test'); }); @@ -55,19 +48,7 @@ describe('bugCommand', () => { const mockContext = createMockCommandContext({ services: { config: { - getModel: () => 'qwen3-coder-plus', getBugCommand: () => undefined, - getIdeMode: () => true, - getSessionId: () => 'test-session-id', - }, - settings: { - merged: { - security: { - auth: { - selectedType: undefined, - }, - }, - }, }, }, }); @@ -75,14 +56,21 @@ describe('bugCommand', () => { if (!bugCommand.action) throw new Error('Action is not defined'); await bugCommand.action(mockContext, 'A test bug'); + const gitCommitLine = + GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) + ? `* **Git Commit:** ${GIT_COMMIT_INFO}\n` + : ''; const expectedInfo = ` * **CLI Version:** 0.1.0 -* **Git Commit:** ${GIT_COMMIT_INFO} +${gitCommitLine}* **Model:** qwen3-coder-plus +* **Sandbox:** test +* **OS Platform:** test-platform +* **OS Arch:** x64 +* **OS Release:** 22.0.0 +* **Node.js Version:** v20.0.0 +* **NPM Version:** 10.0.0 * **Session ID:** test-session-id -* **Operating System:** test-platform v20.0.0 -* **Sandbox Environment:** test -* **Auth Type:** -* **Model Version:** qwen3-coder-plus +* **Auth Method:** * **Memory Usage:** 100 MB * **IDE Client:** VSCode `; @@ -99,19 +87,7 @@ describe('bugCommand', () => { const mockContext = createMockCommandContext({ services: { config: { - getModel: () => 'qwen3-coder-plus', getBugCommand: () => ({ urlTemplate: customTemplate }), - getIdeMode: () => true, - getSessionId: () => 'test-session-id', - }, - settings: { - merged: { - security: { - auth: { - selectedType: undefined, - }, - }, - }, }, }, }); @@ -119,14 +95,21 @@ describe('bugCommand', () => { if (!bugCommand.action) throw new Error('Action is not defined'); await bugCommand.action(mockContext, 'A custom bug'); + const gitCommitLine = + GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) + ? `* **Git Commit:** ${GIT_COMMIT_INFO}\n` + : ''; const expectedInfo = ` * **CLI Version:** 0.1.0 -* **Git Commit:** ${GIT_COMMIT_INFO} +${gitCommitLine}* **Model:** qwen3-coder-plus +* **Sandbox:** test +* **OS Platform:** test-platform +* **OS Arch:** x64 +* **OS Release:** 22.0.0 +* **Node.js Version:** v20.0.0 +* **NPM Version:** 10.0.0 * **Session ID:** test-session-id -* **Operating System:** test-platform v20.0.0 -* **Sandbox Environment:** test -* **Auth Type:** -* **Model Version:** qwen3-coder-plus +* **Auth Method:** * **Memory Usage:** 100 MB * **IDE Client:** VSCode `; @@ -138,25 +121,30 @@ describe('bugCommand', () => { }); it('should include Base URL when auth type is OpenAI', async () => { + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: '0.1.0', + osPlatform: 'test-platform', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'test', + modelVersion: 'qwen3-coder-plus', + selectedAuthType: AuthType.USE_OPENAI, + ideClient: 'VSCode', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: 'https://api.openai.com/v1', + gitCommit: + GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) + ? GIT_COMMIT_INFO + : undefined, + }); + const mockContext = createMockCommandContext({ services: { config: { - getModel: () => 'qwen3-coder-plus', getBugCommand: () => undefined, - getIdeMode: () => true, - getSessionId: () => 'test-session-id', - getContentGeneratorConfig: () => ({ - baseUrl: 'https://api.openai.com/v1', - }), - }, - settings: { - merged: { - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - }, }, }, }); @@ -164,15 +152,22 @@ describe('bugCommand', () => { if (!bugCommand.action) throw new Error('Action is not defined'); await bugCommand.action(mockContext, 'OpenAI bug'); + const gitCommitLine = + GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) + ? `* **Git Commit:** ${GIT_COMMIT_INFO}\n` + : ''; const expectedInfo = ` * **CLI Version:** 0.1.0 -* **Git Commit:** ${GIT_COMMIT_INFO} +${gitCommitLine}* **Model:** qwen3-coder-plus +* **Sandbox:** test +* **OS Platform:** test-platform +* **OS Arch:** x64 +* **OS Release:** 22.0.0 +* **Node.js Version:** v20.0.0 +* **NPM Version:** 10.0.0 * **Session ID:** test-session-id -* **Operating System:** test-platform v20.0.0 -* **Sandbox Environment:** test -* **Auth Type:** ${AuthType.USE_OPENAI} +* **Auth Method:** ${AuthType.USE_OPENAI} * **Base URL:** https://api.openai.com/v1 -* **Model Version:** qwen3-coder-plus * **Memory Usage:** 100 MB * **IDE Client:** VSCode `; diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 2eb9b823..869024b5 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -5,17 +5,17 @@ */ import open from 'open'; -import process from 'node:process'; import { type CommandContext, type SlashCommand, CommandKind, } from './types.js'; import { MessageType } from '../types.js'; -import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; -import { formatMemoryUsage } from '../utils/formatters.js'; -import { getCliVersion } from '../../utils/version.js'; -import { IdeClient, AuthType } from '@qwen-code/qwen-code-core'; +import { getExtendedSystemInfo } from '../../utils/systemInfo.js'; +import { + getSystemInfoFields, + getFieldValue, +} from '../../utils/systemInfoFields.js'; export const bugCommand: SlashCommand = { name: 'bug', @@ -23,50 +23,20 @@ export const bugCommand: SlashCommand = { kind: CommandKind.BUILT_IN, action: async (context: CommandContext, args?: string): Promise => { const bugDescription = (args || '').trim(); - const { config } = context.services; + const systemInfo = await getExtendedSystemInfo(context); - const osVersion = `${process.platform} ${process.version}`; - let sandboxEnv = 'no sandbox'; - if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { - sandboxEnv = process.env['SANDBOX'].replace(/^qwen-(?:code-)?/, ''); - } else if (process.env['SANDBOX'] === 'sandbox-exec') { - sandboxEnv = `sandbox-exec (${ - process.env['SEATBELT_PROFILE'] || 'unknown' - })`; - } - const modelVersion = config?.getModel() || 'Unknown'; - const cliVersion = await getCliVersion(); - const memoryUsage = formatMemoryUsage(process.memoryUsage().rss); - const ideClient = await getIdeClientName(context); - const selectedAuthType = - context.services.settings.merged.security?.auth?.selectedType || ''; - const baseUrl = - selectedAuthType === AuthType.USE_OPENAI - ? config?.getContentGeneratorConfig()?.baseUrl - : undefined; + const fields = getSystemInfoFields(systemInfo); - let info = ` -* **CLI Version:** ${cliVersion} -* **Git Commit:** ${GIT_COMMIT_INFO} -* **Session ID:** ${config?.getSessionId() || 'unknown'} -* **Operating System:** ${osVersion} -* **Sandbox Environment:** ${sandboxEnv} -* **Auth Type:** ${selectedAuthType}`; - if (baseUrl) { - info += `\n* **Base URL:** ${baseUrl}`; - } - info += ` -* **Model Version:** ${modelVersion} -* **Memory Usage:** ${memoryUsage} -`; - if (ideClient) { - info += `* **IDE Client:** ${ideClient}\n`; + // Generate bug report info using the same field configuration + let info = '\n'; + for (const field of fields) { + info += `* **${field.label}:** ${getFieldValue(field, systemInfo)}\n`; } let bugReportUrl = 'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title={title}&info={info}'; - const bugCommandSettings = config?.getBugCommand(); + const bugCommandSettings = context.services.config?.getBugCommand(); if (bugCommandSettings?.urlTemplate) { bugReportUrl = bugCommandSettings.urlTemplate; } @@ -98,11 +68,3 @@ export const bugCommand: SlashCommand = { } }, }; - -async function getIdeClientName(context: CommandContext) { - if (!context.services.config?.getIdeMode()) { - return ''; - } - const ideClient = await IdeClient.getInstance(); - return ideClient.getDetectedIdeDisplayName() ?? ''; -} diff --git a/packages/cli/src/ui/components/AboutBox.tsx b/packages/cli/src/ui/components/AboutBox.tsx index 70cf47cd..fba5fb13 100644 --- a/packages/cli/src/ui/components/AboutBox.tsx +++ b/packages/cli/src/ui/components/AboutBox.tsx @@ -7,127 +7,46 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; -import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; +import type { ExtendedSystemInfo } from '../../utils/systemInfo.js'; +import { + getSystemInfoFields, + getFieldValue, + type SystemInfoField, +} from '../../utils/systemInfoFields.js'; -interface AboutBoxProps { - cliVersion: string; - osVersion: string; - sandboxEnv: string; - modelVersion: string; - selectedAuthType: string; - gcpProject: string; - ideClient: string; -} +type AboutBoxProps = ExtendedSystemInfo; -export const AboutBox: React.FC = ({ - cliVersion, - osVersion, - sandboxEnv, - modelVersion, - selectedAuthType, - gcpProject, - ideClient, -}) => ( - - - - About Qwen Code - - - - - - CLI Version +export const AboutBox: React.FC = (props) => { + const fields = getSystemInfoFields(props); + + return ( + + + + About Qwen Code - - {cliVersion} - + {fields.map((field: SystemInfoField) => ( + + + + {field.label} + + + + + {getFieldValue(field, props)} + + + + ))} - {GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && ( - - - - Git Commit - - - - {GIT_COMMIT_INFO} - - - )} - - - - Model - - - - {modelVersion} - - - - - - Sandbox - - - - {sandboxEnv} - - - - - - OS - - - - {osVersion} - - - - - - Auth Method - - - - - {selectedAuthType.startsWith('oauth') ? 'OAuth' : selectedAuthType} - - - - {gcpProject && ( - - - - GCP Project - - - - {gcpProject} - - - )} - {ideClient && ( - - - - IDE Client - - - - {ideClient} - - - )} - -); + ); +}; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 4eaf8ab3..7cca61ae 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -71,15 +71,24 @@ describe('', () => { it('renders AboutBox for "about" type', () => { const item: HistoryItem = { - ...baseItem, + id: 1, type: MessageType.ABOUT, - cliVersion: '1.0.0', - osVersion: 'test-os', - sandboxEnv: 'test-env', - modelVersion: 'test-model', - selectedAuthType: 'test-auth', - gcpProject: 'test-project', - ideClient: 'test-ide', + systemInfo: { + cliVersion: '1.0.0', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'test-env', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: 'test-ide', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: undefined, + gitCommit: undefined, + }, }; const { lastFrame } = renderWithProviders( , diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 1e86ffa1..bec9c23d 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -95,15 +95,7 @@ const HistoryItemDisplayComponent: React.FC = ({ )} {itemForDisplay.type === 'about' && ( - + )} {itemForDisplay.type === 'help' && commands && ( diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index f2929d56..cba3bf7a 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -138,13 +138,7 @@ export const useSlashCommandProcessor = ( if (message.type === MessageType.ABOUT) { historyItemContent = { type: 'about', - cliVersion: message.cliVersion, - osVersion: message.osVersion, - sandboxEnv: message.sandboxEnv, - modelVersion: message.modelVersion, - selectedAuthType: message.selectedAuthType, - gcpProject: message.gcpProject, - ideClient: message.ideClient, + systemInfo: message.systemInfo, }; } else if (message.type === MessageType.HELP) { historyItemContent = { diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 1d2fa782..bc9a6317 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -120,13 +120,22 @@ export type HistoryItemWarning = HistoryItemBase & { export type HistoryItemAbout = HistoryItemBase & { type: 'about'; - cliVersion: string; - osVersion: string; - sandboxEnv: string; - modelVersion: string; - selectedAuthType: string; - gcpProject: string; - ideClient: string; + systemInfo: { + cliVersion: string; + osPlatform: string; + osArch: string; + osRelease: string; + nodeVersion: string; + npmVersion: string; + sandboxEnv: string; + modelVersion: string; + selectedAuthType: string; + ideClient: string; + sessionId: string; + memoryUsage: string; + baseUrl?: string; + gitCommit?: string; + }; }; export type HistoryItemHelp = HistoryItemBase & { @@ -288,13 +297,22 @@ export type Message = | { type: MessageType.ABOUT; timestamp: Date; - cliVersion: string; - osVersion: string; - sandboxEnv: string; - modelVersion: string; - selectedAuthType: string; - gcpProject: string; - ideClient: string; + systemInfo: { + cliVersion: string; + osPlatform: string; + osArch: string; + osRelease: string; + nodeVersion: string; + npmVersion: string; + sandboxEnv: string; + modelVersion: string; + selectedAuthType: string; + ideClient: string; + sessionId: string; + memoryUsage: string; + baseUrl?: string; + gitCommit?: string; + }; content?: string; // Optional content, not really used for ABOUT } | { diff --git a/packages/cli/src/utils/systemInfo.test.ts b/packages/cli/src/utils/systemInfo.test.ts new file mode 100644 index 00000000..4849f1b1 --- /dev/null +++ b/packages/cli/src/utils/systemInfo.test.ts @@ -0,0 +1,331 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { + getSystemInfo, + getExtendedSystemInfo, + getNpmVersion, + getSandboxEnv, + getIdeClientName, +} from './systemInfo.js'; +import type { CommandContext } from '../ui/commands/types.js'; +import { createMockCommandContext } from '../test-utils/mockCommandContext.js'; +import * as child_process from 'node:child_process'; +import os from 'node:os'; +import { IdeClient } from '@qwen-code/qwen-code-core'; +import * as versionUtils from './version.js'; +import type { ExecSyncOptions } from 'node:child_process'; + +vi.mock('node:child_process'); + +vi.mock('node:os', () => ({ + default: { + release: vi.fn(), + }, +})); + +vi.mock('./version.js', () => ({ + getCliVersion: vi.fn(), +})); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + IdeClient: { + getInstance: vi.fn(), + }, + }; +}); + +describe('systemInfo', () => { + let mockContext: CommandContext; + const originalPlatform = process.platform; + const originalArch = process.arch; + const originalVersion = process.version; + const originalEnv = { ...process.env }; + + beforeEach(() => { + mockContext = createMockCommandContext({ + services: { + config: { + getModel: vi.fn().mockReturnValue('test-model'), + getIdeMode: vi.fn().mockReturnValue(true), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + baseUrl: 'https://api.openai.com', + }), + }, + settings: { + merged: { + security: { + auth: { + selectedType: 'test-auth', + }, + }, + }, + }, + }, + } as unknown as CommandContext); + + vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version'); + vi.mocked(child_process.execSync).mockImplementation( + (command: string, options?: ExecSyncOptions) => { + if ( + options && + typeof options === 'object' && + 'encoding' in options && + options.encoding === 'utf-8' + ) { + return '10.0.0'; + } + return Buffer.from('10.0.0', 'utf-8'); + }, + ); + vi.mocked(os.release).mockReturnValue('22.0.0'); + process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project'; + Object.defineProperty(process, 'platform', { + value: 'test-os', + }); + Object.defineProperty(process, 'arch', { + value: 'x64', + }); + Object.defineProperty(process, 'version', { + value: 'v20.0.0', + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + Object.defineProperty(process, 'arch', { + value: originalArch, + }); + Object.defineProperty(process, 'version', { + value: originalVersion, + }); + process.env = originalEnv; + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('getNpmVersion', () => { + it('should return npm version when available', async () => { + vi.mocked(child_process.execSync).mockImplementation( + (command: string, options?: ExecSyncOptions) => { + if ( + options && + typeof options === 'object' && + 'encoding' in options && + options.encoding === 'utf-8' + ) { + return '10.0.0'; + } + return Buffer.from('10.0.0', 'utf-8'); + }, + ); + const version = await getNpmVersion(); + expect(version).toBe('10.0.0'); + }); + + it('should return unknown when npm command fails', async () => { + vi.mocked(child_process.execSync).mockImplementation(() => { + throw new Error('npm not found'); + }); + const version = await getNpmVersion(); + expect(version).toBe('unknown'); + }); + }); + + describe('getSandboxEnv', () => { + it('should return "no sandbox" when SANDBOX is not set', () => { + delete process.env['SANDBOX']; + expect(getSandboxEnv()).toBe('no sandbox'); + }); + + it('should return sandbox-exec info when SANDBOX is sandbox-exec', () => { + process.env['SANDBOX'] = 'sandbox-exec'; + process.env['SEATBELT_PROFILE'] = 'test-profile'; + expect(getSandboxEnv()).toBe('sandbox-exec (test-profile)'); + }); + + it('should return sandbox name without prefix when stripPrefix is true', () => { + process.env['SANDBOX'] = 'qwen-code-test-sandbox'; + expect(getSandboxEnv(true)).toBe('test-sandbox'); + }); + + it('should return sandbox name with prefix when stripPrefix is false', () => { + process.env['SANDBOX'] = 'qwen-code-test-sandbox'; + expect(getSandboxEnv(false)).toBe('qwen-code-test-sandbox'); + }); + + it('should handle qwen- prefix removal', () => { + process.env['SANDBOX'] = 'qwen-custom-sandbox'; + expect(getSandboxEnv(true)).toBe('custom-sandbox'); + }); + }); + + describe('getIdeClientName', () => { + it('should return IDE client name when IDE mode is enabled', async () => { + vi.mocked(IdeClient.getInstance).mockResolvedValue({ + getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'), + } as unknown as IdeClient); + + const ideClient = await getIdeClientName(mockContext); + expect(ideClient).toBe('test-ide'); + }); + + it('should return empty string when IDE mode is disabled', async () => { + vi.mocked(mockContext.services.config!.getIdeMode).mockReturnValue(false); + + const ideClient = await getIdeClientName(mockContext); + expect(ideClient).toBe(''); + }); + + it('should return empty string when IDE client detection fails', async () => { + vi.mocked(IdeClient.getInstance).mockRejectedValue( + new Error('IDE client error'), + ); + + const ideClient = await getIdeClientName(mockContext); + expect(ideClient).toBe(''); + }); + }); + + describe('getSystemInfo', () => { + it('should collect all system information', async () => { + // Ensure SANDBOX is not set for this test + delete process.env['SANDBOX']; + vi.mocked(IdeClient.getInstance).mockResolvedValue({ + getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'), + } as unknown as IdeClient); + vi.mocked(child_process.execSync).mockImplementation( + (command: string, options?: ExecSyncOptions) => { + if ( + options && + typeof options === 'object' && + 'encoding' in options && + options.encoding === 'utf-8' + ) { + return '10.0.0'; + } + return Buffer.from('10.0.0', 'utf-8'); + }, + ); + + const systemInfo = await getSystemInfo(mockContext); + + expect(systemInfo).toEqual({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: 'test-ide', + sessionId: 'test-session-id', + }); + }); + + it('should handle missing config gracefully', async () => { + mockContext.services.config = null; + vi.mocked(IdeClient.getInstance).mockResolvedValue({ + getDetectedIdeDisplayName: vi.fn().mockReturnValue(''), + } as unknown as IdeClient); + + const systemInfo = await getSystemInfo(mockContext); + + expect(systemInfo.modelVersion).toBe('Unknown'); + expect(systemInfo.sessionId).toBe('unknown'); + }); + }); + + describe('getExtendedSystemInfo', () => { + it('should include memory usage and base URL', async () => { + vi.mocked(IdeClient.getInstance).mockResolvedValue({ + getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'), + } as unknown as IdeClient); + vi.mocked(child_process.execSync).mockImplementation( + (command: string, options?: ExecSyncOptions) => { + if ( + options && + typeof options === 'object' && + 'encoding' in options && + options.encoding === 'utf-8' + ) { + return '10.0.0'; + } + return Buffer.from('10.0.0', 'utf-8'); + }, + ); + + const { AuthType } = await import('@qwen-code/qwen-code-core'); + // Update the mock context to use OpenAI auth + mockContext.services.settings.merged.security!.auth!.selectedType = + AuthType.USE_OPENAI; + + const extendedInfo = await getExtendedSystemInfo(mockContext); + + expect(extendedInfo.memoryUsage).toBeDefined(); + expect(extendedInfo.memoryUsage).toMatch(/\d+\.\d+ (KB|MB|GB)/); + expect(extendedInfo.baseUrl).toBe('https://api.openai.com'); + }); + + it('should use sandbox env without prefix for bug reports', async () => { + process.env['SANDBOX'] = 'qwen-code-test-sandbox'; + vi.mocked(IdeClient.getInstance).mockResolvedValue({ + getDetectedIdeDisplayName: vi.fn().mockReturnValue(''), + } as unknown as IdeClient); + vi.mocked(child_process.execSync).mockImplementation( + (command: string, options?: ExecSyncOptions) => { + if ( + options && + typeof options === 'object' && + 'encoding' in options && + options.encoding === 'utf-8' + ) { + return '10.0.0'; + } + return Buffer.from('10.0.0', 'utf-8'); + }, + ); + + const extendedInfo = await getExtendedSystemInfo(mockContext); + + expect(extendedInfo.sandboxEnv).toBe('test-sandbox'); + }); + + it('should not include base URL for non-OpenAI auth', async () => { + vi.mocked(IdeClient.getInstance).mockResolvedValue({ + getDetectedIdeDisplayName: vi.fn().mockReturnValue(''), + } as unknown as IdeClient); + vi.mocked(child_process.execSync).mockImplementation( + (command: string, options?: ExecSyncOptions) => { + if ( + options && + typeof options === 'object' && + 'encoding' in options && + options.encoding === 'utf-8' + ) { + return '10.0.0'; + } + return Buffer.from('10.0.0', 'utf-8'); + }, + ); + + const extendedInfo = await getExtendedSystemInfo(mockContext); + + expect(extendedInfo.baseUrl).toBeUndefined(); + }); + }); +}); diff --git a/packages/cli/src/utils/systemInfo.ts b/packages/cli/src/utils/systemInfo.ts new file mode 100644 index 00000000..84927a95 --- /dev/null +++ b/packages/cli/src/utils/systemInfo.ts @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import process from 'node:process'; +import os from 'node:os'; +import { execSync } from 'node:child_process'; +import type { CommandContext } from '../ui/commands/types.js'; +import { getCliVersion } from './version.js'; +import { IdeClient, AuthType } from '@qwen-code/qwen-code-core'; +import { formatMemoryUsage } from '../ui/utils/formatters.js'; +import { GIT_COMMIT_INFO } from '../generated/git-commit.js'; + +/** + * System information interface containing all system-related details + * that can be collected for debugging and reporting purposes. + */ +export interface SystemInfo { + cliVersion: string; + osPlatform: string; + osArch: string; + osRelease: string; + nodeVersion: string; + npmVersion: string; + sandboxEnv: string; + modelVersion: string; + selectedAuthType: string; + ideClient: string; + sessionId: string; +} + +/** + * Additional system information for bug reports + */ +export interface ExtendedSystemInfo extends SystemInfo { + memoryUsage: string; + baseUrl?: string; + gitCommit?: string; +} + +/** + * Gets the NPM version, handling cases where npm might not be available. + * Returns 'unknown' if npm command fails or is not found. + */ +export async function getNpmVersion(): Promise { + try { + return execSync('npm --version', { encoding: 'utf-8' }).trim(); + } catch { + return 'unknown'; + } +} + +/** + * Gets the IDE client name if IDE mode is enabled. + * Returns empty string if IDE mode is disabled or IDE client is not detected. + */ +export async function getIdeClientName( + context: CommandContext, +): Promise { + if (!context.services.config?.getIdeMode()) { + return ''; + } + try { + const ideClient = await IdeClient.getInstance(); + return ideClient?.getDetectedIdeDisplayName() ?? ''; + } catch { + return ''; + } +} + +/** + * Gets the sandbox environment information. + * Handles different sandbox types including sandbox-exec and custom sandbox environments. + * For bug reports, removes 'qwen-' or 'qwen-code-' prefixes from sandbox names. + * + * @param stripPrefix - Whether to strip 'qwen-' prefix (used for bug reports) + */ +export function getSandboxEnv(stripPrefix = false): string { + const sandbox = process.env['SANDBOX']; + + if (!sandbox || sandbox === 'sandbox-exec') { + if (sandbox === 'sandbox-exec') { + const profile = process.env['SEATBELT_PROFILE'] || 'unknown'; + return `sandbox-exec (${profile})`; + } + return 'no sandbox'; + } + + // For bug reports, remove qwen- prefix + if (stripPrefix) { + return sandbox.replace(/^qwen-(?:code-)?/, ''); + } + + return sandbox; +} + +/** + * Collects comprehensive system information for debugging and reporting. + * This function gathers all system-related details including OS, versions, + * sandbox environment, authentication, and session information. + * + * @param context - Command context containing config and settings + * @returns Promise resolving to SystemInfo object with all collected information + */ +export async function getSystemInfo( + context: CommandContext, +): Promise { + const osPlatform = process.platform; + const osArch = process.arch; + const osRelease = os.release(); + const nodeVersion = process.version; + const npmVersion = await getNpmVersion(); + const sandboxEnv = getSandboxEnv(); + const modelVersion = context.services.config?.getModel() || 'Unknown'; + const cliVersion = await getCliVersion(); + const selectedAuthType = + context.services.settings.merged.security?.auth?.selectedType || ''; + const ideClient = await getIdeClientName(context); + const sessionId = context.services.config?.getSessionId() || 'unknown'; + + return { + cliVersion, + osPlatform, + osArch, + osRelease, + nodeVersion, + npmVersion, + sandboxEnv, + modelVersion, + selectedAuthType, + ideClient, + sessionId, + }; +} + +/** + * Collects extended system information for bug reports. + * Includes all standard system info plus memory usage and optional base URL. + * + * @param context - Command context containing config and settings + * @returns Promise resolving to ExtendedSystemInfo object + */ +export async function getExtendedSystemInfo( + context: CommandContext, +): Promise { + const baseInfo = await getSystemInfo(context); + const memoryUsage = formatMemoryUsage(process.memoryUsage().rss); + + // For bug reports, use sandbox name without prefix + const sandboxEnv = getSandboxEnv(true); + + // Get base URL if using OpenAI auth + const baseUrl = + baseInfo.selectedAuthType === AuthType.USE_OPENAI + ? context.services.config?.getContentGeneratorConfig()?.baseUrl + : undefined; + + // Get git commit info + const gitCommit = + GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) + ? GIT_COMMIT_INFO + : undefined; + + return { + ...baseInfo, + sandboxEnv, + memoryUsage, + baseUrl, + gitCommit, + }; +} diff --git a/packages/cli/src/utils/systemInfoFields.ts b/packages/cli/src/utils/systemInfoFields.ts new file mode 100644 index 00000000..d4b959fb --- /dev/null +++ b/packages/cli/src/utils/systemInfoFields.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ExtendedSystemInfo } from './systemInfo.js'; + +/** + * Field configuration for system information display + */ +export interface SystemInfoField { + label: string; + key: keyof ExtendedSystemInfo; +} + +/** + * Unified field configuration for system information display. + * This ensures consistent labeling between /about and /bug commands. + */ +export function getSystemInfoFields( + info: ExtendedSystemInfo, +): SystemInfoField[] { + const allFields: SystemInfoField[] = [ + { + label: 'CLI Version', + key: 'cliVersion', + }, + { + label: 'Git Commit', + key: 'gitCommit', + }, + { + label: 'Model', + key: 'modelVersion', + }, + { + label: 'Sandbox', + key: 'sandboxEnv', + }, + { + label: 'OS Platform', + key: 'osPlatform', + }, + { + label: 'OS Arch', + key: 'osArch', + }, + { + label: 'OS Release', + key: 'osRelease', + }, + { + label: 'Node.js Version', + key: 'nodeVersion', + }, + { + label: 'NPM Version', + key: 'npmVersion', + }, + { + label: 'Session ID', + key: 'sessionId', + }, + { + label: 'Auth Method', + key: 'selectedAuthType', + }, + { + label: 'Base URL', + key: 'baseUrl', + }, + { + label: 'Memory Usage', + key: 'memoryUsage', + }, + { + label: 'IDE Client', + key: 'ideClient', + }, + ]; + + // Filter out optional fields that are not present + return allFields.filter((field) => { + const value = info[field.key]; + // Optional fields: only show if they exist and are non-empty + if ( + field.key === 'baseUrl' || + field.key === 'gitCommit' || + field.key === 'ideClient' + ) { + return Boolean(value); + } + return true; + }); +} + +/** + * Get the value for a field from system info + */ +export function getFieldValue( + field: SystemInfoField, + info: ExtendedSystemInfo, +): string { + const value = info[field.key]; + + if (value === undefined || value === null) { + return ''; + } + + // Special formatting for selectedAuthType + if (field.key === 'selectedAuthType') { + return String(value).startsWith('oauth') ? 'OAuth' : String(value); + } + + return String(value); +}