Merge tag 'v0.1.15' into feature/yiheng/sync-gemini-cli-0.1.15

This commit is contained in:
奕桁
2025-08-01 23:06:11 +08:00
340 changed files with 36528 additions and 22931 deletions

View File

@@ -5,13 +5,14 @@
*/
import { getCliVersion } from '../../utils/version.js';
import { SlashCommand } from './types.js';
import { CommandKind, 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',
kind: CommandKind.BUILT_IN,
action: async (context) => {
const osVersion = process.platform;
let sandboxEnv = 'no sandbox';

View File

@@ -4,11 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
export const authCommand: SlashCommand = {
name: 'auth',
description: 'change the auth method',
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'auth',

View File

@@ -0,0 +1,98 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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';
// Mock dependencies
vi.mock('open');
vi.mock('../../utils/version.js');
vi.mock('../utils/formatters.js');
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 }),
},
}));
describe('bugCommand', () => {
beforeEach(() => {
vi.mocked(getCliVersion).mockResolvedValue('0.1.0');
vi.mocked(formatMemoryUsage).mockReturnValue('100 MB');
vi.stubEnv('SANDBOX', 'qwen-test');
});
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});
it('should generate the default GitHub issue URL', async () => {
const mockContext = createMockCommandContext({
services: {
config: {
getModel: () => 'qwen3-coder-plus',
getBugCommand: () => undefined,
},
},
});
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'A test bug');
const expectedInfo = `
* **CLI Version:** 0.1.0
* **Git Commit:** ${GIT_COMMIT_INFO}
* **Operating System:** test-platform v20.0.0
* **Sandbox Environment:** test
* **Model Version:** qwen3-coder-plus
* **Memory Usage:** 100 MB
`;
const expectedUrl =
'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title=A%20test%20bug&info=' +
encodeURIComponent(expectedInfo);
expect(open).toHaveBeenCalledWith(expectedUrl);
});
it('should use a custom URL template from config if provided', async () => {
const customTemplate =
'https://internal.bug-tracker.com/new?desc={title}&details={info}';
const mockContext = createMockCommandContext({
services: {
config: {
getModel: () => 'qwen3-coder-plus',
getBugCommand: () => ({ urlTemplate: customTemplate }),
},
},
});
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'A custom bug');
const expectedInfo = `
* **CLI Version:** 0.1.0
* **Git Commit:** ${GIT_COMMIT_INFO}
* **Operating System:** test-platform v20.0.0
* **Sandbox Environment:** test
* **Model Version:** qwen3-coder-plus
* **Memory Usage:** 100 MB
`;
const expectedUrl = customTemplate
.replace('{title}', encodeURIComponent('A custom bug'))
.replace('{info}', encodeURIComponent(expectedInfo));
expect(open).toHaveBeenCalledWith(expectedUrl);
});
});

View File

@@ -0,0 +1,83 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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';
export const bugCommand: SlashCommand = {
name: 'bug',
description: 'submit a bug report',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string): Promise<void> => {
const bugDescription = (args || '').trim();
const { config } = context.services;
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 info = `
* **CLI Version:** ${cliVersion}
* **Git Commit:** ${GIT_COMMIT_INFO}
* **Operating System:** ${osVersion}
* **Sandbox Environment:** ${sandboxEnv}
* **Model Version:** ${modelVersion}
* **Memory Usage:** ${memoryUsage}
`;
let bugReportUrl =
'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title={title}&info={info}';
const bugCommandSettings = config?.getBugCommand();
if (bugCommandSettings?.urlTemplate) {
bugReportUrl = bugCommandSettings.urlTemplate;
}
bugReportUrl = bugReportUrl
.replace('{title}', encodeURIComponent(bugDescription))
.replace('{info}', encodeURIComponent(info));
context.ui.addItem(
{
type: MessageType.INFO,
text: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}`,
},
Date.now(),
);
try {
await open(bugReportUrl);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Could not open URL in browser: ${errorMessage}`,
},
Date.now(),
);
}
},
};

View File

