mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
Sync upstream Gemini-CLI v0.8.2 (#838)
This commit is contained in:
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
1237
packages/cli/src/ui/AppContainer.test.tsx
Normal file
1237
packages/cli/src/ui/AppContainer.test.tsx
Normal file
File diff suppressed because it is too large
Load diff
1484
packages/cli/src/ui/AppContainer.tsx
Normal file
1484
packages/cli/src/ui/AppContainer.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
`;
|
||||
|
|
@ -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(),
|
||||
);
|
||||
|
|
@ -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(
|
||||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() ?? '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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() ?? '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export const clearCommand: SlashCommand = {
|
|||
context.ui.setDebugMessage('Clearing terminal.');
|
||||
}
|
||||
|
||||
uiTelemetryService.resetLastPromptTokenCount();
|
||||
uiTelemetryService.setLastPromptTokenCount(0);
|
||||
context.ui.clear();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
35
packages/cli/src/ui/commands/permissionsCommand.test.ts
Normal file
35
packages/cli/src/ui/commands/permissionsCommand.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
}),
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -100,6 +100,7 @@ export const summaryCommand: SlashCommand = {
|
|||
],
|
||||
{},
|
||||
new AbortController().signal,
|
||||
config.getModel(),
|
||||
);
|
||||
|
||||
// Extract text from response
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
106
packages/cli/src/ui/components/AnsiOutput.test.tsx
Normal file
106
packages/cli/src/ui/components/AnsiOutput.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
50
packages/cli/src/ui/components/AnsiOutput.tsx
Normal file
50
packages/cli/src/ui/components/AnsiOutput.tsx
Normal 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>
|
||||
));
|
||||
};
|
||||
33
packages/cli/src/ui/components/AppHeader.tsx
Normal file
33
packages/cli/src/ui/components/AppHeader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
434
packages/cli/src/ui/components/Composer.test.tsx
Normal file
434
packages/cli/src/ui/components/Composer.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
163
packages/cli/src/ui/components/Composer.tsx
Normal file
163
packages/cli/src/ui/components/Composer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
47
packages/cli/src/ui/components/ConfigInitDisplay.tsx
Normal file
47
packages/cli/src/ui/components/ConfigInitDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
119
packages/cli/src/ui/components/ConsentPrompt.test.tsx
Normal file
119
packages/cli/src/ui/components/ConsentPrompt.test.tsx
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
51
packages/cli/src/ui/components/ConsentPrompt.tsx
Normal file
51
packages/cli/src/ui/components/ConsentPrompt.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
271
packages/cli/src/ui/components/DialogManager.tsx
Normal file
271
packages/cli/src/ui/components/DialogManager.tsx
Normal 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;
|
||||
};
|
||||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
29
packages/cli/src/ui/components/ExitWarning.tsx
Normal file
29
packages/cli/src/ui/components/ExitWarning.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 ()');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
63
packages/cli/src/ui/components/Help.test.tsx
Normal file
63
packages/cli/src/ui/components/Help.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
91
packages/cli/src/ui/components/IdeTrustChangeDialog.test.tsx
Normal file
91
packages/cli/src/ui/components/IdeTrustChangeDialog.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
47
packages/cli/src/ui/components/IdeTrustChangeDialog.tsx
Normal file
47
packages/cli/src/ui/components/IdeTrustChangeDialog.tsx
Normal 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 'r' to restart Gemini to apply the changes.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
97
packages/cli/src/ui/components/LoopDetectionConfirmation.tsx
Normal file
97
packages/cli/src/ui/components/LoopDetectionConfirmation.tsx
Normal 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 "model.skipLoopDetection" to true in your
|
||||
settings.json.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
73
packages/cli/src/ui/components/MainContent.tsx
Normal file
73
packages/cli/src/ui/components/MainContent.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
227
packages/cli/src/ui/components/ModelDialog.test.tsx
Normal file
227
packages/cli/src/ui/components/ModelDialog.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
104
packages/cli/src/ui/components/ModelDialog.tsx
Normal file
104
packages/cli/src/ui/components/ModelDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
62
packages/cli/src/ui/components/Notifications.tsx
Normal file
62
packages/cli/src/ui/components/Notifications.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
125
packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx
Normal file
125
packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx
Normal 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
|
||||
'r' to restart CLI now.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
123
packages/cli/src/ui/components/PrepareLabel.test.tsx
Normal file
123
packages/cli/src/ui/components/PrepareLabel.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
91
packages/cli/src/ui/components/ProQuotaDialog.test.tsx
Normal file
91
packages/cli/src/ui/components/ProQuotaDialog.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
54
packages/cli/src/ui/components/ProQuotaDialog.tsx
Normal file
54
packages/cli/src/ui/components/ProQuotaDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx
Normal file
76
packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
47
packages/cli/src/ui/components/QueuedMessageDisplay.tsx
Normal file
47
packages/cli/src/ui/components/QueuedMessageDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
37
packages/cli/src/ui/components/QuittingDisplay.tsx
Normal file
37
packages/cli/src/ui/components/QuittingDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue