Sync upstream Gemini-CLI v0.8.2 (#838)

This commit is contained in:
tanzhenxin
2025-10-23 09:27:04 +08:00
committed by GitHub
parent 096fabb5d6
commit eb95c131be
644 changed files with 70389 additions and 23709 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { type DetectedIde, getIdeInfo } from '@qwen-code/qwen-code-core';
import type { IdeInfo } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import type { RadioSelectItem } from './components/shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
import { useKeypress } from './hooks/useKeypress.js';
import { theme } from './semantic-colors.js';
export type IdeIntegrationNudgeResult = {
userSelection: 'yes' | 'no' | 'dismiss';
@@ -16,7 +17,7 @@ export type IdeIntegrationNudgeResult = {
};
interface IdeIntegrationNudgeProps {
ide: DetectedIde;
ide: IdeInfo;
onComplete: (result: IdeIntegrationNudgeResult) => void;
}
@@ -36,7 +37,7 @@ export function IdeIntegrationNudge({
{ isActive: true },
);
const { displayName: ideName } = getIdeInfo(ide);
const { displayName: ideName } = ide;
// Assume extension is already installed if the env variables are set.
const isExtensionPreInstalled =
!!process.env['QWEN_CODE_IDE_SERVER_PORT'] &&
@@ -49,6 +50,7 @@ export function IdeIntegrationNudge({
userSelection: 'yes',
isExtensionPreInstalled,
},
key: 'Yes',
},
{
label: 'No (esc)',
@@ -56,6 +58,7 @@ export function IdeIntegrationNudge({
userSelection: 'no',
isExtensionPreInstalled,
},
key: 'No (esc)',
},
{
label: "No, don't ask again",
@@ -63,6 +66,7 @@ export function IdeIntegrationNudge({
userSelection: 'dismiss',
isExtensionPreInstalled,
},
key: "No, don't ask again",
},
];
@@ -78,17 +82,17 @@ export function IdeIntegrationNudge({
<Box
flexDirection="column"
borderStyle="round"
borderColor="yellow"
borderColor={theme.status.warning}
padding={1}
width="100%"
marginLeft={1}
>
<Box marginBottom={1} flexDirection="column">
<Text>
<Text color="yellow">{'> '}</Text>
<Text color={theme.status.warning}>{'> '}</Text>
{`Do you want to connect ${ideName ?? 'your editor'} to Qwen Code?`}
</Text>
<Text dimColor>{installText}</Text>
<Text color={theme.text.secondary}>{installText}</Text>
</Box>
<RadioButtonSelect items={OPTIONS} onSelect={onComplete} />
</Box>

View File

@@ -1,31 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`App UI > should render correctly with the prompt input box 1`] = `
"
╭────────────────────────────────────────────────────────────────────────────────────────╮
│ > Type your message or @path/to/file │
╰────────────────────────────────────────────────────────────────────────────────────────╯
/test/dir no sandbox (see /docs) model (100% context left)"
`;
exports[`App UI > should render the initial UI correctly 1`] = `
" I'm Feeling Lucky (esc to cancel, 0s)
/test/dir no sandbox (see /docs) model (100% context left)"
`;
exports[`App UI > when in a narrow terminal > should render with a column layout 1`] = `
"
╭────────────────────────────────────────────────────────────────────────────────────────╮
│ > Type your message or @path/to/file │
╰────────────────────────────────────────────────────────────────────────────────────────╯
dir
no sandbox (see /docs)
model (100% context left)| ✖ 5 errors (ctrl+o for details)"
`;

View File

@@ -32,10 +32,12 @@ describe('AuthDialog', () => {
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
originalSettings: {},
path: '',
},
{
@@ -46,13 +48,20 @@ describe('AuthDialog', () => {
},
},
},
originalSettings: {
security: {
auth: {
selectedType: AuthType.USE_GEMINI,
},
},
},
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
@@ -81,21 +90,28 @@ describe('AuthDialog', () => {
ui: { customThemes: {} },
mcpServers: {},
},
originalSettings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: {},
originalSettings: {},
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
@@ -120,21 +136,28 @@ describe('AuthDialog', () => {
ui: { customThemes: {} },
mcpServers: {},
},
originalSettings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: {},
originalSettings: {},
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
@@ -159,21 +182,28 @@ describe('AuthDialog', () => {
ui: { customThemes: {} },
mcpServers: {},
},
originalSettings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: {},
originalSettings: {},
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
@@ -199,21 +229,28 @@ describe('AuthDialog', () => {
ui: { customThemes: {} },
mcpServers: {},
},
originalSettings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: {},
originalSettings: {},
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
@@ -234,21 +271,28 @@ describe('AuthDialog', () => {
ui: { customThemes: {} },
mcpServers: {},
},
originalSettings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: {},
originalSettings: {},
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
@@ -271,21 +315,28 @@ describe('AuthDialog', () => {
ui: { customThemes: {} },
mcpServers: {},
},
originalSettings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: {},
originalSettings: {},
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
@@ -305,10 +356,12 @@ describe('AuthDialog', () => {
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
originalSettings: {},
path: '',
},
{
@@ -317,13 +370,18 @@ describe('AuthDialog', () => {
ui: { customThemes: {} },
mcpServers: {},
},
originalSettings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
@@ -350,10 +408,12 @@ describe('AuthDialog', () => {
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
originalSettings: {},
path: '',
},
{
@@ -362,13 +422,18 @@ describe('AuthDialog', () => {
ui: { customThemes: {} },
mcpServers: {},
},
originalSettings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
@@ -398,10 +463,12 @@ describe('AuthDialog', () => {
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
originalSettings: {},
path: '',
},
{
@@ -410,13 +477,18 @@ describe('AuthDialog', () => {
ui: { customThemes: {} },
mcpServers: {},
},
originalSettings: {
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);

View File

@@ -17,8 +17,8 @@ import {
import { type LoadedSettings, SettingScope } from '../../config/settings.js';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { OpenAIKeyPrompt } from '../components/OpenAIKeyPrompt.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
interface AuthDialogProps {
onSelect: (authMethod: AuthType | undefined, scope: SettingScope) => void;
@@ -48,8 +48,12 @@ export function AuthDialog({
);
const [showOpenAIKeyPrompt, setShowOpenAIKeyPrompt] = useState(false);
const items = [
{ label: 'Qwen OAuth', value: AuthType.QWEN_OAUTH },
{ label: 'OpenAI', value: AuthType.USE_OPENAI },
{
key: AuthType.QWEN_OAUTH,
label: 'Qwen OAuth',
value: AuthType.QWEN_OAUTH,
},
{ key: AuthType.USE_OPENAI, label: 'OpenAI', value: AuthType.USE_OPENAI },
];
const initialAuthIndex = Math.max(

View File

@@ -8,7 +8,7 @@ import type React from 'react';
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface AuthInProgressProps {
@@ -41,13 +41,13 @@ export function AuthInProgress({
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
{timedOut ? (
<Text color={Colors.AccentRed}>
<Text color={theme.status.error}>
Authentication timed out. Please try again.
</Text>
) : (

View File

@@ -12,22 +12,47 @@ import {
getErrorMessage,
} from '@qwen-code/qwen-code-core';
import { runExitCleanup } from '../../utils/cleanup.js';
import { AuthState } from '../types.js';
import { validateAuthMethod } from '../../config/auth.js';
export const useAuthCommand = (
export function validateAuthMethodWithSettings(
authType: AuthType,
settings: LoadedSettings,
setAuthError: (error: string | null) => void,
config: Config,
) => {
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(
settings.merged.security?.auth?.selectedType === undefined,
): string | null {
const enforcedType = settings.merged.security?.auth?.enforcedType;
if (enforcedType && enforcedType !== authType) {
return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`;
}
if (settings.merged.security?.auth?.useExternal) {
return null;
}
return validateAuthMethod(authType);
}
export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
// If no auth type is selected, start in Updating state (shows auth dialog)
const [authState, setAuthState] = useState<AuthState>(
settings.merged.security?.auth?.selectedType === undefined
? AuthState.Updating
: AuthState.Unauthenticated,
);
const openAuthDialog = useCallback(() => {
setIsAuthDialogOpen(true);
}, []);
const [authError, setAuthError] = useState<string | null>(null);
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(false);
const onAuthError = useCallback(
(error: string | null) => {
setAuthError(error);
if (error) {
setAuthState(AuthState.Updating);
}
},
[setAuthError, setAuthState],
);
// Authentication flow
useEffect(() => {
const authFlow = async () => {
const authType = settings.merged.security?.auth?.selectedType;
@@ -35,57 +60,76 @@ export const useAuthCommand = (
return;
}
const validationError = validateAuthMethodWithSettings(
authType,
settings,
);
if (validationError) {
onAuthError(validationError);
return;
}
try {
setIsAuthenticating(true);
await config.refreshAuth(authType);
console.log(`Authenticated via "${authType}".`);
setAuthError(null);
setAuthState(AuthState.Authenticated);
} catch (e) {
setAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
openAuthDialog();
onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
} finally {
setIsAuthenticating(false);
}
};
void authFlow();
}, [isAuthDialogOpen, settings, config, setAuthError, openAuthDialog]);
}, [isAuthDialogOpen, settings, config, onAuthError]);
// Handle auth selection from dialog
const handleAuthSelect = useCallback(
async (authType: AuthType | undefined, scope: SettingScope) => {
if (authType) {
await clearCachedCredentialFile();
settings.setValue(scope, 'security.auth.selectedType', authType);
if (
authType === AuthType.LOGIN_WITH_GOOGLE &&
config.isBrowserLaunchSuppressed()
) {
runExitCleanup();
console.log(
`
await runExitCleanup();
console.log(`
----------------------------------------------------------------
Logging in with Google... Please restart Gemini CLI to continue.
----------------------------------------------------------------
`,
);
`);
process.exit(0);
}
}
setIsAuthDialogOpen(false);
setAuthError(null);
},
[settings, setAuthError, config],
[settings, config],
);
const openAuthDialog = useCallback(() => {
setIsAuthDialogOpen(true);
}, []);
const cancelAuthentication = useCallback(() => {
setIsAuthenticating(false);
}, []);
return {
authState,
setAuthState,
authError,
onAuthError,
isAuthDialogOpen,
openAuthDialog,
handleAuthSelect,
isAuthenticating,
handleAuthSelect,
openAuthDialog,
cancelAuthentication,
};
};

View File

@@ -10,8 +10,20 @@ import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import * as versionUtils from '../../utils/version.js';
import { MessageType } from '../types.js';
import { IdeClient } from '@qwen-code/qwen-code-core';
import type { IdeClient } from '../../../../core/src/ide/ide-client.js';
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
IdeClient: {
getInstance: vi.fn().mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
}),
},
};
});
vi.mock('../../utils/version.js', () => ({
getCliVersion: vi.fn(),
@@ -27,7 +39,6 @@ describe('aboutCommand', () => {
services: {
config: {
getModel: vi.fn(),
getIdeClient: vi.fn(),
getIdeMode: vi.fn().mockReturnValue(true),
},
settings: {
@@ -53,9 +64,6 @@ describe('aboutCommand', () => {
Object.defineProperty(process, 'platform', {
value: 'test-os',
});
vi.spyOn(mockContext.services.config!, 'getIdeClient').mockReturnValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
} as Partial<IdeClient> as IdeClient);
});
afterEach(() => {
@@ -129,9 +137,9 @@ describe('aboutCommand', () => {
});
it('should not show ide client when it is not detected', async () => {
vi.spyOn(mockContext.services.config!, 'getIdeClient').mockReturnValue({
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue(undefined),
} as Partial<IdeClient> as IdeClient);
} as unknown as IdeClient);
process.env['SANDBOX'] = '';
if (!aboutCommand.action) {

View File

@@ -5,10 +5,11 @@
*/
import { getCliVersion } from '../../utils/version.js';
import type { SlashCommand } from './types.js';
import type { CommandContext, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import process from 'node:process';
import { MessageType, type HistoryItemAbout } from '../types.js';
import { IdeClient } from '@qwen-code/qwen-code-core';
export const aboutCommand: SlashCommand = {
name: 'about',
@@ -29,10 +30,7 @@ export const aboutCommand: SlashCommand = {
const selectedAuthType =
context.services.settings.merged.security?.auth?.selectedType || '';
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
const ideClient =
(context.services.config?.getIdeMode() &&
context.services.config?.getIdeClient()?.getDetectedIdeDisplayName()) ||
'';
const ideClient = await getIdeClientName(context);
const aboutItem: Omit<HistoryItemAbout, 'id'> = {
type: MessageType.ABOUT,
@@ -48,3 +46,11 @@ export const aboutCommand: SlashCommand = {
context.ui.addItem(aboutItem, Date.now());
},
};
async function getIdeClientName(context: CommandContext) {
if (!context.services.config?.getIdeMode()) {
return '';
}
const ideClient = await IdeClient.getInstance();
return ideClient?.getDetectedIdeDisplayName() ?? '';
}

View File

@@ -16,7 +16,19 @@ import { formatMemoryUsage } from '../utils/formatters.js';
vi.mock('open');
vi.mock('../../utils/version.js');
vi.mock('../utils/formatters.js');
vi.mock('@qwen-code/qwen-code-core');
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
IdeClient: {
getInstance: () => ({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('VSCode'),
}),
},
sessionId: 'test-session-id',
};
});
vi.mock('node:process', () => ({
default: {
platform: 'test-platform',
@@ -31,9 +43,6 @@ describe('bugCommand', () => {
beforeEach(() => {
vi.mocked(getCliVersion).mockResolvedValue('0.1.0');
vi.mocked(formatMemoryUsage).mockReturnValue('100 MB');
vi.mock('@qwen-code/qwen-code-core', () => ({
sessionId: 'test-session-id',
}));
vi.stubEnv('SANDBOX', 'qwen-test');
});
@@ -48,9 +57,6 @@ describe('bugCommand', () => {
config: {
getModel: () => 'qwen3-coder-plus',
getBugCommand: () => undefined,
getIdeClient: () => ({
getDetectedIdeDisplayName: () => 'VSCode',
}),
getIdeMode: () => true,
},
},
@@ -84,9 +90,6 @@ describe('bugCommand', () => {
config: {
getModel: () => 'qwen3-coder-plus',
getBugCommand: () => ({ urlTemplate: customTemplate }),
getIdeClient: () => ({
getDetectedIdeDisplayName: () => 'VSCode',
}),
getIdeMode: () => true,
},
},

View File

@@ -15,7 +15,7 @@ import { MessageType } from '../types.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatMemoryUsage } from '../utils/formatters.js';
import { getCliVersion } from '../../utils/version.js';
import { sessionId } from '@qwen-code/qwen-code-core';
import { IdeClient, sessionId } from '@qwen-code/qwen-code-core';
export const bugCommand: SlashCommand = {
name: 'bug',
@@ -37,10 +37,7 @@ export const bugCommand: SlashCommand = {
const modelVersion = config?.getModel() || 'Unknown';
const cliVersion = await getCliVersion();
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
const ideClient =
(context.services.config?.getIdeMode() &&
context.services.config?.getIdeClient()?.getDetectedIdeDisplayName()) ||
'';
const ideClient = await getIdeClientName(context);
let info = `
* **CLI Version:** ${cliVersion}
@@ -90,3 +87,11 @@ export const bugCommand: SlashCommand = {
}
},
};
async function getIdeClientName(context: CommandContext) {
if (!context.services.config?.getIdeMode()) {
return '';
}
const ideClient = await IdeClient.getInstance();
return ideClient.getDetectedIdeDisplayName() ?? '';
}

View File

@@ -17,13 +17,15 @@ import type { Content } from '@google/genai';
import type { GeminiClient } from '@qwen-code/qwen-code-core';
import * as fsPromises from 'node:fs/promises';
import { chatCommand } from './chatCommand.js';
import { chatCommand, serializeHistoryToMarkdown } from './chatCommand.js';
import type { Stats } from 'node:fs';
import type { HistoryItemWithoutId } from '../types.js';
import path from 'node:path';
vi.mock('fs/promises', () => ({
stat: vi.fn(),
readdir: vi.fn().mockResolvedValue(['file1.txt', 'file2.txt'] as string[]),
writeFile: vi.fn(),
}));
describe('chatCommand', () => {
@@ -37,7 +39,7 @@ describe('chatCommand', () => {
let mockGetHistory: ReturnType<typeof vi.fn>;
const getSubCommand = (
name: 'list' | 'save' | 'resume' | 'delete',
name: 'list' | 'save' | 'resume' | 'delete' | 'share',
): SlashCommand => {
const subCommand = chatCommand.subCommands?.find(
(cmd) => cmd.name === name,
@@ -86,7 +88,7 @@ describe('chatCommand', () => {
it('should have the correct main command definition', () => {
expect(chatCommand.name).toBe('chat');
expect(chatCommand.description).toBe('Manage conversation history.');
expect(chatCommand.subCommands).toHaveLength(4);
expect(chatCommand.subCommands).toHaveLength(5);
});
describe('list subcommand', () => {
@@ -407,4 +409,293 @@ describe('chatCommand', () => {
});
});
});
describe('share subcommand', () => {
let shareCommand: SlashCommand;
const mockHistory = [
{ role: 'user', parts: [{ text: 'context' }] },
{ role: 'model', parts: [{ text: 'context response' }] },
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there!' }] },
];
beforeEach(() => {
shareCommand = getSubCommand('share');
vi.spyOn(process, 'cwd').mockReturnValue(
path.resolve('/usr/local/google/home/myuser/gemini-cli'),
);
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
mockGetHistory.mockReturnValue(mockHistory);
mockFs.writeFile.mockClear();
});
it('should default to a json file if no path is provided', async () => {
const result = await shareCommand?.action?.(mockContext, '');
const expectedPath = path.join(
process.cwd(),
'gemini-conversation-1234567890.json',
);
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should share the conversation to a JSON file', async () => {
const filePath = 'my-chat.json';
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should share the conversation to a Markdown file', async () => {
const filePath = 'my-chat.md';
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const expectedContent = `🧑‍💻 ## USER
context
---
✨ ## MODEL
context response
---
🧑‍💻 ## USER
Hello
---
✨ ## MODEL
Hi there!`;
expect(actualContent).toEqual(expectedContent);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should return an error for unsupported file extensions', async () => {
const filePath = 'my-chat.txt';
const result = await shareCommand?.action?.(mockContext, filePath);
expect(mockFs.writeFile).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Invalid file format. Only .md and .json are supported.',
});
});
it('should inform if there is no conversation to share', async () => {
mockGetHistory.mockReturnValue([
{ role: 'user', parts: [{ text: 'context' }] },
{ role: 'model', parts: [{ text: 'context response' }] },
]);
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
expect(mockFs.writeFile).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No conversation found to share.',
});
});
it('should handle errors during file writing', async () => {
const error = new Error('Permission denied');
mockFs.writeFile.mockRejectedValue(error);
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: `Error sharing conversation: ${error.message}`,
});
});
it('should output valid JSON schema', async () => {
const filePath = 'my-chat.json';
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const parsedContent = JSON.parse(actualContent);
expect(Array.isArray(parsedContent)).toBe(true);
parsedContent.forEach((item: Content) => {
expect(item).toHaveProperty('role');
expect(item).toHaveProperty('parts');
expect(Array.isArray(item.parts)).toBe(true);
});
});
it('should output correct markdown format', async () => {
const filePath = 'my-chat.md';
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const entries = actualContent.split('\n\n---\n\n');
expect(entries.length).toBe(mockHistory.length);
entries.forEach((entry, index) => {
const { role, parts } = mockHistory[index];
const text = parts.map((p) => p.text).join('');
const roleIcon = role === 'user' ? '🧑‍💻' : '✨';
expect(entry).toBe(`${roleIcon} ## ${role.toUpperCase()}\n\n${text}`);
});
});
});
describe('serializeHistoryToMarkdown', () => {
it('should correctly serialize chat history to Markdown with icons', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there!' }] },
{ role: 'user', parts: [{ text: 'How are you?' }] },
];
const expectedMarkdown =
'🧑‍💻 ## USER\n\nHello\n\n---\n\n' +
'✨ ## MODEL\n\nHi there!\n\n---\n\n' +
'🧑‍💻 ## USER\n\nHow are you?';
const result = serializeHistoryToMarkdown(history);
expect(result).toBe(expectedMarkdown);
});
it('should handle empty history', () => {
const history: Content[] = [];
const result = serializeHistoryToMarkdown(history);
expect(result).toBe('');
});
it('should handle items with no text parts', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [] },
{ role: 'user', parts: [{ text: 'How are you?' }] },
];
const expectedMarkdown = `🧑‍💻 ## USER
Hello
---
✨ ## MODEL
---
🧑‍💻 ## USER
How are you?`;
const result = serializeHistoryToMarkdown(history);
expect(result).toBe(expectedMarkdown);
});
it('should correctly serialize function calls and responses', () => {
const history: Content[] = [
{
role: 'user',
parts: [{ text: 'Please call a function.' }],
},
{
role: 'model',
parts: [
{
functionCall: {
name: 'my-function',
args: { arg1: 'value1' },
},
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
name: 'my-function',
response: { result: 'success' },
},
},
],
},
];
const expectedMarkdown = `🧑‍💻 ## USER
Please call a function.
---
✨ ## MODEL
**Tool Command**:
\`\`\`json
{
"name": "my-function",
"args": {
"arg1": "value1"
}
}
\`\`\`
---
🧑‍💻 ## USER
**Tool Response**:
\`\`\`json
{
"name": "my-function",
"response": {
"result": "success"
}
}
\`\`\``;
const result = serializeHistoryToMarkdown(history);
expect(result).toBe(expectedMarkdown);
});
it('should handle items with undefined role', () => {
const history: Array<Partial<Content>> = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ parts: [{ text: 'Hi there!' }] },
];
const expectedMarkdown = `🧑‍💻 ## USER
Hello
---
✨ ## MODEL
Hi there!`;
const result = serializeHistoryToMarkdown(history as Content[]);
expect(result).toBe(expectedMarkdown);
});
});
});

View File

@@ -7,7 +7,7 @@
import * as fsPromises from 'node:fs/promises';
import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import type {
CommandContext,
SlashCommand,
@@ -19,6 +19,7 @@ import { decodeTagName } from '@qwen-code/qwen-code-core';
import path from 'node:path';
import type { HistoryItemWithoutId } from '../types.js';
import { MessageType } from '../types.js';
import type { Content } from '@google/genai';
interface ChatDetail {
name: string;
@@ -126,7 +127,7 @@ const saveCommand: SlashCommand = {
Text,
null,
'A checkpoint with the tag ',
React.createElement(Text, { color: Colors.AccentPurple }, tag),
React.createElement(Text, { color: theme.text.accent }, tag),
' already exists. Do you want to overwrite it?',
),
originalInvocation: {
@@ -274,9 +275,115 @@ const deleteCommand: SlashCommand = {
},
};
export function serializeHistoryToMarkdown(history: Content[]): string {
return history
.map((item) => {
const text =
item.parts
?.map((part) => {
if (part.text) {
return part.text;
}
if (part.functionCall) {
return `**Tool Command**:\n\`\`\`json\n${JSON.stringify(
part.functionCall,
null,
2,
)}\n\`\`\``;
}
if (part.functionResponse) {
return `**Tool Response**:\n\`\`\`json\n${JSON.stringify(
part.functionResponse,
null,
2,
)}\n\`\`\``;
}
return '';
})
.join('') || '';
const roleIcon = item.role === 'user' ? '🧑‍💻' : '✨';
return `${roleIcon} ## ${(item.role || 'model').toUpperCase()}\n\n${text}`;
})
.join('\n\n---\n\n');
}
const shareCommand: SlashCommand = {
name: 'share',
description:
'Share the current conversation to a markdown or json file. Usage: /chat share <file>',
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => {
let filePathArg = args.trim();
if (!filePathArg) {
filePathArg = `gemini-conversation-${Date.now()}.json`;
}
const filePath = path.resolve(filePathArg);
const extension = path.extname(filePath);
if (extension !== '.md' && extension !== '.json') {
return {
type: 'message',
messageType: 'error',
content: 'Invalid file format. Only .md and .json are supported.',
};
}
const chat = await context.services.config?.getGeminiClient()?.getChat();
if (!chat) {
return {
type: 'message',
messageType: 'error',
content: 'No chat client available to share conversation.',
};
}
const history = chat.getHistory();
// An empty conversation has two hidden messages that setup the context for
// the chat. Thus, to check whether a conversation has been started, we
// can't check for length 0.
if (history.length <= 2) {
return {
type: 'message',
messageType: 'info',
content: 'No conversation found to share.',
};
}
let content = '';
if (extension === '.json') {
content = JSON.stringify(history, null, 2);
} else {
content = serializeHistoryToMarkdown(history);
}
try {
await fsPromises.writeFile(filePath, content);
return {
type: 'message',
messageType: 'info',
content: `Conversation shared to ${filePath}`,
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
return {
type: 'message',
messageType: 'error',
content: `Error sharing conversation: ${errorMessage}`,
};
}
},
};
export const chatCommand: SlashCommand = {
name: 'chat',
description: 'Manage conversation history.',
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, saveCommand, resumeCommand, deleteCommand],
subCommands: [
listCommand,
saveCommand,
resumeCommand,
deleteCommand,
shareCommand,
],
};

View File

@@ -16,7 +16,7 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
return {
...actual,
uiTelemetryService: {
resetLastPromptTokenCount: vi.fn(),
setLastPromptTokenCount: vi.fn(),
},
};
});
@@ -57,9 +57,8 @@ describe('clearCommand', () => {
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1);
expect(mockResetChat).toHaveBeenCalledTimes(1);
expect(uiTelemetryService.resetLastPromptTokenCount).toHaveBeenCalledTimes(
1,
);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1);
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
// Check the order of operations.
@@ -67,7 +66,7 @@ describe('clearCommand', () => {
.invocationCallOrder[0];
const resetChatOrder = mockResetChat.mock.invocationCallOrder[0];
const resetTelemetryOrder = (
uiTelemetryService.resetLastPromptTokenCount as Mock
uiTelemetryService.setLastPromptTokenCount as Mock
).mock.invocationCallOrder[0];
const clearOrder = (mockContext.ui.clear as Mock).mock
.invocationCallOrder[0];
@@ -94,9 +93,8 @@ describe('clearCommand', () => {
'Clearing terminal.',
);
expect(mockResetChat).not.toHaveBeenCalled();
expect(uiTelemetryService.resetLastPromptTokenCount).toHaveBeenCalledTimes(
1,
);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1);
expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1);
});
});

View File

@@ -24,7 +24,7 @@ export const clearCommand: SlashCommand = {
context.ui.setDebugMessage('Clearing terminal.');
}
uiTelemetryService.resetLastPromptTokenCount();
uiTelemetryService.setLastPromptTokenCount(0);
context.ui.clear();
},
};

View File

@@ -9,6 +9,7 @@ import { CommandKind, type SlashCommand } from './types.js';
export const corgiCommand: SlashCommand = {
name: 'corgi',
description: 'Toggles corgi mode.',
hidden: true,
kind: CommandKind.BUILT_IN,
action: (context, _args) => {
context.ui.toggleCorgiMode();

View File

@@ -104,6 +104,7 @@ export const directoryCommand: SlashCommand = {
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
config.getFolderTrust(),
context.services.settings.merged.context?.importFormat ||
'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),

View File

@@ -4,64 +4,332 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { extensionsCommand } from './extensionsCommand.js';
import { type CommandContext } from './types.js';
import type { GeminiCLIExtension } from '@qwen-code/qwen-code-core';
import {
updateAllUpdatableExtensions,
updateExtension,
} from '../../config/extensions/update.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import { extensionsCommand } from './extensionsCommand.js';
import { type CommandContext } from './types.js';
import {
describe,
it,
expect,
vi,
beforeEach,
type MockedFunction,
} from 'vitest';
import { ExtensionUpdateState } from '../state/extensions.js';
vi.mock('../../config/extensions/update.js', () => ({
updateExtension: vi.fn(),
updateAllUpdatableExtensions: vi.fn(),
checkForAllExtensionUpdates: vi.fn(),
}));
const mockUpdateExtension = updateExtension as MockedFunction<
typeof updateExtension
>;
const mockUpdateAllUpdatableExtensions =
updateAllUpdatableExtensions as MockedFunction<
typeof updateAllUpdatableExtensions
>;
const mockGetExtensions = vi.fn();
describe('extensionsCommand', () => {
let mockContext: CommandContext;
it('should display "No active extensions." when none are found', async () => {
beforeEach(() => {
vi.resetAllMocks();
mockContext = createMockCommandContext({
services: {
config: {
getExtensions: () => [],
getExtensions: mockGetExtensions,
getWorkingDir: () => '/test/dir',
},
},
});
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.',
ui: {
dispatchExtensionStateUpdate: vi.fn(),
},
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,
describe('list', () => {
it('should add an EXTENSIONS_LIST item to the UI', async () => {
if (!extensionsCommand.action) throw new Error('Action not defined');
await extensionsCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.EXTENSIONS_LIST,
},
},
expect.any(Number),
);
});
});
describe('update', () => {
const updateAction = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'update',
)?.action;
if (!updateAction) {
throw new Error('Update action not found');
}
it('should show usage if no args are provided', async () => {
await updateAction(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions update <extension-names>|--all',
},
expect.any(Number),
);
});
if (!extensionsCommand.action) throw new Error('Action not defined');
await extensionsCommand.action(mockContext, '');
it('should inform user if there are no extensions to update with --all', async () => {
mockUpdateAllUpdatableExtensions.mockResolvedValue([]);
await updateAction(mockContext, '--all');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'No extensions to update.',
},
expect.any(Number),
);
});
const expectedMessage =
'Active extensions:\n\n' +
` - \u001b[36mext-one (v1.0.0)\u001b[0m\n` +
` - \u001b[36mext-two (v2.1.0)\u001b[0m\n`;
it('should call setPendingItem and addItem in a finally block on success', async () => {
mockUpdateAllUpdatableExtensions.mockResolvedValue([
{
name: 'ext-one',
originalVersion: '1.0.0',
updatedVersion: '1.0.1',
},
{
name: 'ext-two',
originalVersion: '2.0.0',
updatedVersion: '2.0.1',
},
]);
await updateAction(mockContext, '--all');
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
type: MessageType.EXTENSIONS_LIST,
});
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.EXTENSIONS_LIST,
},
expect.any(Number),
);
});
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: expectedMessage,
},
expect.any(Number),
);
it('should call setPendingItem and addItem in a finally block on failure', async () => {
mockUpdateAllUpdatableExtensions.mockRejectedValue(
new Error('Something went wrong'),
);
await updateAction(mockContext, '--all');
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
type: MessageType.EXTENSIONS_LIST,
});
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.EXTENSIONS_LIST,
},
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Something went wrong',
},
expect.any(Number),
);
});
it('should update a single extension by name', async () => {
const extension: GeminiCLIExtension = {
name: 'ext-one',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-one',
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
mockUpdateExtension.mockResolvedValue({
name: extension.name,
originalVersion: extension.version,
updatedVersion: '1.0.1',
});
mockGetExtensions.mockReturnValue([extension]);
mockContext.ui.extensionsUpdateState.set(extension.name, {
status: ExtensionUpdateState.UPDATE_AVAILABLE,
processed: false,
});
await updateAction(mockContext, 'ext-one');
expect(mockUpdateExtension).toHaveBeenCalledWith(
extension,
'/test/dir',
expect.any(Function),
ExtensionUpdateState.UPDATE_AVAILABLE,
expect.any(Function),
);
});
it('should handle errors when updating a single extension', async () => {
mockUpdateExtension.mockRejectedValue(new Error('Extension not found'));
mockGetExtensions.mockReturnValue([]);
await updateAction(mockContext, 'ext-one');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Extension ext-one not found.',
},
expect.any(Number),
);
});
it('should update multiple extensions by name', async () => {
const extensionOne: GeminiCLIExtension = {
name: 'ext-one',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-one',
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
const extensionTwo: GeminiCLIExtension = {
name: 'ext-two',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-two',
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
mockGetExtensions.mockReturnValue([extensionOne, extensionTwo]);
mockContext.ui.extensionsUpdateState.set(
extensionOne.name,
ExtensionUpdateState.UPDATE_AVAILABLE,
);
mockContext.ui.extensionsUpdateState.set(
extensionTwo.name,
ExtensionUpdateState.UPDATE_AVAILABLE,
);
mockUpdateExtension
.mockResolvedValueOnce({
name: 'ext-one',
originalVersion: '1.0.0',
updatedVersion: '1.0.1',
})
.mockResolvedValueOnce({
name: 'ext-two',
originalVersion: '2.0.0',
updatedVersion: '2.0.1',
});
await updateAction(mockContext, 'ext-one ext-two');
expect(mockUpdateExtension).toHaveBeenCalledTimes(2);
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
type: MessageType.EXTENSIONS_LIST,
});
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.EXTENSIONS_LIST,
},
expect.any(Number),
);
});
describe('completion', () => {
const updateCompletion = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'update',
)?.completion;
if (!updateCompletion) {
throw new Error('Update completion not found');
}
const extensionOne: GeminiCLIExtension = {
name: 'ext-one',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-one',
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
const extensionTwo: GeminiCLIExtension = {
name: 'another-ext',
version: '1.0.0',
isActive: true,
path: '/test/dir/another-ext',
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
const allExt: GeminiCLIExtension = {
name: 'all-ext',
version: '1.0.0',
isActive: true,
path: '/test/dir/all-ext',
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
it.each([
{
description: 'should return matching extension names',
extensions: [extensionOne, extensionTwo],
partialArg: 'ext',
expected: ['ext-one'],
},
{
description: 'should return --all when partialArg matches',
extensions: [],
partialArg: '--al',
expected: ['--all'],
},
{
description:
'should return both extension names and --all when both match',
extensions: [allExt],
partialArg: 'all',
expected: ['--all', 'all-ext'],
},
{
description: 'should return an empty array if no matches',
extensions: [extensionOne],
partialArg: 'nomatch',
expected: [],
},
])('$description', async ({ extensions, partialArg, expected }) => {
mockGetExtensions.mockReturnValue(extensions);
const suggestions = await updateCompletion(mockContext, partialArg);
expect(suggestions).toEqual(expected);
});
});
});
});

View File

@@ -4,43 +4,164 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { requestConsentInteractive } from '../../config/extension.js';
import {
updateAllUpdatableExtensions,
type ExtensionUpdateInfo,
updateExtension,
checkForAllExtensionUpdates,
} from '../../config/extensions/update.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionUpdateState } from '../state/extensions.js';
import { MessageType } from '../types.js';
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) {
async function listAction(context: CommandContext) {
context.ui.addItem(
{
type: MessageType.EXTENSIONS_LIST,
},
Date.now(),
);
}
async function updateAction(context: CommandContext, args: string) {
const updateArgs = args.split(' ').filter((value) => value.length > 0);
const all = updateArgs.length === 1 && updateArgs[0] === '--all';
const names = all ? undefined : updateArgs;
let updateInfos: ExtensionUpdateInfo[] = [];
if (!all && names?.length === 0) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Usage: /extensions update <extension-names>|--all',
},
Date.now(),
);
return;
}
try {
await checkForAllExtensionUpdates(
context.services.config!.getExtensions(),
context.ui.dispatchExtensionStateUpdate,
);
context.ui.setPendingItem({
type: MessageType.EXTENSIONS_LIST,
});
if (all) {
updateInfos = await updateAllUpdatableExtensions(
context.services.config!.getWorkingDir(),
// We don't have the ability to prompt for consent yet in this flow.
(description) =>
requestConsentInteractive(
description,
context.ui.addConfirmUpdateExtensionRequest,
),
context.services.config!.getExtensions(),
context.ui.extensionsUpdateState,
context.ui.dispatchExtensionStateUpdate,
);
} else if (names?.length) {
const workingDir = context.services.config!.getWorkingDir();
const extensions = context.services.config!.getExtensions();
for (const name of names) {
const extension = extensions.find(
(extension) => extension.name === name,
);
if (!extension) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Extension ${name} not found.`,
},
Date.now(),
);
continue;
}
const updateInfo = await updateExtension(
extension,
workingDir,
(description) =>
requestConsentInteractive(
description,
context.ui.addConfirmUpdateExtensionRequest,
),
context.ui.extensionsUpdateState.get(extension.name)?.status ??
ExtensionUpdateState.UNKNOWN,
context.ui.dispatchExtensionStateUpdate,
);
if (updateInfo) updateInfos.push(updateInfo);
}
}
if (updateInfos.length === 0) {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'No active extensions.',
text: 'No extensions to update.',
},
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`;
} catch (error) {
context.ui.addItem(
{
type: MessageType.INFO,
text: message,
type: MessageType.ERROR,
text: getErrorMessage(error),
},
Date.now(),
);
} finally {
context.ui.addItem(
{
type: MessageType.EXTENSIONS_LIST,
},
Date.now(),
);
context.ui.setPendingItem(null);
}
}
const listExtensionsCommand: SlashCommand = {
name: 'list',
description: 'List active extensions',
kind: CommandKind.BUILT_IN,
action: listAction,
};
const updateExtensionsCommand: SlashCommand = {
name: 'update',
description: 'Update extensions. Usage: update <extension-names>|--all',
kind: CommandKind.BUILT_IN,
action: updateAction,
completion: async (context, partialArg) => {
const extensions = context.services.config?.getExtensions() ?? [];
const extensionNames = extensions.map((ext) => ext.name);
const suggestions = extensionNames.filter((name) =>
name.startsWith(partialArg),
);
if ('--all'.startsWith(partialArg) || 'all'.startsWith(partialArg)) {
suggestions.unshift('--all');
}
return suggestions;
},
};
export const extensionsCommand: SlashCommand = {
name: 'extensions',
description: 'Manage extensions',
kind: CommandKind.BUILT_IN,
subCommands: [listExtensionsCommand, updateExtensionsCommand],
action: (context, args) =>
// Default to list if no subcommand is provided
listExtensionsCommand.action!(context, args),
};

View File

@@ -8,26 +8,43 @@ import type { MockInstance } from 'vitest';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { ideCommand } from './ideCommand.js';
import { type CommandContext } from './types.js';
import { type Config, DetectedIde } from '@qwen-code/qwen-code-core';
import { IDE_DEFINITIONS } from '@qwen-code/qwen-code-core';
import * as core from '@qwen-code/qwen-code-core';
vi.mock('child_process');
vi.mock('glob');
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original = await importOriginal<typeof core>();
return {
...original,
getOauthClient: vi.fn(original.getOauthClient),
getIdeInstaller: vi.fn(original.getIdeInstaller),
IdeClient: {
getInstance: vi.fn(),
},
};
});
describe('ideCommand', () => {
let mockContext: CommandContext;
let mockConfig: Config;
let mockIdeClient: core.IdeClient;
let platformSpy: MockInstance;
beforeEach(() => {
vi.resetAllMocks();
mockIdeClient = {
reconnect: vi.fn(),
disconnect: vi.fn(),
connect: vi.fn(),
getCurrentIde: vi.fn(),
getConnectionStatus: vi.fn(),
getDetectedIdeDisplayName: vi.fn(),
} as unknown as core.IdeClient;
vi.mocked(core.IdeClient.getInstance).mockResolvedValue(mockIdeClient);
vi.mocked(mockIdeClient.getDetectedIdeDisplayName).mockReturnValue(
'VS Code',
);
mockContext = {
ui: {
addItem: vi.fn(),
@@ -36,22 +53,14 @@ describe('ideCommand', () => {
settings: {
setValue: vi.fn(),
},
config: {
getIdeMode: vi.fn(),
setIdeMode: vi.fn(),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
},
},
} as unknown as CommandContext;
mockConfig = {
getIdeMode: vi.fn(),
getIdeClient: vi.fn(() => ({
reconnect: vi.fn(),
disconnect: vi.fn(),
getCurrentIde: vi.fn(),
getDetectedIdeDisplayName: vi.fn(),
getConnectionStatus: vi.fn(),
})),
setIdeModeAndSyncConnection: vi.fn(),
setIdeMode: vi.fn(),
} as unknown as Config;
platformSpy = vi.spyOn(process, 'platform', 'get');
});
@@ -59,64 +68,52 @@ describe('ideCommand', () => {
vi.restoreAllMocks();
});
it('should return null if config is not provided', () => {
const command = ideCommand(null);
expect(command).toBeNull();
it('should return the ide command', async () => {
vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue(
IDE_DEFINITIONS.vscode,
);
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
status: core.IDEConnectionStatus.Disconnected,
});
const command = await ideCommand();
expect(command).not.toBeNull();
expect(command.name).toBe('ide');
expect(command.subCommands).toHaveLength(3);
expect(command.subCommands?.[0].name).toBe('enable');
expect(command.subCommands?.[1].name).toBe('status');
expect(command.subCommands?.[2].name).toBe('install');
});
it('should return the ide command', () => {
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getCurrentIde: () => DetectedIde.VSCode,
getDetectedIdeDisplayName: () => 'VS Code',
getConnectionStatus: () => ({
status: core.IDEConnectionStatus.Disconnected,
}),
} as ReturnType<Config['getIdeClient']>);
const command = ideCommand(mockConfig);
it('should show disable command when connected', async () => {
vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue(
IDE_DEFINITIONS.vscode,
);
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
status: core.IDEConnectionStatus.Connected,
});
const command = await ideCommand();
expect(command).not.toBeNull();
expect(command?.name).toBe('ide');
expect(command?.subCommands).toHaveLength(3);
expect(command?.subCommands?.[0].name).toBe('enable');
expect(command?.subCommands?.[1].name).toBe('status');
expect(command?.subCommands?.[2].name).toBe('install');
});
it('should show disable command when connected', () => {
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getCurrentIde: () => DetectedIde.VSCode,
getDetectedIdeDisplayName: () => 'VS Code',
getConnectionStatus: () => ({
status: core.IDEConnectionStatus.Connected,
}),
} as ReturnType<Config['getIdeClient']>);
const command = ideCommand(mockConfig);
expect(command).not.toBeNull();
const subCommandNames = command?.subCommands?.map((cmd) => cmd.name);
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
expect(subCommandNames).toContain('disable');
expect(subCommandNames).not.toContain('enable');
});
describe('status subcommand', () => {
const mockGetConnectionStatus = vi.fn();
beforeEach(() => {
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getConnectionStatus: mockGetConnectionStatus,
getCurrentIde: () => DetectedIde.VSCode,
getDetectedIdeDisplayName: () => 'VS Code',
} as unknown as ReturnType<Config['getIdeClient']>);
vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue(
IDE_DEFINITIONS.vscode,
);
});
it('should show connected status', async () => {
mockGetConnectionStatus.mockReturnValue({
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
status: core.IDEConnectionStatus.Connected,
});
const command = ideCommand(mockConfig);
const command = await ideCommand();
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -125,14 +122,14 @@ describe('ideCommand', () => {
});
it('should show connecting status', async () => {
mockGetConnectionStatus.mockReturnValue({
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
status: core.IDEConnectionStatus.Connecting,
});
const command = ideCommand(mockConfig);
const command = await ideCommand();
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -140,14 +137,14 @@ describe('ideCommand', () => {
});
});
it('should show disconnected status', async () => {
mockGetConnectionStatus.mockReturnValue({
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
status: core.IDEConnectionStatus.Disconnected,
});
const command = ideCommand(mockConfig);
const command = await ideCommand();
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
@@ -157,15 +154,15 @@ describe('ideCommand', () => {
it('should show disconnected status with details', async () => {
const details = 'Something went wrong';
mockGetConnectionStatus.mockReturnValue({
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
status: core.IDEConnectionStatus.Disconnected,
details,
});
const command = ideCommand(mockConfig);
const command = await ideCommand();
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
@@ -177,34 +174,39 @@ describe('ideCommand', () => {
describe('install subcommand', () => {
const mockInstall = vi.fn();
beforeEach(() => {
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getCurrentIde: () => DetectedIde.VSCode,
getConnectionStatus: () => ({
status: core.IDEConnectionStatus.Disconnected,
}),
getDetectedIdeDisplayName: () => 'VS Code',
} as unknown as ReturnType<Config['getIdeClient']>);
vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue(
IDE_DEFINITIONS.vscode,
);
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
status: core.IDEConnectionStatus.Disconnected,
});
vi.mocked(core.getIdeInstaller).mockReturnValue({
install: mockInstall,
isInstalled: vi.fn(),
});
platformSpy.mockReturnValue('linux');
});
it('should install the extension', async () => {
vi.useFakeTimers();
mockInstall.mockResolvedValue({
success: true,
message: 'Successfully installed.',
});
const command = ideCommand(mockConfig);
await command!.subCommands!.find((c) => c.name === 'install')!.action!(
mockContext,
'',
);
const command = await ideCommand();
expect(core.getIdeInstaller).toHaveBeenCalledWith('vscode');
// For the polling loop inside the action.
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
status: core.IDEConnectionStatus.Connected,
});
const actionPromise = command!.subCommands!.find(
(c) => c.name === 'install',
)!.action!(mockContext, '');
await vi.runAllTimersAsync();
await actionPromise;
expect(core.getIdeInstaller).toHaveBeenCalledWith(IDE_DEFINITIONS.vscode);
expect(mockInstall).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
@@ -220,6 +222,14 @@ describe('ideCommand', () => {
}),
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: '🟢 Connected to VS Code',
}),
expect.any(Number),
);
vi.useRealTimers();
}, 10000);
it('should show an error if installation fails', async () => {
@@ -228,13 +238,13 @@ describe('ideCommand', () => {
message: 'Installation failed.',
});
const command = ideCommand(mockConfig);
const command = await ideCommand();
await command!.subCommands!.find((c) => c.name === 'install')!.action!(
mockContext,
'',
);
expect(core.getIdeInstaller).toHaveBeenCalledWith('vscode');
expect(core.getIdeInstaller).toHaveBeenCalledWith(IDE_DEFINITIONS.vscode);
expect(mockInstall).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({

View File

@@ -4,12 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, IdeClient, File } from '@qwen-code/qwen-code-core';
import {
type Config,
IdeClient,
type File,
logIdeConnection,
IdeConnectionEvent,
IdeConnectionType,
} from '@qwen-code/qwen-code-core';
import {
QWEN_CODE_COMPANION_EXTENSION_NAME,
getIdeInstaller,
IDEConnectionStatus,
ideContext,
ideContextStore,
} from '@qwen-code/qwen-code-core';
import path from 'node:path';
import type {
@@ -83,7 +90,7 @@ async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
switch (connection.status) {
case IDEConnectionStatus.Connected: {
let content = `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`;
const context = ideContext.getIdeContext();
const context = ideContextStore.get();
const openFiles = context?.workspaceState?.openFiles;
if (openFiles && openFiles.length > 0) {
content += formatFileList(openFiles);
@@ -111,13 +118,24 @@ async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
}
}
export const ideCommand = (config: Config | null): SlashCommand | null => {
if (!config) {
return null;
async function setIdeModeAndSyncConnection(
config: Config,
value: boolean,
): Promise<void> {
config.setIdeMode(value);
const ideClient = await IdeClient.getInstance();
if (value) {
await ideClient.connect();
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.SESSION));
} else {
await ideClient.disconnect();
}
const ideClient = config.getIdeClient();
}
export const ideCommand = async (): Promise<SlashCommand> => {
const ideClient = await IdeClient.getInstance();
const currentIDE = ideClient.getCurrentIde();
if (!currentIDE || !ideClient.getDetectedIdeDisplayName()) {
if (!currentIDE) {
return {
name: 'ide',
description: 'manage IDE integration',
@@ -194,7 +212,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
);
// Poll for up to 5 seconds for the extension to activate.
for (let i = 0; i < 10; i++) {
await config.setIdeModeAndSyncConnection(true);
await setIdeModeAndSyncConnection(context.services.config!, true);
if (
ideClient.getConnectionStatus().status ===
IDEConnectionStatus.Connected
@@ -236,7 +254,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
'ide.enabled',
true,
);
await config.setIdeModeAndSyncConnection(true);
await setIdeModeAndSyncConnection(context.services.config!, true);
const { messageType, content } = getIdeStatusMessage(ideClient);
context.ui.addItem(
{
@@ -258,7 +276,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
'ide.enabled',
false,
);
await config.setIdeModeAndSyncConnection(false);
await setIdeModeAndSyncConnection(context.services.config!, false);
const { messageType, content } = getIdeStatusMessage(ideClient);
context.ui.addItem(
{

View File

@@ -15,34 +15,28 @@ import {
DiscoveredMCPTool,
} from '@qwen-code/qwen-code-core';
import type { MessageActionReturn } from './types.js';
import type { CallableTool } from '@google/genai';
import { Type } from '@google/genai';
import { MessageType } from '../types.js';
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
const mockAuthenticate = vi.fn();
return {
...actual,
getMCPServerStatus: vi.fn(),
getMCPDiscoveryState: vi.fn(),
MCPOAuthProvider: {
authenticate: vi.fn(),
},
MCPOAuthTokenStorage: {
MCPOAuthProvider: vi.fn(() => ({
authenticate: mockAuthenticate,
})),
MCPOAuthTokenStorage: vi.fn(() => ({
getToken: vi.fn(),
isTokenExpired: vi.fn(),
},
})),
};
});
// Helper function to check if result is a message action
const isMessageAction = (result: unknown): result is MessageActionReturn =>
result !== null &&
typeof result === 'object' &&
'type' in result &&
result.type === 'message';
// Helper function to create a mock DiscoveredMCPTool
const createMockMCPTool = (
name: string,
@@ -58,7 +52,6 @@ const createMockMCPTool = (
name,
description || `Description for ${name}`,
{ type: Type.OBJECT, properties: {} },
name, // serverToolName same as name for simplicity
);
describe('mcpCommand', () => {
@@ -68,6 +61,7 @@ describe('mcpCommand', () => {
getMcpServers: ReturnType<typeof vi.fn>;
getBlockedMcpServers: ReturnType<typeof vi.fn>;
getPromptRegistry: ReturnType<typeof vi.fn>;
getGeminiClient: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
@@ -93,6 +87,7 @@ describe('mcpCommand', () => {
getAllPrompts: vi.fn().mockReturnValue([]),
getPromptsByServer: vi.fn().mockReturnValue([]),
}),
getGeminiClient: vi.fn(),
};
mockContext = createMockCommandContext({
@@ -132,26 +127,6 @@ describe('mcpCommand', () => {
});
});
describe('no MCP servers configured', () => {
beforeEach(() => {
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue([]),
});
mockConfig.getMcpServers = vi.fn().mockReturnValue({});
});
it('should display a message with a URL when no MCP servers are configured', async () => {
const result = await mcpCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'No MCP servers configured. Please view MCP documentation in your browser: https://qwenlm.github.io/qwen-code-docs/en/tools/mcp-server/#how-to-set-up-your-mcp-server or use the cli /docs command',
});
});
});
describe('with configured MCP servers', () => {
beforeEach(() => {
const mockMcpServers = {
@@ -189,870 +164,47 @@ describe('mcpCommand', () => {
getAllTools: vi.fn().mockReturnValue(allTools),
});
const result = await mcpCommand.action!(mockContext, '');
await mcpCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Configured MCP servers:'),
});
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
// Server 1 - Connected
expect(message).toContain(
'🟢 \u001b[1mserver1\u001b[0m - Ready (2 tools)',
);
expect(message).toContain('server1_tool1');
expect(message).toContain('server1_tool2');
// Server 2 - Connected
expect(message).toContain(
'🟢 \u001b[1mserver2\u001b[0m - Ready (1 tool)',
);
expect(message).toContain('server2_tool1');
// Server 3 - Disconnected but with cached tools, so shows as Ready
expect(message).toContain(
'🟢 \u001b[1mserver3\u001b[0m - Ready (1 tool)',
);
expect(message).toContain('server3_tool1');
// Check that helpful tips are displayed when no arguments are provided
expect(message).toContain('💡 Tips:');
expect(message).toContain('/mcp desc');
expect(message).toContain('/mcp schema');
expect(message).toContain('/mcp nodesc');
expect(message).toContain('Ctrl+T');
}
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
tools: allTools.map((tool) => ({
serverName: tool.serverName,
name: tool.name,
description: tool.description,
schema: tool.schema,
})),
showTips: true,
}),
expect.any(Number),
);
});
it('should display tool descriptions when desc argument is used', async () => {
const mockMcpServers = {
server1: {
command: 'cmd1',
description: 'This is a server description',
},
};
await mcpCommand.action!(mockContext, 'desc');
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
// Mock tools with descriptions using actual DiscoveredMCPTool instances
const mockServerTools = [
createMockMCPTool('tool1', 'server1', 'This is tool 1 description'),
createMockMCPTool('tool2', 'server1', 'This is tool 2 description'),
];
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue(mockServerTools),
});
const result = await mcpCommand.action!(mockContext, 'desc');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Configured MCP servers:'),
});
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
// Check that server description is included
expect(message).toContain(
'\u001b[1mserver1\u001b[0m - Ready (2 tools)',
);
expect(message).toContain(
'\u001b[32mThis is a server description\u001b[0m',
);
// Check that tool descriptions are included
expect(message).toContain('\u001b[36mtool1\u001b[0m');
expect(message).toContain(
'\u001b[32mThis is tool 1 description\u001b[0m',
);
expect(message).toContain('\u001b[36mtool2\u001b[0m');
expect(message).toContain(
'\u001b[32mThis is tool 2 description\u001b[0m',
);
// Check that tips are NOT displayed when arguments are provided
expect(message).not.toContain('💡 Tips:');
}
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
showDescriptions: true,
showTips: false,
}),
expect.any(Number),
);
});
it('should not display descriptions when nodesc argument is used', async () => {
const mockMcpServers = {
server1: {
command: 'cmd1',
description: 'This is a server description',
},
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
const mockServerTools = [
createMockMCPTool('tool1', 'server1', 'This is tool 1 description'),
];
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue(mockServerTools),
});
const result = await mcpCommand.action!(mockContext, 'nodesc');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Configured MCP servers:'),
});
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
// Check that descriptions are not included
expect(message).not.toContain('This is a server description');
expect(message).not.toContain('This is tool 1 description');
expect(message).toContain('\u001b[36mtool1\u001b[0m');
// Check that tips are NOT displayed when arguments are provided
expect(message).not.toContain('💡 Tips:');
}
});
it('should indicate when a server has no tools', async () => {
const mockMcpServers = {
server1: { command: 'cmd1' },
server2: { command: 'cmd2' },
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
// Setup server statuses
vi.mocked(getMCPServerStatus).mockImplementation((serverName) => {
if (serverName === 'server1') return MCPServerStatus.CONNECTED;
if (serverName === 'server2') return MCPServerStatus.DISCONNECTED;
return MCPServerStatus.DISCONNECTED;
});
// Mock tools - only server1 has tools
const mockServerTools = [createMockMCPTool('server1_tool1', 'server1')];
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue(mockServerTools),
});
const result = await mcpCommand.action!(mockContext, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain(
'🟢 \u001b[1mserver1\u001b[0m - Ready (1 tool)',
);
expect(message).toContain('\u001b[36mserver1_tool1\u001b[0m');
expect(message).toContain(
'🔴 \u001b[1mserver2\u001b[0m - Disconnected (0 tools cached)',
);
expect(message).toContain('No tools or prompts available');
}
});
it('should show startup indicator when servers are connecting', async () => {
const mockMcpServers = {
server1: { command: 'cmd1' },
server2: { command: 'cmd2' },
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
// Setup server statuses with one connecting
vi.mocked(getMCPServerStatus).mockImplementation((serverName) => {
if (serverName === 'server1') return MCPServerStatus.CONNECTED;
if (serverName === 'server2') return MCPServerStatus.CONNECTING;
return MCPServerStatus.DISCONNECTED;
});
// Setup discovery state as in progress
vi.mocked(getMCPDiscoveryState).mockReturnValue(
MCPDiscoveryState.IN_PROGRESS,
);
// Mock tools
const mockServerTools = [
createMockMCPTool('server1_tool1', 'server1'),
createMockMCPTool('server2_tool1', 'server2'),
];
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue(mockServerTools),
});
const result = await mcpCommand.action!(mockContext, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
// Check that startup indicator is shown
expect(message).toContain(
'⏳ MCP servers are starting up (1 initializing)...',
);
expect(message).toContain(
'Note: First startup may take longer. Tool availability will update automatically.',
);
// Check server statuses
expect(message).toContain(
'🟢 \u001b[1mserver1\u001b[0m - Ready (1 tool)',
);
expect(message).toContain(
'🔄 \u001b[1mserver2\u001b[0m - Starting... (first startup may take longer) (tools and prompts will appear when ready)',
);
}
});
it('should display the extension name for servers from extensions', async () => {
const mockMcpServers = {
server1: { command: 'cmd1', extensionName: 'my-extension' },
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
const result = await mcpCommand.action!(mockContext, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('server1 (from my-extension)');
}
});
it('should display blocked MCP servers', async () => {
mockConfig.getMcpServers = vi.fn().mockReturnValue({});
const blockedServers = [
{ name: 'blocked-server', extensionName: 'my-extension' },
];
mockConfig.getBlockedMcpServers = vi.fn().mockReturnValue(blockedServers);
const result = await mcpCommand.action!(mockContext, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain(
'🔴 \u001b[1mblocked-server (from my-extension)\u001b[0m - Blocked',
);
}
});
it('should display both active and blocked servers correctly', async () => {
const mockMcpServers = {
server1: { command: 'cmd1', extensionName: 'my-extension' },
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
const blockedServers = [
{ name: 'blocked-server', extensionName: 'another-extension' },
];
mockConfig.getBlockedMcpServers = vi.fn().mockReturnValue(blockedServers);
const result = await mcpCommand.action!(mockContext, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('server1 (from my-extension)');
expect(message).toContain(
'🔴 \u001b[1mblocked-server (from another-extension)\u001b[0m - Blocked',
);
}
});
});
describe('schema functionality', () => {
it('should display tool schemas when schema argument is used', async () => {
const mockMcpServers = {
server1: {
command: 'cmd1',
description: 'This is a server description',
},
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
// Create tools with parameter schemas
const mockCallableTool1: CallableTool = {
callTool: vi.fn(),
tool: vi.fn(),
} as unknown as CallableTool;
const mockCallableTool2: CallableTool = {
callTool: vi.fn(),
tool: vi.fn(),
} as unknown as CallableTool;
const tool1 = new DiscoveredMCPTool(
mockCallableTool1,
'server1',
'tool1',
'This is tool 1 description',
{
type: Type.OBJECT,
properties: {
param1: { type: Type.STRING, description: 'First parameter' },
},
required: ['param1'],
},
'tool1',
);
const tool2 = new DiscoveredMCPTool(
mockCallableTool2,
'server1',
'tool2',
'This is tool 2 description',
{
type: Type.OBJECT,
properties: {
param2: { type: Type.NUMBER, description: 'Second parameter' },
},
required: ['param2'],
},
'tool2',
);
const mockServerTools = [tool1, tool2];
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue(mockServerTools),
});
const result = await mcpCommand.action!(mockContext, 'schema');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Configured MCP servers:'),
});
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
// Check that server description is included
expect(message).toContain('Ready (2 tools)');
expect(message).toContain('This is a server description');
// Check that tool descriptions and schemas are included
expect(message).toContain('This is tool 1 description');
expect(message).toContain('Parameters:');
expect(message).toContain('param1');
expect(message).toContain('STRING');
expect(message).toContain('This is tool 2 description');
expect(message).toContain('param2');
expect(message).toContain('NUMBER');
}
});
it('should handle tools without parameter schemas gracefully', async () => {
const mockMcpServers = {
server1: { command: 'cmd1' },
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
// Mock tools without parameter schemas
const mockServerTools = [
createMockMCPTool('tool1', 'server1', 'Tool without schema'),
];
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue(mockServerTools),
});
const result = await mcpCommand.action!(mockContext, 'schema');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Configured MCP servers:'),
});
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('tool1');
expect(message).toContain('Tool without schema');
// Should not crash when parameterSchema is undefined
}
});
});
describe('argument parsing', () => {
beforeEach(() => {
const mockMcpServers = {
server1: {
command: 'cmd1',
description: 'Server description',
},
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
const mockServerTools = [
createMockMCPTool('tool1', 'server1', 'Test tool'),
];
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue(mockServerTools),
});
});
it('should handle "descriptions" as alias for "desc"', async () => {
const result = await mcpCommand.action!(mockContext, 'descriptions');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('Test tool');
expect(message).toContain('Server description');
}
});
it('should handle "nodescriptions" as alias for "nodesc"', async () => {
const result = await mcpCommand.action!(mockContext, 'nodescriptions');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).not.toContain('Test tool');
expect(message).not.toContain('Server description');
expect(message).toContain('\u001b[36mtool1\u001b[0m');
}
});
it('should handle mixed case arguments', async () => {
const result = await mcpCommand.action!(mockContext, 'DESC');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('Test tool');
expect(message).toContain('Server description');
}
});
it('should handle multiple arguments - "schema desc"', async () => {
const result = await mcpCommand.action!(mockContext, 'schema desc');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('Test tool');
expect(message).toContain('Server description');
expect(message).toContain('Parameters:');
}
});
it('should handle multiple arguments - "desc schema"', async () => {
const result = await mcpCommand.action!(mockContext, 'desc schema');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('Test tool');
expect(message).toContain('Server description');
expect(message).toContain('Parameters:');
}
});
it('should handle "schema" alone showing descriptions', async () => {
const result = await mcpCommand.action!(mockContext, 'schema');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('Test tool');
expect(message).toContain('Server description');
expect(message).toContain('Parameters:');
}
});
it('should handle "nodesc" overriding "schema" - "schema nodesc"', async () => {
const result = await mcpCommand.action!(mockContext, 'schema nodesc');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).not.toContain('Test tool');
expect(message).not.toContain('Server description');
expect(message).toContain('Parameters:'); // Schema should still show
expect(message).toContain('\u001b[36mtool1\u001b[0m');
}
});
it('should handle "nodesc" overriding "desc" - "desc nodesc"', async () => {
const result = await mcpCommand.action!(mockContext, 'desc nodesc');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).not.toContain('Test tool');
expect(message).not.toContain('Server description');
expect(message).not.toContain('Parameters:');
expect(message).toContain('\u001b[36mtool1\u001b[0m');
}
});
it('should handle "nodesc" overriding both "desc" and "schema" - "desc schema nodesc"', async () => {
const result = await mcpCommand.action!(
mockContext,
'desc schema nodesc',
);
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).not.toContain('Test tool');
expect(message).not.toContain('Server description');
expect(message).toContain('Parameters:'); // Schema should still show
expect(message).toContain('\u001b[36mtool1\u001b[0m');
}
});
it('should handle extra whitespace in arguments', async () => {
const result = await mcpCommand.action!(mockContext, ' desc schema ');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('Test tool');
expect(message).toContain('Server description');
expect(message).toContain('Parameters:');
}
});
it('should handle empty arguments gracefully', async () => {
const result = await mcpCommand.action!(mockContext, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).not.toContain('Test tool');
expect(message).not.toContain('Server description');
expect(message).not.toContain('Parameters:');
expect(message).toContain('\u001b[36mtool1\u001b[0m');
}
});
it('should handle unknown arguments gracefully', async () => {
const result = await mcpCommand.action!(mockContext, 'unknown arg');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).not.toContain('Test tool');
expect(message).not.toContain('Server description');
expect(message).not.toContain('Parameters:');
expect(message).toContain('\u001b[36mtool1\u001b[0m');
}
});
});
describe('edge cases', () => {
it('should handle empty server names gracefully', async () => {
const mockMcpServers = {
'': { command: 'cmd1' }, // Empty server name
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue([]),
});
const result = await mcpCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Configured MCP servers:'),
});
});
it('should handle servers with special characters in names', async () => {
const mockMcpServers = {
'server-with-dashes': { command: 'cmd1' },
server_with_underscores: { command: 'cmd2' },
'server.with.dots': { command: 'cmd3' },
};
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue([]),
});
const result = await mcpCommand.action!(mockContext, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
const message = result.content;
expect(message).toContain('server-with-dashes');
expect(message).toContain('server_with_underscores');
expect(message).toContain('server.with.dots');
}
});
});
describe('auth subcommand', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should list OAuth-enabled servers when no server name is provided', async () => {
const context = createMockCommandContext({
services: {
config: {
getMcpServers: vi.fn().mockReturnValue({
'oauth-server': { oauth: { enabled: true } },
'regular-server': {},
'another-oauth': { oauth: { enabled: true } },
}),
},
},
});
const authCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'auth',
);
expect(authCommand).toBeDefined();
const result = await authCommand!.action!(context, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
expect(result.messageType).toBe('info');
expect(result.content).toContain('oauth-server');
expect(result.content).toContain('another-oauth');
expect(result.content).not.toContain('regular-server');
expect(result.content).toContain('/mcp auth <server-name>');
}
});
it('should show message when no OAuth servers are configured', async () => {
const context = createMockCommandContext({
services: {
config: {
getMcpServers: vi.fn().mockReturnValue({
'regular-server': {},
}),
},
},
});
const authCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'auth',
);
const result = await authCommand!.action!(context, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
expect(result.messageType).toBe('info');
expect(result.content).toBe(
'No MCP servers configured with OAuth authentication.',
);
}
});
it('should authenticate with a specific server', async () => {
const mockToolRegistry = {
discoverToolsForServer: vi.fn(),
};
const mockGeminiClient = {
setTools: vi.fn(),
};
const context = createMockCommandContext({
services: {
config: {
getMcpServers: vi.fn().mockReturnValue({
'test-server': {
url: 'http://localhost:3000',
oauth: { enabled: true },
},
}),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
getPromptRegistry: vi.fn().mockResolvedValue({
removePromptsByServer: vi.fn(),
}),
},
},
});
// Mock the reloadCommands function
context.ui.reloadCommands = vi.fn();
const { MCPOAuthProvider } = await import('@qwen-code/qwen-code-core');
const authCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'auth',
);
const result = await authCommand!.action!(context, 'test-server');
expect(MCPOAuthProvider.authenticate).toHaveBeenCalledWith(
'test-server',
{ enabled: true },
'http://localhost:3000',
);
expect(mockToolRegistry.discoverToolsForServer).toHaveBeenCalledWith(
'test-server',
);
expect(mockGeminiClient.setTools).toHaveBeenCalled();
expect(context.ui.reloadCommands).toHaveBeenCalledTimes(1);
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
expect(result.messageType).toBe('info');
expect(result.content).toContain('Successfully authenticated');
}
});
it('should handle authentication errors', async () => {
const context = createMockCommandContext({
services: {
config: {
getMcpServers: vi.fn().mockReturnValue({
'test-server': { oauth: { enabled: true } },
}),
},
},
});
const { MCPOAuthProvider } = await import('@qwen-code/qwen-code-core');
(
MCPOAuthProvider.authenticate as ReturnType<typeof vi.fn>
).mockRejectedValue(new Error('Auth failed'));
const authCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'auth',
);
const result = await authCommand!.action!(context, 'test-server');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
expect(result.messageType).toBe('error');
expect(result.content).toContain('Failed to authenticate');
expect(result.content).toContain('Auth failed');
}
});
it('should handle non-existent server', async () => {
const context = createMockCommandContext({
services: {
config: {
getMcpServers: vi.fn().mockReturnValue({
'existing-server': {},
}),
},
},
});
const authCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'auth',
);
const result = await authCommand!.action!(context, 'non-existent');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
expect(result.messageType).toBe('error');
expect(result.content).toContain("MCP server 'non-existent' not found");
}
});
});
describe('refresh subcommand', () => {
it('should refresh the list of tools and display the status', async () => {
const mockToolRegistry = {
discoverMcpTools: vi.fn(),
restartMcpServers: vi.fn(),
getAllTools: vi.fn().mockReturnValue([]),
};
const mockGeminiClient = {
setTools: vi.fn(),
};
const context = createMockCommandContext({
services: {
config: {
getMcpServers: vi.fn().mockReturnValue({ server1: {} }),
getBlockedMcpServers: vi.fn().mockReturnValue([]),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
getPromptRegistry: vi.fn().mockResolvedValue({
getPromptsByServer: vi.fn().mockReturnValue([]),
}),
},
},
});
// Mock the reloadCommands function, which is new logic.
context.ui.reloadCommands = vi.fn();
const refreshCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'refresh',
);
expect(refreshCommand).toBeDefined();
const result = await refreshCommand!.action!(context, '');
expect(context.ui.addItem).toHaveBeenCalledWith(
{
type: 'info',
text: 'Restarting MCP servers...',
},
await mcpCommand.action!(mockContext, 'nodesc');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
showDescriptions: false,
showTips: false,
}),
expect.any(Number),
);
expect(mockToolRegistry.restartMcpServers).toHaveBeenCalled();
expect(mockGeminiClient.setTools).toHaveBeenCalled();
expect(context.ui.reloadCommands).toHaveBeenCalledTimes(1);
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
expect(result.messageType).toBe('info');
expect(result.content).toContain('Configured MCP servers:');
}
});
it('should show an error if config is not available', async () => {
const contextWithoutConfig = createMockCommandContext({
services: {
config: null,
},
});
const refreshCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'refresh',
);
const result = await refreshCommand!.action!(contextWithoutConfig, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
});
});
it('should show an error if tool registry is not available', async () => {
mockConfig.getToolRegistry = vi.fn().mockReturnValue(undefined);
const refreshCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'refresh',
);
const result = await refreshCommand!.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Could not retrieve tool registry.',
});
});
});
});

View File

@@ -18,300 +18,11 @@ import {
getMCPServerStatus,
MCPDiscoveryState,
MCPServerStatus,
mcpServerRequiresOAuth,
getErrorMessage,
MCPOAuthTokenStorage,
} from '@qwen-code/qwen-code-core';
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 = 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://qwenlm.github.io/qwen-code-docs/en/tools/mcp-server/#how-to-set-up-your-mcp-server';
return {
type: 'message',
messageType: 'info',
content: `No MCP servers configured. Please view MCP documentation in your browser: ${docsUrl} or use the cli /docs command`,
};
}
// 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 originalStatus = getMCPServerStatus(serverName);
const hasCachedItems = serverTools.length > 0 || serverPrompts.length > 0;
// If the server is "disconnected" but has prompts or cached tools, display it as Ready
// by using CONNECTED as the display status.
const status =
originalStatus === MCPServerStatus.DISCONNECTED && hasCachedItems
? MCPServerStatus.CONNECTED
: originalStatus;
// 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 (originalStatus === MCPServerStatus.DISCONNECTED && needsAuthHint) {
message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}`;
}
message += '\n';
} else if (
originalStatus === 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,
};
};
import { appEvents, AppEvent } from '../../utils/events.js';
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
const authCommand: SlashCommand = {
name: 'auth',
@@ -367,6 +78,12 @@ const authCommand: SlashCommand = {
// Always attempt OAuth authentication, even if not explicitly configured
// The authentication process will discover OAuth requirements automatically
const displayListener = (message: string) => {
context.ui.addItem({ type: 'info', text: message }, Date.now());
};
appEvents.on(AppEvent.OauthDisplayMessage, displayListener);
try {
context.ui.addItem(
{
@@ -384,12 +101,13 @@ const authCommand: SlashCommand = {
oauthConfig = { enabled: false };
}
// Pass the MCP server URL for OAuth discovery
const mcpServerUrl = server.httpUrl || server.url;
await MCPOAuthProvider.authenticate(
const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage());
await authProvider.authenticate(
serverName,
oauthConfig,
mcpServerUrl,
appEvents,
);
context.ui.addItem(
@@ -432,6 +150,8 @@ const authCommand: SlashCommand = {
messageType: 'error',
content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`,
};
} finally {
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
}
},
completion: async (context: CommandContext, partialArg: string) => {
@@ -449,7 +169,28 @@ const listCommand: SlashCommand = {
name: 'list',
description: 'List configured MCP servers and tools',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args: string) => {
action: async (
context: CommandContext,
args: string,
): Promise<void | MessageActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
};
}
const toolRegistry = config.getToolRegistry();
if (!toolRegistry) {
return {
type: 'message',
messageType: 'error',
content: 'Could not retrieve tool registry.',
};
}
const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean);
const hasDesc =
@@ -459,14 +200,79 @@ const listCommand: SlashCommand = {
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 mcpServers = config.getMcpServers() || {};
const serverNames = Object.keys(mcpServers);
const blockedMcpServers = config.getBlockedMcpServers() || [];
const connectingServers = serverNames.filter(
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
);
const discoveryState = getMCPDiscoveryState();
const discoveryInProgress =
discoveryState === MCPDiscoveryState.IN_PROGRESS ||
connectingServers.length > 0;
const allTools = toolRegistry.getAllTools();
const mcpTools = allTools.filter(
(tool) => tool instanceof DiscoveredMCPTool,
) as DiscoveredMCPTool[];
const promptRegistry = await config.getPromptRegistry();
const mcpPrompts = promptRegistry
.getAllPrompts()
.filter(
(prompt) =>
'serverName' in prompt &&
serverNames.includes(prompt.serverName as string),
) as DiscoveredMCPPrompt[];
const authStatus: HistoryItemMcpStatus['authStatus'] = {};
const tokenStorage = new MCPOAuthTokenStorage();
for (const serverName of serverNames) {
const server = mcpServers[serverName];
if (server.oauth?.enabled) {
const creds = await tokenStorage.getCredentials(serverName);
if (creds) {
if (creds.token.expiresAt && creds.token.expiresAt < Date.now()) {
authStatus[serverName] = 'expired';
} else {
authStatus[serverName] = 'authenticated';
}
} else {
authStatus[serverName] = 'unauthenticated';
}
} else {
authStatus[serverName] = 'not-configured';
}
}
const mcpStatusItem: HistoryItemMcpStatus = {
type: MessageType.MCP_STATUS,
servers: mcpServers,
tools: mcpTools.map((tool) => ({
serverName: tool.serverName,
name: tool.name,
description: tool.description,
schema: tool.schema,
})),
prompts: mcpPrompts.map((prompt) => ({
serverName: prompt.serverName as string,
name: prompt.name,
description: prompt.description,
})),
authStatus,
blockedServers: blockedMcpServers,
discoveryInProgress,
connectingServers,
showDescriptions,
showSchema,
showTips,
};
context.ui.addItem(mcpStatusItem, Date.now());
},
};
@@ -476,7 +282,7 @@ const refreshCommand: SlashCommand = {
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
): Promise<SlashCommandActionReturn> => {
): Promise<void | SlashCommandActionReturn> => {
const { config } = context.services;
if (!config) {
return {
@@ -514,7 +320,7 @@ const refreshCommand: SlashCommand = {
// Reload the slash commands to reflect the changes.
context.ui.reloadCommands();
return getMcpStatus(context, false, false, false);
return listCommand.action!(context, '');
},
};
@@ -525,7 +331,10 @@ export const mcpCommand: SlashCommand = {
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, authCommand, refreshCommand],
// Default action when no subcommand is provided
action: async (context: CommandContext, args: string) =>
action: async (
context: CommandContext,
args: string,
): Promise<void | SlashCommandActionReturn> =>
// If no subcommand, run the list command
listCommand.action!(context, args),
};

View File

@@ -15,6 +15,7 @@ import {
getErrorMessage,
loadServerHierarchicalMemory,
type FileDiscoveryService,
type LoadServerHierarchicalMemoryResponse,
} from '@qwen-code/qwen-code-core';
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
@@ -225,6 +226,7 @@ describe('memoryCommand', () => {
ignore: [],
include: [],
}),
getFolderTrust: () => false,
};
mockContext = createMockCommandContext({
@@ -243,7 +245,7 @@ describe('memoryCommand', () => {
it('should display success message when memory is refreshed with content', async () => {
if (!refreshCommand.action) throw new Error('Command has no action');
const refreshResult = {
const refreshResult: LoadServerHierarchicalMemoryResponse = {
memoryContent: 'new memory content',
fileCount: 2,
};

View File

@@ -266,6 +266,7 @@ export const memoryCommand: SlashCommand = {
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
config.getFolderTrust(),
context.services.settings.merged.context?.importFormat ||
'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),

View File

@@ -17,11 +17,7 @@ import * as availableModelsModule from '../models/availableModels.js';
// Mock the availableModels module
vi.mock('../models/availableModels.js', () => ({
AVAILABLE_MODELS_QWEN: [
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
{ id: 'qwen-vl-max-latest', label: 'qwen-vl-max', isVision: true },
],
getOpenAIAvailableModelFromEnv: vi.fn(),
getAvailableModelsForAuthType: vi.fn(),
}));
// Helper function to create a mock config
@@ -35,8 +31,8 @@ function createMockConfig(
describe('modelCommand', () => {
let mockContext: CommandContext;
const mockGetOpenAIAvailableModelFromEnv = vi.mocked(
availableModelsModule.getOpenAIAvailableModelFromEnv,
const mockGetAvailableModelsForAuthType = vi.mocked(
availableModelsModule.getAvailableModelsForAuthType,
);
beforeEach(() => {
@@ -91,6 +87,10 @@ describe('modelCommand', () => {
});
it('should return dialog action for QWEN_OAUTH auth type', async () => {
mockGetAvailableModelsForAuthType.mockReturnValue([
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
]);
const mockConfig = createMockConfig({
model: 'test-model',
authType: AuthType.QWEN_OAUTH,
@@ -106,10 +106,9 @@ describe('modelCommand', () => {
});
it('should return dialog action for USE_OPENAI auth type when model is available', async () => {
mockGetOpenAIAvailableModelFromEnv.mockReturnValue({
id: 'gpt-4',
label: 'gpt-4',
});
mockGetAvailableModelsForAuthType.mockReturnValue([
{ id: 'gpt-4', label: 'gpt-4' },
]);
const mockConfig = createMockConfig({
model: 'test-model',
@@ -126,7 +125,7 @@ describe('modelCommand', () => {
});
it('should return error for USE_OPENAI auth type when no model is available', async () => {
mockGetOpenAIAvailableModelFromEnv.mockReturnValue(null);
mockGetAvailableModelsForAuthType.mockReturnValue([]);
const mockConfig = createMockConfig({
model: 'test-model',
@@ -145,6 +144,8 @@ describe('modelCommand', () => {
});
it('should return error for unsupported auth types', async () => {
mockGetAvailableModelsForAuthType.mockReturnValue([]);
const mockConfig = createMockConfig({
model: 'test-model',
authType: 'UNSUPPORTED_AUTH_TYPE' as AuthType,

View File

@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { AuthType } from '@qwen-code/qwen-code-core';
import type {
SlashCommand,
CommandContext,
@@ -12,26 +11,7 @@ import type {
MessageActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import {
AVAILABLE_MODELS_QWEN,
getOpenAIAvailableModelFromEnv,
type AvailableModel,
} from '../models/availableModels.js';
function getAvailableModelsForAuthType(authType: AuthType): AvailableModel[] {
switch (authType) {
case AuthType.QWEN_OAUTH:
return AVAILABLE_MODELS_QWEN;
case AuthType.USE_OPENAI: {
const openAIModel = getOpenAIAvailableModelFromEnv();
return openAIModel ? [openAIModel] : [];
}
default:
// For other auth types, return empty array for now
// This can be expanded later according to the design doc
return [];
}
}
import { getAvailableModelsForAuthType } from '../models/availableModels.js';
export const modelCommand: SlashCommand = {
name: 'model',

View File

@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { permissionsCommand } from './permissionsCommand.js';
import { type CommandContext, CommandKind } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('permissionsCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should have the correct name and description', () => {
expect(permissionsCommand.name).toBe('permissions');
expect(permissionsCommand.description).toBe('Manage folder trust settings');
});
it('should be a built-in command', () => {
expect(permissionsCommand.kind).toBe(CommandKind.BUILT_IN);
});
it('should return an action to open the permissions dialog', () => {
const actionResult = permissionsCommand.action?.(mockContext, '');
expect(actionResult).toEqual({
type: 'dialog',
dialog: 'permissions',
});
});
});

View File

@@ -7,12 +7,12 @@
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const privacyCommand: SlashCommand = {
name: 'privacy',
description: 'display the privacy notice',
export const permissionsCommand: SlashCommand = {
name: 'permissions',
description: 'Manage folder trust settings',
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'privacy',
dialog: 'permissions',
}),
};

View File

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

View File

@@ -100,6 +100,7 @@ export const summaryCommand: SlashCommand = {
],
{},
new AbortController().signal,
config.getModel(),
);
// Extract text from response

View File

@@ -61,9 +61,11 @@ describe('toolsCommand', () => {
await toolsCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining('No tools available'),
}),
{
type: MessageType.TOOLS_LIST,
tools: [],
showDescriptions: false,
},
expect.any(Number),
);
});
@@ -80,10 +82,12 @@ describe('toolsCommand', () => {
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');
const [message] = (mockContext.ui.addItem as vi.Mock).mock.calls[0];
expect(message.type).toBe(MessageType.TOOLS_LIST);
expect(message.showDescriptions).toBe(false);
expect(message.tools).toHaveLength(2);
expect(message.tools[0].displayName).toBe('File Reader');
expect(message.tools[1].displayName).toBe('Code Editor');
});
it('should list tools with descriptions when "desc" arg is passed', async () => {
@@ -98,8 +102,13 @@ describe('toolsCommand', () => {
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.');
const [message] = (mockContext.ui.addItem as vi.Mock).mock.calls[0];
expect(message.type).toBe(MessageType.TOOLS_LIST);
expect(message.showDescriptions).toBe(true);
expect(message.tools).toHaveLength(2);
expect(message.tools[0].description).toBe(
'Reads files from the local system.',
);
expect(message.tools[1].description).toBe('Edits code files.');
});
});

View File

@@ -9,7 +9,7 @@ import {
type SlashCommand,
CommandKind,
} from './types.js';
import { MessageType } from '../types.js';
import { MessageType, type HistoryItemToolsList } from '../types.js';
export const toolsCommand: SlashCommand = {
name: 'tools',
@@ -40,32 +40,16 @@ export const toolsCommand: SlashCommand = {
// Filter out MCP tools by checking for the absence of a serverName property
const geminiTools = tools.filter((tool) => !('serverName' in tool));
let message = 'Available Qwen Code tools:\n\n';
const toolsListItem: HistoryItemToolsList = {
type: MessageType.TOOLS_LIST,
tools: geminiTools.map((tool) => ({
name: tool.name,
displayName: tool.displayName,
description: tool.description,
})),
showDescriptions: useShowDescriptions,
};
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());
context.ui.addItem(toolsListItem, Date.now());
},
};

View File

@@ -4,13 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { type ReactNode } from 'react';
import type { ReactNode } from 'react';
import type { Content, PartListUnion } from '@google/genai';
import type { HistoryItemWithoutId, HistoryItem } from '../types.js';
import type { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
import type {
HistoryItemWithoutId,
HistoryItem,
ConfirmationRequest,
} from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import type {
ExtensionUpdateAction,
ExtensionUpdateStatus,
} from '../state/extensions.js';
// Grouped dependencies for clarity and easier mocking
export interface CommandContext {
@@ -61,6 +69,9 @@ export interface CommandContext {
toggleVimEnabled: () => Promise<boolean>;
setGeminiMdFileCount: (count: number) => void;
reloadCommands: () => void;
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
};
// Session-specific data
session: {
@@ -114,11 +125,11 @@ export interface OpenDialogActionReturn {
| 'auth'
| 'theme'
| 'editor'
| 'privacy'
| 'settings'
| 'model'
| 'subagent_create'
| 'subagent_list';
| 'subagent_list'
| 'permissions';
}
/**
@@ -186,6 +197,7 @@ export interface SlashCommand {
name: string;
altNames?: string[];
description: string;
hidden?: boolean;
kind: CommandKind;

View File

@@ -6,7 +6,7 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
interface AboutBoxProps {
@@ -30,77 +30,77 @@ export const AboutBox: React.FC<AboutBoxProps> = ({
}) => (
<Box
borderStyle="round"
borderColor={Colors.Gray}
borderColor={theme.border.default}
flexDirection="column"
padding={1}
marginY={1}
width="100%"
>
<Box marginBottom={1}>
<Text bold color={Colors.AccentPurple}>
<Text bold color={theme.text.accent}>
About Qwen Code
</Text>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
<Text bold color={theme.text.link}>
CLI Version
</Text>
</Box>
<Box>
<Text>{cliVersion}</Text>
<Text color={theme.text.primary}>{cliVersion}</Text>
</Box>
</Box>
{GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
<Text bold color={theme.text.link}>
Git Commit
</Text>
</Box>
<Box>
<Text>{GIT_COMMIT_INFO}</Text>
<Text color={theme.text.primary}>{GIT_COMMIT_INFO}</Text>
</Box>
</Box>
)}
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
<Text bold color={theme.text.link}>
Model
</Text>
</Box>
<Box>
<Text>{modelVersion}</Text>
<Text color={theme.text.primary}>{modelVersion}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
<Text bold color={theme.text.link}>
Sandbox
</Text>
</Box>
<Box>
<Text>{sandboxEnv}</Text>
<Text color={theme.text.primary}>{sandboxEnv}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
<Text bold color={theme.text.link}>
OS
</Text>
</Box>
<Box>
<Text>{osVersion}</Text>
<Text color={theme.text.primary}>{osVersion}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
<Text bold color={theme.text.link}>
Auth Method
</Text>
</Box>
<Box>
<Text>
<Text color={theme.text.primary}>
{selectedAuthType.startsWith('oauth') ? 'OAuth' : selectedAuthType}
</Text>
</Box>
@@ -108,24 +108,24 @@ export const AboutBox: React.FC<AboutBoxProps> = ({
{gcpProject && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
<Text bold color={theme.text.link}>
GCP Project
</Text>
</Box>
<Box>
<Text>{gcpProject}</Text>
<Text color={theme.text.primary}>{gcpProject}</Text>
</Box>
</Box>
)}
{ideClient && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={Colors.LightBlue}>
<Text bold color={theme.text.link}>
IDE Client
</Text>
</Box>
<Box>
<Text>{ideClient}</Text>
<Text color={theme.text.primary}>{ideClient}</Text>
</Box>
</Box>
)}

View File

@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { AnsiOutputText } from './AnsiOutput.js';
import type { AnsiOutput, AnsiToken } from '@qwen-code/qwen-code-core';
// Helper to create a valid AnsiToken with default values
const createAnsiToken = (overrides: Partial<AnsiToken>): AnsiToken => ({
text: '',
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
fg: '#ffffff',
bg: '#000000',
...overrides,
});
describe('<AnsiOutputText />', () => {
it('renders a simple AnsiOutput object correctly', () => {
const data: AnsiOutput = [
[
createAnsiToken({ text: 'Hello, ' }),
createAnsiToken({ text: 'world!' }),
],
];
const { lastFrame } = render(<AnsiOutputText data={data} />);
expect(lastFrame()).toBe('Hello, world!');
});
it('correctly applies all the styles', () => {
const data: AnsiOutput = [
[
createAnsiToken({ text: 'Bold', bold: true }),
createAnsiToken({ text: 'Italic', italic: true }),
createAnsiToken({ text: 'Underline', underline: true }),
createAnsiToken({ text: 'Dim', dim: true }),
createAnsiToken({ text: 'Inverse', inverse: true }),
],
];
// Note: ink-testing-library doesn't render styles, so we can only check the text.
// We are testing that it renders without crashing.
const { lastFrame } = render(<AnsiOutputText data={data} />);
expect(lastFrame()).toBe('BoldItalicUnderlineDimInverse');
});
it('correctly applies foreground and background colors', () => {
const data: AnsiOutput = [
[
createAnsiToken({ text: 'Red FG', fg: '#ff0000' }),
createAnsiToken({ text: 'Blue BG', bg: '#0000ff' }),
],
];
// Note: ink-testing-library doesn't render colors, so we can only check the text.
// We are testing that it renders without crashing.
const { lastFrame } = render(<AnsiOutputText data={data} />);
expect(lastFrame()).toBe('Red FGBlue BG');
});
it('handles empty lines and empty tokens', () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'First line' })],
[],
[createAnsiToken({ text: 'Third line' })],
[createAnsiToken({ text: '' })],
];
const { lastFrame } = render(<AnsiOutputText data={data} />);
const output = lastFrame();
expect(output).toBeDefined();
const lines = output!.split('\n');
expect(lines[0]).toBe('First line');
expect(lines[1]).toBe('Third line');
});
it('respects the availableTerminalHeight prop and slices the lines correctly', () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'Line 1' })],
[createAnsiToken({ text: 'Line 2' })],
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
const { lastFrame } = render(
<AnsiOutputText data={data} availableTerminalHeight={2} />,
);
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).toContain('Line 4');
});
it('renders a large AnsiOutput object without crashing', () => {
const largeData: AnsiOutput = [];
for (let i = 0; i < 1000; i++) {
largeData.push([createAnsiToken({ text: `Line ${i}` })]);
}
const { lastFrame } = render(<AnsiOutputText data={largeData} />);
// We are just checking that it renders something without crashing.
expect(lastFrame()).toBeDefined();
});
});

View File

@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import type {
AnsiLine,
AnsiOutput,
AnsiToken,
} from '@qwen-code/qwen-code-core';
const DEFAULT_HEIGHT = 24;
interface AnsiOutputProps {
data: AnsiOutput;
availableTerminalHeight?: number;
}
export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
data,
availableTerminalHeight,
}) => {
const lastLines = data.slice(
-(availableTerminalHeight && availableTerminalHeight > 0
? availableTerminalHeight
: DEFAULT_HEIGHT),
);
return lastLines.map((line: AnsiLine, lineIndex: number) => (
<Text key={lineIndex}>
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.inverse ? token.bg : token.fg}
backgroundColor={token.inverse ? token.fg : token.bg}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
));
};

View File

@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box } from 'ink';
import { Header } from './Header.js';
import { Tips } from './Tips.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
interface AppHeaderProps {
version: string;
}
export const AppHeader = ({ version }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
const { nightly } = useUIState();
return (
<Box flexDirection="column">
{!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
<Header version={version} nightly={nightly} />
)}
{!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
<Tips config={config} />
)}
</Box>
);
};

View File

@@ -6,7 +6,7 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
interface AutoAcceptIndicatorProps {
@@ -22,17 +22,17 @@ export const AutoAcceptIndicator: React.FC<AutoAcceptIndicatorProps> = ({
switch (approvalMode) {
case ApprovalMode.PLAN:
textColor = Colors.AccentBlue;
textColor = theme.status.success;
textContent = 'plan mode';
subText = ' (shift + tab to cycle)';
break;
case ApprovalMode.AUTO_EDIT:
textColor = Colors.AccentGreen;
textColor = theme.status.warning;
textContent = 'auto-accept edits';
subText = ' (shift + tab to cycle)';
break;
case ApprovalMode.YOLO:
textColor = Colors.AccentRed;
textColor = theme.status.error;
textContent = 'YOLO mode';
subText = ' (shift + tab to cycle)';
break;
@@ -45,7 +45,7 @@ export const AutoAcceptIndicator: React.FC<AutoAcceptIndicatorProps> = ({
<Box>
<Text color={textColor}>
{textContent}
{subText && <Text color={Colors.Gray}>{subText}</Text>}
{subText && <Text color={theme.text.secondary}>{subText}</Text>}
</Text>
</Box>
);

View File

@@ -0,0 +1,434 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { render } from 'ink-testing-library';
import { Text } from 'ink';
import { Composer } from './Composer.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import {
UIActionsContext,
type UIActions,
} from '../contexts/UIActionsContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
// Mock VimModeContext hook
vi.mock('../contexts/VimModeContext.js', () => ({
useVimMode: vi.fn(() => ({
vimEnabled: false,
vimMode: 'NORMAL',
})),
}));
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { StreamingState } from '../types.js';
// Mock child components
vi.mock('./LoadingIndicator.js', () => ({
LoadingIndicator: ({ thought }: { thought?: string }) => (
<Text>LoadingIndicator{thought ? `: ${thought}` : ''}</Text>
),
}));
vi.mock('./ContextSummaryDisplay.js', () => ({
ContextSummaryDisplay: () => <Text>ContextSummaryDisplay</Text>,
}));
vi.mock('./AutoAcceptIndicator.js', () => ({
AutoAcceptIndicator: () => <Text>AutoAcceptIndicator</Text>,
}));
vi.mock('./ShellModeIndicator.js', () => ({
ShellModeIndicator: () => <Text>ShellModeIndicator</Text>,
}));
vi.mock('./DetailedMessagesDisplay.js', () => ({
DetailedMessagesDisplay: () => <Text>DetailedMessagesDisplay</Text>,
}));
vi.mock('./InputPrompt.js', () => ({
InputPrompt: () => <Text>InputPrompt</Text>,
calculatePromptWidths: vi.fn(() => ({
inputWidth: 80,
suggestionsWidth: 40,
containerWidth: 84,
})),
}));
vi.mock('./Footer.js', () => ({
Footer: () => <Text>Footer</Text>,
}));
vi.mock('./ShowMoreLines.js', () => ({
ShowMoreLines: () => <Text>ShowMoreLines</Text>,
}));
vi.mock('./QueuedMessageDisplay.js', () => ({
QueuedMessageDisplay: ({ messageQueue }: { messageQueue: string[] }) => {
if (messageQueue.length === 0) {
return null;
}
return (
<>
{messageQueue.map((message, index) => (
<Text key={index}>{message}</Text>
))}
</>
);
},
}));
// Mock contexts
vi.mock('../contexts/OverflowContext.js', () => ({
OverflowProvider: ({ children }: { children: React.ReactNode }) => children,
}));
// Create mock context providers
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
({
streamingState: null,
contextFileNames: [],
showAutoAcceptIndicator: ApprovalMode.DEFAULT,
messageQueue: [],
showErrorDetails: false,
constrainHeight: false,
isInputActive: true,
buffer: '',
inputWidth: 80,
suggestionsWidth: 40,
userMessages: [],
slashCommands: [],
commandContext: null,
shellModeActive: false,
isFocused: true,
thought: '',
currentLoadingPhrase: '',
elapsedTime: 0,
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
showEscapePrompt: false,
ideContextState: null,
geminiMdFileCount: 0,
showToolDescriptions: false,
filteredConsoleMessages: [],
sessionStats: {
lastPromptTokenCount: 0,
sessionTokenCount: 0,
totalPrompts: 0,
},
branchName: 'main',
debugMessage: '',
corgiMode: false,
errorCount: 0,
nightly: false,
isTrustedFolder: true,
...overrides,
}) as UIState;
const createMockUIActions = (): UIActions =>
({
handleFinalSubmit: vi.fn(),
handleClearScreen: vi.fn(),
setShellModeActive: vi.fn(),
onEscapePromptChange: vi.fn(),
vimHandleInput: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any;
const createMockConfig = (overrides = {}) => ({
getModel: vi.fn(() => 'gemini-1.5-pro'),
getTargetDir: vi.fn(() => '/test/dir'),
getDebugMode: vi.fn(() => false),
getAccessibility: vi.fn(() => ({})),
getMcpServers: vi.fn(() => ({})),
getBlockedMcpServers: vi.fn(() => []),
...overrides,
});
const createMockSettings = (merged = {}) => ({
merged: {
hideFooter: false,
showMemoryUsage: false,
...merged,
},
});
/* eslint-disable @typescript-eslint/no-explicit-any */
const renderComposer = (
uiState: UIState,
settings = createMockSettings(),
config = createMockConfig(),
uiActions = createMockUIActions(),
) =>
render(
<ConfigContext.Provider value={config as any}>
<SettingsContext.Provider value={settings as any}>
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<Composer />
</UIActionsContext.Provider>
</UIStateContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
/* eslint-enable @typescript-eslint/no-explicit-any */
describe('Composer', () => {
describe('Footer Display Settings', () => {
it('renders Footer by default when hideFooter is false', () => {
const uiState = createMockUIState();
const settings = createMockSettings({ hideFooter: false });
const { lastFrame } = renderComposer(uiState, settings);
expect(lastFrame()).toContain('Footer');
});
it('does NOT render Footer when hideFooter is true', () => {
const uiState = createMockUIState();
const settings = createMockSettings({ hideFooter: true });
const { lastFrame } = renderComposer(uiState, settings);
// Check for content that only appears IN the Footer component itself
expect(lastFrame()).not.toContain('[NORMAL]'); // Vim mode indicator
expect(lastFrame()).not.toContain('(main'); // Branch name with parentheses
});
it('passes correct props to Footer including vim mode when enabled', async () => {
const uiState = createMockUIState({
branchName: 'feature-branch',
corgiMode: true,
errorCount: 2,
sessionStats: {
sessionId: 'test-session',
sessionStartTime: new Date(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metrics: {} as any,
lastPromptTokenCount: 150,
promptCount: 5,
},
});
const config = createMockConfig({
getModel: vi.fn(() => 'gemini-1.5-flash'),
getTargetDir: vi.fn(() => '/project/path'),
getDebugMode: vi.fn(() => true),
});
const settings = createMockSettings({
hideFooter: false,
showMemoryUsage: true,
});
// Mock vim mode for this test
const { useVimMode } = await import('../contexts/VimModeContext.js');
vi.mocked(useVimMode).mockReturnValueOnce({
vimEnabled: true,
vimMode: 'INSERT',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const { lastFrame } = renderComposer(uiState, settings, config);
expect(lastFrame()).toContain('Footer');
// Footer should be rendered with all the state passed through
});
});
describe('Loading Indicator', () => {
it('renders LoadingIndicator with thought when streaming', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: {
subject: 'Processing',
description: 'Processing your request...',
},
currentLoadingPhrase: 'Analyzing',
elapsedTime: 1500,
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
});
it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: { subject: 'Hidden', description: 'Should not show' },
});
const config = createMockConfig({
getAccessibility: vi.fn(() => ({ disableLoadingPhrases: true })),
});
const { lastFrame } = renderComposer(uiState, undefined, config);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('Should not show');
});
it('suppresses thought when waiting for confirmation', () => {
const uiState = createMockUIState({
streamingState: StreamingState.WaitingForConfirmation,
thought: {
subject: 'Confirmation',
description: 'Should not show during confirmation',
},
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('Should not show during confirmation');
});
});
describe('Message Queue Display', () => {
it('displays queued messages when present', () => {
const uiState = createMockUIState({
messageQueue: [
'First queued message',
'Second queued message',
'Third queued message',
],
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('First queued message');
expect(output).toContain('Second queued message');
expect(output).toContain('Third queued message');
});
it('renders QueuedMessageDisplay with empty message queue', () => {
const uiState = createMockUIState({
messageQueue: [],
});
const { lastFrame } = renderComposer(uiState);
// The component should render but return null for empty queue
// This test verifies that the component receives the correct prop
const output = lastFrame();
expect(output).toContain('InputPrompt'); // Verify basic Composer rendering
});
});
describe('Context and Status Display', () => {
it('shows ContextSummaryDisplay in normal state', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
showEscapePrompt: false,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('ContextSummaryDisplay');
});
it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('Press Ctrl+C again to exit');
});
it('shows Ctrl+D exit prompt when ctrlDPressedOnce is true', () => {
const uiState = createMockUIState({
ctrlDPressedOnce: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('Press Ctrl+D again to exit');
});
it('shows escape prompt when showEscapePrompt is true', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('Press Esc again to clear');
});
});
describe('Input and Indicators', () => {
it('renders InputPrompt when input is active', () => {
const uiState = createMockUIState({
isInputActive: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('InputPrompt');
});
it('does not render InputPrompt when input is inactive', () => {
const uiState = createMockUIState({
isInputActive: false,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).not.toContain('InputPrompt');
});
it('shows AutoAcceptIndicator when approval mode is not default and shell mode is inactive', () => {
const uiState = createMockUIState({
showAutoAcceptIndicator: ApprovalMode.YOLO,
shellModeActive: false,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('AutoAcceptIndicator');
});
it('shows ShellModeIndicator when shell mode is active', () => {
const uiState = createMockUIState({
shellModeActive: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('ShellModeIndicator');
});
});
describe('Error Details Display', () => {
it('shows DetailedMessagesDisplay when showErrorDetails is true', () => {
const uiState = createMockUIState({
showErrorDetails: true,
filteredConsoleMessages: [
{ level: 'error', message: 'Test error', timestamp: new Date() },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('DetailedMessagesDisplay');
expect(lastFrame()).toContain('ShowMoreLines');
});
it('does not show error details when showErrorDetails is false', () => {
const uiState = createMockUIState({
showErrorDetails: false,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).not.toContain('DetailedMessagesDisplay');
});
});
});

View File

@@ -0,0 +1,163 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useMemo } from 'react';
import { LoadingIndicator } from './LoadingIndicator.js';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { InputPrompt, calculatePromptWidths } from './InputPrompt.js';
import { Footer } from './Footer.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { theme } from '../semantic-colors.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { StreamingState } from '../types.js';
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
export const Composer = () => {
const config = useConfig();
const settings = useSettings();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const uiState = useUIState();
const uiActions = useUIActions();
const { vimEnabled } = useVimMode();
const terminalWidth = process.stdout.columns;
const isNarrow = isNarrowWidth(terminalWidth);
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
const { contextFileNames, showAutoAcceptIndicator } = uiState;
// Use the container width of InputPrompt for width of DetailedMessagesDisplay
const { containerWidth } = useMemo(
() => calculatePromptWidths(uiState.terminalWidth),
[uiState.terminalWidth],
);
return (
<Box flexDirection="column">
{!uiState.embeddedShellFocused && (
<LoadingIndicator
thought={
uiState.streamingState === StreamingState.WaitingForConfirmation ||
config.getAccessibility()?.disableLoadingPhrases
? undefined
: uiState.thought
}
currentLoadingPhrase={
config.getAccessibility()?.disableLoadingPhrases
? undefined
: uiState.currentLoadingPhrase
}
elapsedTime={uiState.elapsedTime}
/>
)}
{!uiState.isConfigInitialized && <ConfigInitDisplay />}
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
<Box
marginTop={1}
justifyContent={
settings.merged.ui?.hideContextSummary
? 'flex-start'
: 'space-between'
}
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box marginRight={1}>
{process.env['GEMINI_SYSTEM_MD'] && (
<Text color={theme.status.error}>|_| </Text>
)}
{uiState.ctrlCPressedOnce ? (
<Text color={theme.status.warning}>
Press Ctrl+C again to exit.
</Text>
) : uiState.ctrlDPressedOnce ? (
<Text color={theme.status.warning}>
Press Ctrl+D again to exit.
</Text>
) : uiState.showEscapePrompt ? (
<Text color={theme.text.secondary}>Press Esc again to clear.</Text>
) : (
!settings.merged.ui?.hideContextSummary && (
<ContextSummaryDisplay
ideContext={uiState.ideContextState}
geminiMdFileCount={uiState.geminiMdFileCount}
contextFileNames={contextFileNames}
mcpServers={config.getMcpServers()}
blockedMcpServers={config.getBlockedMcpServers()}
showToolDescriptions={uiState.showToolDescriptions}
/>
)
)}
</Box>
<Box paddingTop={isNarrow ? 1 : 0}>
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
!uiState.shellModeActive && (
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />
)}
{uiState.shellModeActive && <ShellModeIndicator />}
</Box>
</Box>
{uiState.showErrorDetails && (
<OverflowProvider>
<Box flexDirection="column">
<DetailedMessagesDisplay
messages={uiState.filteredConsoleMessages}
maxHeight={
uiState.constrainHeight ? debugConsoleMaxHeight : undefined
}
width={containerWidth}
/>
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>
</OverflowProvider>
)}
{uiState.isInputActive && (
<InputPrompt
buffer={uiState.buffer}
inputWidth={uiState.inputWidth}
suggestionsWidth={uiState.suggestionsWidth}
onSubmit={uiActions.handleFinalSubmit}
userMessages={uiState.userMessages}
onClearScreen={uiActions.handleClearScreen}
config={config}
slashCommands={uiState.slashCommands}
commandContext={uiState.commandContext}
shellModeActive={uiState.shellModeActive}
setShellModeActive={uiActions.setShellModeActive}
approvalMode={showAutoAcceptIndicator}
onEscapePromptChange={uiActions.onEscapePromptChange}
focus={true}
vimHandleInput={uiActions.vimHandleInput}
isEmbeddedShellFocused={uiState.embeddedShellFocused}
placeholder={
vimEnabled
? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode."
: ' Type your message or @path/to/file'
}
/>
)}
{!settings.merged.ui?.hideFooter && !isScreenReaderEnabled && <Footer />}
</Box>
);
};

View File

@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useState } from 'react';
import { appEvents } from './../../utils/events.js';
import { Box, Text } from 'ink';
import { useConfig } from '../contexts/ConfigContext.js';
import { type McpClient, MCPServerStatus } from '@qwen-code/qwen-code-core';
import { GeminiSpinner } from './GeminiRespondingSpinner.js';
import { theme } from '../semantic-colors.js';
export const ConfigInitDisplay = () => {
const config = useConfig();
const [message, setMessage] = useState('Initializing...');
useEffect(() => {
const onChange = (clients?: Map<string, McpClient>) => {
if (!clients || clients.size === 0) {
setMessage(`Initializing...`);
return;
}
let connected = 0;
for (const client of clients.values()) {
if (client.getStatus() === MCPServerStatus.CONNECTED) {
connected++;
}
}
setMessage(`Connecting to MCP servers... (${connected}/${clients.size})`);
};
appEvents.on('mcp-client-update', onChange);
return () => {
appEvents.off('mcp-client-update', onChange);
};
}, [config]);
return (
<Box marginTop={1}>
<Text>
<GeminiSpinner /> <Text color={theme.text.primary}>{message}</Text>
</Text>
</Box>
);
};

View File

@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Text } from 'ink';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from 'ink-testing-library';
import { ConsentPrompt } from './ConsentPrompt.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
vi.mock('./shared/RadioButtonSelect.js', () => ({
RadioButtonSelect: vi.fn(() => null),
}));
vi.mock('../utils/MarkdownDisplay.js', () => ({
MarkdownDisplay: vi.fn(() => null),
}));
const MockedRadioButtonSelect = vi.mocked(RadioButtonSelect);
const MockedMarkdownDisplay = vi.mocked(MarkdownDisplay);
describe('ConsentPrompt', () => {
const onConfirm = vi.fn();
const terminalWidth = 80;
beforeEach(() => {
vi.clearAllMocks();
});
it('renders a string prompt with MarkdownDisplay', () => {
const prompt = 'Are you sure?';
render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
expect(MockedMarkdownDisplay).toHaveBeenCalledWith(
{
isPending: true,
text: prompt,
terminalWidth,
},
undefined,
);
});
it('renders a ReactNode prompt directly', () => {
const prompt = <Text>Are you sure?</Text>;
const { lastFrame } = render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
expect(MockedMarkdownDisplay).not.toHaveBeenCalled();
expect(lastFrame()).toContain('Are you sure?');
});
it('calls onConfirm with true when "Yes" is selected', () => {
const prompt = 'Are you sure?';
render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
onSelect(true);
expect(onConfirm).toHaveBeenCalledWith(true);
});
it('calls onConfirm with false when "No" is selected', () => {
const prompt = 'Are you sure?';
render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
onSelect(false);
expect(onConfirm).toHaveBeenCalledWith(false);
});
it('passes correct items to RadioButtonSelect', () => {
const prompt = 'Are you sure?';
render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
expect(MockedRadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{ label: 'Yes', value: true, key: 'Yes' },
{ label: 'No', value: false, key: 'No' },
],
}),
undefined,
);
});
});

View File

@@ -0,0 +1,51 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box } from 'ink';
import { type ReactNode } from 'react';
import { theme } from '../semantic-colors.js';
import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
type ConsentPromptProps = {
// If a simple string is given, it will render using markdown by default.
prompt: ReactNode;
onConfirm: (value: boolean) => void;
terminalWidth: number;
};
export const ConsentPrompt = (props: ConsentPromptProps) => {
const { prompt, onConfirm, terminalWidth } = props;
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingY={1}
paddingX={2}
>
{typeof prompt === 'string' ? (
<MarkdownDisplay
isPending={true}
text={prompt}
terminalWidth={terminalWidth}
/>
) : (
prompt
)}
<Box marginTop={1}>
<RadioButtonSelect
items={[
{ label: 'Yes', value: true, key: 'Yes' },
{ label: 'No', value: false, key: 'No' },
]}
onSelect={onConfirm}
/>
</Box>
</Box>
);
};

View File

@@ -6,7 +6,7 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
interface ConsoleSummaryDisplayProps {
errorCount: number;
@@ -25,9 +25,9 @@ export const ConsoleSummaryDisplay: React.FC<ConsoleSummaryDisplayProps> = ({
return (
<Box>
{errorCount > 0 && (
<Text color={Colors.AccentRed}>
<Text color={theme.status.error}>
{errorIcon} {errorCount} error{errorCount > 1 ? 's' : ''}{' '}
<Text color={Colors.Gray}>(ctrl+o for details)</Text>
<Text color={theme.text.secondary}>(ctrl+o for details)</Text>
</Text>
)}
</Box>

View File

@@ -6,7 +6,7 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import {
type IdeContext,
type MCPServerConfig,
@@ -102,9 +102,9 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
if (isNarrow) {
return (
<Box flexDirection="column">
<Text color={Colors.Gray}>Using:</Text>
<Text color={theme.text.secondary}>Using:</Text>
{summaryParts.map((part, index) => (
<Text key={index} color={Colors.Gray}>
<Text key={index} color={theme.text.secondary}>
{' '}- {part}
</Text>
))}
@@ -114,7 +114,9 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
return (
<Box>
<Text color={Colors.Gray}>Using: {summaryParts.join(' | ')}</Text>
<Text color={theme.text.secondary}>
Using: {summaryParts.join(' | ')}
</Text>
</Box>
);
};

View File

@@ -5,21 +5,27 @@
*/
import { Text } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import { tokenLimit } from '@qwen-code/qwen-code-core';
export const ContextUsageDisplay = ({
promptTokenCount,
model,
terminalWidth,
}: {
promptTokenCount: number;
model: string;
terminalWidth: number;
}) => {
const percentage = promptTokenCount / tokenLimit(model);
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
const label = terminalWidth < 100 ? '%' : '% context left';
return (
<Text color={Colors.Gray}>
({((1 - percentage) * 100).toFixed(0)}% context left)
<Text color={theme.text.secondary}>
({percentageLeft}
{label})
</Text>
);
};

View File

@@ -6,7 +6,7 @@
import { Text } from 'ink';
import { useEffect, useRef, useState } from 'react';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
export const DebugProfiler = () => {
@@ -31,6 +31,6 @@ export const DebugProfiler = () => {
}
return (
<Text color={Colors.AccentYellow}>Renders: {numRenders.current} </Text>
<Text color={theme.status.warning}>Renders: {numRenders.current} </Text>
);
};

View File

@@ -6,7 +6,7 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import type { ConsoleMessageItem } from '../types.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
@@ -31,31 +31,32 @@ export const DetailedMessagesDisplay: React.FC<
flexDirection="column"
marginTop={1}
borderStyle="round"
borderColor={Colors.Gray}
borderColor={theme.border.default}
paddingX={1}
width={width}
>
<Box marginBottom={1}>
<Text bold color={Colors.Foreground}>
Debug Console <Text color={Colors.Gray}>(ctrl+o to close)</Text>
<Text bold color={theme.text.primary}>
Debug Console{' '}
<Text color={theme.text.secondary}>(ctrl+o to close)</Text>
</Text>
</Box>
<MaxSizedBox maxHeight={maxHeight} maxWidth={width - borderAndPadding}>
{messages.map((msg, index) => {
let textColor = Colors.Foreground;
let textColor = theme.text.primary;
let icon = '\u2139'; // Information source ()
switch (msg.type) {
case 'warn':
textColor = Colors.AccentYellow;
textColor = theme.status.warning;
icon = '\u26A0'; // Warning sign (⚠)
break;
case 'error':
textColor = Colors.AccentRed;
textColor = theme.status.error;
icon = '\u2716'; // Heavy multiplication x (✖)
break;
case 'debug':
textColor = Colors.Gray; // Or Colors.Gray
textColor = theme.text.secondary; // Or theme.text.secondary
icon = '\u{1F50D}'; // Left-pointing magnifying glass (🔍)
break;
case 'log':
@@ -70,7 +71,7 @@ export const DetailedMessagesDisplay: React.FC<
<Text color={textColor} wrap="wrap">
{msg.content}
{msg.count && msg.count > 1 && (
<Text color={Colors.Gray}> (x{msg.count})</Text>
<Text color={theme.text.secondary}> (x{msg.count})</Text>
)}
</Text>
</Box>

View File

@@ -0,0 +1,271 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { IdeIntegrationNudge } from '../IdeIntegrationNudge.js';
import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js';
import { FolderTrustDialog } from './FolderTrustDialog.js';
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
import { ConsentPrompt } from './ConsentPrompt.js';
import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { AuthInProgress } from '../auth/AuthInProgress.js';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
import { ProQuotaDialog } from './ProQuotaDialog.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { ModelDialog } from './ModelDialog.js';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import process from 'node:process';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
import { WelcomeBackDialog } from './WelcomeBackDialog.js';
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
import {
QuitConfirmationDialog,
QuitChoice,
} from './QuitConfirmationDialog.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
terminalWidth: number;
}
// Props for DialogManager
export const DialogManager = ({
addItem,
terminalWidth,
}: DialogManagerProps) => {
const config = useConfig();
const settings = useSettings();
const uiState = useUIState();
const uiActions = useUIActions();
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
uiState;
if (uiState.showWelcomeBackDialog && uiState.welcomeBackInfo?.hasHistory) {
return (
<WelcomeBackDialog
welcomeBackInfo={uiState.welcomeBackInfo}
onSelect={uiActions.handleWelcomeBackSelection}
onClose={uiActions.handleWelcomeBackClose}
/>
);
}
if (uiState.showIdeRestartPrompt) {
return <IdeTrustChangeDialog reason={uiState.ideTrustRestartReason} />;
}
if (uiState.showWorkspaceMigrationDialog) {
return (
<WorkspaceMigrationDialog
workspaceExtensions={uiState.workspaceExtensions}
onOpen={uiActions.onWorkspaceMigrationDialogOpen}
onClose={uiActions.onWorkspaceMigrationDialogClose}
/>
);
}
if (uiState.proQuotaRequest) {
return (
<ProQuotaDialog
failedModel={uiState.proQuotaRequest.failedModel}
fallbackModel={uiState.proQuotaRequest.fallbackModel}
onChoice={uiActions.handleProQuotaChoice}
/>
);
}
if (uiState.shouldShowIdePrompt) {
return (
<IdeIntegrationNudge
ide={uiState.currentIDE!}
onComplete={uiActions.handleIdePromptComplete}
/>
);
}
if (uiState.isFolderTrustDialogOpen) {
return (
<FolderTrustDialog
onSelect={uiActions.handleFolderTrustSelect}
isRestarting={uiState.isRestarting}
/>
);
}
if (uiState.shellConfirmationRequest) {
return (
<ShellConfirmationDialog request={uiState.shellConfirmationRequest} />
);
}
if (uiState.loopDetectionConfirmationRequest) {
return (
<LoopDetectionConfirmation
onComplete={uiState.loopDetectionConfirmationRequest.onComplete}
/>
);
}
if (uiState.quitConfirmationRequest) {
return (
<QuitConfirmationDialog
onSelect={(choice: QuitChoice) => {
if (choice === QuitChoice.CANCEL) {
uiState.quitConfirmationRequest?.onConfirm(false, 'cancel');
} else if (choice === QuitChoice.QUIT) {
uiState.quitConfirmationRequest?.onConfirm(true, 'quit');
} else if (choice === QuitChoice.SAVE_AND_QUIT) {
uiState.quitConfirmationRequest?.onConfirm(true, 'save_and_quit');
} else if (choice === QuitChoice.SUMMARY_AND_QUIT) {
uiState.quitConfirmationRequest?.onConfirm(
true,
'summary_and_quit',
);
}
}}
/>
);
}
if (uiState.confirmationRequest) {
return (
<ConsentPrompt
prompt={uiState.confirmationRequest.prompt}
onConfirm={uiState.confirmationRequest.onConfirm}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.confirmUpdateExtensionRequests.length > 0) {
const request = uiState.confirmUpdateExtensionRequests[0];
return (
<ConsentPrompt
prompt={request.prompt}
onConfirm={request.onConfirm}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.isThemeDialogOpen) {
return (
<Box flexDirection="column">
{uiState.themeError && (
<Box marginBottom={1}>
<Text color={theme.status.error}>{uiState.themeError}</Text>
</Box>
)}
<ThemeDialog
onSelect={uiActions.handleThemeSelect}
onHighlight={uiActions.handleThemeHighlight}
settings={settings}
availableTerminalHeight={
constrainHeight ? terminalHeight - staticExtraHeight : undefined
}
terminalWidth={mainAreaWidth}
/>
</Box>
);
}
if (uiState.isSettingsDialogOpen) {
return (
<Box flexDirection="column">
<SettingsDialog
settings={settings}
onSelect={() => uiActions.closeSettingsDialog()}
onRestartRequest={() => process.exit(0)}
availableTerminalHeight={terminalHeight - staticExtraHeight}
/>
</Box>
);
}
if (uiState.isModelDialogOpen) {
return <ModelDialog onClose={uiActions.closeModelDialog} />;
}
if (uiState.isVisionSwitchDialogOpen) {
return <ModelSwitchDialog onSelect={uiActions.handleVisionSwitchSelect} />;
}
if (uiState.isAuthenticating) {
// Show Qwen OAuth progress if it's Qwen auth and OAuth is active
if (uiState.isQwenAuth && uiState.isQwenAuthenticating) {
return (
<QwenOAuthProgress
deviceAuth={uiState.deviceAuth || undefined}
authStatus={uiState.authStatus}
authMessage={uiState.authMessage}
onTimeout={uiActions.handleQwenAuthTimeout}
onCancel={uiActions.handleQwenAuthCancel}
/>
);
}
// Default auth progress for other auth types
return (
<AuthInProgress
onTimeout={() => {
uiActions.onAuthError('Authentication cancelled.');
}}
/>
);
}
if (uiState.isAuthDialogOpen) {
return (
<Box flexDirection="column">
<AuthDialog
onSelect={uiActions.handleAuthSelect}
settings={settings}
initialErrorMessage={uiState.authError}
/>
</Box>
);
}
if (uiState.isEditorDialogOpen) {
return (
<Box flexDirection="column">
{uiState.editorError && (
<Box marginBottom={1}>
<Text color={theme.status.error}>{uiState.editorError}</Text>
</Box>
)}
<EditorSettingsDialog
onSelect={uiActions.handleEditorSelect}
settings={settings}
onExit={uiActions.exitEditorDialog}
/>
</Box>
);
}
if (uiState.isPermissionsDialogOpen) {
return (
<PermissionsModifyTrustDialog
onExit={uiActions.closePermissionsDialog}
addItem={addItem}
/>
);
}
if (uiState.isSubagentCreateDialogOpen) {
return (
<AgentCreationWizard
onClose={uiActions.closeSubagentCreateDialog}
config={config}
/>
);
}
if (uiState.isAgentsManagerDialogOpen) {
return (
<AgentsManagerDialog
onClose={uiActions.closeAgentsManagerDialog}
config={config}
/>
);
}
return null;
};

View File

@@ -7,7 +7,7 @@
import type React from 'react';
import { useState } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import {
EDITOR_DISPLAY_NAMES,
editorSettingsManager,
@@ -65,8 +65,16 @@ export function EditorSettingsDialog({
}
const scopeItems = [
{ label: 'User Settings', value: SettingScope.User },
{ label: 'Workspace Settings', value: SettingScope.Workspace },
{
label: 'User Settings',
value: SettingScope.User,
key: SettingScope.User,
},
{
label: 'Workspace Settings',
value: SettingScope.Workspace,
key: SettingScope.Workspace,
},
];
const handleEditorSelect = (editorType: EditorType | 'not_set') => {
@@ -112,7 +120,7 @@ export function EditorSettingsDialog({
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
borderColor={theme.border.default}
flexDirection="row"
padding={1}
width="100%"
@@ -120,13 +128,14 @@ export function EditorSettingsDialog({
<Box flexDirection="column" width="45%" paddingRight={2}>
<Text bold={focusedSection === 'editor'}>
{focusedSection === 'editor' ? '> ' : ' '}Select Editor{' '}
<Text color={Colors.Gray}>{otherScopeModifiedMessage}</Text>
<Text color={theme.text.secondary}>{otherScopeModifiedMessage}</Text>
</Text>
<RadioButtonSelect
items={editorItems.map((item) => ({
label: item.name,
value: item.type,
disabled: item.disabled,
key: item.type,
}))}
initialIndex={editorIndex}
onSelect={handleEditorSelect}
@@ -147,26 +156,28 @@ export function EditorSettingsDialog({
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
<Text color={theme.text.secondary}>
(Use Enter to select, Tab to change focus)
</Text>
</Box>
</Box>
<Box flexDirection="column" width="55%" paddingLeft={2}>
<Text bold>Editor Preference</Text>
<Text bold color={theme.text.primary}>
Editor Preference
</Text>
<Box flexDirection="column" gap={1} marginTop={1}>
<Text color={Colors.Gray}>
<Text color={theme.text.secondary}>
These editors are currently supported. Please note that some editors
cannot be used in sandbox mode.
</Text>
<Text color={Colors.Gray}>
<Text color={theme.text.secondary}>
Your preferred editor is:{' '}
<Text
color={
mergedEditorName === 'None'
? Colors.AccentRed
: Colors.AccentCyan
? theme.status.error
: theme.text.link
}
bold
>

View File

@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { theme } from '../semantic-colors.js';
export const ExitWarning: React.FC = () => {
const uiState = useUIState();
return (
<>
{uiState.dialogsVisible && uiState.ctrlCPressedOnce && (
<Box marginTop={1}>
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
</Box>
)}
{uiState.dialogsVisible && uiState.ctrlDPressedOnce && (
<Box marginTop={1}>
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
</Box>
)}
</>
);
};

View File

@@ -8,19 +8,29 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import { FolderTrustDialog, FolderTrustChoice } from './FolderTrustDialog.js';
import * as process from 'node:process';
import * as processUtils from '../../utils/processUtils.js';
vi.mock('process', async () => {
const actual = await vi.importActual('process');
vi.mock('../../utils/processUtils.js', () => ({
relaunchApp: vi.fn(),
}));
const mockedExit = vi.hoisted(() => vi.fn());
const mockedCwd = vi.hoisted(() => vi.fn());
vi.mock('node:process', async () => {
const actual =
await vi.importActual<typeof import('node:process')>('node:process');
return {
...actual,
exit: vi.fn(),
exit: mockedExit,
cwd: mockedCwd,
};
});
describe('FolderTrustDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedCwd.mockReturnValue('/home/user/project');
});
it('should render the dialog with title and description', () => {
@@ -65,21 +75,18 @@ describe('FolderTrustDialog', () => {
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
expect(lastFrame()).toContain(
'To see changes, Qwen Code must be restarted',
);
expect(lastFrame()).toContain(' Qwen Code is restarting');
});
it('should call process.exit when "r" is pressed and isRestarting is true', async () => {
const { stdin } = renderWithProviders(
it('should call relaunchApp when isRestarting is true', async () => {
vi.useFakeTimers();
const relaunchApp = vi.spyOn(processUtils, 'relaunchApp');
renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
stdin.write('r');
await waitFor(() => {
expect(process.exit).toHaveBeenCalledWith(0);
});
await vi.advanceTimersByTimeAsync(1000);
expect(relaunchApp).toHaveBeenCalled();
vi.useRealTimers();
});
it('should not call process.exit when "r" is pressed and isRestarting is false', async () => {
@@ -90,7 +97,33 @@ describe('FolderTrustDialog', () => {
stdin.write('r');
await waitFor(() => {
expect(process.exit).not.toHaveBeenCalled();
expect(mockedExit).not.toHaveBeenCalled();
});
});
describe('directory display', () => {
it('should correctly display the folder name for a nested directory', () => {
mockedCwd.mockReturnValue('/home/user/project');
const { lastFrame } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
expect(lastFrame()).toContain('Trust folder (project)');
});
it('should correctly display the parent folder name for a nested directory', () => {
mockedCwd.mockReturnValue('/home/user/project');
const { lastFrame } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
expect(lastFrame()).toContain('Trust parent folder (user)');
});
it('should correctly display an empty parent folder name for a directory directly under root', () => {
mockedCwd.mockReturnValue('/project');
const { lastFrame } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
expect(lastFrame()).toContain('Trust parent folder ()');
});
});
});

View File

@@ -6,11 +6,14 @@
import { Box, Text } from 'ink';
import type React from 'react';
import { Colors } from '../colors.js';
import { useEffect } from 'react';
import { theme } from '../semantic-colors.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import * as process from 'node:process';
import * as path from 'node:path';
import { relaunchApp } from '../../utils/processUtils.js';
export enum FolderTrustChoice {
TRUST_FOLDER = 'trust_folder',
@@ -27,6 +30,17 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
onSelect,
isRestarting,
}) => {
useEffect(() => {
const doRelaunch = async () => {
if (isRestarting) {
setTimeout(async () => {
await relaunchApp();
}, 250);
}
};
doRelaunch();
}, [isRestarting]);
useKeypress(
(key) => {
if (key.name === 'escape') {
@@ -36,27 +50,24 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
{ isActive: !isRestarting },
);
useKeypress(
(key) => {
if (key.name === 'r') {
process.exit(0);
}
},
{ isActive: !!isRestarting },
);
const dirName = path.basename(process.cwd());
const parentFolder = path.basename(path.dirname(process.cwd()));
const options: Array<RadioSelectItem<FolderTrustChoice>> = [
{
label: 'Trust folder',
label: `Trust folder (${dirName})`,
value: FolderTrustChoice.TRUST_FOLDER,
key: `Trust folder (${dirName})`,
},
{
label: 'Trust parent folder',
label: `Trust parent folder (${parentFolder})`,
value: FolderTrustChoice.TRUST_PARENT,
key: `Trust parent folder (${parentFolder})`,
},
{
label: "Don't trust (esc)",
value: FolderTrustChoice.DO_NOT_TRUST,
key: "Don't trust (esc)",
},
];
@@ -65,14 +76,16 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.AccentYellow}
borderColor={theme.status.warning}
padding={1}
width="100%"
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text bold>Do you trust this folder?</Text>
<Text>
<Text bold color={theme.text.primary}>
Do you trust this folder?
</Text>
<Text color={theme.text.primary}>
Trusting a folder allows Qwen Code to execute commands it suggests.
This is a security feature to prevent accidental execution in
untrusted directories.
@@ -87,9 +100,8 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
</Box>
{isRestarting && (
<Box marginLeft={1} marginTop={1}>
<Text color={Colors.AccentYellow}>
To see changes, Qwen Code must be restarted. Press r to exit and
apply changes now.
<Text color={theme.status.warning}>
Qwen Code is restarting to apply the trust changes...
</Text>
</Box>
)}

View File

@@ -9,7 +9,11 @@ import { describe, it, expect, vi } from 'vitest';
import { Footer } from './Footer.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import { tildeifyPath } from '@qwen-code/qwen-code-core';
import path from 'node:path';
import { type UIState, UIStateContext } from '../contexts/UIStateContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../config/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
vi.mock('../hooks/useTerminalSize.js');
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
@@ -33,92 +37,135 @@ const defaultProps = {
targetDir:
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
branchName: 'main',
debugMode: false,
debugMessage: '',
corgiMode: false,
errorCount: 0,
showErrorDetails: false,
showMemoryUsage: false,
promptTokenCount: 100,
nightly: false,
};
const renderWithWidth = (width: number, props = defaultProps) => {
const createMockConfig = (overrides = {}) => ({
getModel: vi.fn(() => defaultProps.model),
getTargetDir: vi.fn(() => defaultProps.targetDir),
getDebugMode: vi.fn(() => false),
...overrides,
});
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
({
sessionStats: {
lastPromptTokenCount: 100,
},
branchName: defaultProps.branchName,
...overrides,
}) as UIState;
const createDefaultSettings = (
options: {
showMemoryUsage?: boolean;
hideCWD?: boolean;
hideSandboxStatus?: boolean;
hideModelInfo?: boolean;
} = {},
): LoadedSettings =>
({
merged: {
ui: {
showMemoryUsage: options.showMemoryUsage,
footer: {
hideCWD: options.hideCWD,
hideSandboxStatus: options.hideSandboxStatus,
hideModelInfo: options.hideModelInfo,
},
},
},
}) as never;
const renderWithWidth = (
width: number,
uiState: UIState,
settings: LoadedSettings = createDefaultSettings(),
) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
return render(<Footer {...props} />);
return render(
<ConfigContext.Provider value={createMockConfig() as never}>
<SettingsContext.Provider value={settings}>
<VimModeProvider settings={settings}>
<UIStateContext.Provider value={uiState}>
<Footer />
</UIStateContext.Provider>
</VimModeProvider>
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
};
describe('<Footer />', () => {
it('renders the component', () => {
const { lastFrame } = renderWithWidth(120);
const { lastFrame } = renderWithWidth(120, createMockUIState());
expect(lastFrame()).toBeDefined();
});
describe('path display', () => {
it('should display shortened path on a wide terminal', () => {
const { lastFrame } = renderWithWidth(120);
it('should display a shortened path on a narrow terminal', () => {
const { lastFrame } = renderWithWidth(79, createMockUIState());
const tildePath = tildeifyPath(defaultProps.targetDir);
const expectedPath = '...' + tildePath.slice(tildePath.length - 48 + 3);
expect(lastFrame()).toContain(expectedPath);
});
it('should display only the base directory name on a narrow terminal', () => {
const { lastFrame } = renderWithWidth(79);
const expectedPath = path.basename(defaultProps.targetDir);
const pathLength = Math.max(20, Math.floor(79 * 0.25));
const expectedPath =
'...' + tildePath.slice(tildePath.length - pathLength + 3);
expect(lastFrame()).toContain(expectedPath);
});
it('should use wide layout at 80 columns', () => {
const { lastFrame } = renderWithWidth(80);
const { lastFrame } = renderWithWidth(80, createMockUIState());
const tildePath = tildeifyPath(defaultProps.targetDir);
const expectedPath = '...' + tildePath.slice(tildePath.length - 32 + 3);
const expectedPath =
'...' + tildePath.slice(tildePath.length - 80 * 0.25 + 3);
expect(lastFrame()).toContain(expectedPath);
});
it('should use narrow layout at 79 columns', () => {
const { lastFrame } = renderWithWidth(79);
const expectedPath = path.basename(defaultProps.targetDir);
expect(lastFrame()).toContain(expectedPath);
const tildePath = tildeifyPath(defaultProps.targetDir);
const unexpectedPath = '...' + tildePath.slice(tildePath.length - 31 + 3);
expect(lastFrame()).not.toContain(unexpectedPath);
});
});
it('displays the branch name when provided', () => {
const { lastFrame } = renderWithWidth(120);
const { lastFrame } = renderWithWidth(120, createMockUIState());
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
});
it('does not display the branch name when not provided', () => {
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
branchName: undefined,
});
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
branchName: undefined,
}),
);
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
});
it('displays the model name and context percentage', () => {
const { lastFrame } = renderWithWidth(120);
const { lastFrame } = renderWithWidth(120, createMockUIState());
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context[\s\S]*left\)/);
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
});
it('displays the model name and abbreviated context percentage', () => {
const { lastFrame } = renderWithWidth(99, createMockUIState());
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+%\)/);
});
describe('sandbox and trust info', () => {
it('should display untrusted when isTrustedFolder is false', () => {
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
isTrustedFolder: false,
});
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
isTrustedFolder: false,
}),
);
expect(lastFrame()).toContain('untrusted');
});
it('should display custom sandbox info when SANDBOX env is set', () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
isTrustedFolder: undefined,
});
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
isTrustedFolder: undefined,
}),
);
expect(lastFrame()).toContain('test');
vi.unstubAllEnvs();
});
@@ -126,10 +173,12 @@ describe('<Footer />', () => {
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', () => {
vi.stubEnv('SANDBOX', 'sandbox-exec');
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
isTrustedFolder: true,
});
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
isTrustedFolder: true,
}),
);
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
vi.unstubAllEnvs();
});
@@ -137,23 +186,78 @@ describe('<Footer />', () => {
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', () => {
// Clear any SANDBOX env var that might be set.
vi.stubEnv('SANDBOX', '');
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
isTrustedFolder: true,
});
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
isTrustedFolder: true,
}),
);
expect(lastFrame()).toContain('no sandbox');
vi.unstubAllEnvs();
});
it('should prioritize untrusted message over sandbox info', () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
isTrustedFolder: false,
});
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
isTrustedFolder: false,
}),
);
expect(lastFrame()).toContain('untrusted');
expect(lastFrame()).not.toMatch(/test-sandbox/s);
vi.unstubAllEnvs();
});
});
describe('footer configuration filtering (golden snapshots)', () => {
it('renders complete footer with all sections visible (baseline)', () => {
const { lastFrame } = renderWithWidth(120, createMockUIState());
expect(lastFrame()).toMatchSnapshot('complete-footer-wide');
});
it('renders footer with all optional sections hidden (minimal footer)', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState(),
createDefaultSettings({
hideCWD: true,
hideSandboxStatus: true,
hideModelInfo: true,
}),
);
expect(lastFrame()).toMatchSnapshot('footer-minimal');
});
it('renders footer with only model info hidden (partial filtering)', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState(),
createDefaultSettings({
hideCWD: false,
hideSandboxStatus: false,
hideModelInfo: true,
}),
);
expect(lastFrame()).toMatchSnapshot('footer-no-model');
});
it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState(),
createDefaultSettings({
hideCWD: true,
hideSandboxStatus: false,
hideModelInfo: true,
}),
);
expect(lastFrame()).toMatchSnapshot('footer-only-sandbox');
});
it('renders complete footer in narrow terminal (baseline narrow)', () => {
const { lastFrame } = renderWithWidth(79, createMockUIState());
expect(lastFrame()).toMatchSnapshot('complete-footer-narrow');
});
});
});

View File

@@ -10,146 +10,168 @@ import { theme } from '../semantic-colors.js';
import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
import path from 'node:path';
import Gradient from 'ink-gradient';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { DebugProfiler } from './DebugProfiler.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
interface FooterProps {
model: string;
targetDir: string;
branchName?: string;
debugMode: boolean;
debugMessage: string;
corgiMode: boolean;
errorCount: number;
showErrorDetails: boolean;
showMemoryUsage?: boolean;
promptTokenCount: number;
nightly: boolean;
vimMode?: string;
isTrustedFolder?: boolean;
}
import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
export const Footer: React.FC = () => {
const uiState = useUIState();
const config = useConfig();
const settings = useSettings();
const { vimEnabled, vimMode } = useVimMode();
const {
model,
targetDir,
debugMode,
branchName,
debugMessage,
corgiMode,
errorCount,
showErrorDetails,
promptTokenCount,
nightly,
isTrustedFolder,
} = {
model: config.getModel(),
targetDir: config.getTargetDir(),
debugMode: config.getDebugMode(),
branchName: uiState.branchName,
debugMessage: uiState.debugMessage,
corgiMode: uiState.corgiMode,
errorCount: uiState.errorCount,
showErrorDetails: uiState.showErrorDetails,
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
nightly: uiState.nightly,
isTrustedFolder: uiState.isTrustedFolder,
};
const showMemoryUsage =
config.getDebugMode() || settings.merged.ui?.showMemoryUsage || false;
const hideCWD = settings.merged.ui?.footer?.hideCWD || false;
const hideSandboxStatus =
settings.merged.ui?.footer?.hideSandboxStatus || false;
const hideModelInfo = settings.merged.ui?.footer?.hideModelInfo || false;
export const Footer: React.FC<FooterProps> = ({
model,
targetDir,
branchName,
debugMode,
debugMessage,
corgiMode,
errorCount,
showErrorDetails,
showMemoryUsage,
promptTokenCount,
nightly,
vimMode,
isTrustedFolder,
}) => {
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
const pathLength = Math.max(20, Math.floor(terminalWidth * 0.25));
const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
// Adjust path length based on terminal width
const pathLength = Math.max(20, Math.floor(terminalWidth * 0.4));
const displayPath = isNarrow
? path.basename(tildeifyPath(targetDir))
: shortenPath(tildeifyPath(targetDir), pathLength);
const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
const displayVimMode = vimEnabled ? vimMode : undefined;
return (
<Box
justifyContent="space-between"
justifyContent={justifyContent}
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
flexDirection="row"
alignItems="center"
>
<Box>
{debugMode && <DebugProfiler />}
{vimMode && <Text color={theme.text.secondary}>[{vimMode}] </Text>}
{nightly ? (
<Gradient colors={theme.ui.gradient}>
<Text>
{displayPath}
{branchName && <Text> ({branchName}*)</Text>}
{(debugMode || displayVimMode || !hideCWD) && (
<Box>
{debugMode && <DebugProfiler />}
{displayVimMode && (
<Text color={theme.text.secondary}>[{displayVimMode}] </Text>
)}
{!hideCWD &&
(nightly ? (
<Gradient colors={theme.ui.gradient}>
<Text>
{displayPath}
{branchName && <Text> ({branchName}*)</Text>}
</Text>
</Gradient>
) : (
<Text color={theme.text.link}>
{displayPath}
{branchName && (
<Text color={theme.text.secondary}> ({branchName}*)</Text>
)}
</Text>
))}
{debugMode && (
<Text color={theme.status.error}>
{' ' + (debugMessage || '--debug')}
</Text>
</Gradient>
) : (
<Text color={theme.text.link}>
{displayPath}
{branchName && (
<Text color={theme.text.secondary}> ({branchName}*)</Text>
)}
</Text>
)}
{debugMode && (
<Text color={theme.status.error}>
{' ' + (debugMessage || '--debug')}
</Text>
)}
</Box>
)}
</Box>
)}
{/* Middle Section: Centered Trust/Sandbox Info */}
<Box
flexGrow={isNarrow ? 0 : 1}
alignItems="center"
justifyContent={isNarrow ? 'flex-start' : 'center'}
display="flex"
paddingX={isNarrow ? 0 : 1}
paddingTop={isNarrow ? 1 : 0}
>
{isTrustedFolder === false ? (
<Text color={theme.status.warning}>untrusted</Text>
) : process.env['SANDBOX'] &&
process.env['SANDBOX'] !== 'sandbox-exec' ? (
<Text color="green">
{process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
</Text>
) : process.env['SANDBOX'] === 'sandbox-exec' ? (
<Text color={theme.status.warning}>
macOS Seatbelt{' '}
<Text color={theme.text.secondary}>
({process.env['SEATBELT_PROFILE']})
{!hideSandboxStatus && (
<Box
flexGrow={1}
alignItems="center"
justifyContent="center"
display="flex"
>
{isTrustedFolder === false ? (
<Text color={theme.status.warning}>untrusted</Text>
) : process.env['SANDBOX'] &&
process.env['SANDBOX'] !== 'sandbox-exec' ? (
<Text color="green">
{process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
</Text>
</Text>
) : (
<Text color={theme.status.error}>
no sandbox <Text color={theme.text.secondary}>(see /docs)</Text>
</Text>
)}
</Box>
) : process.env['SANDBOX'] === 'sandbox-exec' ? (
<Text color={theme.status.warning}>
macOS Seatbelt{' '}
<Text color={theme.text.secondary}>
({process.env['SEATBELT_PROFILE']})
</Text>
</Text>
) : (
<Text color={theme.status.error}>
no sandbox
{terminalWidth >= 100 && (
<Text color={theme.text.secondary}> (see /docs)</Text>
)}
</Text>
)}
</Box>
)}
{/* Right Section: Gemini Label and Console Summary */}
<Box alignItems="center" paddingTop={isNarrow ? 1 : 0}>
<Text color={theme.text.accent}>
{isNarrow ? '' : ' '}
{model}{' '}
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
/>
</Text>
{corgiMode && (
<Text>
<Text color={theme.ui.symbol}>| </Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>(´</Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>`)</Text>
<Text color={theme.status.error}>▼ </Text>
</Text>
)}
{!showErrorDetails && errorCount > 0 && (
<Box>
<Text color={theme.ui.symbol}>| </Text>
<ConsoleSummaryDisplay errorCount={errorCount} />
{!hideModelInfo && (
<Box alignItems="center" justifyContent="flex-end">
<Box alignItems="center">
<Text color={theme.text.accent}>
{model}{' '}
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
terminalWidth={terminalWidth}
/>
</Text>
{showMemoryUsage && <MemoryUsageDisplay />}
</Box>
)}
{showMemoryUsage && <MemoryUsageDisplay />}
</Box>
<Box alignItems="center" paddingLeft={2}>
{corgiMode && (
<Text>
<Text color={theme.ui.symbol}>| </Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>(´</Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>`)</Text>
<Text color={theme.status.error}>▼ </Text>
</Text>
)}
{!showErrorDetails && errorCount > 0 && (
<Box>
<Text color={theme.ui.symbol}>| </Text>
<ConsoleSummaryDisplay errorCount={errorCount} />
</Box>
)}
</Box>
</Box>
)}
</Box>
);
};

View File

@@ -14,6 +14,7 @@ import {
SCREEN_READER_LOADING,
SCREEN_READER_RESPONDING,
} from '../textConstants.js';
import { theme } from '../semantic-colors.js';
interface GeminiRespondingSpinnerProps {
/**
@@ -30,17 +31,37 @@ export const GeminiRespondingSpinner: React.FC<
const streamingState = useStreamingContext();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
if (streamingState === StreamingState.Responding) {
return isScreenReaderEnabled ? (
<Text>{SCREEN_READER_RESPONDING}</Text>
) : (
<Spinner type={spinnerType} />
return (
<GeminiSpinner
spinnerType={spinnerType}
altText={SCREEN_READER_RESPONDING}
/>
);
} else if (nonRespondingDisplay) {
return isScreenReaderEnabled ? (
<Text>{SCREEN_READER_LOADING}</Text>
) : (
<Text>{nonRespondingDisplay}</Text>
<Text color={theme.text.primary}>{nonRespondingDisplay}</Text>
);
}
return null;
};
interface GeminiSpinnerProps {
spinnerType?: SpinnerName;
altText?: string;
}
export const GeminiSpinner: React.FC<GeminiSpinnerProps> = ({
spinnerType = 'dots',
altText,
}) => {
const isScreenReaderEnabled = useIsScreenReaderEnabled();
return isScreenReaderEnabled ? (
<Text>{altText}</Text>
) : (
<Text color={theme.text.primary}>
<Spinner type={spinnerType} />
</Text>
);
};

View File

@@ -7,7 +7,7 @@
import type React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
@@ -47,8 +47,8 @@ export const Header: React.FC<HeaderProps> = ({
flexShrink={0}
flexDirection="column"
>
{Colors.GradientColors ? (
<Gradient colors={Colors.GradientColors}>
{theme.ui.gradient ? (
<Gradient colors={theme.ui.gradient}>
<Text>{displayTitle}</Text>
</Gradient>
) : (
@@ -56,8 +56,8 @@ export const Header: React.FC<HeaderProps> = ({
)}
{nightly && (
<Box width="100%" flexDirection="row" justifyContent="flex-end">
{Colors.GradientColors ? (
<Gradient colors={Colors.GradientColors}>
{theme.ui.gradient ? (
<Gradient colors={theme.ui.gradient}>
<Text>v{version}</Text>
</Gradient>
) : (

View File

@@ -0,0 +1,63 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/** @vitest-environment jsdom */
import { render } from 'ink-testing-library';
import { describe, it, expect } from 'vitest';
import { Help } from './Help.js';
import type { SlashCommand } from '../commands/types.js';
import { CommandKind } from '../commands/types.js';
const mockCommands: readonly SlashCommand[] = [
{
name: 'test',
description: 'A test command',
kind: CommandKind.BUILT_IN,
},
{
name: 'hidden',
description: 'A hidden command',
hidden: true,
kind: CommandKind.BUILT_IN,
},
{
name: 'parent',
description: 'A parent command',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'visible-child',
description: 'A visible child command',
kind: CommandKind.BUILT_IN,
},
{
name: 'hidden-child',
description: 'A hidden child command',
hidden: true,
kind: CommandKind.BUILT_IN,
},
],
},
];
describe('Help Component', () => {
it('should not render hidden commands', () => {
const { lastFrame } = render(<Help commands={mockCommands} />);
const output = lastFrame();
expect(output).toContain('/test');
expect(output).not.toContain('/hidden');
});
it('should not render hidden subcommands', () => {
const { lastFrame } = render(<Help commands={mockCommands} />);
const output = lastFrame();
expect(output).toContain('visible-child');
expect(output).not.toContain('hidden-child');
});
});

View File

@@ -6,8 +6,8 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import type { SlashCommand } from '../commands/types.js';
import { theme } from '../semantic-colors.js';
import { type SlashCommand, CommandKind } from '../commands/types.js';
interface Help {
commands: readonly SlashCommand[];
@@ -17,42 +17,42 @@ export const Help: React.FC<Help> = ({ commands }) => (
<Box
flexDirection="column"
marginBottom={1}
borderColor={Colors.Gray}
borderColor={theme.border.default}
borderStyle="round"
padding={1}
>
{/* Basics */}
<Text bold color={Colors.Foreground}>
<Text bold color={theme.text.primary}>
Basics:
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Add context
</Text>
: Use{' '}
<Text bold color={Colors.AccentPurple}>
<Text bold color={theme.text.accent}>
@
</Text>{' '}
to specify files for context (e.g.,{' '}
<Text bold color={Colors.AccentPurple}>
<Text bold color={theme.text.accent}>
@src/myFile.ts
</Text>
) to target specific files or folders.
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Shell mode
</Text>
: Execute shell commands via{' '}
<Text bold color={Colors.AccentPurple}>
<Text bold color={theme.text.accent}>
!
</Text>{' '}
(e.g.,{' '}
<Text bold color={Colors.AccentPurple}>
<Text bold color={theme.text.accent}>
!npm run start
</Text>
) or use natural language (e.g.{' '}
<Text bold color={Colors.AccentPurple}>
<Text bold color={theme.text.accent}>
start server
</Text>
).
@@ -61,106 +61,115 @@ export const Help: React.FC<Help> = ({ commands }) => (
<Box height={1} />
{/* Commands */}
<Text bold color={Colors.Foreground}>
<Text bold color={theme.text.primary}>
Commands:
</Text>
{commands
.filter((command) => command.description)
.filter((command) => command.description && !command.hidden)
.map((command: SlashCommand) => (
<Box key={command.name} flexDirection="column">
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{' '}
/{command.name}
</Text>
{command.kind === CommandKind.MCP_PROMPT && (
<Text color={theme.text.secondary}> [MCP]</Text>
)}
{command.description && ' - ' + command.description}
</Text>
{command.subCommands &&
command.subCommands.map((subCommand) => (
<Text key={subCommand.name} color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
{' '}
{subCommand.name}
command.subCommands
.filter((subCommand) => !subCommand.hidden)
.map((subCommand) => (
<Text key={subCommand.name} color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{' '}
{subCommand.name}
</Text>
{subCommand.description && ' - ' + subCommand.description}
</Text>
{subCommand.description && ' - ' + subCommand.description}
</Text>
))}
))}
</Box>
))}
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{' '}
!{' '}
</Text>
- shell command
</Text>
<Text color={theme.text.primary}>
<Text color={theme.text.secondary}>[MCP]</Text> - Model Context Protocol
command (from external servers)
</Text>
<Box height={1} />
{/* Shortcuts */}
<Text bold color={Colors.Foreground}>
<Text bold color={theme.text.primary}>
Keyboard Shortcuts:
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Alt+Left/Right
</Text>{' '}
- Jump through words in the input
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Ctrl+C
</Text>{' '}
- Close dialogs, cancel requests, or quit application
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J'}
</Text>{' '}
{process.platform === 'linux'
? '- New line (Alt+Enter works for certain linux distros)'
: '- New line'}
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Ctrl+L
</Text>{' '}
- Clear the screen
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'}
</Text>{' '}
- Open input in external editor
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Enter
</Text>{' '}
- Send message
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Esc
</Text>{' '}
- Cancel operation / Clear input (double press)
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Shift+Tab
</Text>{' '}
- Cycle approval modes
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Up/Down
</Text>{' '}
- Cycle through your prompt history
</Text>
<Box height={1} />
<Text color={Colors.Foreground}>
<Text color={theme.text.primary}>
For a full list of shortcuts, see{' '}
<Text bold color={Colors.AccentPurple}>
<Text bold color={theme.text.accent}>
docs/keyboard-shortcuts.md
</Text>
</Text>

View File

@@ -4,17 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import type { HistoryItem } from '../types.js';
import { type HistoryItem, ToolCallStatus } from '../types.js';
import { MessageType } from '../types.js';
import { SessionStatsProvider } from '../contexts/SessionContext.js';
import type { Config } from '@qwen-code/qwen-code-core';
import type {
Config,
ToolExecuteConfirmationDetails,
} from '@qwen-code/qwen-code-core';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { renderWithProviders } from '../../test-utils/render.js';
// Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({
ToolGroupMessage: () => <div />,
ToolGroupMessage: vi.fn(() => <div />),
}));
describe('<HistoryItemDisplay />', () => {
@@ -33,7 +37,7 @@ describe('<HistoryItemDisplay />', () => {
type: MessageType.USER,
text: 'Hello',
};
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
expect(lastFrame()).toContain('Hello');
@@ -45,7 +49,7 @@ describe('<HistoryItemDisplay />', () => {
type: MessageType.USER,
text: '/theme',
};
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
expect(lastFrame()).toContain('/theme');
@@ -57,7 +61,7 @@ describe('<HistoryItemDisplay />', () => {
type: MessageType.STATS,
duration: '1s',
};
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
@@ -77,7 +81,7 @@ describe('<HistoryItemDisplay />', () => {
gcpProject: 'test-project',
ideClient: 'test-ide',
};
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
expect(lastFrame()).toContain('About Qwen Code');
@@ -88,7 +92,7 @@ describe('<HistoryItemDisplay />', () => {
...baseItem,
type: 'model_stats',
};
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
@@ -103,7 +107,7 @@ describe('<HistoryItemDisplay />', () => {
...baseItem,
type: 'tool_stats',
};
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
@@ -119,11 +123,151 @@ describe('<HistoryItemDisplay />', () => {
type: 'quit',
duration: '1s',
};
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
expect(lastFrame()).toContain('Agent powering down. Goodbye!');
});
it('should escape ANSI codes in text content', () => {
const historyItem: HistoryItem = {
id: 1,
type: 'user',
text: 'Hello, \u001b[31mred\u001b[0m world!',
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
item={historyItem}
terminalWidth={80}
isPending={false}
/>,
);
// The ANSI codes should be escaped for display.
expect(lastFrame()).toContain('Hello, \\u001b[31mred\\u001b[0m world!');
// The raw ANSI codes should not be present.
expect(lastFrame()).not.toContain('Hello, \u001b[31mred\u001b[0m world!');
});
it('should escape ANSI codes in tool confirmation details', () => {
const historyItem: HistoryItem = {
id: 1,
type: 'tool_group',
tools: [
{
callId: '123',
name: 'run_shell_command',
description: 'Run a shell command',
resultDisplay: 'blank',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'exec',
title: 'Run Shell Command',
command: 'echo "\u001b[31mhello\u001b[0m"',
rootCommand: 'echo',
onConfirm: async () => {},
},
},
],
};
renderWithProviders(
<HistoryItemDisplay
item={historyItem}
terminalWidth={80}
isPending={false}
/>,
);
const passedProps = vi.mocked(ToolGroupMessage).mock.calls[0][0];
const confirmationDetails = passedProps.toolCalls[0]
.confirmationDetails as ToolExecuteConfirmationDetails;
expect(confirmationDetails.command).toBe(
'echo "\\u001b[31mhello\\u001b[0m"',
);
});
const longCode =
'# Example code block:\n' +
'```python\n' +
Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`).join('\n') +
'\n```';
it('should render a truncated gemini item', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini',
text: longCode,
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render a full gemini item when using availableTerminalHeightGemini', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini',
text: longCode,
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render a truncated gemini_content item', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini_content',
text: longCode,
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render a full gemini_content item when using availableTerminalHeightGemini', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini_content',
text: longCode,
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@@ -5,7 +5,8 @@
*/
import type React from 'react';
import { memo } from 'react';
import { useMemo } from 'react';
import { escapeAnsiCtrlCodes } from '../utils/textUtils.js';
import type { HistoryItem } from '../types.js';
import { UserMessage } from './messages/UserMessage.js';
import { UserShellMessage } from './messages/UserShellMessage.js';
@@ -16,24 +17,30 @@ import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
import { CompressionMessage } from './messages/CompressionMessage.js';
import { SummaryMessage } from './messages/SummaryMessage.js';
import { WarningMessage } from './messages/WarningMessage.js';
import { Box } from 'ink';
import { AboutBox } from './AboutBox.js';
import { StatsDisplay } from './StatsDisplay.js';
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { Help } from './Help.js';
import type { SlashCommand } from '../commands/types.js';
import { ExtensionsList } from './views/ExtensionsList.js';
import { getMCPServerStatus } from '@qwen-code/qwen-code-core';
import { ToolsList } from './views/ToolsList.js';
import { McpStatus } from './views/McpStatus.js';
interface HistoryItemDisplayProps {
item: HistoryItem;
availableTerminalHeight?: number;
terminalWidth: number;
isPending: boolean;
config: Config;
isFocused?: boolean;
commands?: readonly SlashCommand[];
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
availableTerminalHeightGemini?: number;
}
const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
@@ -41,68 +48,106 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
availableTerminalHeight,
terminalWidth,
isPending,
config,
commands,
isFocused = true,
}) => (
<Box flexDirection="column" key={item.id}>
{/* Render standard message types */}
{item.type === 'user' && <UserMessage text={item.text} />}
{item.type === 'user_shell' && <UserShellMessage text={item.text} />}
{item.type === 'gemini' && (
<GeminiMessage
text={item.text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/>
)}
{item.type === 'gemini_content' && (
<GeminiMessageContent
text={item.text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/>
)}
{item.type === 'info' && <InfoMessage text={item.text} />}
{item.type === 'error' && <ErrorMessage text={item.text} />}
{item.type === 'about' && (
<AboutBox
cliVersion={item.cliVersion}
osVersion={item.osVersion}
sandboxEnv={item.sandboxEnv}
modelVersion={item.modelVersion}
selectedAuthType={item.selectedAuthType}
gcpProject={item.gcpProject}
ideClient={item.ideClient}
/>
)}
{item.type === 'help' && commands && <Help commands={commands} />}
{item.type === 'stats' && <StatsDisplay duration={item.duration} />}
{item.type === 'model_stats' && <ModelStatsDisplay />}
{item.type === 'tool_stats' && <ToolStatsDisplay />}
{item.type === 'quit' && <SessionSummaryDisplay duration={item.duration} />}
{item.type === 'quit_confirmation' && (
<SessionSummaryDisplay duration={item.duration} />
)}
{item.type === 'tool_group' && (
<ToolGroupMessage
toolCalls={item.tools}
groupId={item.id}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
config={config}
isFocused={isFocused}
/>
)}
{item.type === 'compression' && (
<CompressionMessage compression={item.compression} />
)}
{item.type === 'summary' && <SummaryMessage summary={item.summary} />}
</Box>
);
activeShellPtyId,
embeddedShellFocused,
availableTerminalHeightGemini,
}) => {
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
HistoryItemDisplayComponent.displayName = 'HistoryItemDisplay';
return (
<Box flexDirection="column" key={itemForDisplay.id}>
{/* Render standard message types */}
{itemForDisplay.type === 'user' && (
<UserMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'user_shell' && (
<UserShellMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'gemini' && (
<GeminiMessage
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={
availableTerminalHeightGemini ?? availableTerminalHeight
}
terminalWidth={terminalWidth}
/>
)}
{itemForDisplay.type === 'gemini_content' && (
<GeminiMessageContent
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={
availableTerminalHeightGemini ?? availableTerminalHeight
}
terminalWidth={terminalWidth}
/>
)}
{itemForDisplay.type === 'info' && (
<InfoMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'warning' && (
<WarningMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'error' && (
<ErrorMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'about' && (
<AboutBox
cliVersion={itemForDisplay.cliVersion}
osVersion={itemForDisplay.osVersion}
sandboxEnv={itemForDisplay.sandboxEnv}
modelVersion={itemForDisplay.modelVersion}
selectedAuthType={itemForDisplay.selectedAuthType}
gcpProject={itemForDisplay.gcpProject}
ideClient={itemForDisplay.ideClient}
/>
)}
{itemForDisplay.type === 'help' && commands && (
<Help commands={commands} />
)}
{itemForDisplay.type === 'stats' && (
<StatsDisplay duration={itemForDisplay.duration} />
)}
{itemForDisplay.type === 'model_stats' && <ModelStatsDisplay />}
{itemForDisplay.type === 'tool_stats' && <ToolStatsDisplay />}
{itemForDisplay.type === 'quit' && (
<SessionSummaryDisplay duration={itemForDisplay.duration} />
)}
{itemForDisplay.type === 'quit_confirmation' && (
<SessionSummaryDisplay duration={itemForDisplay.duration} />
)}
{itemForDisplay.type === 'tool_group' && (
<ToolGroupMessage
toolCalls={itemForDisplay.tools}
groupId={itemForDisplay.id}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
isFocused={isFocused}
activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused}
/>
)}
{itemForDisplay.type === 'compression' && (
<CompressionMessage compression={itemForDisplay.compression} />
)}
{item.type === 'summary' && <SummaryMessage summary={item.summary} />}
{itemForDisplay.type === 'extensions_list' && <ExtensionsList />}
{itemForDisplay.type === 'tools_list' && (
<ToolsList
terminalWidth={terminalWidth}
tools={itemForDisplay.tools}
showDescriptions={itemForDisplay.showDescriptions}
/>
)}
{itemForDisplay.type === 'mcp_status' && (
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
)}
</Box>
);
};
export const HistoryItemDisplay = memo(HistoryItemDisplayComponent);
// Export alias for backward compatibility
export { HistoryItemDisplayComponent as HistoryItemDisplay };

View File

@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import * as processUtils from '../../utils/processUtils.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
describe('IdeTrustChangeDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the correct message for CONNECTION_CHANGE', () => {
const { lastFrame } = renderWithProviders(
<IdeTrustChangeDialog reason="CONNECTION_CHANGE" />,
);
const frameText = lastFrame();
expect(frameText).toContain(
'Workspace trust has changed due to a change in the IDE connection.',
);
expect(frameText).toContain("Press 'r' to restart Gemini");
});
it('renders the correct message for TRUST_CHANGE', () => {
const { lastFrame } = renderWithProviders(
<IdeTrustChangeDialog reason="TRUST_CHANGE" />,
);
const frameText = lastFrame();
expect(frameText).toContain(
'Workspace trust has changed due to a change in the IDE trust.',
);
expect(frameText).toContain("Press 'r' to restart Gemini");
});
it('renders a generic message and logs an error for NONE reason', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const { lastFrame } = renderWithProviders(
<IdeTrustChangeDialog reason="NONE" />,
);
const frameText = lastFrame();
expect(frameText).toContain('Workspace trust has changed.');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'IdeTrustChangeDialog rendered with unexpected reason "NONE"',
);
});
it('calls relaunchApp when "r" is pressed', () => {
const relaunchAppSpy = vi.spyOn(processUtils, 'relaunchApp');
const { stdin } = renderWithProviders(
<IdeTrustChangeDialog reason="NONE" />,
);
stdin.write('r');
expect(relaunchAppSpy).toHaveBeenCalledTimes(1);
});
it('calls relaunchApp when "R" is pressed', () => {
const relaunchAppSpy = vi.spyOn(processUtils, 'relaunchApp');
const { stdin } = renderWithProviders(
<IdeTrustChangeDialog reason="CONNECTION_CHANGE" />,
);
stdin.write('R');
expect(relaunchAppSpy).toHaveBeenCalledTimes(1);
});
it('does not call relaunchApp when another key is pressed', async () => {
const relaunchAppSpy = vi.spyOn(processUtils, 'relaunchApp');
const { stdin } = renderWithProviders(
<IdeTrustChangeDialog reason="CONNECTION_CHANGE" />,
);
stdin.write('a');
// Give it a moment to ensure no async actions are triggered
await new Promise((resolve) => setTimeout(resolve, 50));
expect(relaunchAppSpy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { relaunchApp } from '../../utils/processUtils.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
interface IdeTrustChangeDialogProps {
reason: RestartReason;
}
export const IdeTrustChangeDialog = ({ reason }: IdeTrustChangeDialogProps) => {
useKeypress(
(key) => {
if (key.name === 'r' || key.name === 'R') {
relaunchApp();
}
},
{ isActive: true },
);
let message = 'Workspace trust has changed.';
if (reason === 'NONE') {
// This should not happen, but provides a fallback and a debug log.
console.error(
'IdeTrustChangeDialog rendered with unexpected reason "NONE"',
);
} else if (reason === 'CONNECTION_CHANGE') {
message =
'Workspace trust has changed due to a change in the IDE connection.';
} else if (reason === 'TRUST_CHANGE') {
message = 'Workspace trust has changed due to a change in the IDE trust.';
}
return (
<Box borderStyle="round" borderColor={theme.status.warning} paddingX={1}>
<Text color={theme.status.warning}>
{message} Press &apos;r&apos; to restart Gemini to apply the changes.
</Text>
</Box>
);
};

View File

@@ -10,6 +10,7 @@ import type { InputPromptProps } from './InputPrompt.js';
import { InputPrompt } from './InputPrompt.js';
import type { TextBuffer } from './shared/text-buffer.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import * as path from 'node:path';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import { CommandKind } from '../commands/types.js';
@@ -20,12 +21,17 @@ import type { UseCommandCompletionReturn } from '../hooks/useCommandCompletion.j
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import type { UseInputHistoryReturn } from '../hooks/useInputHistory.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import type { UseReverseSearchCompletionReturn } from '../hooks/useReverseSearchCompletion.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import * as clipboardUtils from '../utils/clipboardUtils.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import stripAnsi from 'strip-ansi';
import chalk from 'chalk';
vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCommandCompletion.js');
vi.mock('../hooks/useInputHistory.js');
vi.mock('../hooks/useReverseSearchCompletion.js');
vi.mock('../utils/clipboardUtils.js');
const mockSlashCommands: SlashCommand[] = [
@@ -81,12 +87,16 @@ describe('InputPrompt', () => {
let mockShellHistory: UseShellHistoryReturn;
let mockCommandCompletion: UseCommandCompletionReturn;
let mockInputHistory: UseInputHistoryReturn;
let mockReverseSearchCompletion: UseReverseSearchCompletionReturn;
let mockBuffer: TextBuffer;
let mockCommandContext: CommandContext;
const mockedUseShellHistory = vi.mocked(useShellHistory);
const mockedUseCommandCompletion = vi.mocked(useCommandCompletion);
const mockedUseInputHistory = vi.mocked(useInputHistory);
const mockedUseReverseSearchCompletion = vi.mocked(
useReverseSearchCompletion,
);
beforeEach(() => {
vi.resetAllMocks();
@@ -103,6 +113,7 @@ describe('InputPrompt', () => {
mockBuffer.cursor = [0, newText.length];
mockBuffer.viewportVisualLines = [newText];
mockBuffer.allVisualLines = [newText];
mockBuffer.visualToLogicalMap = [[0, 0]];
}),
replaceRangeByOffset: vi.fn(),
viewportVisualLines: [''],
@@ -118,16 +129,17 @@ describe('InputPrompt', () => {
killLineLeft: vi.fn(),
openInExternalEditor: vi.fn(),
newline: vi.fn(),
undo: vi.fn(),
redo: vi.fn(),
backspace: vi.fn(),
preferredCol: null,
selectionAnchor: null,
insert: vi.fn(),
del: vi.fn(),
undo: vi.fn(),
redo: vi.fn(),
replaceRange: vi.fn(),
deleteWordLeft: vi.fn(),
deleteWordRight: vi.fn(),
visualToLogicalMap: [[0, 0]],
} as unknown as TextBuffer;
mockShellHistory = {
@@ -167,6 +179,21 @@ describe('InputPrompt', () => {
};
mockedUseInputHistory.mockReturnValue(mockInputHistory);
mockReverseSearchCompletion = {
suggestions: [],
activeSuggestionIndex: -1,
visibleStartIndex: 0,
showSuggestions: false,
isLoadingSuggestions: false,
navigateUp: vi.fn(),
navigateDown: vi.fn(),
handleAutocomplete: vi.fn(),
resetCompletionState: vi.fn(),
};
mockedUseReverseSearchCompletion.mockReturnValue(
mockReverseSearchCompletion,
);
props = {
buffer: mockBuffer,
onSubmit: vi.fn(),
@@ -184,6 +211,7 @@ describe('InputPrompt', () => {
commandContext: mockCommandContext,
shellModeActive: false,
setShellModeActive: vi.fn(),
approvalMode: ApprovalMode.DEFAULT,
inputWidth: 80,
suggestionsWidth: 80,
focus: true,
@@ -1208,6 +1236,263 @@ describe('InputPrompt', () => {
});
});
describe('Highlighting and Cursor Display', () => {
it('should display cursor mid-word by highlighting the character', async () => {
mockBuffer.text = 'hello world';
mockBuffer.lines = ['hello world'];
mockBuffer.viewportVisualLines = ['hello world'];
mockBuffer.visualCursor = [0, 3]; // cursor on the second 'l'
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
const frame = stdout.lastFrame();
// The component will render the text with the character at the cursor inverted.
expect(frame).toContain(`hel${chalk.inverse('l')}o world`);
unmount();
});
it('should display cursor at the beginning of the line', async () => {
mockBuffer.text = 'hello';
mockBuffer.lines = ['hello'];
mockBuffer.viewportVisualLines = ['hello'];
mockBuffer.visualCursor = [0, 0]; // cursor on 'h'
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
const frame = stdout.lastFrame();
expect(frame).toContain(`${chalk.inverse('h')}ello`);
unmount();
});
it('should display cursor at the end of the line as an inverted space', async () => {
mockBuffer.text = 'hello';
mockBuffer.lines = ['hello'];
mockBuffer.viewportVisualLines = ['hello'];
mockBuffer.visualCursor = [0, 5]; // cursor after 'o'
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
const frame = stdout.lastFrame();
expect(frame).toContain(`hello${chalk.inverse(' ')}`);
unmount();
});
it('should display cursor correctly on a highlighted token', async () => {
mockBuffer.text = 'run @path/to/file';
mockBuffer.lines = ['run @path/to/file'];
mockBuffer.viewportVisualLines = ['run @path/to/file'];
mockBuffer.visualCursor = [0, 9]; // cursor on 't' in 'to'
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
const frame = stdout.lastFrame();
// The token '@path/to/file' is colored, and the cursor highlights one char inside it.
expect(frame).toContain(`@path/${chalk.inverse('t')}o/file`);
unmount();
});
it('should display cursor correctly for multi-byte unicode characters', async () => {
const text = 'hello 👍 world';
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualCursor = [0, 6]; // cursor on '👍'
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
const frame = stdout.lastFrame();
expect(frame).toContain(`hello ${chalk.inverse('👍')} world`);
unmount();
});
it('should display cursor at the end of a line with unicode characters', async () => {
const text = 'hello 👍';
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualCursor = [0, 8]; // cursor after '👍' (length is 6 + 2 for emoji)
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
const frame = stdout.lastFrame();
expect(frame).toContain(`hello 👍${chalk.inverse(' ')}`);
unmount();
});
it('should display cursor on an empty line', async () => {
mockBuffer.text = '';
mockBuffer.lines = [''];
mockBuffer.viewportVisualLines = [''];
mockBuffer.visualCursor = [0, 0];
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
const frame = stdout.lastFrame();
expect(frame).toContain(chalk.inverse(' '));
unmount();
});
it('should display cursor on a space between words', async () => {
mockBuffer.text = 'hello world';
mockBuffer.lines = ['hello world'];
mockBuffer.viewportVisualLines = ['hello world'];
mockBuffer.visualCursor = [0, 5]; // cursor on the space
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
const frame = stdout.lastFrame();
expect(frame).toContain(`hello${chalk.inverse(' ')}world`);
unmount();
});
it('should display cursor in the middle of a line in a multiline block', async () => {
const text = 'first line\nsecond line\nthird line';
mockBuffer.text = text;
mockBuffer.lines = text.split('\n');
mockBuffer.viewportVisualLines = text.split('\n');
mockBuffer.visualCursor = [1, 3]; // cursor on 'o' in 'second'
mockBuffer.visualToLogicalMap = [
[0, 0],
[1, 0],
[2, 0],
];
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
const frame = stdout.lastFrame();
expect(frame).toContain(`sec${chalk.inverse('o')}nd line`);
unmount();
});
it('should display cursor at the beginning of a line in a multiline block', async () => {
const text = 'first line\nsecond line';
mockBuffer.text = text;
mockBuffer.lines = text.split('\n');
mockBuffer.viewportVisualLines = text.split('\n');
mockBuffer.visualCursor = [1, 0]; // cursor on 's' in 'second'
mockBuffer.visualToLogicalMap = [
[0, 0],
[1, 0],
];
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
const frame = stdout.lastFrame();
expect(frame).toContain(`${chalk.inverse('s')}econd line`);
unmount();
});
it('should display cursor at the end of a line in a multiline block', async () => {
const text = 'first line\nsecond line';
mockBuffer.text = text;
mockBuffer.lines = text.split('\n');
mockBuffer.viewportVisualLines = text.split('\n');
mockBuffer.visualCursor = [0, 10]; // cursor after 'first line'
mockBuffer.visualToLogicalMap = [
[0, 0],
[1, 0],
];
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
const frame = stdout.lastFrame();
expect(frame).toContain(`first line${chalk.inverse(' ')}`);
unmount();
});
it('should display cursor on a blank line in a multiline block', async () => {
const text = 'first line\n\nthird line';
mockBuffer.text = text;
mockBuffer.lines = text.split('\n');
mockBuffer.viewportVisualLines = text.split('\n');
mockBuffer.visualCursor = [1, 0]; // cursor on the blank line
mockBuffer.visualToLogicalMap = [
[0, 0],
[1, 0],
[2, 0],
];
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
const frame = stdout.lastFrame();
const lines = frame!.split('\n');
// The line with the cursor should just be an inverted space inside the box border
expect(
lines.find((l) => l.includes(chalk.inverse(' '))),
).not.toBeUndefined();
unmount();
});
});
describe('multiline rendering', () => {
it('should correctly render multiline input including blank lines', async () => {
const text = 'hello\n\nworld';
mockBuffer.text = text;
mockBuffer.lines = text.split('\n');
mockBuffer.viewportVisualLines = text.split('\n');
mockBuffer.allVisualLines = text.split('\n');
mockBuffer.visualCursor = [2, 5]; // cursor at the end of "world"
// Provide a visual-to-logical mapping for each visual line
mockBuffer.visualToLogicalMap = [
[0, 0], // 'hello' starts at col 0 of logical line 0
[1, 0], // '' (blank) is logical line 1, col 0
[2, 0], // 'world' is logical line 2, col 0
];
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
const frame = stdout.lastFrame();
// Check that all lines, including the empty one, are rendered.
// This implicitly tests that the Box wrapper provides height for the empty line.
expect(frame).toContain('hello');
expect(frame).toContain(`world${chalk.inverse(' ')}`);
const outputLines = frame!.split('\n');
// The number of lines should be 2 for the border plus 3 for the content.
expect(outputLines.length).toBe(5);
unmount();
});
});
describe('multiline paste', () => {
it.each([
{
@@ -1245,6 +1530,77 @@ describe('InputPrompt', () => {
});
});
describe('paste auto-submission protection', () => {
it('should prevent auto-submission immediately after paste with newlines', async () => {
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// First type some text manually
stdin.write('test command');
await wait();
// Simulate a paste operation (this should set the paste protection)
stdin.write(`\x1b[200~\npasted content\x1b[201~`);
await wait();
// Simulate an Enter key press immediately after paste
stdin.write('\r');
await wait();
// Verify that onSubmit was NOT called due to recent paste protection
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
});
it('should allow submission after paste protection timeout', async () => {
// Set up buffer with text for submission
props.buffer.text = 'test command';
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// Simulate a paste operation (this sets the protection)
stdin.write(`\x1b[200~\npasted\x1b[201~`);
await wait();
// Wait for the protection timeout to naturally expire
await new Promise((resolve) => setTimeout(resolve, 600));
// Now Enter should work normally
stdin.write('\r');
await wait();
// Verify that onSubmit was called after the timeout
expect(props.onSubmit).toHaveBeenCalledWith('test command');
unmount();
});
it('should not interfere with normal Enter key submission when no recent paste', async () => {
// Set up buffer with text before rendering to ensure submission works
props.buffer.text = 'normal command';
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// Press Enter without any recent paste
stdin.write('\r');
await wait();
// Verify that onSubmit was called normally
expect(props.onSubmit).toHaveBeenCalledWith('normal command');
unmount();
});
});
describe('enhanced input UX - double ESC clear functionality', () => {
it('should clear buffer on second ESC press', async () => {
const onEscapePromptChange = vi.fn();
@@ -1372,12 +1728,27 @@ describe('InputPrompt', () => {
});
it('invokes reverse search on Ctrl+R', async () => {
// Mock the reverse search completion to return suggestions
mockedUseReverseSearchCompletion.mockReturnValue({
...mockReverseSearchCompletion,
suggestions: [
{ label: 'echo hello', value: 'echo hello' },
{ label: 'echo world', value: 'echo world' },
{ label: 'ls', value: 'ls' },
],
showSuggestions: true,
activeSuggestionIndex: 0,
});
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x12');
// Trigger reverse search with Ctrl+R
act(() => {
stdin.write('\x12');
});
await wait();
const frame = stdout.lastFrame();
@@ -1409,6 +1780,27 @@ describe('InputPrompt', () => {
});
it('completes the highlighted entry on Tab and exits reverse-search', async () => {
// Mock the reverse search completion
const mockHandleAutocomplete = vi.fn(() => {
props.buffer.setText('echo hello');
});
mockedUseReverseSearchCompletion.mockImplementation(
(buffer, shellHistory, reverseSearchActive) => ({
...mockReverseSearchCompletion,
suggestions: reverseSearchActive
? [
{ label: 'echo hello', value: 'echo hello' },
{ label: 'echo world', value: 'echo world' },
{ label: 'ls', value: 'ls' },
]
: [],
showSuggestions: reverseSearchActive,
activeSuggestionIndex: reverseSearchActive ? 0 : -1,
handleAutocomplete: mockHandleAutocomplete,
}),
);
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
@@ -1426,19 +1818,26 @@ describe('InputPrompt', () => {
act(() => {
stdin.write('\t');
});
await wait();
await waitFor(
() => {
expect(stdout.lastFrame()).not.toContain('(r:)');
},
{ timeout: 5000 },
); // Increase timeout
expect(mockHandleAutocomplete).toHaveBeenCalledWith(0);
expect(props.buffer.setText).toHaveBeenCalledWith('echo hello');
unmount();
});
}, 15000);
it('submits the highlighted entry on Enter and exits reverse-search', async () => {
// Mock the reverse search completion to return suggestions
mockedUseReverseSearchCompletion.mockReturnValue({
...mockReverseSearchCompletion,
suggestions: [
{ label: 'echo hello', value: 'echo hello' },
{ label: 'echo world', value: 'echo world' },
{ label: 'ls', value: 'ls' },
],
showSuggestions: true,
activeSuggestionIndex: 0,
});
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
@@ -1520,4 +1919,206 @@ describe('InputPrompt', () => {
unmount();
});
});
describe('command search (Ctrl+R when not in shell)', () => {
it('enters command search on Ctrl+R and shows suggestions', async () => {
props.shellModeActive = false;
vi.mocked(useReverseSearchCompletion).mockImplementation(
(buffer, data, isActive) => ({
...mockReverseSearchCompletion,
suggestions: isActive
? [
{ label: 'git commit -m "msg"', value: 'git commit -m "msg"' },
{ label: 'git push', value: 'git push' },
]
: [],
showSuggestions: !!isActive,
activeSuggestionIndex: isActive ? 0 : -1,
}),
);
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
act(() => {
stdin.write('\x12'); // Ctrl+R
});
await wait();
const frame = stdout.lastFrame() ?? '';
expect(frame).toContain('(r:)');
expect(frame).toContain('git commit');
expect(frame).toContain('git push');
unmount();
});
it('expands and collapses long suggestion via Right/Left arrows', async () => {
props.shellModeActive = false;
const longValue = 'l'.repeat(200);
vi.mocked(useReverseSearchCompletion).mockReturnValue({
...mockReverseSearchCompletion,
suggestions: [{ label: longValue, value: longValue, matchedIndex: 0 }],
showSuggestions: true,
activeSuggestionIndex: 0,
visibleStartIndex: 0,
isLoadingSuggestions: false,
});
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x12');
await wait();
expect(clean(stdout.lastFrame())).toContain('→');
stdin.write('\u001B[C');
await wait(200);
expect(clean(stdout.lastFrame())).toContain('←');
expect(stdout.lastFrame()).toMatchSnapshot(
'command-search-expanded-match',
);
stdin.write('\u001B[D');
await wait();
expect(clean(stdout.lastFrame())).toContain('→');
expect(stdout.lastFrame()).toMatchSnapshot(
'command-search-collapsed-match',
);
unmount();
});
it('renders match window and expanded view (snapshots)', async () => {
props.shellModeActive = false;
props.buffer.setText('commit');
const label = 'git commit -m "feat: add search" in src/app';
const matchedIndex = label.indexOf('commit');
vi.mocked(useReverseSearchCompletion).mockReturnValue({
...mockReverseSearchCompletion,
suggestions: [{ label, value: label, matchedIndex }],
showSuggestions: true,
activeSuggestionIndex: 0,
visibleStartIndex: 0,
isLoadingSuggestions: false,
});
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x12');
await wait();
expect(stdout.lastFrame()).toMatchSnapshot(
'command-search-collapsed-match',
);
stdin.write('\u001B[C');
await wait();
expect(stdout.lastFrame()).toMatchSnapshot(
'command-search-expanded-match',
);
unmount();
});
it('does not show expand/collapse indicator for short suggestions', async () => {
props.shellModeActive = false;
const shortValue = 'echo hello';
vi.mocked(useReverseSearchCompletion).mockReturnValue({
...mockReverseSearchCompletion,
suggestions: [{ label: shortValue, value: shortValue }],
showSuggestions: true,
activeSuggestionIndex: 0,
visibleStartIndex: 0,
isLoadingSuggestions: false,
});
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x12');
await wait();
const frame = clean(stdout.lastFrame());
expect(frame).not.toContain('→');
expect(frame).not.toContain('←');
unmount();
});
});
describe('snapshots', () => {
it('should render correctly in shell mode', async () => {
props.shellModeActive = true;
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
expect(stdout.lastFrame()).toMatchSnapshot();
unmount();
});
it('should render correctly when accepting edits', async () => {
props.approvalMode = ApprovalMode.AUTO_EDIT;
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
expect(stdout.lastFrame()).toMatchSnapshot();
unmount();
});
it('should render correctly in yolo mode', async () => {
props.approvalMode = ApprovalMode.YOLO;
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
expect(stdout.lastFrame()).toMatchSnapshot();
unmount();
});
it('should not show inverted cursor when shell is focused', async () => {
props.isEmbeddedShellFocused = true;
props.focus = false;
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
expect(stdout.lastFrame()).not.toContain(`{chalk.inverse(' ')}`);
// This snapshot is good to make sure there was an input prompt but does
// not show the inverted cursor because snapshots do not show colors.
expect(stdout.lastFrame()).toMatchSnapshot();
unmount();
});
});
it('should still allow input when shell is not focused', async () => {
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
shellFocus: false,
});
await wait();
stdin.write('a');
await wait();
expect(mockBuffer.handleInput).toHaveBeenCalled();
unmount();
});
});
function clean(str: string | undefined): string {
if (!str) return '';
// Remove ANSI escape codes and trim whitespace
return stripAnsi(str).trim();
}

View File

@@ -7,8 +7,8 @@
import type React from 'react';
import { useCallback, useEffect, useState, useRef } from 'react';
import { Box, Text } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import type { TextBuffer } from './shared/text-buffer.js';
import { logicalPosToOffset } from './shared/text-buffer.js';
@@ -23,6 +23,11 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import {
parseInputForHighlighting,
buildSegmentsForVisualSlice,
} from '../utils/highlight.js';
import {
clipboardHasImage,
saveClipboardImage,
@@ -30,7 +35,7 @@ import {
} from '../utils/clipboardUtils.js';
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
export interface InputPromptProps {
buffer: TextBuffer;
onSubmit: (value: string) => void;
@@ -45,10 +50,37 @@ export interface InputPromptProps {
suggestionsWidth: number;
shellModeActive: boolean;
setShellModeActive: (value: boolean) => void;
approvalMode: ApprovalMode;
onEscapePromptChange?: (showPrompt: boolean) => void;
vimHandleInput?: (key: Key) => boolean;
isEmbeddedShellFocused?: boolean;
}
// The input content, input container, and input suggestions list may have different widths
export const calculatePromptWidths = (terminalWidth: number) => {
const widthFraction = 0.9;
const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2)
const PROMPT_PREFIX_WIDTH = 2; // '> ' or '! '
const MIN_CONTENT_WIDTH = 2;
const innerContentWidth =
Math.floor(terminalWidth * widthFraction) -
FRAME_PADDING_AND_BORDER -
PROMPT_PREFIX_WIDTH;
const inputWidth = Math.max(MIN_CONTENT_WIDTH, innerContentWidth);
const FRAME_OVERHEAD = FRAME_PADDING_AND_BORDER + PROMPT_PREFIX_WIDTH;
const containerWidth = inputWidth + FRAME_OVERHEAD;
const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 1.0));
return {
inputWidth,
containerWidth,
suggestionsWidth,
frameOverhead: FRAME_OVERHEAD,
} as const;
};
export const InputPrompt: React.FC<InputPromptProps> = ({
buffer,
onSubmit,
@@ -63,13 +95,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
suggestionsWidth,
shellModeActive,
setShellModeActive,
approvalMode,
onEscapePromptChange,
vimHandleInput,
isEmbeddedShellFocused,
}) => {
const isShellFocused = useShellFocusState();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
const [recentPasteTime, setRecentPasteTime] = useState<number | null>(null);
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [dirs, setDirs] = useState<readonly string[]>(
config.getWorkspaceContext().getDirectories(),
@@ -81,12 +118,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}, [dirs.length, dirsChanged]);
const [reverseSearchActive, setReverseSearchActive] = useState(false);
const [commandSearchActive, setCommandSearchActive] = useState(false);
const [textBeforeReverseSearch, setTextBeforeReverseSearch] = useState('');
const [cursorPosition, setCursorPosition] = useState<[number, number]>([
0, 0,
]);
const shellHistory = useShellHistory(config.getProjectRoot(), config.storage);
const historyData = shellHistory.history;
const [expandedSuggestionIndex, setExpandedSuggestionIndex] =
useState<number>(-1);
const shellHistory = useShellHistory(config.getProjectRoot());
const shellHistoryData = shellHistory.history;
const completion = useCommandCompletion(
buffer,
@@ -100,12 +140,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const reverseSearchCompletion = useReverseSearchCompletion(
buffer,
historyData,
shellHistoryData,
reverseSearchActive,
);
const commandSearchCompletion = useReverseSearchCompletion(
buffer,
userMessages,
commandSearchActive,
);
const resetCompletionState = completion.resetCompletionState;
const resetReverseSearchCompletionState =
reverseSearchCompletion.resetCompletionState;
const resetCommandSearchCompletionState =
commandSearchCompletion.resetCompletionState;
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
const resetEscapeState = useCallback(() => {
if (escapeTimerRef.current) {
@@ -129,6 +180,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
}
if (pasteTimeoutRef.current) {
clearTimeout(pasteTimeoutRef.current);
}
},
[],
);
@@ -178,6 +232,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (justNavigatedHistory) {
resetCompletionState();
resetReverseSearchCompletionState();
resetCommandSearchCompletionState();
setExpandedSuggestionIndex(-1);
setJustNavigatedHistory(false);
}
}, [
@@ -186,6 +242,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetCompletionState,
setJustNavigatedHistory,
resetReverseSearchCompletionState,
resetCommandSearchCompletionState,
]);
// Handle clipboard image pasting with Ctrl+V
@@ -238,12 +295,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const handleInput = useCallback(
(key: Key) => {
// TODO(jacobr): this special case is likely not needed anymore.
// We should probably stop supporting paste if the InputPrompt is not
// focused.
/// We want to handle paste even when not focused to support drag and drop.
if (!focus && !key.paste) {
return;
}
if (key.paste) {
// Record paste time to prevent accidental auto-submission
setRecentPasteTime(Date.now());
// Clear any existing paste timeout
if (pasteTimeoutRef.current) {
clearTimeout(pasteTimeoutRef.current);
}
// Clear the paste protection after a safe delay
pasteTimeoutRef.current = setTimeout(() => {
setRecentPasteTime(null);
pasteTimeoutRef.current = null;
}, 500);
// Ensure we never accidentally interpret paste as regular input.
buffer.handleInput(key);
return;
@@ -271,9 +345,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (keyMatchers[Command.ESCAPE](key)) {
if (reverseSearchActive) {
setReverseSearchActive(false);
reverseSearchCompletion.resetCompletionState();
const cancelSearch = (
setActive: (active: boolean) => void,
resetCompletion: () => void,
) => {
setActive(false);
resetCompletion();
buffer.setText(textBeforeReverseSearch);
const offset = logicalPosToOffset(
buffer.lines,
@@ -281,8 +358,24 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
cursorPosition[1],
);
buffer.moveToOffset(offset);
setExpandedSuggestionIndex(-1);
};
if (reverseSearchActive) {
cancelSearch(
setReverseSearchActive,
reverseSearchCompletion.resetCompletionState,
);
return;
}
if (commandSearchActive) {
cancelSearch(
setCommandSearchActive,
commandSearchCompletion.resetCompletionState,
);
return;
}
if (shellModeActive) {
setShellModeActive(false);
resetEscapeState();
@@ -291,6 +384,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (completion.showSuggestions) {
completion.resetCompletionState();
setExpandedSuggestionIndex(-1);
resetEscapeState();
return;
}
@@ -329,14 +423,24 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
if (reverseSearchActive) {
if (reverseSearchActive || commandSearchActive) {
const isCommandSearch = commandSearchActive;
const sc = isCommandSearch
? commandSearchCompletion
: reverseSearchCompletion;
const {
activeSuggestionIndex,
navigateUp,
navigateDown,
showSuggestions,
suggestions,
} = reverseSearchCompletion;
} = sc;
const setActive = isCommandSearch
? setCommandSearchActive
: setReverseSearchActive;
const resetState = sc.resetCompletionState;
if (showSuggestions) {
if (keyMatchers[Command.NAVIGATION_UP](key)) {
@@ -347,10 +451,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
navigateDown();
return;
}
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
setExpandedSuggestionIndex(-1);
return;
}
}
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
setExpandedSuggestionIndex(activeSuggestionIndex);
return;
}
}
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex);
reverseSearchCompletion.resetCompletionState();
setReverseSearchActive(false);
sc.handleAutocomplete(activeSuggestionIndex);
resetState();
setActive(false);
return;
}
}
@@ -361,8 +477,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
? suggestions[activeSuggestionIndex].value
: buffer.text;
handleSubmitAndClear(textToSubmit);
reverseSearchCompletion.resetCompletionState();
setReverseSearchActive(false);
resetState();
setActive(false);
return;
}
@@ -385,10 +501,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (completion.suggestions.length > 1) {
if (keyMatchers[Command.COMPLETION_UP](key)) {
completion.navigateUp();
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return;
}
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
completion.navigateDown();
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return;
}
}
@@ -401,6 +519,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
: completion.activeSuggestionIndex;
if (targetIndex < completion.suggestions.length) {
completion.handleAutocomplete(targetIndex);
setExpandedSuggestionIndex(-1); // Reset expansion after selection
}
}
return;
@@ -418,6 +537,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (!shellModeActive) {
if (keyMatchers[Command.REVERSE_SEARCH](key)) {
setCommandSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
}
if (keyMatchers[Command.HISTORY_UP](key)) {
inputHistory.navigateUp();
return;
@@ -459,6 +585,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (keyMatchers[Command.SUBMIT](key)) {
if (buffer.text.trim()) {
// Check if a paste operation occurred recently to prevent accidental auto-submission
if (recentPasteTime !== null) {
// Paste occurred recently, ignore this submit to prevent auto-execution
return;
}
const [row, col] = buffer.cursor;
const line = buffer.lines[row];
const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
@@ -506,6 +638,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
buffer.deleteWordLeft();
return;
}
// External editor
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
buffer.openInExternalEditor();
@@ -530,6 +667,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
!key.meta
) {
completion.promptCompletion.clear();
setExpandedSuggestionIndex(-1);
}
},
[
@@ -552,12 +690,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchActive,
textBeforeReverseSearch,
cursorPosition,
recentPasteTime,
commandSearchActive,
commandSearchCompletion,
],
);
useKeypress(handleInput, {
isActive: true,
});
useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
@@ -676,18 +815,47 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
]);
const { inlineGhost, additionalLines } = getGhostTextLines();
const getActiveCompletion = () => {
if (commandSearchActive) return commandSearchCompletion;
if (reverseSearchActive) return reverseSearchCompletion;
return completion;
};
const activeCompletion = getActiveCompletion();
const shouldShowSuggestions = activeCompletion.showSuggestions;
const showAutoAcceptStyling =
!shellModeActive && approvalMode === ApprovalMode.AUTO_EDIT;
const showYoloStyling =
!shellModeActive && approvalMode === ApprovalMode.YOLO;
let statusColor: string | undefined;
let statusText = '';
if (shellModeActive) {
statusColor = theme.ui.symbol;
statusText = 'Shell mode';
} else if (showYoloStyling) {
statusColor = theme.status.error;
statusText = 'YOLO mode';
} else if (showAutoAcceptStyling) {
statusColor = theme.status.warning;
statusText = 'Accepting edits';
}
return (
<>
<Box
borderStyle="round"
borderColor={
shellModeActive ? theme.status.warning : theme.border.focused
isShellFocused && !isEmbeddedShellFocused
? (statusColor ?? theme.border.focused)
: theme.border.default
}
paddingX={1}
>
<Text
color={shellModeActive ? theme.status.warning : theme.text.accent}
color={statusColor ?? theme.text.accent}
aria-label={statusText || undefined}
>
{shellModeActive ? (
reverseSearchActive ? (
@@ -698,15 +866,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
(r:){' '}
</Text>
) : (
'! '
'!'
)
) : commandSearchActive ? (
<Text color={theme.text.accent}>(r:) </Text>
) : showYoloStyling ? (
'*'
) : (
'> '
)}
'>'
)}{' '}
</Text>
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
focus ? (
showCursor ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
@@ -717,70 +889,113 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
) : (
linesToRender
.map((lineText, visualIdxInRenderedSet) => {
const absoluteVisualIdx =
scrollVisualRow + visualIdxInRenderedSet;
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
const cursorVisualRow =
cursorVisualRowAbsolute - scrollVisualRow;
let display = cpSlice(lineText, 0, inputWidth);
const isOnCursorLine =
focus && visualIdxInRenderedSet === cursorVisualRow;
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
const ghostWidth = stringWidth(currentLineGhost);
const renderedLine: React.ReactNode[] = [];
if (focus && visualIdxInRenderedSet === cursorVisualRow) {
const relativeVisualColForHighlight = cursorVisualColAbsolute;
const [logicalLineIdx, logicalStartCol] = mapEntry;
const logicalLine = buffer.lines[logicalLineIdx] || '';
const tokens = parseInputForHighlighting(
logicalLine,
logicalLineIdx,
);
if (relativeVisualColForHighlight >= 0) {
if (relativeVisualColForHighlight < cpLen(display)) {
const charToHighlight =
cpSlice(
display,
relativeVisualColForHighlight,
relativeVisualColForHighlight + 1,
) || ' ';
const highlighted = chalk.inverse(charToHighlight);
display =
cpSlice(display, 0, relativeVisualColForHighlight) +
highlighted +
cpSlice(display, relativeVisualColForHighlight + 1);
} else if (
relativeVisualColForHighlight === cpLen(display)
const visualStart = logicalStartCol;
const visualEnd = logicalStartCol + cpLen(lineText);
const segments = buildSegmentsForVisualSlice(
tokens,
visualStart,
visualEnd,
);
let charCount = 0;
segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text);
let display = seg.text;
if (isOnCursorLine) {
const relativeVisualColForHighlight =
cursorVisualColAbsolute;
const segStart = charCount;
const segEnd = segStart + segLen;
if (
relativeVisualColForHighlight >= segStart &&
relativeVisualColForHighlight < segEnd
) {
if (!currentLineGhost) {
display = display + chalk.inverse(' ');
}
const charToHighlight = cpSlice(
seg.text,
relativeVisualColForHighlight - segStart,
relativeVisualColForHighlight - segStart + 1,
);
const highlighted = showCursor
? chalk.inverse(charToHighlight)
: charToHighlight;
display =
cpSlice(
seg.text,
0,
relativeVisualColForHighlight - segStart,
) +
highlighted +
cpSlice(
seg.text,
relativeVisualColForHighlight - segStart + 1,
);
}
charCount = segEnd;
}
const color =
seg.type === 'command' || seg.type === 'file'
? theme.text.accent
: theme.text.primary;
renderedLine.push(
<Text key={`token-${segIdx}`} color={color}>
{display}
</Text>,
);
});
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
if (
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
if (!currentLineGhost) {
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') : ' '}
</Text>,
);
}
}
const showCursorBeforeGhost =
focus &&
visualIdxInRenderedSet === cursorVisualRow &&
cursorVisualColAbsolute ===
// eslint-disable-next-line no-control-regex
cpLen(display.replace(/\x1b\[[0-9;]*m/g, '')) &&
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText) &&
currentLineGhost;
const actualDisplayWidth = stringWidth(display);
const cursorWidth = showCursorBeforeGhost ? 1 : 0;
const totalContentWidth =
actualDisplayWidth + cursorWidth + ghostWidth;
const trailingPadding = Math.max(
0,
inputWidth - totalContentWidth,
);
return (
<Text key={`line-${visualIdxInRenderedSet}`}>
{display}
{showCursorBeforeGhost && chalk.inverse(' ')}
{currentLineGhost && (
<Text color={theme.text.secondary}>
{currentLineGhost}
</Text>
)}
{trailingPadding > 0 && ' '.repeat(trailingPadding)}
</Text>
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
<Text>
{renderedLine}
{showCursorBeforeGhost &&
(showCursor ? chalk.inverse(' ') : ' ')}
{currentLineGhost && (
<Text color={theme.text.secondary}>
{currentLineGhost}
</Text>
)}
</Text>
</Box>
);
})
.concat(
@@ -803,27 +1018,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
)}
</Box>
</Box>
{completion.showSuggestions && (
{shouldShowSuggestions && (
<Box paddingRight={2}>
<SuggestionsDisplay
suggestions={completion.suggestions}
activeIndex={completion.activeSuggestionIndex}
isLoading={completion.isLoadingSuggestions}
suggestions={activeCompletion.suggestions}
activeIndex={activeCompletion.activeSuggestionIndex}
isLoading={activeCompletion.isLoadingSuggestions}
width={suggestionsWidth}
scrollOffset={completion.visibleStartIndex}
userInput={buffer.text}
/>
</Box>
)}
{reverseSearchActive && (
<Box paddingRight={2}>
<SuggestionsDisplay
suggestions={reverseSearchCompletion.suggestions}
activeIndex={reverseSearchCompletion.activeSuggestionIndex}
isLoading={reverseSearchCompletion.isLoadingSuggestions}
width={suggestionsWidth}
scrollOffset={reverseSearchCompletion.visibleStartIndex}
scrollOffset={activeCompletion.visibleStartIndex}
userInput={buffer.text}
mode={
buffer.text.startsWith('/') &&
!reverseSearchActive &&
!commandSearchActive
? 'slash'
: 'reverse'
}
expandedIndex={expandedSuggestionIndex}
/>
</Box>
)}

View File

@@ -233,6 +233,21 @@ describe('<LoadingIndicator />', () => {
expect(output).not.toContain('This should not be displayed');
});
it('should truncate long primary text instead of wrapping', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator
{...defaultProps}
currentLoadingPhrase={
'This is an extremely long loading phrase that should be truncated in the UI to keep the primary line concise.'
}
/>,
StreamingState.Responding,
80,
);
expect(lastFrame()).toMatchSnapshot();
});
describe('responsive layout', () => {
it('should render on a single line on a wide terminal', () => {
const { lastFrame } = renderWithContext(

View File

@@ -7,7 +7,7 @@
import type { ThoughtSummary } from '@qwen-code/qwen-code-core';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
@@ -62,10 +62,12 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
/>
</Box>
{primaryText && (
<Text color={Colors.AccentPurple}>{primaryText}</Text>
<Text color={theme.text.accent} wrap="truncate-end">
{primaryText}
</Text>
)}
{!isNarrow && cancelAndTimerContent && (
<Text color={Colors.Gray}> {cancelAndTimerContent}</Text>
<Text color={theme.text.secondary}> {cancelAndTimerContent}</Text>
)}
</Box>
{!isNarrow && <Box flexGrow={1}>{/* Spacer */}</Box>}
@@ -73,7 +75,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
</Box>
{isNarrow && cancelAndTimerContent && (
<Box>
<Text color={Colors.Gray}>{cancelAndTimerContent}</Text>
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
</Box>
)}
{isNarrow && rightContent && <Box>{rightContent}</Box>}

View File

@@ -0,0 +1,39 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { describe, it, expect, vi } from 'vitest';
import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js';
describe('LoopDetectionConfirmation', () => {
const onComplete = vi.fn();
it('renders correctly', () => {
const { lastFrame } = renderWithProviders(
<LoopDetectionConfirmation onComplete={onComplete} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('contains the expected options', () => {
const { lastFrame } = renderWithProviders(
<LoopDetectionConfirmation onComplete={onComplete} />,
);
const output = lastFrame()!.toString();
expect(output).toContain('A potential loop was detected');
expect(output).toContain('Keep loop detection enabled (esc)');
expect(output).toContain('Disable loop detection for this session');
expect(output).toContain(
'This can happen due to repetitive tool calls or other model behavior',
);
expect(output).toContain(
'Note: To disable loop detection checks for all future sessions',
);
expect(output).toContain('model.skipLoopDetection');
expect(output).toContain('settings.json');
});
});

View File

@@ -0,0 +1,97 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
export type LoopDetectionConfirmationResult = {
userSelection: 'disable' | 'keep';
};
interface LoopDetectionConfirmationProps {
onComplete: (result: LoopDetectionConfirmationResult) => void;
}
export function LoopDetectionConfirmation({
onComplete,
}: LoopDetectionConfirmationProps) {
useKeypress(
(key) => {
if (key.name === 'escape') {
onComplete({
userSelection: 'keep',
});
}
},
{ isActive: true },
);
const OPTIONS: Array<RadioSelectItem<LoopDetectionConfirmationResult>> = [
{
label: 'Keep loop detection enabled (esc)',
value: {
userSelection: 'keep',
},
key: 'Keep loop detection enabled (esc)',
},
{
label: 'Disable loop detection for this session',
value: {
userSelection: 'disable',
},
key: 'Disable loop detection for this session',
},
];
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.status.warning}
width="100%"
marginLeft={1}
>
<Box paddingX={1} paddingY={0} flexDirection="column">
<Box minHeight={1}>
<Box minWidth={3}>
<Text color={theme.status.warning} aria-label="Loop detected:">
?
</Text>
</Box>
<Box>
<Text wrap="truncate-end">
<Text color={theme.text.primary} bold>
A potential loop was detected
</Text>{' '}
</Text>
</Box>
</Box>
<Box width="100%" marginTop={1}>
<Box flexDirection="column">
<Text color={theme.text.secondary}>
This can happen due to repetitive tool calls or other model
behavior. Do you want to keep loop detection enabled or disable it
for this session?
</Text>
<Box marginTop={1}>
<RadioButtonSelect items={OPTIONS} onSelect={onComplete} />
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Note: To disable loop detection checks for all future sessions,
set &quot;model.skipLoopDetection&quot; to true in your
settings.json.
</Text>
</Box>
</Box>
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,73 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Static } from 'ink';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
// Limit Gemini messages to a very high number of lines to mitigate performance
// issues in the worst case if we somehow get an enormous response from Gemini.
// This threshold is arbitrary but should be high enough to never impact normal
// usage.
const MAX_GEMINI_MESSAGE_LINES = 65536;
export const MainContent = () => {
const { version } = useAppContext();
const uiState = useUIState();
const {
pendingHistoryItems,
mainAreaWidth,
staticAreaMaxItemHeight,
availableTerminalHeight,
} = uiState;
return (
<>
<Static
key={uiState.historyRemountKey}
items={[
<AppHeader key="app-header" version={version} />,
...uiState.history.map((h) => (
<HistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
item={h}
isPending={false}
commands={uiState.slashCommands}
/>
)),
]}
>
{(item) => item}
</Static>
<OverflowProvider>
<Box flexDirection="column">
{pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={!uiState.isEditorDialogOpen}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
/>
))}
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>
</OverflowProvider>
</>
);
};

View File

@@ -7,20 +7,24 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import process from 'node:process';
import { formatMemoryUsage } from '../utils/formatters.js';
export const MemoryUsageDisplay: React.FC = () => {
const [memoryUsage, setMemoryUsage] = useState<string>('');
const [memoryUsageColor, setMemoryUsageColor] = useState<string>(Colors.Gray);
const [memoryUsageColor, setMemoryUsageColor] = useState<string>(
theme.text.secondary,
);
useEffect(() => {
const updateMemory = () => {
const usage = process.memoryUsage().rss;
setMemoryUsage(formatMemoryUsage(usage));
setMemoryUsageColor(
usage >= 2 * 1024 * 1024 * 1024 ? Colors.AccentRed : Colors.Gray,
usage >= 2 * 1024 * 1024 * 1024
? theme.status.error
: theme.text.secondary,
);
};
const intervalId = setInterval(updateMemory, 2000);
@@ -30,7 +34,7 @@ export const MemoryUsageDisplay: React.FC = () => {
return (
<Box>
<Text color={Colors.Gray}>| </Text>
<Text color={theme.text.secondary}> | </Text>
<Text color={memoryUsageColor}>{memoryUsage}</Text>
</Box>
);

View File

@@ -0,0 +1,227 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ModelDialog } from './ModelDialog.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import type { Config } from '@qwen-code/qwen-code-core';
import {
AVAILABLE_MODELS_QWEN,
MAINLINE_CODER,
MAINLINE_VLM,
} from '../models/availableModels.js';
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
const mockedUseKeypress = vi.mocked(useKeypress);
vi.mock('./shared/DescriptiveRadioButtonSelect.js', () => ({
DescriptiveRadioButtonSelect: vi.fn(() => null),
}));
const mockedSelect = vi.mocked(DescriptiveRadioButtonSelect);
const renderComponent = (
props: Partial<React.ComponentProps<typeof ModelDialog>> = {},
contextValue: Partial<Config> | undefined = undefined,
) => {
const defaultProps = {
onClose: vi.fn(),
};
const combinedProps = { ...defaultProps, ...props };
const mockConfig = contextValue
? ({
// --- Functions used by ModelDialog ---
getModel: vi.fn(() => MAINLINE_CODER),
setModel: vi.fn(),
getAuthType: vi.fn(() => 'qwen-oauth'),
// --- Functions used by ClearcutLogger ---
getUsageStatisticsEnabled: vi.fn(() => true),
getSessionId: vi.fn(() => 'mock-session-id'),
getDebugMode: vi.fn(() => false),
getContentGeneratorConfig: vi.fn(() => ({ authType: 'mock' })),
getUseSmartEdit: vi.fn(() => false),
getUseModelRouter: vi.fn(() => false),
getProxy: vi.fn(() => undefined),
// --- Spread test-specific overrides ---
...contextValue,
} as unknown as Config)
: undefined;
const renderResult = render(
<ConfigContext.Provider value={mockConfig}>
<ModelDialog {...combinedProps} />
</ConfigContext.Provider>,
);
return {
...renderResult,
props: combinedProps,
mockConfig,
};
};
describe('<ModelDialog />', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
it('renders the title and help text', () => {
const { getByText } = renderComponent();
expect(getByText('Select Model')).toBeDefined();
expect(getByText('(Press Esc to close)')).toBeDefined();
});
it('passes all model options to DescriptiveRadioButtonSelect', () => {
renderComponent();
expect(mockedSelect).toHaveBeenCalledTimes(1);
const props = mockedSelect.mock.calls[0][0];
expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length);
expect(props.items[0].value).toBe(MAINLINE_CODER);
expect(props.items[1].value).toBe(MAINLINE_VLM);
expect(props.showNumbers).toBe(true);
});
it('initializes with the model from ConfigContext', () => {
const mockGetModel = vi.fn(() => MAINLINE_VLM);
renderComponent({}, { getModel: mockGetModel });
expect(mockGetModel).toHaveBeenCalled();
expect(mockedSelect).toHaveBeenCalledWith(
expect.objectContaining({
initialIndex: 1,
}),
undefined,
);
});
it('initializes with default coder model if context is not provided', () => {
renderComponent({}, undefined);
expect(mockedSelect).toHaveBeenCalledWith(
expect.objectContaining({
initialIndex: 0,
}),
undefined,
);
});
it('initializes with default coder model if getModel returns undefined', () => {
const mockGetModel = vi.fn(() => undefined);
// @ts-expect-error This test validates component robustness when getModel
// returns an unexpected undefined value.
renderComponent({}, { getModel: mockGetModel });
expect(mockGetModel).toHaveBeenCalled();
// When getModel returns undefined, preferredModel falls back to MAINLINE_CODER
// which has index 0, so initialIndex should be 0
expect(mockedSelect).toHaveBeenCalledWith(
expect.objectContaining({
initialIndex: 0,
}),
undefined,
);
expect(mockedSelect).toHaveBeenCalledTimes(1);
});
it('calls config.setModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', () => {
const { props, mockConfig } = renderComponent({}, {}); // Pass empty object for contextValue
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
expect(childOnSelect).toBeDefined();
childOnSelect(MAINLINE_CODER);
// Assert against the default mock provided by renderComponent
expect(mockConfig?.setModel).toHaveBeenCalledWith(MAINLINE_CODER);
expect(props.onClose).toHaveBeenCalledTimes(1);
});
it('does not pass onHighlight to DescriptiveRadioButtonSelect', () => {
renderComponent();
const childOnHighlight = mockedSelect.mock.calls[0][0].onHighlight;
expect(childOnHighlight).toBeUndefined();
});
it('calls onClose prop when "escape" key is pressed', () => {
const { props } = renderComponent();
expect(mockedUseKeypress).toHaveBeenCalled();
const keyPressHandler = mockedUseKeypress.mock.calls[0][0];
const options = mockedUseKeypress.mock.calls[0][1];
expect(options).toEqual({ isActive: true });
keyPressHandler({
name: 'escape',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '',
});
expect(props.onClose).toHaveBeenCalledTimes(1);
keyPressHandler({
name: 'a',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '',
});
expect(props.onClose).toHaveBeenCalledTimes(1);
});
it('updates initialIndex when config context changes', () => {
const mockGetModel = vi.fn(() => MAINLINE_CODER);
const mockGetAuthType = vi.fn(() => 'qwen-oauth');
const { rerender } = render(
<ConfigContext.Provider
value={
{
getModel: mockGetModel,
getAuthType: mockGetAuthType,
} as unknown as Config
}
>
<ModelDialog onClose={vi.fn()} />
</ConfigContext.Provider>,
);
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
mockGetModel.mockReturnValue(MAINLINE_VLM);
const newMockConfig = {
getModel: mockGetModel,
getAuthType: mockGetAuthType,
} as unknown as Config;
rerender(
<ConfigContext.Provider value={newMockConfig}>
<ModelDialog onClose={vi.fn()} />
</ConfigContext.Provider>,
);
// Should be called at least twice: initial render + re-render after context change
expect(mockedSelect).toHaveBeenCalledTimes(2);
expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(1);
});
});

View File

@@ -0,0 +1,104 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useContext, useMemo } from 'react';
import { Box, Text } from 'ink';
import {
AuthType,
ModelSlashCommandEvent,
logModelSlashCommand,
} from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import {
getAvailableModelsForAuthType,
MAINLINE_CODER,
} from '../models/availableModels.js';
interface ModelDialogProps {
onClose: () => void;
}
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const config = useContext(ConfigContext);
// Get auth type from config, default to QWEN_OAUTH if not available
const authType = config?.getAuthType() ?? AuthType.QWEN_OAUTH;
// Get available models based on auth type
const availableModels = useMemo(
() => getAvailableModelsForAuthType(authType),
[authType],
);
const MODEL_OPTIONS = useMemo(
() =>
availableModels.map((model) => ({
value: model.id,
title: model.label,
description: model.description || '',
key: model.id,
})),
[availableModels],
);
// Determine the Preferred Model (read once when the dialog opens).
const preferredModel = config?.getModel() || MAINLINE_CODER;
useKeypress(
(key) => {
if (key.name === 'escape') {
onClose();
}
},
{ isActive: true },
);
// Calculate the initial index based on the preferred model.
const initialIndex = useMemo(
() => MODEL_OPTIONS.findIndex((option) => option.value === preferredModel),
[MODEL_OPTIONS, preferredModel],
);
// Handle selection internally (Autonomous Dialog).
const handleSelect = useCallback(
(model: string) => {
if (config) {
config.setModel(model);
const event = new ModelSlashCommandEvent(model);
logModelSlashCommand(config, event);
}
onClose();
},
[config, onClose],
);
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>Select Model</Text>
<Box marginTop={1}>
<DescriptiveRadioButtonSelect
items={MODEL_OPTIONS}
onSelect={handleSelect}
initialIndex={initialIndex}
showNumbers={true}
/>
</Box>
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>(Press Esc to close)</Text>
</Box>
</Box>
);
}

View File

@@ -1,246 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ModelSelectionDialog } from './ModelSelectionDialog.js';
import type { AvailableModel } from '../models/availableModels.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
// Mock the useKeypress hook
const mockUseKeypress = vi.hoisted(() => vi.fn());
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: mockUseKeypress,
}));
// Mock the RadioButtonSelect component
const mockRadioButtonSelect = vi.hoisted(() => vi.fn());
vi.mock('./shared/RadioButtonSelect.js', () => ({
RadioButtonSelect: mockRadioButtonSelect,
}));
describe('ModelSelectionDialog', () => {
const mockAvailableModels: AvailableModel[] = [
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
{ id: 'qwen-vl-max-latest', label: 'qwen-vl-max', isVision: true },
{ id: 'gpt-4', label: 'GPT-4' },
];
const mockOnSelect = vi.fn();
const mockOnCancel = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
// Mock RadioButtonSelect to return a simple div
mockRadioButtonSelect.mockReturnValue(
React.createElement('div', { 'data-testid': 'radio-select' }),
);
});
it('should setup escape key handler to call onCancel', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="qwen3-coder-plus"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
expect(mockUseKeypress).toHaveBeenCalledWith(expect.any(Function), {
isActive: true,
});
// Simulate escape key press
const keypressHandler = mockUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'escape' });
expect(mockOnCancel).toHaveBeenCalled();
});
it('should not call onCancel for non-escape keys', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="qwen3-coder-plus"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const keypressHandler = mockUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'enter' });
expect(mockOnCancel).not.toHaveBeenCalled();
});
it('should set correct initial index for current model', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="qwen-vl-max-latest"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(callArgs.initialIndex).toBe(1); // qwen-vl-max-latest is at index 1
});
it('should set initial index to 0 when current model is not found', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="non-existent-model"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(callArgs.initialIndex).toBe(0);
});
it('should call onSelect when a model is selected', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="qwen3-coder-plus"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(typeof callArgs.onSelect).toBe('function');
// Simulate selection
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
onSelectCallback('qwen-vl-max-latest');
expect(mockOnSelect).toHaveBeenCalledWith('qwen-vl-max-latest');
});
it('should handle empty models array', () => {
render(
<ModelSelectionDialog
availableModels={[]}
currentModel=""
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(callArgs.items).toEqual([]);
expect(callArgs.initialIndex).toBe(0);
});
it('should create correct option items with proper labels', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="qwen3-coder-plus"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const expectedItems = [
{
label: 'qwen3-coder-plus (current)',
value: 'qwen3-coder-plus',
},
{
label: 'qwen-vl-max [Vision]',
value: 'qwen-vl-max-latest',
},
{
label: 'GPT-4',
value: 'gpt-4',
},
];
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(callArgs.items).toEqual(expectedItems);
});
it('should show vision indicator for vision models', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="gpt-4"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
const visionModelItem = callArgs.items.find(
(item: RadioSelectItem<string>) => item.value === 'qwen-vl-max-latest',
);
expect(visionModelItem?.label).toContain('[Vision]');
});
it('should show current indicator for the current model', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="qwen-vl-max-latest"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
const currentModelItem = callArgs.items.find(
(item: RadioSelectItem<string>) => item.value === 'qwen-vl-max-latest',
);
expect(currentModelItem?.label).toContain('(current)');
});
it('should pass isFocused prop to RadioButtonSelect', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="qwen3-coder-plus"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(callArgs.isFocused).toBe(true);
});
it('should handle multiple onSelect calls correctly', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="qwen3-coder-plus"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
// Call multiple times
onSelectCallback('qwen3-coder-plus');
onSelectCallback('qwen-vl-max-latest');
onSelectCallback('gpt-4');
expect(mockOnSelect).toHaveBeenCalledTimes(3);
expect(mockOnSelect).toHaveBeenNthCalledWith(1, 'qwen3-coder-plus');
expect(mockOnSelect).toHaveBeenNthCalledWith(2, 'qwen-vl-max-latest');
expect(mockOnSelect).toHaveBeenNthCalledWith(3, 'gpt-4');
});
});

View File

@@ -1,87 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import type { AvailableModel } from '../models/availableModels.js';
export interface ModelSelectionDialogProps {
availableModels: AvailableModel[];
currentModel: string;
onSelect: (modelId: string) => void;
onCancel: () => void;
}
export const ModelSelectionDialog: React.FC<ModelSelectionDialogProps> = ({
availableModels,
currentModel,
onSelect,
onCancel,
}) => {
useKeypress(
(key) => {
if (key.name === 'escape') {
onCancel();
}
},
{ isActive: true },
);
const options: Array<RadioSelectItem<string>> = availableModels.map(
(model) => {
const visionIndicator = model.isVision ? ' [Vision]' : '';
const currentIndicator = model.id === currentModel ? ' (current)' : '';
return {
label: `${model.label}${visionIndicator}${currentIndicator}`,
value: model.id,
};
},
);
const initialIndex = Math.max(
0,
availableModels.findIndex((model) => model.id === currentModel),
);
const handleSelect = (modelId: string) => {
onSelect(modelId);
};
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.AccentBlue}
padding={1}
width="100%"
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text bold>Select Model</Text>
<Text>Choose a model for this session:</Text>
</Box>
<Box marginBottom={1}>
<RadioButtonSelect
items={options}
initialIndex={initialIndex}
onSelect={handleSelect}
isFocused
/>
</Box>
<Box>
<Text color={Colors.Gray}>Press Enter to select, Esc to cancel</Text>
</Box>
</Box>
);
};

View File

@@ -6,7 +6,7 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import { formatDuration } from '../utils/formatters.js';
import {
calculateAverageLatency,
@@ -34,13 +34,16 @@ const StatRow: React.FC<StatRowProps> = ({
}) => (
<Box>
<Box width={METRIC_COL_WIDTH}>
<Text bold={isSection} color={isSection ? undefined : Colors.LightBlue}>
<Text
bold={isSection}
color={isSection ? theme.text.primary : theme.text.link}
>
{isSubtle ? `${title}` : title}
</Text>
</Box>
{values.map((value, index) => (
<Box width={MODEL_COL_WIDTH} key={index}>
<Text>{value}</Text>
<Text color={theme.text.primary}>{value}</Text>
</Box>
))}
</Box>
@@ -57,11 +60,13 @@ export const ModelStatsDisplay: React.FC = () => {
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
borderColor={theme.border.default}
paddingY={1}
paddingX={2}
>
<Text>No API calls have been made in this session.</Text>
<Text color={theme.text.primary}>
No API calls have been made in this session.
</Text>
</Box>
);
}
@@ -83,12 +88,12 @@ export const ModelStatsDisplay: React.FC = () => {
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
borderColor={theme.border.default}
flexDirection="column"
paddingY={1}
paddingX={2}
>
<Text bold color={Colors.AccentPurple}>
<Text bold color={theme.text.accent}>
Model Stats For Nerds
</Text>
<Box height={1} />
@@ -96,11 +101,15 @@ export const ModelStatsDisplay: React.FC = () => {
{/* Header */}
<Box>
<Box width={METRIC_COL_WIDTH}>
<Text bold>Metric</Text>
<Text bold color={theme.text.primary}>
Metric
</Text>
</Box>
{modelNames.map((name) => (
<Box width={MODEL_COL_WIDTH} key={name}>
<Text bold>{name}</Text>
<Text bold color={theme.text.primary}>
{name}
</Text>
</Box>
))}
</Box>
@@ -112,6 +121,7 @@ export const ModelStatsDisplay: React.FC = () => {
borderTop={false}
borderLeft={false}
borderRight={false}
borderColor={theme.border.default}
/>
{/* API Section */}
@@ -127,7 +137,7 @@ export const ModelStatsDisplay: React.FC = () => {
return (
<Text
color={
m.api.totalErrors > 0 ? Colors.AccentRed : Colors.Foreground
m.api.totalErrors > 0 ? theme.status.error : theme.text.primary
}
>
{m.api.totalErrors.toLocaleString()} ({errorRate.toFixed(1)}%)
@@ -150,7 +160,7 @@ export const ModelStatsDisplay: React.FC = () => {
<StatRow
title="Total"
values={getModelValues((m) => (
<Text color={Colors.AccentYellow}>
<Text color={theme.status.warning}>
{m.tokens.total.toLocaleString()}
</Text>
))}
@@ -167,7 +177,7 @@ export const ModelStatsDisplay: React.FC = () => {
values={getModelValues((m) => {
const cacheHitRate = calculateCacheHitRate(m);
return (
<Text color={Colors.AccentGreen}>
<Text color={theme.status.success}>
{m.tokens.cached.toLocaleString()} ({cacheHitRate.toFixed(1)}%)
</Text>
);

View File

@@ -38,14 +38,17 @@ describe('ModelSwitchDialog', () => {
const expectedItems = [
{
key: 'switch-once',
label: 'Switch for this request only',
value: VisionSwitchOutcome.SwitchOnce,
},
{
key: 'switch-session',
label: 'Switch session to vision model',
value: VisionSwitchOutcome.SwitchSessionToVL,
},
{
key: 'continue',
label: 'Continue with current model',
value: VisionSwitchOutcome.ContinueWithCurrentModel,
},

View File

@@ -37,14 +37,17 @@ export const ModelSwitchDialog: React.FC<ModelSwitchDialogProps> = ({
const options: Array<RadioSelectItem<VisionSwitchOutcome>> = [
{
key: 'switch-once',
label: 'Switch for this request only',
value: VisionSwitchOutcome.SwitchOnce,
},
{
key: 'switch-session',
label: 'Switch session to vision model',
value: VisionSwitchOutcome.SwitchSessionToVL,
},
{
key: 'continue',
label: 'Continue with current model',
value: VisionSwitchOutcome.ContinueWithCurrentModel,
},

View File

@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useAppContext } from '../contexts/AppContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { theme } from '../semantic-colors.js';
import { StreamingState } from '../types.js';
import { UpdateNotification } from './UpdateNotification.js';
export const Notifications = () => {
const { startupWarnings } = useAppContext();
const { initError, streamingState, updateInfo } = useUIState();
const showStartupWarnings = startupWarnings.length > 0;
const showInitError =
initError && streamingState !== StreamingState.Responding;
if (!showStartupWarnings && !showInitError && !updateInfo) {
return null;
}
return (
<>
{updateInfo && <UpdateNotification message={updateInfo.message} />}
{showStartupWarnings && (
<Box
borderStyle="round"
borderColor={theme.status.warning}
paddingX={1}
marginY={1}
flexDirection="column"
>
{startupWarnings.map((warning, index) => (
<Text key={index} color={theme.status.warning}>
{warning}
</Text>
))}
</Box>
)}
{showInitError && (
<Box
borderStyle="round"
borderColor={theme.status.error}
paddingX={1}
marginBottom={1}
>
<Text color={theme.status.error}>
Initialization Error: {initError}
</Text>
<Text color={theme.status.error}>
{' '}
Please check API key and configuration.
</Text>
</Box>
)}
</>
);
};

View File

@@ -5,10 +5,18 @@
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
// Mock useKeypress hook
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
describe('OpenAIKeyPrompt', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render the prompt correctly', () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();

View File

@@ -6,8 +6,9 @@
import type React from 'react';
import { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface OpenAIKeyPromptProps {
onSubmit: (apiKey: string, baseUrl: string, model: string) => void;
@@ -25,104 +26,130 @@ export function OpenAIKeyPrompt({
'apiKey' | 'baseUrl' | 'model'
>('apiKey');
useInput((input, key) => {
// 过滤粘贴相关的控制序列
let cleanInput = (input || '')
// 过滤 ESC 开头的控制序列(如 \u001b[200~、\u001b[201~ 等)
.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '') // eslint-disable-line no-control-regex
// 过滤粘贴开始标记 [200~
.replace(/\[200~/g, '')
// 过滤粘贴结束标记 [201~
.replace(/\[201~/g, '')
// 过滤单独的 [ 和 ~ 字符(可能是粘贴标记的残留)
.replace(/^\[|~$/g, '');
// 再过滤所有不可见字符ASCII < 32除了回车换行
cleanInput = cleanInput
.split('')
.filter((ch) => ch.charCodeAt(0) >= 32)
.join('');
if (cleanInput.length > 0) {
if (currentField === 'apiKey') {
setApiKey((prev) => prev + cleanInput);
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev + cleanInput);
} else if (currentField === 'model') {
setModel((prev) => prev + cleanInput);
useKeypress(
(key) => {
// Handle escape
if (key.name === 'escape') {
onCancel();
return;
}
return;
}
// 检查是否是 Enter 键(通过检查输入是否包含换行符)
if (input.includes('\n') || input.includes('\r')) {
if (currentField === 'apiKey') {
// 允许空 API key 跳转到下一个字段,让用户稍后可以返回修改
setCurrentField('baseUrl');
// Handle Enter key
if (key.name === 'return') {
if (currentField === 'apiKey') {
// 允许空 API key 跳转到下一个字段,让用户稍后可以返回修改
setCurrentField('baseUrl');
return;
} else if (currentField === 'baseUrl') {
setCurrentField('model');
return;
} else if (currentField === 'model') {
// 只有在提交时才检查 API key 是否为空
if (apiKey.trim()) {
onSubmit(apiKey.trim(), baseUrl.trim(), model.trim());
} else {
// 如果 API key 为空,回到 API key 字段
setCurrentField('apiKey');
}
}
return;
} else if (currentField === 'baseUrl') {
setCurrentField('model');
return;
} else if (currentField === 'model') {
// 只有在提交时才检查 API key 是否为空
if (apiKey.trim()) {
onSubmit(apiKey.trim(), baseUrl.trim(), model.trim());
} else {
// 如果 API key 为空,回到 API key 字段
}
// Handle Tab key for field navigation
if (key.name === 'tab') {
if (currentField === 'apiKey') {
setCurrentField('baseUrl');
} else if (currentField === 'baseUrl') {
setCurrentField('model');
} else if (currentField === 'model') {
setCurrentField('apiKey');
}
return;
}
return;
}
if (key.escape) {
onCancel();
return;
}
// Handle Tab key for field navigation
if (key.tab) {
if (currentField === 'apiKey') {
setCurrentField('baseUrl');
} else if (currentField === 'baseUrl') {
setCurrentField('model');
} else if (currentField === 'model') {
setCurrentField('apiKey');
// Handle arrow keys for field navigation
if (key.name === 'up') {
if (currentField === 'baseUrl') {
setCurrentField('apiKey');
} else if (currentField === 'model') {
setCurrentField('baseUrl');
}
return;
}
return;
}
// Handle arrow keys for field navigation
if (key.upArrow) {
if (currentField === 'baseUrl') {
setCurrentField('apiKey');
} else if (currentField === 'model') {
setCurrentField('baseUrl');
if (key.name === 'down') {
if (currentField === 'apiKey') {
setCurrentField('baseUrl');
} else if (currentField === 'baseUrl') {
setCurrentField('model');
}
return;
}
return;
}
if (key.downArrow) {
if (currentField === 'apiKey') {
setCurrentField('baseUrl');
} else if (currentField === 'baseUrl') {
setCurrentField('model');
// Handle backspace/delete
if (key.name === 'backspace' || key.name === 'delete') {
if (currentField === 'apiKey') {
setApiKey((prev) => prev.slice(0, -1));
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev.slice(0, -1));
} else if (currentField === 'model') {
setModel((prev) => prev.slice(0, -1));
}
return;
}
return;
}
// Handle backspace - check both key.backspace and delete key
if (key.backspace || key.delete) {
if (currentField === 'apiKey') {
setApiKey((prev) => prev.slice(0, -1));
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev.slice(0, -1));
} else if (currentField === 'model') {
setModel((prev) => prev.slice(0, -1));
// Handle paste mode - if it's a paste event with content
if (key.paste && key.sequence) {
// 过滤粘贴相关的控制序列
let cleanInput = key.sequence
// 过滤 ESC 开头的控制序列(如 \u001b[200~、\u001b[201~ 等)
.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '') // eslint-disable-line no-control-regex
// 过滤粘贴开始标记 [200~
.replace(/\[200~/g, '')
// 过滤粘贴结束标记 [201~
.replace(/\[201~/g, '')
// 过滤单独的 [ 和 ~ 字符(可能是粘贴标记的残留)
.replace(/^\[|~$/g, '');
// 再过滤所有不可见字符ASCII < 32除了回车换行
cleanInput = cleanInput
.split('')
.filter((ch) => ch.charCodeAt(0) >= 32)
.join('');
if (cleanInput.length > 0) {
if (currentField === 'apiKey') {
setApiKey((prev) => prev + cleanInput);
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev + cleanInput);
} else if (currentField === 'model') {
setModel((prev) => prev + cleanInput);
}
}
return;
}
return;
}
});
// Handle regular character input
if (key.sequence && !key.ctrl && !key.meta && !key.name) {
// Filter control characters
const cleanInput = key.sequence
.split('')
.filter((ch) => ch.charCodeAt(0) >= 32)
.join('');
if (cleanInput.length > 0) {
if (currentField === 'apiKey') {
setApiKey((prev) => prev + cleanInput);
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev + cleanInput);
} else if (currentField === 'model') {
setModel((prev) => prev + cleanInput);
}
}
}
},
{ isActive: true },
);
return (
<Box

View File

@@ -0,0 +1,199 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/// <reference types="vitest/globals" />
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Mock } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { TrustLevel } from '../../config/trustedFolders.js';
import { waitFor, act } from '@testing-library/react';
import * as processUtils from '../../utils/processUtils.js';
import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js';
// Hoist mocks for dependencies of the usePermissionsModifyTrust hook
const mockedCwd = vi.hoisted(() => vi.fn());
const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn());
const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn());
// Mock the modules themselves
vi.mock('node:process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:process')>();
return {
...actual,
cwd: mockedCwd,
};
});
vi.mock('../../config/trustedFolders.js', () => ({
loadTrustedFolders: mockedLoadTrustedFolders,
isWorkspaceTrusted: mockedIsWorkspaceTrusted,
TrustLevel: {
TRUST_FOLDER: 'TRUST_FOLDER',
TRUST_PARENT: 'TRUST_PARENT',
DO_NOT_TRUST: 'DO_NOT_TRUST',
},
}));
vi.mock('../hooks/usePermissionsModifyTrust.js');
describe('PermissionsModifyTrustDialog', () => {
let mockUpdateTrustLevel: Mock;
let mockCommitTrustLevelChange: Mock;
beforeEach(() => {
mockUpdateTrustLevel = vi.fn();
mockCommitTrustLevelChange = vi.fn();
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
cwd: '/test/dir',
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
isInheritedTrustFromParent: false,
isInheritedTrustFromIde: false,
needsRestart: false,
updateTrustLevel: mockUpdateTrustLevel,
commitTrustLevelChange: mockCommitTrustLevelChange,
isFolderTrustEnabled: true,
});
});
afterEach(() => {
vi.resetAllMocks();
});
it('should render the main dialog with current trust level', async () => {
const { lastFrame } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
await waitFor(() => {
expect(lastFrame()).toContain('Modify Trust Level');
expect(lastFrame()).toContain('Folder: /test/dir');
expect(lastFrame()).toContain('Current Level: DO_NOT_TRUST');
});
});
it('should display the inherited trust note from parent', async () => {
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
cwd: '/test/dir',
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
isInheritedTrustFromParent: true,
isInheritedTrustFromIde: false,
needsRestart: false,
updateTrustLevel: mockUpdateTrustLevel,
commitTrustLevelChange: mockCommitTrustLevelChange,
isFolderTrustEnabled: true,
});
const { lastFrame } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
await waitFor(() => {
expect(lastFrame()).toContain(
'Note: This folder behaves as a trusted folder because one of the parent folders is trusted.',
);
});
});
it('should display the inherited trust note from IDE', async () => {
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
cwd: '/test/dir',
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
isInheritedTrustFromParent: false,
isInheritedTrustFromIde: true,
needsRestart: false,
updateTrustLevel: mockUpdateTrustLevel,
commitTrustLevelChange: mockCommitTrustLevelChange,
isFolderTrustEnabled: true,
});
const { lastFrame } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
await waitFor(() => {
expect(lastFrame()).toContain(
'Note: This folder behaves as a trusted folder because the connected IDE workspace is trusted.',
);
});
});
it('should call onExit when escape is pressed', async () => {
const onExit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
);
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
act(() => {
stdin.write('\x1b'); // escape key
});
await waitFor(() => {
expect(onExit).toHaveBeenCalled();
});
});
it('should commit, restart, and exit on `r` keypress', async () => {
const mockRelaunchApp = vi
.spyOn(processUtils, 'relaunchApp')
.mockResolvedValue(undefined);
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
cwd: '/test/dir',
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
isInheritedTrustFromParent: false,
isInheritedTrustFromIde: false,
needsRestart: true,
updateTrustLevel: mockUpdateTrustLevel,
commitTrustLevelChange: mockCommitTrustLevelChange,
isFolderTrustEnabled: true,
});
const onExit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
);
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
act(() => stdin.write('r')); // Press 'r' to restart
await waitFor(() => {
expect(mockCommitTrustLevelChange).toHaveBeenCalled();
expect(mockRelaunchApp).toHaveBeenCalled();
expect(onExit).toHaveBeenCalled();
});
mockRelaunchApp.mockRestore();
});
it('should not commit when escape is pressed during restart prompt', async () => {
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
cwd: '/test/dir',
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
isInheritedTrustFromParent: false,
isInheritedTrustFromIde: false,
needsRestart: true,
updateTrustLevel: mockUpdateTrustLevel,
commitTrustLevelChange: mockCommitTrustLevelChange,
isFolderTrustEnabled: true,
});
const onExit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
);
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
act(() => stdin.write('\x1b')); // Press escape
await waitFor(() => {
expect(mockCommitTrustLevelChange).not.toHaveBeenCalled();
expect(onExit).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,125 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import type React from 'react';
import { TrustLevel } from '../../config/trustedFolders.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js';
import { theme } from '../semantic-colors.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { relaunchApp } from '../../utils/processUtils.js';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
interface PermissionsModifyTrustDialogProps {
onExit: () => void;
addItem: UseHistoryManagerReturn['addItem'];
}
const TRUST_LEVEL_ITEMS = [
{
label: 'Trust this folder',
value: TrustLevel.TRUST_FOLDER,
key: TrustLevel.TRUST_FOLDER,
},
{
label: 'Trust parent folder',
value: TrustLevel.TRUST_PARENT,
key: TrustLevel.TRUST_PARENT,
},
{
label: "Don't trust",
value: TrustLevel.DO_NOT_TRUST,
key: TrustLevel.DO_NOT_TRUST,
},
];
export function PermissionsModifyTrustDialog({
onExit,
addItem,
}: PermissionsModifyTrustDialogProps): React.JSX.Element {
const {
cwd,
currentTrustLevel,
isInheritedTrustFromParent,
isInheritedTrustFromIde,
needsRestart,
updateTrustLevel,
commitTrustLevelChange,
} = usePermissionsModifyTrust(onExit, addItem);
useKeypress(
(key) => {
if (key.name === 'escape') {
onExit();
}
if (needsRestart && key.name === 'r') {
commitTrustLevelChange();
relaunchApp();
onExit();
}
},
{ isActive: true },
);
const index = TRUST_LEVEL_ITEMS.findIndex(
(item) => item.value === currentTrustLevel,
);
const initialIndex = index === -1 ? 0 : index;
return (
<>
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
>
<Box flexDirection="column" paddingBottom={1}>
<Text bold>{'> '}Modify Trust Level</Text>
<Box marginTop={1} />
<Text>Folder: {cwd}</Text>
<Text>
Current Level: <Text bold>{currentTrustLevel || 'Not Set'}</Text>
</Text>
{isInheritedTrustFromParent && (
<Text color={theme.text.secondary}>
Note: This folder behaves as a trusted folder because one of the
parent folders is trusted. It will remain trusted even if you set
a different trust level here. To change this, you need to modify
the trust setting in the parent folder.
</Text>
)}
{isInheritedTrustFromIde && (
<Text color={theme.text.secondary}>
Note: This folder behaves as a trusted folder because the
connected IDE workspace is trusted. It will remain trusted even if
you set a different trust level here.
</Text>
)}
</Box>
<RadioButtonSelect
items={TRUST_LEVEL_ITEMS}
onSelect={updateTrustLevel}
isFocused={true}
initialIndex={initialIndex}
/>
<Box marginTop={1}>
<Text color={theme.text.secondary}>(Use Enter to select)</Text>
</Box>
</Box>
{needsRestart && (
<Box marginLeft={1} marginTop={1}>
<Text color={theme.status.warning}>
To apply the trust changes, Gemini CLI must be restarted. Press
&apos;r&apos; to restart CLI now.
</Text>
</Box>
)}
</>
);
}

View File

@@ -0,0 +1,123 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { render } from 'ink-testing-library';
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
describe('PrepareLabel', () => {
const color = 'white';
const flat = (s: string | undefined) => (s ?? '').replace(/\n/g, '');
it('renders plain label when no match (short label)', () => {
const { lastFrame } = render(
<PrepareLabel
label="simple command"
userInput=""
matchedIndex={undefined}
textColor={color}
isExpanded={false}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('truncates long label when collapsed and no match', () => {
const long = 'x'.repeat(MAX_WIDTH + 25);
const { lastFrame } = render(
<PrepareLabel
label={long}
userInput=""
textColor={color}
isExpanded={false}
/>,
);
const out = lastFrame();
const f = flat(out);
expect(f.endsWith('...')).toBe(true);
expect(f.length).toBe(MAX_WIDTH + 3);
expect(out).toMatchSnapshot();
});
it('shows full long label when expanded and no match', () => {
const long = 'y'.repeat(MAX_WIDTH + 25);
const { lastFrame } = render(
<PrepareLabel
label={long}
userInput=""
textColor={color}
isExpanded={true}
/>,
);
const out = lastFrame();
const f = flat(out);
expect(f.length).toBe(long.length);
expect(out).toMatchSnapshot();
});
it('highlights matched substring when expanded (text only visible)', () => {
const label = 'run: git commit -m "feat: add search"';
const userInput = 'commit';
const matchedIndex = label.indexOf(userInput);
const { lastFrame } = render(
<PrepareLabel
label={label}
userInput={userInput}
matchedIndex={matchedIndex}
textColor={color}
isExpanded={true}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('creates centered window around match when collapsed', () => {
const prefix = 'cd /very/long/path/that/keeps/going/'.repeat(3);
const core = 'search-here';
const suffix = '/and/then/some/more/components/'.repeat(3);
const label = prefix + core + suffix;
const matchedIndex = prefix.length;
const { lastFrame } = render(
<PrepareLabel
label={label}
userInput={core}
matchedIndex={matchedIndex}
textColor={color}
isExpanded={false}
/>,
);
const out = lastFrame();
const f = flat(out);
expect(f.includes(core)).toBe(true);
expect(f.startsWith('...')).toBe(true);
expect(f.endsWith('...')).toBe(true);
expect(out).toMatchSnapshot();
});
it('truncates match itself when match is very long', () => {
const prefix = 'find ';
const core = 'x'.repeat(MAX_WIDTH + 25);
const suffix = ' in this text';
const label = prefix + core + suffix;
const matchedIndex = prefix.length;
const { lastFrame } = render(
<PrepareLabel
label={label}
userInput={core}
matchedIndex={matchedIndex}
textColor={color}
isExpanded={false}
/>,
);
const out = lastFrame();
const f = flat(out);
expect(f.includes('...')).toBe(true);
expect(f.startsWith('...')).toBe(false);
expect(f.endsWith('...')).toBe(true);
expect(f.length).toBe(MAX_WIDTH + 2);
expect(out).toMatchSnapshot();
});
});

View File

@@ -4,45 +4,113 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
interface PrepareLabelProps {
export const MAX_WIDTH = 150; // Maximum width for the text that is shown
export interface PrepareLabelProps {
label: string;
matchedIndex?: number;
userInput: string;
textColor: string;
highlightColor?: string;
isExpanded?: boolean;
}
export const PrepareLabel: React.FC<PrepareLabelProps> = ({
const _PrepareLabel: React.FC<PrepareLabelProps> = ({
label,
matchedIndex,
userInput,
textColor,
highlightColor = Colors.AccentYellow,
isExpanded = false,
}) => {
if (
matchedIndex === undefined ||
matchedIndex < 0 ||
matchedIndex >= label.length ||
userInput.length === 0
) {
return <Text color={textColor}>{label}</Text>;
const hasMatch =
matchedIndex !== undefined &&
matchedIndex >= 0 &&
matchedIndex < label.length &&
userInput.length > 0;
// Render the plain label if there's no match
if (!hasMatch) {
const display = isExpanded
? label
: label.length > MAX_WIDTH
? label.slice(0, MAX_WIDTH) + '...'
: label;
return (
<Text wrap="wrap" color={textColor}>
{display}
</Text>
);
}
const start = label.slice(0, matchedIndex);
const match = label.slice(matchedIndex, matchedIndex + userInput.length);
const end = label.slice(matchedIndex + userInput.length);
const matchLength = userInput.length;
let before = '';
let match = '';
let after = '';
// Case 1: Show the full string if it's expanded or already fits
if (isExpanded || label.length <= MAX_WIDTH) {
before = label.slice(0, matchedIndex);
match = label.slice(matchedIndex, matchedIndex + matchLength);
after = label.slice(matchedIndex + matchLength);
}
// Case 2: The match itself is too long, so we only show a truncated portion of the match
else if (matchLength >= MAX_WIDTH) {
match = label.slice(matchedIndex, matchedIndex + MAX_WIDTH - 1) + '...';
}
// Case 3: Truncate the string to create a window around the match
else {
const contextSpace = MAX_WIDTH - matchLength;
const beforeSpace = Math.floor(contextSpace / 2);
const afterSpace = Math.ceil(contextSpace / 2);
let start = matchedIndex - beforeSpace;
let end = matchedIndex + matchLength + afterSpace;
if (start < 0) {
end += -start; // Slide window right
start = 0;
}
if (end > label.length) {
start -= end - label.length; // Slide window left
end = label.length;
}
start = Math.max(0, start);
const finalMatchIndex = matchedIndex - start;
const slicedLabel = label.slice(start, end);
before = slicedLabel.slice(0, finalMatchIndex);
match = slicedLabel.slice(finalMatchIndex, finalMatchIndex + matchLength);
after = slicedLabel.slice(finalMatchIndex + matchLength);
if (start > 0) {
before = before.length >= 3 ? '...' + before.slice(3) : '...';
}
if (end < label.length) {
after = after.length >= 3 ? after.slice(0, -3) + '...' : '...';
}
}
return (
<Text>
<Text color={textColor}>{start}</Text>
<Text color="black" bold backgroundColor={highlightColor}>
{match}
</Text>
<Text color={textColor}>{end}</Text>
<Text color={textColor} wrap="wrap">
{before}
{match
? match.split(/(\s+)/).map((part, index) => (
<Text
key={`match-${index}`}
color={theme.background.primary}
backgroundColor={theme.text.primary}
>
{part}
</Text>
))
: null}
{after}
</Text>
);
};
export const PrepareLabel = React.memo(_PrepareLabel);

View File

@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { ProQuotaDialog } from './ProQuotaDialog.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
// Mock the child component to make it easier to test the parent
vi.mock('./shared/RadioButtonSelect.js', () => ({
RadioButtonSelect: vi.fn(),
}));
describe('ProQuotaDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render with correct title and options', () => {
const { lastFrame } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
onChoice={() => {}}
/>,
);
const output = lastFrame();
expect(output).toContain('Pro quota limit reached for gemini-2.5-pro.');
// Check that RadioButtonSelect was called with the correct items
expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Change auth (executes the /auth command)',
value: 'auth',
key: 'auth',
},
{
label: `Continue with gemini-2.5-flash`,
value: 'continue',
key: 'continue',
},
],
}),
undefined,
);
});
it('should call onChoice with "auth" when "Change auth" is selected', () => {
const mockOnChoice = vi.fn();
render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
onChoice={mockOnChoice}
/>,
);
// Get the onSelect function passed to RadioButtonSelect
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
// Simulate the selection
onSelect('auth');
expect(mockOnChoice).toHaveBeenCalledWith('auth');
});
it('should call onChoice with "continue" when "Continue with flash" is selected', () => {
const mockOnChoice = vi.fn();
render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
onChoice={mockOnChoice}
/>,
);
// Get the onSelect function passed to RadioButtonSelect
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
// Simulate the selection
onSelect('continue');
expect(mockOnChoice).toHaveBeenCalledWith('continue');
});
});

View File

@@ -0,0 +1,54 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { theme } from '../semantic-colors.js';
interface ProQuotaDialogProps {
failedModel: string;
fallbackModel: string;
onChoice: (choice: 'auth' | 'continue') => void;
}
export function ProQuotaDialog({
failedModel,
fallbackModel,
onChoice,
}: ProQuotaDialogProps): React.JSX.Element {
const items = [
{
label: 'Change auth (executes the /auth command)',
value: 'auth' as const,
key: 'auth',
},
{
label: `Continue with ${fallbackModel}`,
value: 'continue' as const,
key: 'continue',
},
];
const handleSelect = (choice: 'auth' | 'continue') => {
onChoice(choice);
};
return (
<Box borderStyle="round" flexDirection="column" paddingX={1}>
<Text bold color={theme.status.warning}>
Pro quota limit reached for {failedModel}.
</Text>
<Box marginTop={1}>
<RadioButtonSelect
items={items}
initialIndex={1}
onSelect={handleSelect}
/>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { render } from 'ink-testing-library';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
describe('QueuedMessageDisplay', () => {
it('renders nothing when message queue is empty', () => {
const { lastFrame } = render(<QueuedMessageDisplay messageQueue={[]} />);
expect(lastFrame()).toBe('');
});
it('displays single queued message', () => {
const { lastFrame } = render(
<QueuedMessageDisplay messageQueue={['First message']} />,
);
const output = lastFrame();
expect(output).toContain('First message');
});
it('displays multiple queued messages', () => {
const messageQueue = [
'First queued message',
'Second queued message',
'Third queued message',
];
const { lastFrame } = render(
<QueuedMessageDisplay messageQueue={messageQueue} />,
);
const output = lastFrame();
expect(output).toContain('First queued message');
expect(output).toContain('Second queued message');
expect(output).toContain('Third queued message');
});
it('shows overflow indicator when more than 3 messages are queued', () => {
const messageQueue = [
'Message 1',
'Message 2',
'Message 3',
'Message 4',
'Message 5',
];
const { lastFrame } = render(
<QueuedMessageDisplay messageQueue={messageQueue} />,
);
const output = lastFrame();
expect(output).toContain('Message 1');
expect(output).toContain('Message 2');
expect(output).toContain('Message 3');
expect(output).toContain('... (+2 more)');
expect(output).not.toContain('Message 4');
expect(output).not.toContain('Message 5');
});
it('normalizes whitespace in messages', () => {
const messageQueue = ['Message with\tmultiple\n whitespace'];
const { lastFrame } = render(
<QueuedMessageDisplay messageQueue={messageQueue} />,
);
const output = lastFrame();
expect(output).toContain('Message with multiple whitespace');
});
});

View File

@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
const MAX_DISPLAYED_QUEUED_MESSAGES = 3;
export interface QueuedMessageDisplayProps {
messageQueue: string[];
}
export const QueuedMessageDisplay = ({
messageQueue,
}: QueuedMessageDisplayProps) => {
if (messageQueue.length === 0) {
return null;
}
return (
<Box flexDirection="column" marginTop={1}>
{messageQueue
.slice(0, MAX_DISPLAYED_QUEUED_MESSAGES)
.map((message, index) => {
const preview = message.replace(/\s+/g, ' ');
return (
<Box key={index} paddingLeft={2} width="100%">
<Text dimColor wrap="truncate">
{preview}
</Text>
</Box>
);
})}
{messageQueue.length > MAX_DISPLAYED_QUEUED_MESSAGES && (
<Box paddingLeft={2}>
<Text dimColor>
... (+
{messageQueue.length - MAX_DISPLAYED_QUEUED_MESSAGES} more)
</Text>
</Box>
)}
</Box>
);
};

View File

@@ -38,18 +38,22 @@ export const QuitConfirmationDialog: React.FC<QuitConfirmationDialogProps> = ({
const options: Array<RadioSelectItem<QuitChoice>> = [
{
key: 'quit',
label: 'Quit immediately (/quit)',
value: QuitChoice.QUIT,
},
{
key: 'summary-and-quit',
label: 'Generate summary and quit (/summary)',
value: QuitChoice.SUMMARY_AND_QUIT,
},
{
key: 'save-and-quit',
label: 'Save conversation and quit (/chat save)',
value: QuitChoice.SAVE_AND_QUIT,
},
{
key: 'cancel',
label: 'Cancel (stay in application)',
value: QuitChoice.CANCEL,
},

View File

@@ -0,0 +1,37 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
export const QuittingDisplay = () => {
const uiState = useUIState();
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
const availableTerminalHeight = terminalHeight;
if (!uiState.quittingMessages) {
return null;
}
return (
<Box flexDirection="column" marginBottom={1}>
{uiState.quittingMessages.map((item) => (
<HistoryItemDisplay
key={item.id}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={terminalWidth}
item={item}
isPending={false}
/>
))}
</Box>
);
};

View File

@@ -9,6 +9,13 @@ import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
import { useKeypress } from '../hooks/useKeypress.js';
import type { Key } from '../contexts/KeypressContext.js';
// Mock useKeypress hook
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
// Mock qrcode-terminal module
vi.mock('qrcode-terminal', () => ({
@@ -31,6 +38,8 @@ vi.mock('ink-link', () => ({
describe('QwenOAuthProgress', () => {
const mockOnTimeout = vi.fn();
const mockOnCancel = vi.fn();
const mockedUseKeypress = vi.mocked(useKeypress);
let keypressHandler: ((key: Key) => void) | null = null;
const createMockDeviceAuth = (
overrides: Partial<DeviceAuthorizationInfo> = {},
@@ -68,6 +77,12 @@ describe('QwenOAuthProgress', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
keypressHandler = null;
// Mock useKeypress to capture the handler
mockedUseKeypress.mockImplementation((handler) => {
keypressHandler = handler;
});
});
afterEach(() => {
@@ -419,7 +434,7 @@ describe('QwenOAuthProgress', () => {
describe('User interactions', () => {
it('should call onCancel when ESC key is pressed', () => {
const { stdin } = render(
render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
@@ -428,24 +443,42 @@ describe('QwenOAuthProgress', () => {
);
// Simulate ESC key press
stdin.write('\u001b'); // ESC character
if (keypressHandler) {
keypressHandler({
name: 'escape',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\u001b',
});
}
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
it('should call onCancel when ESC is pressed in loading state', () => {
const { stdin } = render(
render(
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
);
// Simulate ESC key press
stdin.write('\u001b'); // ESC character
if (keypressHandler) {
keypressHandler({
name: 'escape',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\u001b',
});
}
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
it('should not call onCancel for other key presses', () => {
const { stdin } = render(
render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
@@ -454,9 +487,32 @@ describe('QwenOAuthProgress', () => {
);
// Simulate other key presses
stdin.write('a');
stdin.write('\r'); // Enter
stdin.write(' '); // Space
if (keypressHandler) {
keypressHandler({
name: 'a',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: 'a',
});
keypressHandler({
name: 'return',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\r',
});
keypressHandler({
name: 'space',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: ' ',
});
}
expect(mockOnCancel).not.toHaveBeenCalled();
});
@@ -529,17 +585,35 @@ describe('QwenOAuthProgress', () => {
});
it('should call onCancel for any key press in timeout state', () => {
const { stdin } = renderComponent({
renderComponent({
authStatus: 'timeout',
});
// Simulate any key press
stdin.write('a');
if (keypressHandler) {
keypressHandler({
name: 'a',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: 'a',
});
}
expect(mockOnCancel).toHaveBeenCalledTimes(1);
// Reset mock and try enter key
mockOnCancel.mockClear();
stdin.write('\r');
if (keypressHandler) {
keypressHandler({
name: 'return',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\r',
});
}
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
});

View File

@@ -6,12 +6,13 @@
import type React from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import Link from 'ink-link';
import qrcode from 'qrcode-terminal';
import { Colors } from '../colors.js';
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface QwenOAuthProgressProps {
onTimeout: () => void;
@@ -128,14 +129,17 @@ export function QwenOAuthProgress({
const [dots, setDots] = useState<string>('');
const [qrCodeData, setQrCodeData] = useState<string | null>(null);
useInput((input, key) => {
if (authStatus === 'timeout') {
// Any key press in timeout state should trigger cancel to return to auth dialog
onCancel();
} else if (key.escape || (key.ctrl && input === 'c')) {
onCancel();
}
});
useKeypress(
(key) => {
if (authStatus === 'timeout') {
// Any key press in timeout state should trigger cancel to return to auth dialog
onCancel();
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
onCancel();
}
},
{ isActive: true },
);
// Generate QR code once when device auth is available
useEffect(() => {

View File

@@ -22,17 +22,33 @@
*/
import { render } from 'ink-testing-library';
import { waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SettingsDialog } from './SettingsDialog.js';
import { LoadedSettings } from '../../config/settings.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { act } from 'react';
import { saveModifiedSettings, TEST_ONLY } from '../../utils/settingsUtils.js';
import {
getSettingsSchema,
type SettingDefinition,
type SettingsSchemaType,
} from '../../config/settingsSchema.js';
// Mock the VimModeContext
const mockToggleVimEnabled = vi.fn();
const mockSetVimMode = vi.fn();
enum TerminalKeys {
ENTER = '\u000D',
TAB = '\t',
UP_ARROW = '\u001B[A',
DOWN_ARROW = '\u001B[B',
LEFT_ARROW = '\u001B[D',
RIGHT_ARROW = '\u001B[C',
ESCAPE = '\u001B',
}
const createMockSettings = (
userSettings = {},
systemSettings = {},
@@ -41,10 +57,16 @@ const createMockSettings = (
new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {}, ...systemSettings },
originalSettings: {
ui: { customThemes: {} },
mcpServers: {},
...systemSettings,
},
path: '/system/settings.json',
},
{
settings: {},
originalSettings: {},
path: '/system/system-defaults.json',
},
{
@@ -53,6 +75,11 @@ const createMockSettings = (
mcpServers: {},
...userSettings,
},
originalSettings: {
ui: { customThemes: {} },
mcpServers: {},
...userSettings,
},
path: '/user/settings.json',
},
{
@@ -61,33 +88,23 @@ const createMockSettings = (
mcpServers: {},
...workspaceSettings,
},
originalSettings: {
ui: { customThemes: {} },
mcpServers: {},
...workspaceSettings,
},
path: '/workspace/settings.json',
},
[],
true,
new Set(),
);
vi.mock('../contexts/SettingsContext.js', async () => {
const actual = await vi.importActual('../contexts/SettingsContext.js');
let settings = createMockSettings({ 'a.string.setting': 'initial' });
vi.mock('../../config/settingsSchema.js', async (importOriginal) => {
const original =
await importOriginal<typeof import('../../config/settingsSchema.js')>();
return {
...actual,
useSettings: () => ({
settings,
setSetting: (key: string, value: string) => {
settings = createMockSettings({ [key]: value });
},
getSettingDefinition: (key: string) => {
if (key === 'a.string.setting') {
return {
type: 'string',
description: 'A string setting',
};
}
return undefined;
},
}),
...original,
getSettingsSchema: vi.fn(original.getSettingsSchema),
};
});
@@ -134,10 +151,33 @@ vi.mock('../../utils/settingsUtils.js', async () => {
// const originalConsoleError = console.error;
describe('SettingsDialog', () => {
// Simple delay function for remaining tests that need gradual migration
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
// Custom waitFor utility for ink testing environment (not compatible with @testing-library/react)
const waitFor = async (
predicate: () => void,
options: { timeout?: number; interval?: number } = {},
) => {
const { timeout = 1000, interval = 10 } = options;
const start = Date.now();
let lastError: unknown;
while (Date.now() - start < timeout) {
try {
predicate();
return;
} catch (e) {
lastError = e;
}
await new Promise((resolve) => setTimeout(resolve, interval));
}
if (lastError) {
throw lastError;
}
throw new Error('waitFor timed out');
};
beforeEach(() => {
vi.clearAllMocks();
// Reset keypress mock state (variables are commented out)
// currentKeypressHandler = null;
// isKeypressActive = false;
@@ -147,6 +187,9 @@ describe('SettingsDialog', () => {
});
afterEach(() => {
TEST_ONLY.clearFlattenedSchema();
vi.clearAllMocks();
vi.resetAllMocks();
// Reset keypress mock state (variables are commented out)
// currentKeypressHandler = null;
// isKeypressActive = false;
@@ -154,45 +197,6 @@ describe('SettingsDialog', () => {
// console.error = originalConsoleError;
});
const createMockSettings = (
userSettings = {},
systemSettings = {},
workspaceSettings = {},
) =>
new LoadedSettings(
{
settings: {
ui: { customThemes: {} },
mcpServers: {},
...systemSettings,
},
path: '/system/settings.json',
},
{
settings: {},
path: '/system/system-defaults.json',
},
{
settings: {
ui: { customThemes: {} },
mcpServers: {},
...userSettings,
},
path: '/user/settings.json',
},
{
settings: {
ui: { customThemes: {} },
mcpServers: {},
...workspaceSettings,
},
path: '/workspace/settings.json',
},
[],
true,
new Set(),
);
describe('Initial Rendering', () => {
it('should render the settings dialog with default state', () => {
const settings = createMockSettings();
@@ -210,6 +214,26 @@ describe('SettingsDialog', () => {
expect(output).toContain('Use Enter to select, Tab to change focus');
});
it('should accept availableTerminalHeight prop without errors', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog
settings={settings}
onSelect={onSelect}
availableTerminalHeight={20}
/>
</KeypressProvider>,
);
const output = lastFrame();
// Should still render properly with the height prop
expect(output).toContain('Settings');
expect(output).toContain('Use Enter to select');
});
it('should show settings list with default values', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
@@ -246,15 +270,18 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
const { stdin, unmount, lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Press down arrow
stdin.write('\u001B[B'); // Down arrow
await wait();
act(() => {
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down arrow
});
expect(lastFrame()).toContain('● Disable Auto Update');
// The active index should have changed (tested indirectly through behavior)
unmount();
@@ -271,9 +298,9 @@ describe('SettingsDialog', () => {
);
// First go down, then up
stdin.write('\u001B[B'); // Down arrow
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down arrow
await wait();
stdin.write('\u001B[A'); // Up arrow
stdin.write(TerminalKeys.UP_ARROW as string);
await wait();
unmount();
@@ -298,43 +325,203 @@ describe('SettingsDialog', () => {
unmount();
});
it('should not navigate beyond bounds', async () => {
it('wraps around when at the top of the list', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
const { stdin, unmount, lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Try to go up from first item
stdin.write('\u001B[A'); // Up arrow
act(() => {
stdin.write(TerminalKeys.UP_ARROW);
});
await wait();
// Should still be on first item
expect(lastFrame()).toContain('● Vision Model Preview');
unmount();
});
});
describe('Settings Toggling', () => {
it('should toggle setting with Enter key', async () => {
vi.mocked(saveModifiedSettings).mockClear();
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
const component = (
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
</KeypressProvider>
);
// Press Enter to toggle current setting
stdin.write('\u000D'); // Enter key
await wait();
const { stdin, unmount, lastFrame } = render(component);
// Wait for initial render and verify we're on Vim Mode (first setting)
await waitFor(() => {
expect(lastFrame()).toContain('● Vim Mode');
});
// Navigate to Disable Auto Update setting and verify we're there
act(() => {
stdin.write(TerminalKeys.DOWN_ARROW as string);
});
await waitFor(() => {
expect(lastFrame()).toContain('● Disable Auto Update');
});
// Toggle the setting
act(() => {
stdin.write(TerminalKeys.ENTER as string);
});
// Wait for the setting change to be processed
await waitFor(() => {
expect(
vi.mocked(saveModifiedSettings).mock.calls.length,
).toBeGreaterThan(0);
});
// Wait for the mock to be called
await waitFor(() => {
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled();
});
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
new Set<string>(['general.disableAutoUpdate']),
{
general: {
disableAutoUpdate: true,
},
},
expect.any(LoadedSettings),
SettingScope.User,
);
unmount();
});
describe('enum values', () => {
enum StringEnum {
FOO = 'foo',
BAR = 'bar',
BAZ = 'baz',
}
const SETTING: SettingDefinition = {
type: 'enum',
label: 'Theme',
options: [
{
label: 'Foo',
value: StringEnum.FOO,
},
{
label: 'Bar',
value: StringEnum.BAR,
},
{
label: 'Baz',
value: StringEnum.BAZ,
},
],
category: 'UI',
requiresRestart: false,
default: StringEnum.BAR,
description: 'The color theme for the UI.',
showInDialog: true,
};
const FAKE_SCHEMA: SettingsSchemaType = {
ui: {
showInDialog: false,
properties: {
theme: {
...SETTING,
},
},
},
} as unknown as SettingsSchemaType;
it('toggles enum values with the enter key', async () => {
vi.mocked(saveModifiedSettings).mockClear();
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
const settings = createMockSettings();
const onSelect = vi.fn();
const component = (
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>
);
const { stdin, unmount } = render(component);
// Press Enter to toggle current setting
stdin.write(TerminalKeys.DOWN_ARROW as string);
await wait();
stdin.write(TerminalKeys.ENTER as string);
await wait();
await waitFor(() => {
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled();
});
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
new Set<string>(['ui.theme']),
{
ui: {
theme: StringEnum.BAZ,
},
},
expect.any(LoadedSettings),
SettingScope.User,
);
unmount();
});
it('loops back when reaching the end of an enum', async () => {
vi.mocked(saveModifiedSettings).mockClear();
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
const settings = createMockSettings();
settings.setValue(SettingScope.User, 'ui.theme', StringEnum.BAZ);
const onSelect = vi.fn();
const component = (
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>
);
const { stdin, unmount } = render(component);
// Press Enter to toggle current setting
stdin.write(TerminalKeys.DOWN_ARROW as string);
await wait();
stdin.write(TerminalKeys.ENTER as string);
await wait();
await waitFor(() => {
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled();
});
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
new Set<string>(['ui.theme']),
{
ui: {
theme: StringEnum.FOO,
},
},
expect.any(LoadedSettings),
SettingScope.User,
);
unmount();
});
});
it('should toggle setting with Space key', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
@@ -364,7 +551,7 @@ describe('SettingsDialog', () => {
// Navigate to vim mode setting and toggle it
// This would require knowing the exact position, so we'll just test that the mock is called
stdin.write('\u000D'); // Enter key
stdin.write(TerminalKeys.ENTER as string); // Enter key
await wait();
// The mock should potentially be called if vim mode was toggled
@@ -384,7 +571,7 @@ describe('SettingsDialog', () => {
);
// Switch to scope focus
stdin.write('\t'); // Tab key
stdin.write(TerminalKeys.TAB); // Tab key
await wait();
// Select different scope (numbers 1-3 typically available)
@@ -504,7 +691,7 @@ describe('SettingsDialog', () => {
);
// Switch to scope selector
stdin.write('\t'); // Tab
stdin.write(TerminalKeys.TAB as string); // Tab
await wait();
// Change scope
@@ -549,7 +736,7 @@ describe('SettingsDialog', () => {
);
// Try to toggle a setting (this might trigger vim mode toggle)
stdin.write('\u000D'); // Enter
stdin.write(TerminalKeys.ENTER as string); // Enter
await wait();
// Should not crash
@@ -569,13 +756,13 @@ describe('SettingsDialog', () => {
);
// Toggle a setting
stdin.write('\u000D'); // Enter
stdin.write(TerminalKeys.ENTER as string); // Enter
await wait();
// Toggle another setting
stdin.write('\u001B[B'); // Down
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down
await wait();
stdin.write('\u000D'); // Enter
stdin.write(TerminalKeys.ENTER as string); // Enter
await wait();
// Should track multiple modified settings
@@ -594,7 +781,7 @@ describe('SettingsDialog', () => {
// Navigate down many times to test scrolling
for (let i = 0; i < 10; i++) {
stdin.write('\u001B[B'); // Down arrow
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down arrow
await wait(10);
}
@@ -617,7 +804,7 @@ describe('SettingsDialog', () => {
// Navigate to and toggle vim mode setting
// This would require knowing the exact position of vim mode setting
stdin.write('\u000D'); // Enter
stdin.write(TerminalKeys.ENTER as string); // Enter
await wait();
unmount();
@@ -655,7 +842,7 @@ describe('SettingsDialog', () => {
);
// Toggle a non-restart-required setting (like hideTips)
stdin.write('\u000D'); // Enter - toggle current setting
stdin.write(TerminalKeys.ENTER as string); // Enter - toggle current setting
await wait();
// Should save immediately without showing restart prompt
@@ -752,8 +939,8 @@ describe('SettingsDialog', () => {
// Rapid navigation
for (let i = 0; i < 5; i++) {
stdin.write('\u001B[B'); // Down arrow
stdin.write('\u001B[A'); // Up arrow
stdin.write(TerminalKeys.DOWN_ARROW as string);
stdin.write(TerminalKeys.UP_ARROW as string);
}
await wait(100);
@@ -808,9 +995,9 @@ describe('SettingsDialog', () => {
);
// Try to navigate when potentially at bounds
stdin.write('\u001B[B'); // Down
stdin.write(TerminalKeys.DOWN_ARROW as string);
await wait();
stdin.write('\u001B[A'); // Up
stdin.write(TerminalKeys.UP_ARROW as string);
await wait();
unmount();
@@ -897,7 +1084,7 @@ describe('SettingsDialog', () => {
expect(lastFrame()).toContain('Settings'); // Title
expect(lastFrame()).toContain('● Vim Mode'); // Active setting
expect(lastFrame()).toContain('Apply To'); // Scope section
expect(lastFrame()).toContain('1. User Settings'); // Scope options
expect(lastFrame()).toContain('User Settings'); // Scope options (no numbers when settings focused)
expect(lastFrame()).toContain(
'(Use Enter to select, Tab to change focus)',
); // Help text
@@ -919,19 +1106,19 @@ describe('SettingsDialog', () => {
);
// Toggle first setting (should require restart)
stdin.write('\u000D'); // Enter
stdin.write(TerminalKeys.ENTER as string); // Enter
await wait();
// Navigate to next setting and toggle it (should not require restart - e.g., vimMode)
stdin.write('\u001B[B'); // Down
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down
await wait();
stdin.write('\u000D'); // Enter
stdin.write(TerminalKeys.ENTER as string); // Enter
await wait();
// Navigate to another setting and toggle it (should also require restart)
stdin.write('\u001B[B'); // Down
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down
await wait();
stdin.write('\u000D'); // Enter
stdin.write(TerminalKeys.ENTER as string); // Enter
await wait();
// The test verifies that all changes are preserved and the dialog still works
@@ -950,13 +1137,13 @@ describe('SettingsDialog', () => {
);
// Multiple scope changes
stdin.write('\t'); // Tab to scope
stdin.write(TerminalKeys.TAB as string); // Tab to scope
await wait();
stdin.write('2'); // Workspace
await wait();
stdin.write('\t'); // Tab to settings
stdin.write(TerminalKeys.TAB as string); // Tab to settings
await wait();
stdin.write('\t'); // Tab to scope
stdin.write(TerminalKeys.TAB as string); // Tab to scope
await wait();
stdin.write('1'); // User
await wait();
@@ -1043,4 +1230,335 @@ describe('SettingsDialog', () => {
unmount();
});
});
describe('Snapshot Tests', () => {
/**
* Snapshot tests for SettingsDialog component using ink-testing-library.
* These tests capture the visual output of the component in various states:
*
* - Default rendering with no custom settings
* - Various combinations of boolean settings (enabled/disabled)
* - Mixed boolean and number settings configurations
* - Different focus states (settings vs scope selector)
* - Different scope selections (User, System, Workspace)
* - Accessibility settings enabled
* - File filtering configurations
* - Tools and security settings
* - All settings disabled state
*
* The snapshots help ensure UI consistency and catch unintended visual changes.
*/
it('should render default state correctly', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render with various boolean settings enabled', () => {
const settings = createMockSettings({
general: {
vimMode: true,
disableAutoUpdate: true,
debugKeystrokeLogging: true,
enablePromptCompletion: true,
},
ui: {
hideWindowTitle: true,
hideTips: true,
showMemoryUsage: true,
showLineNumbers: true,
showCitations: true,
accessibility: {
disableLoadingPhrases: true,
screenReader: true,
},
},
ide: {
enabled: true,
},
context: {
loadMemoryFromIncludeDirectories: true,
fileFiltering: {
respectGitIgnore: true,
respectQwenIgnore: true,
enableRecursiveFileSearch: true,
disableFuzzySearch: false,
},
},
tools: {
enableInteractiveShell: true,
autoAccept: true,
useRipgrep: true,
},
security: {
folderTrust: {
enabled: true,
},
},
});
const onSelect = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render with mixed boolean and number settings', () => {
const settings = createMockSettings({
general: {
vimMode: false,
disableAutoUpdate: true,
},
ui: {
showMemoryUsage: true,
hideWindowTitle: false,
},
tools: {
truncateToolOutputThreshold: 50000,
truncateToolOutputLines: 1000,
},
context: {
discoveryMaxDirs: 500,
},
model: {
maxSessionTurns: 100,
skipNextSpeakerCheck: false,
},
});
const onSelect = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render focused on scope selector', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame, stdin } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Switch focus to scope selector with Tab
stdin.write('\t');
expect(lastFrame()).toMatchSnapshot();
});
it('should render with different scope selected (System)', () => {
const settings = createMockSettings(
{}, // userSettings
{
// systemSettings
general: {
vimMode: true,
disableAutoUpdate: false,
},
ui: {
showMemoryUsage: true,
},
},
);
const onSelect = vi.fn();
const { lastFrame, stdin } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Switch to scope selector
stdin.write('\t');
// Navigate to System scope
stdin.write('ArrowDown');
stdin.write('\r'); // Enter to select
expect(lastFrame()).toMatchSnapshot();
});
it('should render with different scope selected (Workspace)', () => {
const settings = createMockSettings(
{}, // userSettings
{}, // systemSettings
{
// workspaceSettings
general: {
vimMode: false,
debugKeystrokeLogging: true,
},
tools: {
useRipgrep: true,
enableInteractiveShell: false,
},
},
);
const onSelect = vi.fn();
const { lastFrame, stdin } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Switch to scope selector
stdin.write('\t');
// Navigate to Workspace scope (down twice)
stdin.write('ArrowDown');
stdin.write('ArrowDown');
stdin.write('\r'); // Enter to select
expect(lastFrame()).toMatchSnapshot();
});
it('should render with accessibility settings enabled', () => {
const settings = createMockSettings({
ui: {
accessibility: {
disableLoadingPhrases: true,
screenReader: true,
},
showMemoryUsage: true,
showLineNumbers: true,
},
general: {
vimMode: true,
},
});
const onSelect = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render with file filtering settings configured', () => {
const settings = createMockSettings({
context: {
fileFiltering: {
respectGitIgnore: false,
respectQwemIgnore: true,
enableRecursiveFileSearch: false,
disableFuzzySearch: true,
},
loadMemoryFromIncludeDirectories: true,
discoveryMaxDirs: 100,
},
});
const onSelect = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render with tools and security settings', () => {
const settings = createMockSettings({
tools: {
enableInteractiveShell: true,
autoAccept: false,
useRipgrep: true,
truncateToolOutputThreshold: 25000,
truncateToolOutputLines: 500,
},
security: {
folderTrust: {
enabled: true,
},
},
model: {
maxSessionTurns: 50,
skipNextSpeakerCheck: true,
},
});
const onSelect = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render with all boolean settings disabled', () => {
const settings = createMockSettings({
general: {
vimMode: false,
disableAutoUpdate: false,
debugKeystrokeLogging: false,
enablePromptCompletion: false,
},
ui: {
hideWindowTitle: false,
hideTips: false,
showMemoryUsage: false,
showLineNumbers: false,
showCitations: false,
accessibility: {
disableLoadingPhrases: false,
screenReader: false,
},
},
ide: {
enabled: false,
},
context: {
loadMemoryFromIncludeDirectories: false,
fileFiltering: {
respectGitIgnore: false,
respectQwemIgnore: false,
enableRecursiveFileSearch: false,
disableFuzzySearch: false,
},
},
tools: {
enableInteractiveShell: false,
autoAccept: false,
useRipgrep: false,
},
security: {
folderTrust: {
enabled: false,
},
},
});
const onSelect = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
});
});

View File

@@ -6,7 +6,7 @@
import React, { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import type { LoadedSettings, Settings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import {
@@ -16,7 +16,6 @@ import {
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import {
getDialogSettingKeys,
getSettingValue,
setPendingSettingValue,
getDisplayValue,
hasRestartRequiredSettings,
@@ -28,16 +27,22 @@ import {
getDefaultValue,
setPendingSettingValueAny,
getNestedValue,
getEffectiveValue,
} from '../../utils/settingsUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useKeypress } from '../hooks/useKeypress.js';
import chalk from 'chalk';
import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
import {
type SettingsValue,
TOGGLE_TYPES,
} from '../../config/settingsSchema.js';
interface SettingsDialogProps {
settings: LoadedSettings;
onSelect: (settingName: string | undefined, scope: SettingScope) => void;
onRestartRequest?: () => void;
availableTerminalHeight?: number;
}
const maxItemsToShow = 8;
@@ -46,6 +51,7 @@ export function SettingsDialog({
settings,
onSelect,
onRestartRequest,
availableTerminalHeight,
}: SettingsDialogProps): React.JSX.Element {
// Get vim mode context to sync vim mode changes
const { vimEnabled, toggleVimEnabled } = useVimMode();
@@ -122,15 +128,33 @@ export function SettingsDialog({
value: key,
type: definition?.type,
toggle: () => {
if (definition?.type !== 'boolean') {
// For non-boolean items, toggle will be handled via edit mode.
if (!TOGGLE_TYPES.has(definition?.type)) {
return;
}
const currentValue = getSettingValue(key, pendingSettings, {});
const newValue = !currentValue;
const currentValue = getEffectiveValue(key, pendingSettings, {});
let newValue: SettingsValue;
if (definition?.type === 'boolean') {
newValue = !(currentValue as boolean);
setPendingSettings((prev) =>
setPendingSettingValue(key, newValue as boolean, prev),
);
} else if (definition?.type === 'enum' && definition.options) {
const options = definition.options;
const currentIndex = options?.findIndex(
(opt) => opt.value === currentValue,
);
if (currentIndex !== -1 && currentIndex < options.length - 1) {
newValue = options[currentIndex + 1].value;
} else {
newValue = options[0].value; // loop back to start.
}
setPendingSettings((prev) =>
setPendingSettingValueAny(key, newValue, prev),
);
}
setPendingSettings((prev) =>
setPendingSettingValue(key, newValue, prev),
setPendingSettingValue(key, newValue as boolean, prev),
);
if (!requiresRestart(key)) {
@@ -334,7 +358,10 @@ export function SettingsDialog({
};
// Scope selector items
const scopeItems = getScopeItems();
const scopeItems = getScopeItems().map((item) => ({
...item,
key: item.value,
}));
const handleScopeHighlight = (scope: SettingScope) => {
setSelectedScope(scope);
@@ -345,16 +372,97 @@ export function SettingsDialog({
setFocusSection('settings');
};
// Height constraint calculations similar to ThemeDialog
const DIALOG_PADDING = 2;
const SETTINGS_TITLE_HEIGHT = 2; // "Settings" title + spacing
const SCROLL_ARROWS_HEIGHT = 2; // Up and down arrows
const SPACING_HEIGHT = 1; // Space between settings list and scope
const SCOPE_SELECTION_HEIGHT = 4; // Apply To section height
const BOTTOM_HELP_TEXT_HEIGHT = 1; // Help text
const RESTART_PROMPT_HEIGHT = showRestartPrompt ? 1 : 0;
let currentAvailableTerminalHeight =
availableTerminalHeight ?? Number.MAX_SAFE_INTEGER;
currentAvailableTerminalHeight -= 2; // Top and bottom borders
// Start with basic fixed height (without scope selection)
let totalFixedHeight =
DIALOG_PADDING +
SETTINGS_TITLE_HEIGHT +
SCROLL_ARROWS_HEIGHT +
SPACING_HEIGHT +
BOTTOM_HELP_TEXT_HEIGHT +
RESTART_PROMPT_HEIGHT;
// Calculate how much space we have for settings
let availableHeightForSettings = Math.max(
1,
currentAvailableTerminalHeight - totalFixedHeight,
);
// Each setting item takes 2 lines (the setting row + spacing)
let maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 2));
// Decide whether to show scope selection based on remaining space
let showScopeSelection = true;
// If we have limited height, prioritize showing more settings over scope selection
if (availableTerminalHeight && availableTerminalHeight < 25) {
// For very limited height, hide scope selection to show more settings
const totalWithScope = totalFixedHeight + SCOPE_SELECTION_HEIGHT;
const availableWithScope = Math.max(
1,
currentAvailableTerminalHeight - totalWithScope,
);
const maxItemsWithScope = Math.max(1, Math.floor(availableWithScope / 2));
// If hiding scope selection allows us to show significantly more settings, do it
if (maxVisibleItems > maxItemsWithScope + 1) {
showScopeSelection = false;
} else {
// Otherwise include scope selection and recalculate
totalFixedHeight += SCOPE_SELECTION_HEIGHT;
availableHeightForSettings = Math.max(
1,
currentAvailableTerminalHeight - totalFixedHeight,
);
maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 2));
}
} else {
// For normal height, include scope selection
totalFixedHeight += SCOPE_SELECTION_HEIGHT;
availableHeightForSettings = Math.max(
1,
currentAvailableTerminalHeight - totalFixedHeight,
);
maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 2));
}
// Use the calculated maxVisibleItems or fall back to the original maxItemsToShow
const effectiveMaxItemsToShow = availableTerminalHeight
? Math.min(maxVisibleItems, items.length)
: maxItemsToShow;
// Ensure focus stays on settings when scope selection is hidden
React.useEffect(() => {
if (!showScopeSelection && focusSection === 'scope') {
setFocusSection('settings');
}
}, [showScopeSelection, focusSection]);
// Scroll logic for settings
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
// Always show arrows for consistent UI and to indicate circular navigation
const showScrollUp = true;
const showScrollDown = true;
const visibleItems = items.slice(
scrollOffset,
scrollOffset + effectiveMaxItemsToShow,
);
// Show arrows if there are more items than can be displayed
const showScrollUp = items.length > effectiveMaxItemsToShow;
const showScrollDown = items.length > effectiveMaxItemsToShow;
useKeypress(
(key) => {
const { name, ctrl } = key;
if (name === 'tab') {
if (name === 'tab' && showScopeSelection) {
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
}
if (focusSection === 'settings') {
@@ -457,7 +565,9 @@ export function SettingsDialog({
setActiveSettingIndex(newIndex);
// Adjust scroll offset for wrap-around
if (newIndex === items.length - 1) {
setScrollOffset(Math.max(0, items.length - maxItemsToShow));
setScrollOffset(
Math.max(0, items.length - effectiveMaxItemsToShow),
);
} else if (newIndex < scrollOffset) {
setScrollOffset(newIndex);
}
@@ -472,8 +582,8 @@ export function SettingsDialog({
// Adjust scroll offset for wrap-around
if (newIndex === 0) {
setScrollOffset(0);
} else if (newIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newIndex - maxItemsToShow + 1);
} else if (newIndex >= scrollOffset + effectiveMaxItemsToShow) {
setScrollOffset(newIndex - effectiveMaxItemsToShow + 1);
}
} else if (name === 'return' || name === 'space') {
const currentItem = items[activeSettingIndex];
@@ -633,18 +743,18 @@ export function SettingsDialog({
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
borderColor={theme.border.default}
flexDirection="row"
padding={1}
width="100%"
height="100%"
>
<Box flexDirection="column" flexGrow={1}>
<Text bold color={Colors.AccentBlue}>
Settings
<Text bold={focusSection === 'settings'} wrap="truncate">
{focusSection === 'settings' ? '> ' : ' '}Settings
</Text>
<Box height={1} />
{showScrollUp && <Text color={Colors.Gray}></Text>}
{showScrollUp && <Text color={theme.text.secondary}></Text>}
{visibleItems.map((item, idx) => {
const isActive =
focusSection === 'settings' &&
@@ -725,17 +835,21 @@ export function SettingsDialog({
<React.Fragment key={item.value}>
<Box flexDirection="row" alignItems="center">
<Box minWidth={2} flexShrink={0}>
<Text color={isActive ? Colors.AccentGreen : Colors.Gray}>
<Text
color={
isActive ? theme.status.success : theme.text.secondary
}
>
{isActive ? '●' : ''}
</Text>
</Box>
<Box minWidth={50}>
<Text
color={isActive ? Colors.AccentGreen : Colors.Foreground}
color={isActive ? theme.status.success : theme.text.primary}
>
{item.label}
{scopeMessage && (
<Text color={Colors.Gray}> {scopeMessage}</Text>
<Text color={theme.text.secondary}> {scopeMessage}</Text>
)}
</Text>
</Box>
@@ -743,10 +857,10 @@ export function SettingsDialog({
<Text
color={
isActive
? Colors.AccentGreen
? theme.status.success
: shouldBeGreyedOut
? Colors.Gray
: Colors.Foreground
? theme.text.secondary
: theme.text.primary
}
>
{displayValue}
@@ -756,30 +870,36 @@ export function SettingsDialog({
</React.Fragment>
);
})}
{showScrollDown && <Text color={Colors.Gray}></Text>}
{showScrollDown && <Text color={theme.text.secondary}></Text>}
<Box height={1} />
<Box marginTop={1} flexDirection="column">
<Text bold={focusSection === 'scope'} wrap="truncate">
{focusSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={0}
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={focusSection === 'scope'}
showNumbers={focusSection === 'scope'}
/>
</Box>
{/* Scope Selection - conditionally visible based on height constraints */}
{showScopeSelection && (
<Box marginTop={1} flexDirection="column">
<Text bold={focusSection === 'scope'} wrap="truncate">
{focusSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={scopeItems.findIndex(
(item) => item.value === selectedScope,
)}
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={focusSection === 'scope'}
showNumbers={focusSection === 'scope'}
/>
</Box>
)}
<Box height={1} />
<Text color={Colors.Gray}>
(Use Enter to select, Tab to change focus)
<Text color={theme.text.secondary}>
(Use Enter to select
{showScopeSelection ? ', Tab to change focus' : ''})
</Text>
{showRestartPrompt && (
<Text color={Colors.AccentYellow}>
<Text color={theme.status.warning}>
To see changes, Qwen Code must be restarted. Press r to exit and
apply changes now.
</Text>

View File

@@ -7,7 +7,7 @@
import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import type React from 'react';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import { RenderInline } from '../utils/InlineMarkdownRenderer.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
@@ -53,14 +53,17 @@ export const ShellConfirmationDialog: React.FC<
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
key: 'Yes, allow once',
},
{
label: 'Yes, allow always for this session',
value: ToolConfirmationOutcome.ProceedAlways,
key: 'Yes, allow always for this session',
},
{
label: 'No (esc)',
value: ToolConfirmationOutcome.Cancel,
key: 'No (esc)',
},
];
@@ -68,23 +71,27 @@ export const ShellConfirmationDialog: React.FC<
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.AccentYellow}
borderColor={theme.status.warning}
padding={1}
width="100%"
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text bold>Shell Command Execution</Text>
<Text>A custom command wants to run the following shell commands:</Text>
<Text bold color={theme.text.primary}>
Shell Command Execution
</Text>
<Text color={theme.text.primary}>
A custom command wants to run the following shell commands:
</Text>
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.Gray}
borderColor={theme.border.default}
paddingX={1}
marginTop={1}
>
{commands.map((cmd) => (
<Text key={cmd} color={Colors.AccentCyan}>
<Text key={cmd} color={theme.text.link}>
<RenderInline text={cmd} />
</Text>
))}
@@ -92,7 +99,7 @@ export const ShellConfirmationDialog: React.FC<
</Box>
<Box marginBottom={1}>
<Text>Do you want to proceed?</Text>
<Text color={theme.text.primary}>Do you want to proceed?</Text>
</Box>
<RadioButtonSelect items={options} onSelect={handleSelect} isFocused />

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