@@ -0,0 +1,300 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
Mocked,
} from 'vitest';
import {
type CommandContext,
MessageActionReturn,
SlashCommand,
} from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { Content } from '@google/genai';
import { GeminiClient } from '@qwen-code/qwen-code-core';
import * as fsPromises from 'fs/promises';
import { chatCommand } from './chatCommand.js';
import { Stats } from 'fs';
import { HistoryItemWithoutId } from '../types.js';
vi.mock('fs/promises', () => ({
stat: vi.fn(),
readdir: vi.fn().mockResolvedValue(['file1.txt', 'file2.txt'] as string[]),
}));
describe('chatCommand', () => {
const mockFs = fsPromises as Mocked<typeof fsPromises>;
let mockContext: CommandContext;
let mockGetChat: ReturnType<typeof vi.fn>;
let mockSaveCheckpoint: ReturnType<typeof vi.fn>;
let mockLoadCheckpoint: ReturnType<typeof vi.fn>;
let mockGetHistory: ReturnType<typeof vi.fn>;
const getSubCommand = (name: 'list' | 'save' | 'resume'): SlashCommand => {
const subCommand = chatCommand.subCommands?.find(
(cmd) => cmd.name === name,
);
if (!subCommand) {
throw new Error(`/memory ${name} command not found.`);
}
return subCommand;
};
beforeEach(() => {
mockGetHistory = vi.fn().mockReturnValue([]);
mockGetChat = vi.fn().mockResolvedValue({
getHistory: mockGetHistory,
});
mockSaveCheckpoint = vi.fn().mockResolvedValue(undefined);
mockLoadCheckpoint = vi.fn().mockResolvedValue([]);
mockContext = createMockCommandContext({
services: {
config: {
getProjectTempDir: () => '/tmp/gemini',
getGeminiClient: () =>
({
getChat: mockGetChat,
}) as unknown as GeminiClient,
},
logger: {
saveCheckpoint: mockSaveCheckpoint,
loadCheckpoint: mockLoadCheckpoint,
initialize: vi.fn().mockResolvedValue(undefined),
},
},
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should have the correct main command definition', () => {
expect(chatCommand.name).toBe('chat');
expect(chatCommand.description).toBe('Manage conversation history.');
expect(chatCommand.subCommands).toHaveLength(3);
});
describe('list subcommand', () => {
let listCommand: SlashCommand;
beforeEach(() => {
listCommand = getSubCommand('list');
});
it('should inform when no checkpoints are found', async () => {
mockFs.readdir.mockImplementation(
(async (_: string): Promise<string[]> =>
[] as string[]) as unknown as typeof fsPromises.readdir,
);
const result = await listCommand?.action?.(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No saved conversation checkpoints found.',
});
});
it('should list found checkpoints', async () => {
const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json'];
const date = new Date();
mockFs.readdir.mockImplementation(
(async (_: string): Promise<string[]> =>
fakeFiles as string[]) as unknown as typeof fsPromises.readdir,
);
mockFs.stat.mockImplementation((async (path: string): Promise<Stats> => {
if (path.endsWith('test1.json')) {
return { mtime: date } as Stats;
}
return { mtime: new Date(date.getTime() + 1000) } as Stats;
}) as unknown as typeof fsPromises.stat);
const result = (await listCommand?.action?.(
mockContext,
'',
)) as MessageActionReturn;
const content = result?.content ?? '';
expect(result?.type).toBe('message');
expect(content).toContain('List of saved conversations:');
const isoDate = date
.toISOString()
.match(/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
const formattedDate = isoDate ? `${isoDate[1]} ${isoDate[2]}` : '';
expect(content).toContain(formattedDate);
const index1 = content.indexOf('- \u001b[36mtest1\u001b[0m');
const index2 = content.indexOf('- \u001b[36mtest2\u001b[0m');
expect(index1).toBeGreaterThanOrEqual(0);
expect(index2).toBeGreaterThan(index1);
});
it('should handle invalid date formats gracefully', async () => {
const fakeFiles = ['checkpoint-baddate.json'];
const badDate = {
toISOString: () => 'an-invalid-date-string',
} as Date;
mockFs.readdir.mockResolvedValue(fakeFiles);
mockFs.stat.mockResolvedValue({ mtime: badDate } as Stats);
const result = (await listCommand?.action?.(
mockContext,
'',
)) as MessageActionReturn;
const content = result?.content ?? '';
expect(content).toContain('(saved on Invalid Date)');
});
});
describe('save subcommand', () => {
let saveCommand: SlashCommand;
const tag = 'my-tag';
beforeEach(() => {
saveCommand = getSubCommand('save');
});
it('should return an error if tag is missing', async () => {
const result = await saveCommand?.action?.(mockContext, ' ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat save <tag>',
});
});
it('should inform if conversation history is empty', async () => {
mockGetHistory.mockReturnValue([]);
const result = await saveCommand?.action?.(mockContext, tag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No conversation found to save.',
});
});
it('should save the conversation', async () => {
const history: HistoryItemWithoutId[] = [
{
type: 'user',
text: 'hello',
},
];
mockGetHistory.mockReturnValue(history);
const result = await saveCommand?.action?.(mockContext, tag);
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation checkpoint saved with tag: ${tag}.`,
});
});
});
describe('resume subcommand', () => {
const goodTag = 'good-tag';
const badTag = 'bad-tag';
let resumeCommand: SlashCommand;
beforeEach(() => {
resumeCommand = getSubCommand('resume');
});
it('should return an error if tag is missing', async () => {
const result = await resumeCommand?.action?.(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat resume <tag>',
});
});
it('should inform if checkpoint is not found', async () => {
mockLoadCheckpoint.mockResolvedValue([]);
const result = await resumeCommand?.action?.(mockContext, badTag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `No saved checkpoint found with tag: ${badTag}.`,
});
});
it('should resume a conversation', async () => {
const conversation: Content[] = [
{ role: 'user', parts: [{ text: 'hello gemini' }] },
{ role: 'model', parts: [{ text: 'hello world' }] },
];
mockLoadCheckpoint.mockResolvedValue(conversation);
const result = await resumeCommand?.action?.(mockContext, goodTag);
expect(result).toEqual({
type: 'load_history',
history: [
{ type: 'user', text: 'hello gemini' },
{ type: 'gemini', text: 'hello world' },
] as HistoryItemWithoutId[],
clientHistory: conversation,
});
});
describe('completion', () => {
it('should provide completion suggestions', async () => {
const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json'];
mockFs.readdir.mockImplementation(
(async (_: string): Promise<string[]> =>
fakeFiles as string[]) as unknown as typeof fsPromises.readdir,
);
mockFs.stat.mockImplementation(
(async (_: string): Promise<Stats> =>
({
mtime: new Date(),
}) as Stats) as unknown as typeof fsPromises.stat,
);
const result = await resumeCommand?.completion?.(mockContext, 'a');
expect(result).toEqual(['alpha']);
});
it('should suggest filenames sorted by modified time (newest first)', async () => {
const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json'];
const date = new Date();
mockFs.readdir.mockImplementation(
(async (_: string): Promise<string[]> =>
fakeFiles as string[]) as unknown as typeof fsPromises.readdir,
);
mockFs.stat.mockImplementation((async (
path: string,
): Promise<Stats> => {
if (path.endsWith('test1.json')) {
return { mtime: date } as Stats;
}
return { mtime: new Date(date.getTime() + 1000) } as Stats;
}) as unknown as typeof fsPromises.stat);
const result = await resumeCommand?.completion?.(mockContext, '');
// Sort items by last modified time (newest first)
expect(result).toEqual(['test2', 'test1']);
});
});
});
});

View File

@@ -0,0 +1,214 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fsPromises from 'fs/promises';
import {
CommandContext,
SlashCommand,
MessageActionReturn,
CommandKind,
} from './types.js';
import path from 'path';
import { HistoryItemWithoutId, MessageType } from '../types.js';
interface ChatDetail {
name: string;
mtime: Date;
}
const getSavedChatTags = async (
context: CommandContext,
mtSortDesc: boolean,
): Promise<ChatDetail[]> => {
const geminiDir = context.services.config?.getProjectTempDir();
if (!geminiDir) {
return [];
}
try {
const file_head = 'checkpoint-';
const file_tail = '.json';
const files = await fsPromises.readdir(geminiDir);
const chatDetails: Array<{ name: string; mtime: Date }> = [];
for (const file of files) {
if (file.startsWith(file_head) && file.endsWith(file_tail)) {
const filePath = path.join(geminiDir, file);
const stats = await fsPromises.stat(filePath);
chatDetails.push({
name: file.slice(file_head.length, -file_tail.length),
mtime: stats.mtime,
});
}
}
chatDetails.sort((a, b) =>
mtSortDesc
? b.mtime.getTime() - a.mtime.getTime()
: a.mtime.getTime() - b.mtime.getTime(),
);
return chatDetails;
} catch (_err) {
return [];
}
};
const listCommand: SlashCommand = {
name: 'list',
description: 'List saved conversation checkpoints',
kind: CommandKind.BUILT_IN,
action: async (context): Promise<MessageActionReturn> => {
const chatDetails = await getSavedChatTags(context, false);
if (chatDetails.length === 0) {
return {
type: 'message',
messageType: 'info',
content: 'No saved conversation checkpoints found.',
};
}
const maxNameLength = Math.max(
...chatDetails.map((chat) => chat.name.length),
);
let message = 'List of saved conversations:\n\n';
for (const chat of chatDetails) {
const paddedName = chat.name.padEnd(maxNameLength, ' ');
const isoString = chat.mtime.toISOString();
const match = isoString.match(/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
const formattedDate = match ? `${match[1]} ${match[2]}` : 'Invalid Date';
message += ` - \u001b[36m${paddedName}\u001b[0m \u001b[90m(saved on ${formattedDate})\u001b[0m\n`;
}
message += `\n\u001b[90mNote: Newest last, oldest first\u001b[0m`;
return {
type: 'message',
messageType: 'info',
content: message,
};
},
};
const saveCommand: SlashCommand = {
name: 'save',
description:
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => {
const tag = args.trim();
if (!tag) {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat save <tag>',
};
}
const { logger, config } = context.services;
await logger.initialize();
const chat = await config?.getGeminiClient()?.getChat();
if (!chat) {
return {
type: 'message',
messageType: 'error',
content: 'No chat client available to save conversation.',
};
}
const history = chat.getHistory();
if (history.length > 0) {
await logger.saveCheckpoint(history, tag);
return {
type: 'message',
messageType: 'info',
content: `Conversation checkpoint saved with tag: ${tag}.`,
};
} else {
return {
type: 'message',
messageType: 'info',
content: 'No conversation found to save.',
};
}
},
};
const resumeCommand: SlashCommand = {
name: 'resume',
altNames: ['load'],
description:
'Resume a conversation from a checkpoint. Usage: /chat resume <tag>',
kind: CommandKind.BUILT_IN,
action: async (context, args) => {
const tag = args.trim();
if (!tag) {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat resume <tag>',
};
}
const { logger } = context.services;
await logger.initialize();
const conversation = await logger.loadCheckpoint(tag);
if (conversation.length === 0) {
return {
type: 'message',
messageType: 'info',
content: `No saved checkpoint found with tag: ${tag}.`,
};
}
const rolemap: { [key: string]: MessageType } = {
user: MessageType.USER,
model: MessageType.GEMINI,
};
const uiHistory: HistoryItemWithoutId[] = [];
let hasSystemPrompt = false;
let i = 0;
for (const item of conversation) {
i += 1;
const text =
item.parts
?.filter((m) => !!m.text)
.map((m) => m.text)
.join('') || '';
if (!text) {
continue;
}
if (i === 1 && text.match(/context for our chat/)) {
hasSystemPrompt = true;
}
if (i > 2 || !hasSystemPrompt) {
uiHistory.push({
type: (item.role && rolemap[item.role]) || MessageType.GEMINI,
text,
} as HistoryItemWithoutId);
}
}
return {
type: 'load_history',
history: uiHistory,
clientHistory: conversation,
};
},
completion: async (context, partialArg) => {
const chatDetails = await getSavedChatTags(context, true);
return chatDetails
.map((chat) => chat.name)
.filter((name) => name.startsWith(partialArg));
},
};
export const chatCommand: SlashCommand = {
name: 'chat',
description: 'Manage conversation history.',
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, saveCommand, resumeCommand],
};

View File

@@ -8,7 +8,19 @@ 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';
// Mock the telemetry service
vi.mock('@qwen-code/qwen-code-core', async () => {
const actual = await vi.importActual('@qwen-code/qwen-code-core');
return {
...actual,
uiTelemetryService: {
resetLastPromptTokenCount: vi.fn(),
},
};
});
import { GeminiClient, uiTelemetryService } from '@qwen-code/qwen-code-core';
describe('clearCommand', () => {
let mockContext: CommandContext;
@@ -16,6 +28,7 @@ describe('clearCommand', () => {
beforeEach(() => {
mockResetChat = vi.fn().mockResolvedValue(undefined);
vi.clearAllMocks();
mockContext = createMockCommandContext({
services: {
@@ -29,7 +42,7 @@ describe('clearCommand', () => {
});
});
it('should set debug message, reset chat, and clear UI when config is available', async () => {
it('should set debug message, reset chat, reset telemetry, and clear UI when config is available', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');
}
@@ -42,23 +55,24 @@ describe('clearCommand', () => {
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1);
expect(mockResetChat).toHaveBeenCalledTimes(1);
expect(mockContext.session.resetSession).toHaveBeenCalledTimes(1);
expect(uiTelemetryService.resetLastPromptTokenCount).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 resetSessionOrder = (mockContext.session.resetSession as Mock).mock
.invocationCallOrder[0];
const resetTelemetryOrder = (
uiTelemetryService.resetLastPromptTokenCount as Mock
).mock.invocationCallOrder[0];
const clearOrder = (mockContext.ui.clear as Mock).mock
.invocationCallOrder[0];
expect(setDebugMessageOrder).toBeLessThan(resetChatOrder);
expect(resetChatOrder).toBeLessThan(resetSessionOrder);
expect(resetSessionOrder).toBeLessThan(clearOrder);
expect(resetChatOrder).toBeLessThan(resetTelemetryOrder);
expect(resetTelemetryOrder).toBeLessThan(clearOrder);
});
it('should not attempt to reset chat if config service is not available', async () => {
@@ -75,10 +89,12 @@ describe('clearCommand', () => {
await clearCommand.action(nullConfigContext, '');
expect(nullConfigContext.ui.setDebugMessage).toHaveBeenCalledWith(
'Clearing terminal and resetting chat.',
'Clearing terminal.',
);
expect(mockResetChat).not.toHaveBeenCalled();
expect(nullConfigContext.session.resetSession).toHaveBeenCalledTimes(1);
expect(uiTelemetryService.resetLastPromptTokenCount).toHaveBeenCalledTimes(
1,
);
expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1);
});
});

View File

@@ -4,15 +4,26 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { SlashCommand } from './types.js';
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
import { CommandKind, SlashCommand } from './types.js';
export const clearCommand: SlashCommand = {
name: 'clear',
description: 'clear the screen and conversation history',
kind: CommandKind.BUILT_IN,
action: async (context, _args) => {
context.ui.setDebugMessage('Clearing terminal and resetting chat.');
await context.services.config?.getGeminiClient()?.resetChat();
context.session.resetSession();
const geminiClient = context.services.config?.getGeminiClient();
if (geminiClient) {
context.ui.setDebugMessage('Clearing terminal and resetting chat.');
// If resetChat fails, the exception will propagate and halt the command,
// which is the correct behavior to signal a failure to the user.
await geminiClient.resetChat();
} else {
context.ui.setDebugMessage('Clearing terminal.');
}
uiTelemetryService.resetLastPromptTokenCount();
context.ui.clear();
},
};

View File

@@ -0,0 +1,129 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { GeminiClient } from '@qwen-code/qwen-code-core';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { compressCommand } from './compressCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
describe('compressCommand', () => {
let context: ReturnType<typeof createMockCommandContext>;
let mockTryCompressChat: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockTryCompressChat = vi.fn();
context = createMockCommandContext({
services: {
config: {
getGeminiClient: () =>
({
tryCompressChat: mockTryCompressChat,
}) as unknown as GeminiClient,
},
},
});
});
it('should do nothing if a compression is already pending', async () => {
context.ui.pendingItem = {
type: MessageType.COMPRESSION,
compression: {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
},
};
await compressCommand.action!(context, '');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Already compressing, wait for previous request to complete',
}),
expect.any(Number),
);
expect(context.ui.setPendingItem).not.toHaveBeenCalled();
expect(mockTryCompressChat).not.toHaveBeenCalled();
});
it('should set pending item, call tryCompressChat, and add result on success', async () => {
const compressedResult = {
originalTokenCount: 200,
newTokenCount: 100,
};
mockTryCompressChat.mockResolvedValue(compressedResult);
await compressCommand.action!(context, '');
expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
type: MessageType.COMPRESSION,
compression: {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
},
}),
);
expect(mockTryCompressChat).toHaveBeenCalledWith(
expect.stringMatching(/^compress-\d+$/),
true,
);
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.COMPRESSION,
compression: {
isPending: false,
originalTokenCount: 200,
newTokenCount: 100,
},
}),
expect.any(Number),
);
expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(2, null);
});
it('should add an error message if tryCompressChat returns falsy', async () => {
mockTryCompressChat.mockResolvedValue(null);
await compressCommand.action!(context, '');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Failed to compress chat history.',
}),
expect.any(Number),
);
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
});
it('should add an error message if tryCompressChat throws', async () => {
const error = new Error('Compression failed');
mockTryCompressChat.mockRejectedValue(error);
await compressCommand.action!(context, '');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: `Failed to compress chat history: ${error.message}`,
}),
expect.any(Number),
);
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
});
it('should clear the pending item in a finally block', async () => {
mockTryCompressChat.mockRejectedValue(new Error('some error'));
await compressCommand.action!(context, '');
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
});
});

View File

@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { HistoryItemCompression, MessageType } from '../types.js';
import { CommandKind, SlashCommand } from './types.js';
export const compressCommand: SlashCommand = {
name: 'compress',
altNames: ['summarize'],
description: 'Compresses the context by replacing it with a summary.',
kind: CommandKind.BUILT_IN,
action: async (context) => {
const { ui } = context;
if (ui.pendingItem) {
ui.addItem(
{
type: MessageType.ERROR,
text: 'Already compressing, wait for previous request to complete',
},
Date.now(),
);
return;
}
const pendingMessage: HistoryItemCompression = {
type: MessageType.COMPRESSION,
compression: {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
},
};
try {
ui.setPendingItem(pendingMessage);
const promptId = `compress-${Date.now()}`;
const compressed = await context.services.config
?.getGeminiClient()
?.tryCompressChat(promptId, true);
if (compressed) {
ui.addItem(
{
type: MessageType.COMPRESSION,
compression: {
isPending: false,
originalTokenCount: compressed.originalTokenCount,
newTokenCount: compressed.newTokenCount,
},
} as HistoryItemCompression,
Date.now(),
);
} else {
ui.addItem(
{
type: MessageType.ERROR,
text: 'Failed to compress chat history.',
},
Date.now(),
);
}
} catch (e) {
ui.addItem(
{
type: MessageType.ERROR,
text: `Failed to compress chat history: ${
e instanceof Error ? e.message : String(e)
}`,
},
Date.now(),
);
} finally {
ui.setPendingItem(null);
}
},
};

View File

@@ -0,0 +1,296 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
import { copyCommand } from './copyCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { copyToClipboard } from '../utils/commandUtils.js';
vi.mock('../utils/commandUtils.js', () => ({
copyToClipboard: vi.fn(),
}));
describe('copyCommand', () => {
let mockContext: CommandContext;
let mockCopyToClipboard: Mock;
let mockGetChat: Mock;
let mockGetHistory: Mock;
beforeEach(() => {
vi.clearAllMocks();
mockCopyToClipboard = vi.mocked(copyToClipboard);
mockGetChat = vi.fn();
mockGetHistory = vi.fn();
mockContext = createMockCommandContext({
services: {
config: {
getGeminiClient: () => ({
getChat: mockGetChat,
}),
},
},
});
mockGetChat.mockReturnValue({
getHistory: mockGetHistory,
});
});
it('should return info message when no history is available', async () => {
if (!copyCommand.action) throw new Error('Command has no action');
mockGetChat.mockReturnValue(undefined);
const result = await copyCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No output in history',
});
expect(mockCopyToClipboard).not.toHaveBeenCalled();
});
it('should return info message when history is empty', async () => {
if (!copyCommand.action) throw new Error('Command has no action');
mockGetHistory.mockReturnValue([]);
const result = await copyCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No output in history',
});
expect(mockCopyToClipboard).not.toHaveBeenCalled();
});
it('should return info message when no AI messages are found in history', async () => {
if (!copyCommand.action) throw new Error('Command has no action');
const historyWithUserOnly = [
{
role: 'user',
parts: [{ text: 'Hello' }],
},
];
mockGetHistory.mockReturnValue(historyWithUserOnly);
const result = await copyCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No output in history',
});
expect(mockCopyToClipboard).not.toHaveBeenCalled();
});
it('should copy last AI message to clipboard successfully', async () => {
if (!copyCommand.action) throw new Error('Command has no action');
const historyWithAiMessage = [
{
role: 'user',
parts: [{ text: 'Hello' }],
},
{
role: 'model',
parts: [{ text: 'Hi there! How can I help you?' }],
},
];
mockGetHistory.mockReturnValue(historyWithAiMessage);
mockCopyToClipboard.mockResolvedValue(undefined);
const result = await copyCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Last output copied to the clipboard',
});
expect(mockCopyToClipboard).toHaveBeenCalledWith(
'Hi there! How can I help you?',
);
});
it('should handle multiple text parts in AI message', async () => {
if (!copyCommand.action) throw new Error('Command has no action');
const historyWithMultipleParts = [
{
role: 'model',
parts: [{ text: 'Part 1: ' }, { text: 'Part 2: ' }, { text: 'Part 3' }],
},
];
mockGetHistory.mockReturnValue(historyWithMultipleParts);
mockCopyToClipboard.mockResolvedValue(undefined);
const result = await copyCommand.action(mockContext, '');
expect(mockCopyToClipboard).toHaveBeenCalledWith('Part 1: Part 2: Part 3');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Last output copied to the clipboard',
});
});
it('should filter out non-text parts', async () => {
if (!copyCommand.action) throw new Error('Command has no action');
const historyWithMixedParts = [
{
role: 'model',
parts: [
{ text: 'Text part' },
{ image: 'base64data' }, // Non-text part
{ text: ' more text' },
],
},
];
mockGetHistory.mockReturnValue(historyWithMixedParts);
mockCopyToClipboard.mockResolvedValue(undefined);
const result = await copyCommand.action(mockContext, '');
expect(mockCopyToClipboard).toHaveBeenCalledWith('Text part more text');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Last output copied to the clipboard',
});
});
it('should get the last AI message when multiple AI messages exist', async () => {
if (!copyCommand.action) throw new Error('Command has no action');
const historyWithMultipleAiMessages = [
{
role: 'model',
parts: [{ text: 'First AI response' }],
},
{
role: 'user',
parts: [{ text: 'User message' }],
},
{
role: 'model',
parts: [{ text: 'Second AI response' }],
},
];
mockGetHistory.mockReturnValue(historyWithMultipleAiMessages);
mockCopyToClipboard.mockResolvedValue(undefined);
const result = await copyCommand.action(mockContext, '');
expect(mockCopyToClipboard).toHaveBeenCalledWith('Second AI response');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Last output copied to the clipboard',
});
});
it('should handle clipboard copy error', async () => {
if (!copyCommand.action) throw new Error('Command has no action');
const historyWithAiMessage = [
{
role: 'model',
parts: [{ text: 'AI response' }],
},
];
mockGetHistory.mockReturnValue(historyWithAiMessage);
const clipboardError = new Error('Clipboard access denied');
mockCopyToClipboard.mockRejectedValue(clipboardError);
const result = await copyCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Failed to copy to the clipboard.',
});
});
it('should handle non-Error clipboard errors', async () => {
if (!copyCommand.action) throw new Error('Command has no action');
const historyWithAiMessage = [
{
role: 'model',
parts: [{ text: 'AI response' }],
},
];
mockGetHistory.mockReturnValue(historyWithAiMessage);
mockCopyToClipboard.mockRejectedValue('String error');
const result = await copyCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Failed to copy to the clipboard.',
});
});
it('should return info message when no text parts found in AI message', async () => {
if (!copyCommand.action) throw new Error('Command has no action');
const historyWithEmptyParts = [
{
role: 'model',
parts: [{ image: 'base64data' }], // No text parts
},
];
mockGetHistory.mockReturnValue(historyWithEmptyParts);
const result = await copyCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Last AI output contains no text to copy.',
});
expect(mockCopyToClipboard).not.toHaveBeenCalled();
});
it('should handle unavailable config service', async () => {
if (!copyCommand.action) throw new Error('Command has no action');
const nullConfigContext = createMockCommandContext({
services: { config: null },
});
const result = await copyCommand.action(nullConfigContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No output in history',
});
expect(mockCopyToClipboard).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { copyToClipboard } from '../utils/commandUtils.js';
import {
CommandKind,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
export const copyCommand: SlashCommand = {
name: 'copy',
description: 'Copy the last result or code snippet to clipboard',
kind: CommandKind.BUILT_IN,
action: async (context, _args): Promise<SlashCommandActionReturn | void> => {
const chat = await context.services.config?.getGeminiClient()?.getChat();
const history = chat?.getHistory();
// Get the last message from the AI (model role)
const lastAiMessage = history
? history.filter((item) => item.role === 'model').pop()
: undefined;
if (!lastAiMessage) {
return {
type: 'message',
messageType: 'info',
content: 'No output in history',
};
}
// Extract text from the parts
const lastAiOutput = lastAiMessage.parts
?.filter((part) => part.text)
.map((part) => part.text)
.join('');
if (lastAiOutput) {
try {
await copyToClipboard(lastAiOutput);
return {
type: 'message',
messageType: 'info',
content: 'Last output copied to the clipboard',
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.debug(message);
return {
type: 'message',
messageType: 'error',
content: 'Failed to copy to the clipboard.',
};
}
} else {
return {
type: 'message',
messageType: 'info',
content: 'Last AI output contains no text to copy.',
};
}
},
};

View File

@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { corgiCommand } from './corgiCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('corgiCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
vi.spyOn(mockContext.ui, 'toggleCorgiMode');
});
it('should call the toggleCorgiMode function on the UI context', async () => {
if (!corgiCommand.action) {
throw new Error('The corgi command must have an action.');
}
await corgiCommand.action(mockContext, '');
expect(mockContext.ui.toggleCorgiMode).toHaveBeenCalledTimes(1);
});
it('should have the correct name and description', () => {
expect(corgiCommand.name).toBe('corgi');
expect(corgiCommand.description).toBe('Toggles corgi mode.');
});
});

View File

@@ -0,0 +1,16 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
export const corgiCommand: SlashCommand = {
name: 'corgi',
description: 'Toggles corgi mode.',
kind: CommandKind.BUILT_IN,
action: (context, _args) => {
context.ui.toggleCorgiMode();
},
};

View File

@@ -0,0 +1,99 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import open from 'open';
import { docsCommand } from './docsCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
// Mock the 'open' library
vi.mock('open', () => ({
default: vi.fn(),
}));
describe('docsCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
// Create a fresh mock context before each test
mockContext = createMockCommandContext();
// Reset the `open` mock
vi.mocked(open).mockClear();
});
afterEach(() => {
// Restore any stubbed environment variables
vi.unstubAllEnvs();
});
it("should add an info message and call 'open' in a non-sandbox environment", async () => {
if (!docsCommand.action) {
throw new Error('docsCommand must have an action.');
}
const docsUrl = 'https://goo.gle/gemini-cli-docs';
await docsCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Opening documentation in your browser: ${docsUrl}`,
},
expect.any(Number),
);
expect(open).toHaveBeenCalledWith(docsUrl);
});
it('should only add an info message in a sandbox environment', async () => {
if (!docsCommand.action) {
throw new Error('docsCommand must have an action.');
}
// Simulate a sandbox environment
process.env.SANDBOX = 'gemini-sandbox';
const docsUrl = 'https://goo.gle/gemini-cli-docs';
await docsCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Please open the following URL in your browser to view the documentation:\n${docsUrl}`,
},
expect.any(Number),
);
// Ensure 'open' was not called in the sandbox
expect(open).not.toHaveBeenCalled();
});
it("should not open browser for 'sandbox-exec'", async () => {
if (!docsCommand.action) {
throw new Error('docsCommand must have an action.');
}
// Simulate the specific 'sandbox-exec' environment
process.env.SANDBOX = 'sandbox-exec';
const docsUrl = 'https://goo.gle/gemini-cli-docs';
await docsCommand.action(mockContext, '');
// The logic should fall through to the 'else' block
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Opening documentation in your browser: ${docsUrl}`,
},
expect.any(Number),
);
// 'open' should be called in this specific sandbox case
expect(open).toHaveBeenCalledWith(docsUrl);
});
});

View File

@@ -0,0 +1,42 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import open from 'open';
import process from 'node:process';
import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
import { MessageType } from '../types.js';
export const docsCommand: SlashCommand = {
name: 'docs',
description: 'open full Gemini CLI documentation in your browser',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext): Promise<void> => {
const docsUrl = 'https://goo.gle/gemini-cli-docs';
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') {
context.ui.addItem(
{
type: MessageType.INFO,
text: `Please open the following URL in your browser to view the documentation:\n${docsUrl}`,
},
Date.now(),
);
} else {
context.ui.addItem(
{
type: MessageType.INFO,
text: `Opening documentation in your browser: ${docsUrl}`,
},
Date.now(),
);
await open(docsUrl);
}
},
};

View File

@@ -0,0 +1,30 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { editorCommand } from './editorCommand.js';
// 1. Import the mock context utility
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('editorCommand', () => {
it('should return a dialog action to open the editor dialog', () => {
if (!editorCommand.action) {
throw new Error('The editor command must have an action.');
}
const mockContext = createMockCommandContext();
const result = editorCommand.action(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'editor',
});
});
it('should have the correct name and description', () => {
expect(editorCommand.name).toBe('editor');
expect(editorCommand.description).toBe('set external editor preference');
});
});

View File

@@ -0,0 +1,21 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
CommandKind,
type OpenDialogActionReturn,
type SlashCommand,
} from './types.js';
export const editorCommand: SlashCommand = {
name: 'editor',
description: 'set external editor preference',
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'editor',
}),
};

View File

@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { extensionsCommand } from './extensionsCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
describe('extensionsCommand', () => {
let mockContext: CommandContext;
it('should display "No active extensions." when none are found', async () => {
mockContext = createMockCommandContext({
services: {
config: {
getExtensions: () => [],
},
},
});
if (!extensionsCommand.action) throw new Error('Action not defined');
await extensionsCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'No active extensions.',
},
expect.any(Number),
);
});
it('should list active extensions when they are found', async () => {
const mockExtensions = [
{ name: 'ext-one', version: '1.0.0', isActive: true },
{ name: 'ext-two', version: '2.1.0', isActive: true },
{ name: 'ext-three', version: '3.0.0', isActive: false },
];
mockContext = createMockCommandContext({
services: {
config: {
getExtensions: () => mockExtensions,
},
},
});
if (!extensionsCommand.action) throw new Error('Action not defined');
await extensionsCommand.action(mockContext, '');
const expectedMessage =
'Active extensions:\n\n' +
` - \u001b[36mext-one (v1.0.0)\u001b[0m\n` +
` - \u001b[36mext-two (v2.1.0)\u001b[0m\n`;
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: expectedMessage,
},
expect.any(Number),
);
});
});

View File

@@ -0,0 +1,46 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
import { MessageType } from '../types.js';
export const extensionsCommand: SlashCommand = {
name: 'extensions',
description: 'list active extensions',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext): Promise<void> => {
const activeExtensions = context.services.config
?.getExtensions()
.filter((ext) => ext.isActive);
if (!activeExtensions || activeExtensions.length === 0) {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'No active extensions.',
},
Date.now(),
);
return;
}
const extensionLines = activeExtensions.map(
(ext) => ` - \u001b[36m${ext.name} (v${ext.version})\u001b[0m`,
);
const message = `Active extensions:\n\n${extensionLines.join('\n')}\n`;
context.ui.addItem(
{
type: MessageType.INFO,
text: message,
},
Date.now(),
);
},
};

View File

@@ -32,9 +32,9 @@ describe('helpCommand', () => {
});
it("should also be triggered by its alternative name '?'", () => {
// This test is more conceptual. The routing of altName to the command
// This test is more conceptual. The routing of altNames 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('?');
// altNames is correctly defined on the command object itself.
expect(helpCommand.altNames).toContain('?');
});
});

View File

@@ -4,12 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
export const helpCommand: SlashCommand = {
name: 'help',
altName: '?',
description: 'for help on qwen code',
altNames: ['?'],
description: 'for help on Qwen Code',
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => {
console.debug('Opening help UI ...');
return {

View File

@@ -0,0 +1,270 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
MockInstance,
vi,
describe,
it,
expect,
beforeEach,
afterEach,
} from 'vitest';
import { ideCommand } from './ideCommand.js';
import { type CommandContext } from './types.js';
import { type Config } from '@qwen-code/qwen-code-core';
import * as child_process from 'child_process';
import { glob } from 'glob';
import { IDEConnectionStatus } from '@qwen-code/qwen-code-core/index.js';
vi.mock('child_process');
vi.mock('glob');
function regexEscape(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
describe('ideCommand', () => {
let mockContext: CommandContext;
let mockConfig: Config;
let execSyncSpy: MockInstance;
let globSyncSpy: MockInstance;
let platformSpy: MockInstance;
beforeEach(() => {
mockContext = {
ui: {
addItem: vi.fn(),
},
} as unknown as CommandContext;
mockConfig = {
getIdeMode: vi.fn(),
getIdeClient: vi.fn(),
} as unknown as Config;
execSyncSpy = vi.spyOn(child_process, 'execSync');
globSyncSpy = vi.spyOn(glob, 'sync');
platformSpy = vi.spyOn(process, 'platform', 'get');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return null if ideMode is not enabled', () => {
vi.mocked(mockConfig.getIdeMode).mockReturnValue(false);
const command = ideCommand(mockConfig);
expect(command).toBeNull();
});
it('should return the ide command if ideMode is enabled', () => {
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
const command = ideCommand(mockConfig);
expect(command).not.toBeNull();
expect(command?.name).toBe('ide');
expect(command?.subCommands).toHaveLength(2);
expect(command?.subCommands?.[0].name).toBe('status');
expect(command?.subCommands?.[1].name).toBe('install');
});
describe('status subcommand', () => {
const mockGetConnectionStatus = vi.fn();
beforeEach(() => {
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getConnectionStatus: mockGetConnectionStatus,
} as ReturnType<Config['getIdeClient']>);
});
it('should show connected status', () => {
mockGetConnectionStatus.mockReturnValue({
status: IDEConnectionStatus.Connected,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands![0].action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: '🟢 Connected',
});
});
it('should show connecting status', () => {
mockGetConnectionStatus.mockReturnValue({
status: IDEConnectionStatus.Connecting,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands![0].action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `🟡 Connecting...`,
});
});
it('should show disconnected status', () => {
mockGetConnectionStatus.mockReturnValue({
status: IDEConnectionStatus.Disconnected,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands![0].action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: `🔴 Disconnected`,
});
});
it('should show disconnected status with details', () => {
const details = 'Something went wrong';
mockGetConnectionStatus.mockReturnValue({
status: IDEConnectionStatus.Disconnected,
details,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands![0].action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: `🔴 Disconnected: ${details}`,
});
});
});
describe('install subcommand', () => {
beforeEach(() => {
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
platformSpy.mockReturnValue('linux');
});
it('should show an error if VSCode is not installed', async () => {
execSyncSpy.mockImplementation(() => {
throw new Error('Command not found');
});
const command = ideCommand(mockConfig);
await command!.subCommands![1].action!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text: expect.stringMatching(/VS Code command-line tool .* not found/),
}),
expect.any(Number),
);
});
it('should show an error if the VSIX file is not found', async () => {
execSyncSpy.mockReturnValue(''); // VSCode is installed
globSyncSpy.mockReturnValue([]); // No .vsix file found
const command = ideCommand(mockConfig);
await command!.subCommands![1].action!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text: 'Could not find the required VS Code companion extension. Please file a bug via /bug.',
}),
expect.any(Number),
);
});
it('should install the extension if found in the bundle directory', async () => {
const vsixPath = '/path/to/bundle/gemini.vsix';
execSyncSpy.mockReturnValue(''); // VSCode is installed
globSyncSpy.mockReturnValue([vsixPath]); // Found .vsix file
const command = ideCommand(mockConfig);
await command!.subCommands![1].action!(mockContext, '');
expect(globSyncSpy).toHaveBeenCalledWith(
expect.stringContaining('.vsix'),
);
expect(execSyncSpy).toHaveBeenCalledWith(
expect.stringMatching(
new RegExp(
`code(.cmd)? --install-extension ${regexEscape(vsixPath)} --force`,
),
),
{ stdio: 'pipe' },
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: `Installing VS Code companion extension...`,
}),
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.',
}),
expect.any(Number),
);
});
it('should install the extension if found in the dev directory', async () => {
const vsixPath = '/path/to/dev/gemini.vsix';
execSyncSpy.mockReturnValue(''); // VSCode is installed
// First glob call for bundle returns nothing, second for dev returns path.
globSyncSpy.mockReturnValueOnce([]).mockReturnValueOnce([vsixPath]);
const command = ideCommand(mockConfig);
await command!.subCommands![1].action!(mockContext, '');
expect(globSyncSpy).toHaveBeenCalledTimes(2);
expect(execSyncSpy).toHaveBeenCalledWith(
expect.stringMatching(
new RegExp(
`code(.cmd)? --install-extension ${regexEscape(vsixPath)} --force`,
),
),
{ stdio: 'pipe' },
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.',
}),
expect.any(Number),
);
});
it('should show an error if installation fails', async () => {
const vsixPath = '/path/to/bundle/gemini.vsix';
const errorMessage = 'Installation failed';
execSyncSpy
.mockReturnValueOnce('') // VSCode is installed check
.mockImplementation(() => {
// Installation command
const error: Error & { stderr?: Buffer } = new Error(
'Command failed',
);
error.stderr = Buffer.from(errorMessage);
throw error;
});
globSyncSpy.mockReturnValue([vsixPath]);
const command = ideCommand(mockConfig);
await command!.subCommands![1].action!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text: `Failed to install VS Code companion extension.`,
}),
expect.any(Number),
);
});
});
});

View File

@@ -0,0 +1,157 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { fileURLToPath } from 'url';
import { Config, IDEConnectionStatus } from '@qwen-code/qwen-code-core';
import {
CommandContext,
SlashCommand,
SlashCommandActionReturn,
CommandKind,
} from './types.js';
import * as child_process from 'child_process';
import * as process from 'process';
import { glob } from 'glob';
import * as path from 'path';
const VSCODE_COMMAND = process.platform === 'win32' ? 'code.cmd' : 'code';
const VSCODE_COMPANION_EXTENSION_FOLDER = 'vscode-ide-companion';
function isVSCodeInstalled(): boolean {
try {
child_process.execSync(
process.platform === 'win32'
? `where.exe ${VSCODE_COMMAND}`
: `command -v ${VSCODE_COMMAND}`,
{ stdio: 'ignore' },
);
return true;
} catch {
return false;
}
}
export const ideCommand = (config: Config | null): SlashCommand | null => {
if (!config?.getIdeMode()) {
return null;
}
return {
name: 'ide',
description: 'manage IDE integration',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'status',
description: 'check status of IDE integration',
kind: CommandKind.BUILT_IN,
action: (_context: CommandContext): SlashCommandActionReturn => {
const connection = config.getIdeClient()?.getConnectionStatus();
switch (connection?.status) {
case IDEConnectionStatus.Connected:
return {
type: 'message',
messageType: 'info',
content: `🟢 Connected`,
} as const;
case IDEConnectionStatus.Connecting:
return {
type: 'message',
messageType: 'info',
content: `🟡 Connecting...`,
} as const;
default: {
let content = `🔴 Disconnected`;
if (connection?.details) {
content += `: ${connection.details}`;
}
return {
type: 'message',
messageType: 'error',
content,
} as const;
}
}
},
},
{
name: 'install',
description: 'install required VS Code companion extension',
kind: CommandKind.BUILT_IN,
action: async (context) => {
if (!isVSCodeInstalled()) {
context.ui.addItem(
{
type: 'error',
text: `VS Code command-line tool "${VSCODE_COMMAND}" not found in your PATH.`,
},
Date.now(),
);
return;
}
const bundleDir = path.dirname(fileURLToPath(import.meta.url));
// The VSIX file is copied to the bundle directory as part of the build.
let vsixFiles = glob.sync(path.join(bundleDir, '*.vsix'));
if (vsixFiles.length === 0) {
// If the VSIX file is not in the bundle, it might be a dev
// environment running with `npm start`. Look for it in the original
// package location, relative to the bundle dir.
const devPath = path.join(
bundleDir,
'..',
'..',
'..',
'..',
'..',
VSCODE_COMPANION_EXTENSION_FOLDER,
'*.vsix',
);
vsixFiles = glob.sync(devPath);
}
if (vsixFiles.length === 0) {
context.ui.addItem(
{
type: 'error',
text: 'Could not find the required VS Code companion extension. Please file a bug via /bug.',
},
Date.now(),
);
return;
}
const vsixPath = vsixFiles[0];
const command = `${VSCODE_COMMAND} --install-extension ${vsixPath} --force`;
context.ui.addItem(
{
type: 'info',
text: `Installing VS Code companion extension...`,
},
Date.now(),
);
try {
child_process.execSync(command, { stdio: 'pipe' });
context.ui.addItem(
{
type: 'info',
text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.',
},
Date.now(),
);
} catch (_error) {
context.ui.addItem(
{
type: 'error',
text: `Failed to install VS Code companion extension.`,
},
Date.now(),
);
}
},
},
],
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,524 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
SlashCommand,
SlashCommandActionReturn,
CommandContext,
CommandKind,
MessageActionReturn,
} from './types.js';
import {
DiscoveredMCPPrompt,
DiscoveredMCPTool,
getMCPDiscoveryState,
getMCPServerStatus,
MCPDiscoveryState,
MCPServerStatus,
mcpServerRequiresOAuth,
getErrorMessage,
} from '@qwen-code/qwen-code-core';
import open from 'open';
const COLOR_GREEN = '\u001b[32m';
const COLOR_YELLOW = '\u001b[33m';
const COLOR_RED = '\u001b[31m';
const COLOR_CYAN = '\u001b[36m';
const COLOR_GREY = '\u001b[90m';
const RESET_COLOR = '\u001b[0m';
const getMcpStatus = async (
context: CommandContext,
showDescriptions: boolean,
showSchema: boolean,
showTips: boolean = false,
): Promise<SlashCommandActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
};
}
const toolRegistry = await config.getToolRegistry();
if (!toolRegistry) {
return {
type: 'message',
messageType: 'error',
content: 'Could not retrieve tool registry.',
};
}
const mcpServers = config.getMcpServers() || {};
const serverNames = Object.keys(mcpServers);
const blockedMcpServers = config.getBlockedMcpServers() || [];
if (serverNames.length === 0 && blockedMcpServers.length === 0) {
const docsUrl = 'https://goo.gle/gemini-cli-docs-mcp';
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') {
return {
type: 'message',
messageType: 'info',
content: `No MCP servers configured. Please open the following URL in your browser to view documentation:\n${docsUrl}`,
};
} else {
// Open the URL in the browser
await open(docsUrl);
return {
type: 'message',
messageType: 'info',
content: `No MCP servers configured. Opening documentation in your browser: ${docsUrl}`,
};
}
}
// Check if any servers are still connecting
const connectingServers = serverNames.filter(
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
);
const discoveryState = getMCPDiscoveryState();
let message = '';
// Add overall discovery status message if needed
if (
discoveryState === MCPDiscoveryState.IN_PROGRESS ||
connectingServers.length > 0
) {
message += `${COLOR_YELLOW}⏳ MCP servers are starting up (${connectingServers.length} initializing)...${RESET_COLOR}\n`;
message += `${COLOR_CYAN}Note: First startup may take longer. Tool availability will update automatically.${RESET_COLOR}\n\n`;
}
message += 'Configured MCP servers:\n\n';
const allTools = toolRegistry.getAllTools();
for (const serverName of serverNames) {
const serverTools = allTools.filter(
(tool) =>
tool instanceof DiscoveredMCPTool && tool.serverName === serverName,
) as DiscoveredMCPTool[];
const promptRegistry = await config.getPromptRegistry();
const serverPrompts = promptRegistry.getPromptsByServer(serverName) || [];
const status = getMCPServerStatus(serverName);
// Add status indicator with descriptive text
let statusIndicator = '';
let statusText = '';
switch (status) {
case MCPServerStatus.CONNECTED:
statusIndicator = '🟢';
statusText = 'Ready';
break;
case MCPServerStatus.CONNECTING:
statusIndicator = '🔄';
statusText = 'Starting... (first startup may take longer)';
break;
case MCPServerStatus.DISCONNECTED:
default:
statusIndicator = '🔴';
statusText = 'Disconnected';
break;
}
// Get server description if available
const server = mcpServers[serverName];
let serverDisplayName = serverName;
if (server.extensionName) {
serverDisplayName += ` (from ${server.extensionName})`;
}
// Format server header with bold formatting and status
message += `${statusIndicator} \u001b[1m${serverDisplayName}\u001b[0m - ${statusText}`;
let needsAuthHint = mcpServerRequiresOAuth.get(serverName) || false;
// Add OAuth status if applicable
if (server?.oauth?.enabled) {
needsAuthHint = true;
try {
const { MCPOAuthTokenStorage } = await import(
'@qwen-code/qwen-code-core'
);
const hasToken = await MCPOAuthTokenStorage.getToken(serverName);
if (hasToken) {
const isExpired = MCPOAuthTokenStorage.isTokenExpired(hasToken.token);
if (isExpired) {
message += ` ${COLOR_YELLOW}(OAuth token expired)${RESET_COLOR}`;
} else {
message += ` ${COLOR_GREEN}(OAuth authenticated)${RESET_COLOR}`;
needsAuthHint = false;
}
} else {
message += ` ${COLOR_RED}(OAuth not authenticated)${RESET_COLOR}`;
}
} catch (_err) {
// If we can't check OAuth status, just continue
}
}
// Add tool count with conditional messaging
if (status === MCPServerStatus.CONNECTED) {
const parts = [];
if (serverTools.length > 0) {
parts.push(
`${serverTools.length} ${serverTools.length === 1 ? 'tool' : 'tools'}`,
);
}
if (serverPrompts.length > 0) {
parts.push(
`${serverPrompts.length} ${
serverPrompts.length === 1 ? 'prompt' : 'prompts'
}`,
);
}
if (parts.length > 0) {
message += ` (${parts.join(', ')})`;
} else {
message += ` (0 tools)`;
}
} else if (status === MCPServerStatus.CONNECTING) {
message += ` (tools and prompts will appear when ready)`;
} else {
message += ` (${serverTools.length} tools cached)`;
}
// Add server description with proper handling of multi-line descriptions
if (showDescriptions && server?.description) {
const descLines = server.description.trim().split('\n');
if (descLines) {
message += ':\n';
for (const descLine of descLines) {
message += ` ${COLOR_GREEN}${descLine}${RESET_COLOR}\n`;
}
} else {
message += '\n';
}
} else {
message += '\n';
}
// Reset formatting after server entry
message += RESET_COLOR;
if (serverTools.length > 0) {
message += ` ${COLOR_CYAN}Tools:${RESET_COLOR}\n`;
serverTools.forEach((tool) => {
if (showDescriptions && tool.description) {
// Format tool name in cyan using simple ANSI cyan color
message += ` - ${COLOR_CYAN}${tool.name}${RESET_COLOR}`;
// Handle multi-line descriptions by properly indenting and preserving formatting
const descLines = tool.description.trim().split('\n');
if (descLines) {
message += ':\n';
for (const descLine of descLines) {
message += ` ${COLOR_GREEN}${descLine}${RESET_COLOR}\n`;
}
} else {
message += '\n';
}
// Reset is handled inline with each line now
} else {
// Use cyan color for the tool name even when not showing descriptions
message += ` - ${COLOR_CYAN}${tool.name}${RESET_COLOR}\n`;
}
const parameters =
tool.schema.parametersJsonSchema ?? tool.schema.parameters;
if (showSchema && parameters) {
// Prefix the parameters in cyan
message += ` ${COLOR_CYAN}Parameters:${RESET_COLOR}\n`;
const paramsLines = JSON.stringify(parameters, null, 2)
.trim()
.split('\n');
if (paramsLines) {
for (const paramsLine of paramsLines) {
message += ` ${COLOR_GREEN}${paramsLine}${RESET_COLOR}\n`;
}
}
}
});
}
if (serverPrompts.length > 0) {
if (serverTools.length > 0) {
message += '\n';
}
message += ` ${COLOR_CYAN}Prompts:${RESET_COLOR}\n`;
serverPrompts.forEach((prompt: DiscoveredMCPPrompt) => {
if (showDescriptions && prompt.description) {
message += ` - ${COLOR_CYAN}${prompt.name}${RESET_COLOR}`;
const descLines = prompt.description.trim().split('\n');
if (descLines) {
message += ':\n';
for (const descLine of descLines) {
message += ` ${COLOR_GREEN}${descLine}${RESET_COLOR}\n`;
}
} else {
message += '\n';
}
} else {
message += ` - ${COLOR_CYAN}${prompt.name}${RESET_COLOR}\n`;
}
});
}
if (serverTools.length === 0 && serverPrompts.length === 0) {
message += ' No tools or prompts available\n';
} else if (serverTools.length === 0) {
message += ' No tools available';
if (status === MCPServerStatus.DISCONNECTED && needsAuthHint) {
message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}`;
}
message += '\n';
} else if (status === MCPServerStatus.DISCONNECTED && needsAuthHint) {
// This case is for when serverTools.length > 0
message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}\n`;
}
message += '\n';
}
for (const server of blockedMcpServers) {
let serverDisplayName = server.name;
if (server.extensionName) {
serverDisplayName += ` (from ${server.extensionName})`;
}
message += `🔴 \u001b[1m${serverDisplayName}\u001b[0m - Blocked\n\n`;
}
// Add helpful tips when no arguments are provided
if (showTips) {
message += '\n';
message += `${COLOR_CYAN}💡 Tips:${RESET_COLOR}\n`;
message += ` • Use ${COLOR_CYAN}/mcp desc${RESET_COLOR} to show server and tool descriptions\n`;
message += ` • Use ${COLOR_CYAN}/mcp schema${RESET_COLOR} to show tool parameter schemas\n`;
message += ` • Use ${COLOR_CYAN}/mcp nodesc${RESET_COLOR} to hide descriptions\n`;
message += ` • Use ${COLOR_CYAN}/mcp auth <server-name>${RESET_COLOR} to authenticate with OAuth-enabled servers\n`;
message += ` • Press ${COLOR_CYAN}Ctrl+T${RESET_COLOR} to toggle tool descriptions on/off\n`;
message += '\n';
}
// Make sure to reset any ANSI formatting at the end to prevent it from affecting the terminal
message += RESET_COLOR;
return {
type: 'message',
messageType: 'info',
content: message,
};
};
const authCommand: SlashCommand = {
name: 'auth',
description: 'Authenticate with an OAuth-enabled MCP server',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const serverName = args.trim();
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
};
}
const mcpServers = config.getMcpServers() || {};
if (!serverName) {
// List servers that support OAuth
const oauthServers = Object.entries(mcpServers)
.filter(([_, server]) => server.oauth?.enabled)
.map(([name, _]) => name);
if (oauthServers.length === 0) {
return {
type: 'message',
messageType: 'info',
content: 'No MCP servers configured with OAuth authentication.',
};
}
return {
type: 'message',
messageType: 'info',
content: `MCP servers with OAuth authentication:\n${oauthServers.map((s) => ` - ${s}`).join('\n')}\n\nUse /mcp auth <server-name> to authenticate.`,
};
}
const server = mcpServers[serverName];
if (!server) {
return {
type: 'message',
messageType: 'error',
content: `MCP server '${serverName}' not found.`,
};
}
// Always attempt OAuth authentication, even if not explicitly configured
// The authentication process will discover OAuth requirements automatically
try {
context.ui.addItem(
{
type: 'info',
text: `Starting OAuth authentication for MCP server '${serverName}'...`,
},
Date.now(),
);
// Import dynamically to avoid circular dependencies
const { MCPOAuthProvider } = await import('@qwen-code/qwen-code-core');
let oauthConfig = server.oauth;
if (!oauthConfig) {
oauthConfig = { enabled: false };
}
// Pass the MCP server URL for OAuth discovery
const mcpServerUrl = server.httpUrl || server.url;
await MCPOAuthProvider.authenticate(
serverName,
oauthConfig,
mcpServerUrl,
);
context.ui.addItem(
{
type: 'info',
text: `✅ Successfully authenticated with MCP server '${serverName}'!`,
},
Date.now(),
);
// Trigger tool re-discovery to pick up authenticated server
const toolRegistry = await config.getToolRegistry();
if (toolRegistry) {
context.ui.addItem(
{
type: 'info',
text: `Re-discovering tools from '${serverName}'...`,
},
Date.now(),
);
await toolRegistry.discoverToolsForServer(serverName);
}
// Update the client with the new tools
const geminiClient = config.getGeminiClient();
if (geminiClient) {
await geminiClient.setTools();
}
return {
type: 'message',
messageType: 'info',
content: `Successfully authenticated and refreshed tools for '${serverName}'.`,
};
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`,
};
}
},
completion: async (context: CommandContext, partialArg: string) => {
const { config } = context.services;
if (!config) return [];
const mcpServers = config.getMcpServers() || {};
return Object.keys(mcpServers).filter((name) =>
name.startsWith(partialArg),
);
},
};
const listCommand: SlashCommand = {
name: 'list',
description: 'List configured MCP servers and tools',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args: string) => {
const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean);
const hasDesc =
lowerCaseArgs.includes('desc') || lowerCaseArgs.includes('descriptions');
const hasNodesc =
lowerCaseArgs.includes('nodesc') ||
lowerCaseArgs.includes('nodescriptions');
const showSchema = lowerCaseArgs.includes('schema');
// Show descriptions if `desc` or `schema` is present,
// but `nodesc` takes precedence and disables them.
const showDescriptions = !hasNodesc && (hasDesc || showSchema);
// Show tips only when no arguments are provided
const showTips = lowerCaseArgs.length === 0;
return getMcpStatus(context, showDescriptions, showSchema, showTips);
},
};
const refreshCommand: SlashCommand = {
name: 'refresh',
description: 'Refresh the list of MCP servers and tools',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
): Promise<SlashCommandActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
};
}
const toolRegistry = await config.getToolRegistry();
if (!toolRegistry) {
return {
type: 'message',
messageType: 'error',
content: 'Could not retrieve tool registry.',
};
}
context.ui.addItem(
{
type: 'info',
text: 'Refreshing MCP servers and tools...',
},
Date.now(),
);
await toolRegistry.discoverMcpTools();
// Update the client with the new tools
const geminiClient = config.getGeminiClient();
if (geminiClient) {
await geminiClient.setTools();
}
return getMcpStatus(context, false, false, false);
},
};
export const mcpCommand: SlashCommand = {
name: 'mcp',
description:
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers',
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, authCommand, refreshCommand],
// Default action when no subcommand is provided
action: async (context: CommandContext, args: string) =>
// If no subcommand, run the list command
listCommand.action!(context, args),
};

View File

@@ -9,7 +9,12 @@ 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-code/qwen-code-core';
import { LoadedSettings } from '../../config/settings.js';
import {
getErrorMessage,
loadServerHierarchicalMemory,
type FileDiscoveryService,
} from '@qwen-code/qwen-code-core';
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
@@ -20,9 +25,12 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
if (error instanceof Error) return error.message;
return String(error);
}),
loadServerHierarchicalMemory: vi.fn(),
};
});
const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock;
describe('memoryCommand', () => {
let mockContext: CommandContext;
@@ -139,19 +147,37 @@ describe('memoryCommand', () => {
describe('/memory refresh', () => {
let refreshCommand: SlashCommand;
let mockRefreshMemory: Mock;
let mockSetUserMemory: Mock;
let mockSetGeminiMdFileCount: Mock;
beforeEach(() => {
refreshCommand = getSubCommand('refresh');
mockRefreshMemory = vi.fn();
mockSetUserMemory = vi.fn();
mockSetGeminiMdFileCount = vi.fn();
const mockConfig = {
setUserMemory: mockSetUserMemory,
setGeminiMdFileCount: mockSetGeminiMdFileCount,
getWorkingDir: () => '/test/dir',
getDebugMode: () => false,
getFileService: () => ({}) as FileDiscoveryService,
getExtensionContextFilePaths: () => [],
getFileFilteringOptions: () => ({
ignore: [],
include: [],
}),
};
mockContext = createMockCommandContext({
services: {
config: {
refreshMemory: mockRefreshMemory,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
config: Promise.resolve(mockConfig),
settings: {
merged: {
memoryDiscoveryMaxDirs: 1000,
},
} as LoadedSettings,
},
});
mockLoadServerHierarchicalMemory.mockClear();
});
it('should display success message when memory is refreshed with content', async () => {
@@ -161,7 +187,7 @@ describe('memoryCommand', () => {
memoryContent: 'new memory content',
fileCount: 2,
};
mockRefreshMemory.mockResolvedValue(refreshResult);
mockLoadServerHierarchicalMemory.mockResolvedValue(refreshResult);
await refreshCommand.action(mockContext, '');
@@ -173,7 +199,13 @@ describe('memoryCommand', () => {
expect.any(Number),
);
expect(mockRefreshMemory).toHaveBeenCalledOnce();
expect(loadServerHierarchicalMemory).toHaveBeenCalledOnce();
expect(mockSetUserMemory).toHaveBeenCalledWith(
refreshResult.memoryContent,
);
expect(mockSetGeminiMdFileCount).toHaveBeenCalledWith(
refreshResult.fileCount,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
@@ -188,11 +220,13 @@ describe('memoryCommand', () => {
if (!refreshCommand.action) throw new Error('Command has no action');
const refreshResult = { memoryContent: '', fileCount: 0 };
mockRefreshMemory.mockResolvedValue(refreshResult);
mockLoadServerHierarchicalMemory.mockResolvedValue(refreshResult);
await refreshCommand.action(mockContext, '');
expect(mockRefreshMemory).toHaveBeenCalledOnce();
expect(loadServerHierarchicalMemory).toHaveBeenCalledOnce();
expect(mockSetUserMemory).toHaveBeenCalledWith('');
expect(mockSetGeminiMdFileCount).toHaveBeenCalledWith(0);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
@@ -207,11 +241,13 @@ describe('memoryCommand', () => {
if (!refreshCommand.action) throw new Error('Command has no action');
const error = new Error('Failed to read memory files.');
mockRefreshMemory.mockRejectedValue(error);
mockLoadServerHierarchicalMemory.mockRejectedValue(error);
await refreshCommand.action(mockContext, '');
expect(mockRefreshMemory).toHaveBeenCalledOnce();
expect(loadServerHierarchicalMemory).toHaveBeenCalledOnce();
expect(mockSetUserMemory).not.toHaveBeenCalled();
expect(mockSetGeminiMdFileCount).not.toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
@@ -243,7 +279,7 @@ describe('memoryCommand', () => {
expect.any(Number),
);
expect(mockRefreshMemory).not.toHaveBeenCalled();
expect(loadServerHierarchicalMemory).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,17 +4,26 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { getErrorMessage } from '@qwen-code/qwen-code-core';
import {
getErrorMessage,
loadServerHierarchicalMemory,
} from '@qwen-code/qwen-code-core';
import { MessageType } from '../types.js';
import { SlashCommand, SlashCommandActionReturn } from './types.js';
import {
CommandKind,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
export const memoryCommand: SlashCommand = {
name: 'memory',
description: 'Commands for interacting with memory.',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'show',
description: 'Show the current memory contents.',
kind: CommandKind.BUILT_IN,
action: async (context) => {
const memoryContent = context.services.config?.getUserMemory() || '';
const fileCount = context.services.config?.getGeminiMdFileCount() || 0;
@@ -36,6 +45,7 @@ export const memoryCommand: SlashCommand = {
{
name: 'add',
description: 'Add content to the memory.',
kind: CommandKind.BUILT_IN,
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
@@ -63,6 +73,7 @@ export const memoryCommand: SlashCommand = {
{
name: 'refresh',
description: 'Refresh the memory from the source.',
kind: CommandKind.BUILT_IN,
action: async (context) => {
context.ui.addItem(
{
@@ -73,10 +84,20 @@ export const memoryCommand: SlashCommand = {
);
try {
const result = await context.services.config?.refreshMemory();
const config = await context.services.config;
if (config) {
const { memoryContent, fileCount } =
await loadServerHierarchicalMemory(
config.getWorkingDir(),
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
config.getFileFilteringOptions(),
context.services.settings.merged.memoryDiscoveryMaxDirs,
);
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);
if (result) {
const { memoryContent, fileCount } = result;
const successMessage =
memoryContent.length > 0
? `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`

View File

@@ -4,11 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
export const privacyCommand: SlashCommand = {
name: 'privacy',
description: 'display the privacy notice',
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'privacy',

View File

@@ -0,0 +1,55 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { quitCommand } from './quitCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { formatDuration } from '../utils/formatters.js';
vi.mock('../utils/formatters.js');
describe('quitCommand', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-01T01:00:00Z'));
vi.mocked(formatDuration).mockReturnValue('1h 0m 0s');
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it('returns a QuitActionReturn object with the correct messages', () => {
const mockContext = createMockCommandContext({
session: {
stats: {
sessionStartTime: new Date('2025-01-01T00:00:00Z'),
},
},
});
if (!quitCommand.action) throw new Error('Action is not defined');
const result = quitCommand.action(mockContext, 'quit');
expect(formatDuration).toHaveBeenCalledWith(3600000); // 1 hour in ms
expect(result).toEqual({
type: 'quit',
messages: [
{
type: 'user',
text: '/quit',
id: expect.any(Number),
},
{
type: 'quit',
duration: '1h 0m 0s',
id: expect.any(Number),
},
],
});
});
});

View File

@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { formatDuration } from '../utils/formatters.js';
import { CommandKind, type SlashCommand } from './types.js';
export const quitCommand: SlashCommand = {
name: 'quit',
altNames: ['exit'],
description: 'exit the cli',
kind: CommandKind.BUILT_IN,
action: (context) => {
const now = Date.now();
const { sessionStartTime } = context.session.stats;
const wallDuration = now - sessionStartTime.getTime();
return {
type: 'quit',
messages: [
{
type: 'user',
text: `/quit`, // Keep it consistent, even if /exit was used
id: now - 1,
},
{
type: 'quit',
duration: formatDuration(wallDuration),
id: now,
},
],
};
},
};

View File

@@ -0,0 +1,250 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { restoreCommand } from './restoreCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { Config, GitService } from '@qwen-code/qwen-code-core';
describe('restoreCommand', () => {
let mockContext: CommandContext;
let mockConfig: Config;
let mockGitService: GitService;
let mockSetHistory: ReturnType<typeof vi.fn>;
let testRootDir: string;
let geminiTempDir: string;
let checkpointsDir: string;
beforeEach(async () => {
testRootDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'restore-command-test-'),
);
geminiTempDir = path.join(testRootDir, '.gemini');
checkpointsDir = path.join(geminiTempDir, 'checkpoints');
// The command itself creates this, but for tests it's easier to have it ready.
// Some tests might remove it to test error paths.
await fs.mkdir(checkpointsDir, { recursive: true });
mockSetHistory = vi.fn().mockResolvedValue(undefined);
mockGitService = {
restoreProjectFromSnapshot: vi.fn().mockResolvedValue(undefined),
} as unknown as GitService;
mockConfig = {
getCheckpointingEnabled: vi.fn().mockReturnValue(true),
getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir),
getGeminiClient: vi.fn().mockReturnValue({
setHistory: mockSetHistory,
}),
} as unknown as Config;
mockContext = createMockCommandContext({
services: {
config: mockConfig,
git: mockGitService,
},
});
});
afterEach(async () => {
vi.restoreAllMocks();
await fs.rm(testRootDir, { recursive: true, force: true });
});
it('should return null if checkpointing is not enabled', () => {
vi.mocked(mockConfig.getCheckpointingEnabled).mockReturnValue(false);
expect(restoreCommand(mockConfig)).toBeNull();
});
it('should return the command if checkpointing is enabled', () => {
expect(restoreCommand(mockConfig)).toEqual(
expect.objectContaining({
name: 'restore',
description: expect.any(String),
action: expect.any(Function),
completion: expect.any(Function),
}),
);
});
describe('action', () => {
it('should return an error if temp dir is not found', async () => {
vi.mocked(mockConfig.getProjectTempDir).mockReturnValue('');
expect(
await restoreCommand(mockConfig)?.action?.(mockContext, ''),
).toEqual({
type: 'message',
messageType: 'error',
content: 'Could not determine the .gemini directory path.',
});
});
it('should inform when no checkpoints are found if no args are passed', async () => {
// Remove the directory to ensure the command creates it.
await fs.rm(checkpointsDir, { recursive: true, force: true });
const command = restoreCommand(mockConfig);
expect(await command?.action?.(mockContext, '')).toEqual({
type: 'message',
messageType: 'info',
content: 'No restorable tool calls found.',
});
// Verify the directory was created by the command.
await expect(fs.stat(checkpointsDir)).resolves.toBeDefined();
});
it('should list available checkpoints if no args are passed', async () => {
await fs.writeFile(path.join(checkpointsDir, 'test1.json'), '{}');
await fs.writeFile(path.join(checkpointsDir, 'test2.json'), '{}');
const command = restoreCommand(mockConfig);
expect(await command?.action?.(mockContext, '')).toEqual({
type: 'message',
messageType: 'info',
content: 'Available tool calls to restore:\n\ntest1\ntest2',
});
});
it('should return an error if the specified file is not found', async () => {
await fs.writeFile(path.join(checkpointsDir, 'test1.json'), '{}');
const command = restoreCommand(mockConfig);
expect(await command?.action?.(mockContext, 'test2')).toEqual({
type: 'message',
messageType: 'error',
content: 'File not found: test2.json',
});
});
it('should handle file read errors gracefully', async () => {
const checkpointName = 'test1';
const checkpointPath = path.join(
checkpointsDir,
`${checkpointName}.json`,
);
// Create a directory instead of a file to cause a read error.
await fs.mkdir(checkpointPath);
const command = restoreCommand(mockConfig);
expect(await command?.action?.(mockContext, checkpointName)).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining(
'Could not read restorable tool calls.',
),
});
});
it('should restore a tool call and project state', async () => {
const toolCallData = {
history: [{ type: 'user', text: 'do a thing' }],
clientHistory: [{ role: 'user', parts: [{ text: 'do a thing' }] }],
commitHash: 'abcdef123',
toolCall: { name: 'run_shell_command', args: 'ls' },
};
await fs.writeFile(
path.join(checkpointsDir, 'my-checkpoint.json'),
JSON.stringify(toolCallData),
);
const command = restoreCommand(mockConfig);
expect(await command?.action?.(mockContext, 'my-checkpoint')).toEqual({
type: 'tool',
toolName: 'run_shell_command',
toolArgs: 'ls',
});
expect(mockContext.ui.loadHistory).toHaveBeenCalledWith(
toolCallData.history,
);
expect(mockSetHistory).toHaveBeenCalledWith(toolCallData.clientHistory);
expect(mockGitService.restoreProjectFromSnapshot).toHaveBeenCalledWith(
toolCallData.commitHash,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: 'info',
text: 'Restored project to the state before the tool call.',
},
expect.any(Number),
);
});
it('should restore even if only toolCall is present', async () => {
const toolCallData = {
toolCall: { name: 'run_shell_command', args: 'ls' },
};
await fs.writeFile(
path.join(checkpointsDir, 'my-checkpoint.json'),
JSON.stringify(toolCallData),
);
const command = restoreCommand(mockConfig);
expect(await command?.action?.(mockContext, 'my-checkpoint')).toEqual({
type: 'tool',
toolName: 'run_shell_command',
toolArgs: 'ls',
});
expect(mockContext.ui.loadHistory).not.toHaveBeenCalled();
expect(mockSetHistory).not.toHaveBeenCalled();
expect(mockGitService.restoreProjectFromSnapshot).not.toHaveBeenCalled();
});
});
it('should return an error for a checkpoint file missing the toolCall property', async () => {
const checkpointName = 'missing-toolcall';
await fs.writeFile(
path.join(checkpointsDir, `${checkpointName}.json`),
JSON.stringify({ history: [] }), // An object that is valid JSON but missing the 'toolCall' property
);
const command = restoreCommand(mockConfig);
expect(await command?.action?.(mockContext, checkpointName)).toEqual({
type: 'message',
messageType: 'error',
// A more specific error message would be ideal, but for now, we can assert the current behavior.
content: expect.stringContaining('Could not read restorable tool calls.'),
});
});
describe('completion', () => {
it('should return an empty array if temp dir is not found', async () => {
vi.mocked(mockConfig.getProjectTempDir).mockReturnValue('');
const command = restoreCommand(mockConfig);
expect(await command?.completion?.(mockContext, '')).toEqual([]);
});
it('should return an empty array on readdir error', async () => {
await fs.rm(checkpointsDir, { recursive: true, force: true });
const command = restoreCommand(mockConfig);
expect(await command?.completion?.(mockContext, '')).toEqual([]);
});
it('should return a list of checkpoint names', async () => {
await fs.writeFile(path.join(checkpointsDir, 'test1.json'), '{}');
await fs.writeFile(path.join(checkpointsDir, 'test2.json'), '{}');
await fs.writeFile(
path.join(checkpointsDir, 'not-a-checkpoint.txt'),
'{}',
);
const command = restoreCommand(mockConfig);
expect(await command?.completion?.(mockContext, '')).toEqual([
'test1',
'test2',
]);
});
});
});

View File

@@ -0,0 +1,157 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs/promises';
import path from 'path';
import {
type CommandContext,
type SlashCommand,
type SlashCommandActionReturn,
CommandKind,
} from './types.js';
import { Config } from '@qwen-code/qwen-code-core';
async function restoreAction(
context: CommandContext,
args: string,
): Promise<void | SlashCommandActionReturn> {
const { services, ui } = context;
const { config, git: gitService } = services;
const { addItem, loadHistory } = ui;
const checkpointDir = config?.getProjectTempDir()
? path.join(config.getProjectTempDir(), 'checkpoints')
: undefined;
if (!checkpointDir) {
return {
type: 'message',
messageType: 'error',
content: 'Could not determine the .gemini directory path.',
};
}
try {
// Ensure the directory exists before trying to read it.
await fs.mkdir(checkpointDir, { recursive: true });
const files = await fs.readdir(checkpointDir);
const jsonFiles = files.filter((file) => file.endsWith('.json'));
if (!args) {
if (jsonFiles.length === 0) {
return {
type: 'message',
messageType: 'info',
content: 'No restorable tool calls found.',
};
}
const truncatedFiles = jsonFiles.map((file) => {
const components = file.split('.');
if (components.length <= 1) {
return file;
}
components.pop();
return components.join('.');
});
const fileList = truncatedFiles.join('\n');
return {
type: 'message',
messageType: 'info',
content: `Available tool calls to restore:\n\n${fileList}`,
};
}
const selectedFile = args.endsWith('.json') ? args : `${args}.json`;
if (!jsonFiles.includes(selectedFile)) {
return {
type: 'message',
messageType: 'error',
content: `File not found: ${selectedFile}`,
};
}
const filePath = path.join(checkpointDir, selectedFile);
const data = await fs.readFile(filePath, 'utf-8');
const toolCallData = JSON.parse(data);
if (toolCallData.history) {
if (!loadHistory) {
// This should not happen
return {
type: 'message',
messageType: 'error',
content: 'loadHistory function is not available.',
};
}
loadHistory(toolCallData.history);
}
if (toolCallData.clientHistory) {
await config?.getGeminiClient()?.setHistory(toolCallData.clientHistory);
}
if (toolCallData.commitHash) {
await gitService?.restoreProjectFromSnapshot(toolCallData.commitHash);
addItem(
{
type: 'info',
text: 'Restored project to the state before the tool call.',
},
Date.now(),
);
}
return {
type: 'tool',
toolName: toolCallData.toolCall.name,
toolArgs: toolCallData.toolCall.args,
};
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Could not read restorable tool calls. This is the error: ${error}`,
};
}
}
async function completion(
context: CommandContext,
_partialArg: string,
): Promise<string[]> {
const { services } = context;
const { config } = services;
const checkpointDir = config?.getProjectTempDir()
? path.join(config.getProjectTempDir(), 'checkpoints')
: undefined;
if (!checkpointDir) {
return [];
}
try {
const files = await fs.readdir(checkpointDir);
return files
.filter((file) => file.endsWith('.json'))
.map((file) => file.replace('.json', ''));
} catch (_err) {
return [];
}
}
export const restoreCommand = (config: Config | null): SlashCommand | null => {
if (!config?.getCheckpointingEnabled()) {
return null;
}
return {
name: 'restore',
description:
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
kind: CommandKind.BUILT_IN,
action: restoreAction,
completion,
};
};

View File

@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { statsCommand } from './statsCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import { formatDuration } from '../utils/formatters.js';
describe('statsCommand', () => {
let mockContext: CommandContext;
const startTime = new Date('2025-07-14T10:00:00.000Z');
const endTime = new Date('2025-07-14T10:00:30.000Z');
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(endTime);
// 1. Create the mock context with all default values
mockContext = createMockCommandContext();
// 2. Directly set the property on the created mock context
mockContext.session.stats.sessionStartTime = startTime;
});
it('should display general session stats when run with no subcommand', () => {
if (!statsCommand.action) throw new Error('Command has no action');
statsCommand.action(mockContext, '');
const expectedDuration = formatDuration(
endTime.getTime() - startTime.getTime(),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.STATS,
duration: expectedDuration,
},
expect.any(Number),
);
});
it('should display model stats when using the "model" subcommand', () => {
const modelSubCommand = statsCommand.subCommands?.find(
(sc) => sc.name === 'model',
);
if (!modelSubCommand?.action) throw new Error('Subcommand has no action');
modelSubCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.MODEL_STATS,
},
expect.any(Number),
);
});
it('should display tool stats when using the "tools" subcommand', () => {
const toolsSubCommand = statsCommand.subCommands?.find(
(sc) => sc.name === 'tools',
);
if (!toolsSubCommand?.action) throw new Error('Subcommand has no action');
toolsSubCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.TOOL_STATS,
},
expect.any(Number),
);
});
});

View File

@@ -0,0 +1,70 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { MessageType, HistoryItemStats } from '../types.js';
import { formatDuration } from '../utils/formatters.js';
import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
export const statsCommand: SlashCommand = {
name: 'stats',
altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]',
kind: CommandKind.BUILT_IN,
action: (context: CommandContext) => {
const now = new Date();
const { sessionStartTime } = context.session.stats;
if (!sessionStartTime) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Session start time is unavailable, cannot calculate stats.',
},
Date.now(),
);
return;
}
const wallDuration = now.getTime() - sessionStartTime.getTime();
const statsItem: HistoryItemStats = {
type: MessageType.STATS,
duration: formatDuration(wallDuration),
};
context.ui.addItem(statsItem, Date.now());
},
subCommands: [
{
name: 'model',
description: 'Show model-specific usage statistics.',
kind: CommandKind.BUILT_IN,
action: (context: CommandContext) => {
context.ui.addItem(
{
type: MessageType.MODEL_STATS,
},
Date.now(),
);
},
},
{
name: 'tools',
description: 'Show tool-specific usage statistics.',
kind: CommandKind.BUILT_IN,
action: (context: CommandContext) => {
context.ui.addItem(
{
type: MessageType.TOOL_STATS,
},
Date.now(),
);
},
},
],
};

View File

@@ -4,11 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
export const themeCommand: SlashCommand = {
name: 'theme',
description: 'change the theme',
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'theme',

View File

@@ -0,0 +1,108 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { toolsCommand } from './toolsCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import { Tool } from '@qwen-code/qwen-code-core';
// Mock tools for testing
const mockTools = [
{
name: 'file-reader',
displayName: 'File Reader',
description: 'Reads files from the local system.',
schema: {},
},
{
name: 'code-editor',
displayName: 'Code Editor',
description: 'Edits code files.',
schema: {},
},
] as Tool[];
describe('toolsCommand', () => {
it('should display an error if the tool registry is unavailable', async () => {
const mockContext = createMockCommandContext({
services: {
config: {
getToolRegistry: () => Promise.resolve(undefined),
},
},
});
if (!toolsCommand.action) throw new Error('Action not defined');
await toolsCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Could not retrieve tool registry.',
},
expect.any(Number),
);
});
it('should display "No tools available" when none are found', async () => {
const mockContext = createMockCommandContext({
services: {
config: {
getToolRegistry: () =>
Promise.resolve({ getAllTools: () => [] as Tool[] }),
},
},
});
if (!toolsCommand.action) throw new Error('Action not defined');
await toolsCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining('No tools available'),
}),
expect.any(Number),
);
});
it('should list tools without descriptions by default', async () => {
const mockContext = createMockCommandContext({
services: {
config: {
getToolRegistry: () =>
Promise.resolve({ getAllTools: () => mockTools }),
},
},
});
if (!toolsCommand.action) throw new Error('Action not defined');
await toolsCommand.action(mockContext, '');
const message = (mockContext.ui.addItem as vi.Mock).mock.calls[0][0].text;
expect(message).not.toContain('Reads files from the local system.');
expect(message).toContain('File Reader');
expect(message).toContain('Code Editor');
});
it('should list tools with descriptions when "desc" arg is passed', async () => {
const mockContext = createMockCommandContext({
services: {
config: {
getToolRegistry: () =>
Promise.resolve({ getAllTools: () => mockTools }),
},
},
});
if (!toolsCommand.action) throw new Error('Action not defined');
await toolsCommand.action(mockContext, 'desc');
const message = (mockContext.ui.addItem as vi.Mock).mock.calls[0][0].text;
expect(message).toContain('Reads files from the local system.');
expect(message).toContain('Edits code files.');
});
});

View File

@@ -0,0 +1,71 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
import { MessageType } from '../types.js';
export const toolsCommand: SlashCommand = {
name: 'tools',
description: 'list available Gemini CLI tools',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string): Promise<void> => {
const subCommand = args?.trim();
// Default to NOT showing descriptions. The user must opt in with an argument.
let useShowDescriptions = false;
if (subCommand === 'desc' || subCommand === 'descriptions') {
useShowDescriptions = true;
}
const toolRegistry = await context.services.config?.getToolRegistry();
if (!toolRegistry) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Could not retrieve tool registry.',
},
Date.now(),
);
return;
}
const tools = toolRegistry.getAllTools();
// Filter out MCP tools by checking for the absence of a serverName property
const geminiTools = tools.filter((tool) => !('serverName' in tool));
let message = 'Available Gemini CLI tools:\n\n';
if (geminiTools.length > 0) {
geminiTools.forEach((tool) => {
if (useShowDescriptions && tool.description) {
message += ` - \u001b[36m${tool.displayName} (${tool.name})\u001b[0m:\n`;
const greenColor = '\u001b[32m';
const resetColor = '\u001b[0m';
// Handle multi-line descriptions
const descLines = tool.description.trim().split('\n');
for (const descLine of descLines) {
message += ` ${greenColor}${descLine}${resetColor}\n`;
}
} else {
message += ` - \u001b[36m${tool.displayName}\u001b[0m\n`;
}
});
} else {
message += ' No tools available\n';
}
message += '\n';
message += '\u001b[0m';
context.ui.addItem({ type: MessageType.INFO, text: message }, Date.now());
},
};

View File

@@ -4,13 +4,25 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Content } from '@google/genai';
import { HistoryItemWithoutId } from '../types.js';
import { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
import { LoadedSettings } from '../../config/settings.js';
import { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import type { HistoryItem } from '../types.js';
import { SessionStatsState } from '../contexts/SessionContext.js';
// Grouped dependencies for clarity and easier mocking
export interface CommandContext {
// Invocation properties for when commands are called.
invocation?: {
/** The raw, untrimmed input string from the user. */
raw: string;
/** The primary name of the command that was matched. */
name: string;
/** The arguments string that follows the command name. */
args: string;
};
// Core services and configuration
services: {
// TODO(abhipatel12): Ensure that config is never null.
@@ -21,11 +33,6 @@ export interface CommandContext {
};
// 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. */
@@ -34,11 +41,30 @@ export interface CommandContext {
* Sets the transient debug message displayed in the application footer in debug mode.
*/
setDebugMessage: (message: string) => void;
/** The currently pending history item, if any. */
pendingItem: HistoryItemWithoutId | null;
/**
* Sets a pending item in the history, which is useful for indicating
* that a long-running operation is in progress.
*
* @param item The history item to display as pending, or `null` to clear.
*/
setPendingItem: (item: HistoryItemWithoutId | null) => void;
/**
* Loads a new set of history items, replacing the current history.
*
* @param history The array of history items to load.
*/
loadHistory: UseHistoryManagerReturn['loadHistory'];
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
toggleVimEnabled: () => Promise<boolean>;
};
// Session-specific data
session: {
stats: SessionStatsState;
resetSession: () => void;
/** A transient list of shell commands the user has approved for this session. */
sessionShellAllowlist: Set<string>;
};
}
@@ -51,6 +77,12 @@ export interface ToolActionReturn {
toolArgs: Record<string, unknown>;
}
/** The return type for a command action that results in the app quitting. */
export interface QuitActionReturn {
type: 'quit';
messages: HistoryItem[];
}
/**
* The return type for a command action that results in a simple message
* being displayed to the user.
@@ -66,24 +98,69 @@ export interface MessageActionReturn {
*/
export interface OpenDialogActionReturn {
type: 'dialog';
// TODO: Add 'theme' | 'auth' | 'editor' | 'privacy' as migration happens.
dialog: 'help' | 'auth' | 'theme' | 'privacy';
dialog: 'help' | 'auth' | 'theme' | 'editor' | 'privacy';
}
/**
* The return type for a command action that results in replacing
* the entire conversation history.
*/
export interface LoadHistoryActionReturn {
type: 'load_history';
history: HistoryItemWithoutId[];
clientHistory: Content[]; // The history for the generative client
}
/**
* The return type for a command action that should immediately submit
* content as a prompt to the Gemini model.
*/
export interface SubmitPromptActionReturn {
type: 'submit_prompt';
content: string;
}
/**
* The return type for a command action that needs to pause and request
* confirmation for a set of shell commands before proceeding.
*/
export interface ConfirmShellCommandsActionReturn {
type: 'confirm_shell_commands';
/** The list of shell commands that require user confirmation. */
commandsToConfirm: string[];
/** The original invocation context to be re-run after confirmation. */
originalInvocation: {
raw: string;
};
}
export type SlashCommandActionReturn =
| ToolActionReturn
| MessageActionReturn
| OpenDialogActionReturn;
| QuitActionReturn
| OpenDialogActionReturn
| LoadHistoryActionReturn
| SubmitPromptActionReturn
| ConfirmShellCommandsActionReturn;
export enum CommandKind {
BUILT_IN = 'built-in',
FILE = 'file',
MCP_PROMPT = 'mcp-prompt',
}
// The standardized contract for any command in the system.
export interface SlashCommand {
name: string;
altName?: string;
description?: string;
altNames?: string[];
description: string;
kind: CommandKind;
// The action to run. Optional for parent commands that only group sub-commands.
action?: (
context: CommandContext,
args: string,
args: string, // TODO: Remove args. CommandContext now contains the complete invocation.
) =>
| void
| SlashCommandActionReturn

View File

@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, SlashCommand } from './types.js';
export const vimCommand: SlashCommand = {
name: 'vim',
description: 'toggle vim mode on/off',
kind: CommandKind.BUILT_IN,
action: async (context, _args) => {
const newVimState = await context.ui.toggleVimEnabled();
const message = newVimState
? 'Entered Vim mode. Run /vim again to exit.'
: 'Exited Vim mode.';
return {
type: 'message',
messageType: 'info',
content: message,
};
},
};