mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 04:30:48 +00:00
Merge branch 'main' into feature/arena-agent-collaboration
This commit is contained in:
commit
eff6543d05
109 changed files with 3861 additions and 2407 deletions
|
|
@ -78,7 +78,7 @@ describe('<Header />', () => {
|
|||
|
||||
it('renders with border around info panel', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
expect(lastFrame()).toContain('╭');
|
||||
expect(lastFrame()).toContain('╯');
|
||||
expect(lastFrame()).toContain('┌');
|
||||
expect(lastFrame()).toContain('┐');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||
{/* Right side: Info panel (flexible width, max 60 in two-column layout) */}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={infoPanelPaddingX}
|
||||
flexGrow={showLogo ? 0 : 1}
|
||||
|
|
|
|||
|
|
@ -17,18 +17,6 @@ vi.mock('../hooks/useKeypress.js', () => ({
|
|||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock qrcode-terminal module
|
||||
vi.mock('qrcode-terminal', () => ({
|
||||
default: {
|
||||
generate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock ink-spinner
|
||||
vi.mock('ink-spinner', () => ({
|
||||
default: ({ type }: { type: string }) => `MockSpinner(${type})`,
|
||||
}));
|
||||
|
||||
// Mock ink-link
|
||||
vi.mock('ink-link', () => ({
|
||||
default: ({ children }: { children: React.ReactNode; url: string }) =>
|
||||
|
|
@ -95,19 +83,17 @@ describe('QwenOAuthProgress', () => {
|
|||
const { lastFrame } = renderComponent();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('MockSpinner(dots)');
|
||||
expect(output).toContain('Waiting for Qwen OAuth authentication...');
|
||||
expect(output).toContain('(Press ESC or CTRL+C to cancel)');
|
||||
expect(output).toContain('Esc to cancel');
|
||||
});
|
||||
|
||||
it('should render loading state with gray border', () => {
|
||||
it('should render loading state with single border', () => {
|
||||
const { lastFrame } = renderComponent();
|
||||
const output = lastFrame();
|
||||
|
||||
// Should not contain auth flow elements
|
||||
expect(output).not.toContain('Qwen OAuth Authentication');
|
||||
expect(output).not.toContain('Please visit this URL to authorize:');
|
||||
// Loading state still shows time remaining with default timeout
|
||||
// Should contain the auth title even in loading state
|
||||
expect(output).toContain('Qwen OAuth Authentication');
|
||||
// Loading state shows time remaining with default timeout
|
||||
expect(output).toContain('Time remaining:');
|
||||
});
|
||||
});
|
||||
|
|
@ -117,44 +103,20 @@ describe('QwenOAuthProgress', () => {
|
|||
const { lastFrame } = renderComponent({ deviceAuth: mockDeviceAuth });
|
||||
|
||||
const output = lastFrame();
|
||||
// Initially no QR code shown until it's generated, but the status area should be visible
|
||||
expect(output).toContain('MockSpinner(dots)');
|
||||
expect(output).toContain('Waiting for authorization');
|
||||
expect(output).toContain('Time remaining: 5:00');
|
||||
expect(output).toContain('(Press ESC or CTRL+C to cancel)');
|
||||
expect(output).toContain('Esc to cancel');
|
||||
});
|
||||
|
||||
it('should display correct URL in Static component when QR code is generated', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
const mockGenerate = vi.mocked(qrcode.default.generate);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let qrCallback: any = null;
|
||||
mockGenerate.mockImplementation((url, options, callback) => {
|
||||
qrCallback = callback;
|
||||
});
|
||||
|
||||
it('should display correct URL in auth URL display', () => {
|
||||
const customAuth = createMockDeviceAuth({
|
||||
verification_uri_complete: 'https://custom.com/auth?code=XYZ789',
|
||||
});
|
||||
|
||||
const { lastFrame, rerender } = renderComponent({
|
||||
const { lastFrame } = renderComponent({
|
||||
deviceAuth: customAuth,
|
||||
});
|
||||
|
||||
// Manually trigger the QR code callback
|
||||
if (qrCallback && typeof qrCallback === 'function') {
|
||||
qrCallback('Mock QR Code Data');
|
||||
}
|
||||
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={customAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('https://custom.com/auth?code=XYZ789');
|
||||
});
|
||||
|
||||
|
|
@ -282,10 +244,11 @@ describe('QwenOAuthProgress', () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
// Initial state should have no dots
|
||||
expect(lastFrame()).toContain('Waiting for authorization');
|
||||
// Initial state should show '...' (default value)
|
||||
const initialOutput = lastFrame();
|
||||
expect(initialOutput).toContain('Waiting for authorization');
|
||||
|
||||
// Advance by 500ms to add first dot
|
||||
// Advance by 500ms to cycle animation
|
||||
vi.advanceTimersByTime(500);
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
|
|
@ -294,9 +257,10 @@ describe('QwenOAuthProgress', () => {
|
|||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Waiting for authorization.');
|
||||
const after500ms = lastFrame();
|
||||
expect(after500ms).toContain('Waiting for authorization');
|
||||
|
||||
// Advance by another 500ms to add second dot
|
||||
// Advance by another 500ms to continue animation
|
||||
vi.advanceTimersByTime(500);
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
|
|
@ -305,9 +269,10 @@ describe('QwenOAuthProgress', () => {
|
|||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Waiting for authorization..');
|
||||
const after1000ms = lastFrame();
|
||||
expect(after1000ms).toContain('Waiting for authorization');
|
||||
|
||||
// Advance by another 500ms to add third dot
|
||||
// Advance by another 500ms to complete cycle
|
||||
vi.advanceTimersByTime(500);
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
|
|
@ -316,110 +281,8 @@ describe('QwenOAuthProgress', () => {
|
|||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Waiting for authorization...');
|
||||
|
||||
// Advance by another 500ms to reset dots
|
||||
vi.advanceTimersByTime(500);
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Waiting for authorization');
|
||||
});
|
||||
});
|
||||
|
||||
describe('QR Code functionality', () => {
|
||||
it('should generate QR code when deviceAuth is provided', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
const mockGenerate = vi.mocked(qrcode.default.generate);
|
||||
|
||||
mockGenerate.mockImplementation((url, options, callback) => {
|
||||
callback!('Mock QR Code Data');
|
||||
});
|
||||
|
||||
render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockGenerate).toHaveBeenCalledWith(
|
||||
mockDeviceAuth.verification_uri_complete,
|
||||
{ small: true },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display QR code in Static component when available', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
const mockGenerate = vi.mocked(qrcode.default.generate);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let qrCallback: any = null;
|
||||
mockGenerate.mockImplementation((url, options, callback) => {
|
||||
qrCallback = callback;
|
||||
});
|
||||
|
||||
const { lastFrame, rerender } = render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Manually trigger the QR code callback
|
||||
if (qrCallback && typeof qrCallback === 'function') {
|
||||
qrCallback('Mock QR Code Data');
|
||||
}
|
||||
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Or scan the QR code below:');
|
||||
expect(output).toContain('Mock QR Code Data');
|
||||
});
|
||||
|
||||
it('should handle QR code generation errors gracefully', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
const mockGenerate = vi.mocked(qrcode.default.generate);
|
||||
mockGenerate.mockImplementation(() => {
|
||||
throw new Error('QR Code generation failed');
|
||||
});
|
||||
|
||||
const { lastFrame } = render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should not crash and should not show QR code section since QR generation failed
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Or scan the QR code below:');
|
||||
});
|
||||
|
||||
it('should not generate QR code when deviceAuth is null', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
const mockGenerate = vi.mocked(qrcode.default.generate);
|
||||
|
||||
render(
|
||||
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
|
||||
);
|
||||
|
||||
expect(mockGenerate).not.toHaveBeenCalled();
|
||||
const after1500ms = lastFrame();
|
||||
expect(after1500ms).toContain('Waiting for authorization');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,14 +5,11 @@
|
|||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
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 { theme } from '../semantic-colors.js';
|
||||
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
|
|
@ -30,98 +27,10 @@ interface QwenOAuthProgressProps {
|
|||
authMessage?: string | null;
|
||||
}
|
||||
|
||||
const debugLogger = createDebugLogger('QWEN_OAUTH_PROGRESS');
|
||||
|
||||
/**
|
||||
* Static QR Code Display Component
|
||||
* Renders the QR code and URL once and doesn't re-render unless the URL changes
|
||||
*/
|
||||
function QrCodeDisplay({
|
||||
verificationUrl,
|
||||
qrCodeData,
|
||||
}: {
|
||||
verificationUrl: string;
|
||||
qrCodeData: string | null;
|
||||
}): React.JSX.Element | null {
|
||||
if (!qrCodeData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentBlue}>
|
||||
{t('Qwen OAuth Authentication')}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Please visit this URL to authorize:')}</Text>
|
||||
</Box>
|
||||
|
||||
<Link url={verificationUrl} fallback={false}>
|
||||
<Text color={Colors.AccentGreen} bold>
|
||||
{verificationUrl}
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Or scan the QR code below:')}</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>{qrCodeData}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic Status Display Component
|
||||
* Shows the loading spinner, timer, and status messages
|
||||
*/
|
||||
function StatusDisplay({
|
||||
timeRemaining,
|
||||
dots,
|
||||
}: {
|
||||
timeRemaining: number;
|
||||
dots: string;
|
||||
}): React.JSX.Element {
|
||||
const formatTime = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
<Spinner type="dots" /> {t('Waiting for authorization')}
|
||||
{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} justifyContent="space-between">
|
||||
<Text color={Colors.Gray}>
|
||||
{t('Time remaining:')} {formatTime(timeRemaining)}
|
||||
</Text>
|
||||
<Text color={Colors.AccentPurple}>
|
||||
{t('(Press ESC or CTRL+C to cancel)')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
function formatTime(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function QwenOAuthProgress({
|
||||
|
|
@ -133,13 +42,11 @@ export function QwenOAuthProgress({
|
|||
}: QwenOAuthProgressProps): React.JSX.Element {
|
||||
const defaultTimeout = deviceAuth?.expires_in || 300; // Default 5 minutes
|
||||
const [timeRemaining, setTimeRemaining] = useState<number>(defaultTimeout);
|
||||
const [dots, setDots] = useState<string>('');
|
||||
const [qrCodeData, setQrCodeData] = useState<string | null>(null);
|
||||
const [dots, setDots] = useState<string>('...');
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (authStatus === 'timeout' || authStatus === 'error') {
|
||||
// Any key press in timeout or error state should trigger cancel to return to auth dialog
|
||||
onCancel();
|
||||
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
||||
onCancel();
|
||||
|
|
@ -148,30 +55,6 @@ export function QwenOAuthProgress({
|
|||
{ isActive: true },
|
||||
);
|
||||
|
||||
// Generate QR code once when device auth is available
|
||||
useEffect(() => {
|
||||
if (!deviceAuth?.verification_uri_complete) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generateQR = () => {
|
||||
try {
|
||||
qrcode.generate(
|
||||
deviceAuth.verification_uri_complete,
|
||||
{ small: true },
|
||||
(qrcode: string) => {
|
||||
setQrCodeData(qrcode);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to generate QR code:', error);
|
||||
setQrCodeData(null);
|
||||
}
|
||||
};
|
||||
|
||||
generateQR();
|
||||
}, [deviceAuth?.verification_uri_complete]);
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
|
|
@ -187,41 +70,29 @@ export function QwenOAuthProgress({
|
|||
return () => clearInterval(timer);
|
||||
}, [onTimeout]);
|
||||
|
||||
// Animated dots
|
||||
// Animated dots — cycle through fixed-width patterns to avoid layout shift
|
||||
useEffect(() => {
|
||||
const dotFrames = ['. ', '.. ', '...'];
|
||||
let frameIndex = 0;
|
||||
const dotsTimer = setInterval(() => {
|
||||
setDots((prev) => {
|
||||
if (prev.length >= 3) return '';
|
||||
return prev + '.';
|
||||
});
|
||||
frameIndex = (frameIndex + 1) % dotFrames.length;
|
||||
setDots(dotFrames[frameIndex]!);
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(dotsTimer);
|
||||
}, []);
|
||||
|
||||
// Memoize the QR code display to prevent unnecessary re-renders
|
||||
const qrCodeDisplay = useMemo(() => {
|
||||
if (!deviceAuth?.verification_uri_complete) return null;
|
||||
|
||||
return (
|
||||
<QrCodeDisplay
|
||||
verificationUrl={deviceAuth.verification_uri_complete}
|
||||
qrCodeData={qrCodeData}
|
||||
/>
|
||||
);
|
||||
}, [deviceAuth?.verification_uri_complete, qrCodeData]);
|
||||
|
||||
// Handle timeout state
|
||||
if (authStatus === 'timeout') {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentRed}
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentRed}>
|
||||
<Text bold color={theme.status.error}>
|
||||
{t('Qwen OAuth Authentication Timeout')}
|
||||
</Text>
|
||||
|
||||
|
|
@ -238,7 +109,7 @@ export function QwenOAuthProgress({
|
|||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Press any key to return to authentication type selection.')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
|
@ -249,26 +120,26 @@ export function QwenOAuthProgress({
|
|||
if (authStatus === 'error') {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentRed}
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentRed}>
|
||||
Qwen OAuth Authentication Error
|
||||
<Text bold color={theme.status.error}>
|
||||
{t('Qwen OAuth Authentication Error')}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
{authMessage ||
|
||||
'An error occurred during authentication. Please try again.'}
|
||||
t('An error occurred during authentication. Please try again.')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
Press any key to return to authentication type selection.
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Press any key to return to authentication type selection.')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -279,38 +150,61 @@ export function QwenOAuthProgress({
|
|||
if (!deviceAuth) {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box>
|
||||
<Text bold>{t('Qwen OAuth Authentication')}</Text>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text>{t('Waiting for Qwen OAuth authentication...')}</Text>
|
||||
<Text>
|
||||
<Spinner type="dots" />
|
||||
{t('Waiting for Qwen OAuth authentication...')}
|
||||
{t('Time remaining:')} {formatTime(timeRemaining)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} justifyContent="space-between">
|
||||
<Text color={Colors.Gray}>
|
||||
{t('Time remaining:')} {Math.floor(timeRemaining / 60)}:
|
||||
{(timeRemaining % 60).toString().padStart(2, '0')}
|
||||
</Text>
|
||||
<Text color={Colors.AccentPurple}>
|
||||
{t('(Press ESC or CTRL+C to cancel)')}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Esc to cancel')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{/* Static QR Code Display */}
|
||||
{qrCodeDisplay}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>{t('Qwen OAuth Authentication')}</Text>
|
||||
|
||||
{/* Dynamic Status Display */}
|
||||
<StatusDisplay timeRemaining={timeRemaining} dots={dots} />
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Please visit this URL to authorize:')}</Text>
|
||||
</Box>
|
||||
|
||||
<Link url={deviceAuth.verification_uri_complete || ''} fallback={false}>
|
||||
<Text color={theme.text.link} bold>
|
||||
{deviceAuth.verification_uri_complete}
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text>
|
||||
{t('Waiting for authorization')}
|
||||
{dots}
|
||||
</Text>
|
||||
<Text>
|
||||
{t('Time remaining:')} {formatTime(timeRemaining)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Esc to cancel')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type React from 'react';
|
|||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { ShellExecutionService } from '@qwen-code/qwen-code-core';
|
||||
import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
|
||||
export interface ShellInputPromptProps {
|
||||
activeShellPtyId: number | null;
|
||||
|
|
@ -33,6 +34,11 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
|
|||
if (!focus || !activeShellPtyId) {
|
||||
return;
|
||||
}
|
||||
// Don't forward Ctrl+F to the PTY — it's used to toggle shell focus.
|
||||
// Without this, the raw ^F control character gets written to the shell.
|
||||
if (keyMatchers[Command.TOGGLE_SHELL_INPUT_FOCUS](key)) {
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && key.shift && key.name === 'up') {
|
||||
ShellExecutionService.scrollPty(activeShellPtyId, -1);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { useConfig } from '../../contexts/ConfigContext.js';
|
|||
import {
|
||||
getMCPServerStatus,
|
||||
DiscoveredMCPTool,
|
||||
MCPOAuthTokenStorage,
|
||||
type MCPServerConfig,
|
||||
type AnyDeclarativeTool,
|
||||
type DiscoveredMCPPrompt,
|
||||
|
|
@ -95,16 +96,10 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
let source: 'user' | 'project' | 'extension' = 'user';
|
||||
if (serverConfig.extensionName) {
|
||||
source = 'extension';
|
||||
}
|
||||
|
||||
// Determine the scope of the configuration
|
||||
let scope: 'user' | 'workspace' | 'extension' = 'user';
|
||||
if (serverConfig.extensionName) {
|
||||
scope = 'extension';
|
||||
} else if (workspaceSettings.mcpServers?.[name]) {
|
||||
scope = 'workspace';
|
||||
source = 'project';
|
||||
} else if (userSettings.mcpServers?.[name]) {
|
||||
scope = 'user';
|
||||
source = 'user';
|
||||
}
|
||||
|
||||
// Use config.isMcpServerDisabled() to check if server is disabled
|
||||
|
|
@ -115,16 +110,26 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
(t) => !t.name || !t.description,
|
||||
).length;
|
||||
|
||||
// Check if OAuth tokens exist for this server
|
||||
let hasOAuthTokens = false;
|
||||
try {
|
||||
const tokenStorage = new MCPOAuthTokenStorage();
|
||||
const credentials = await tokenStorage.getCredentials(name);
|
||||
hasOAuthTokens = credentials !== null;
|
||||
} catch {
|
||||
// Ignore errors when checking token existence
|
||||
}
|
||||
|
||||
serverInfos.push({
|
||||
name,
|
||||
status,
|
||||
source,
|
||||
scope,
|
||||
config: serverConfig,
|
||||
toolCount: serverTools.length,
|
||||
invalidToolCount,
|
||||
promptCount: serverPrompts.length,
|
||||
isDisabled,
|
||||
hasOAuthTokens,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -256,6 +261,36 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
}
|
||||
}, [fetchServerData]);
|
||||
|
||||
// Clear OAuth authentication tokens and disconnect the server
|
||||
const handleClearAuth = useCallback(async () => {
|
||||
if (!config || !selectedServer) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const tokenStorage = new MCPOAuthTokenStorage();
|
||||
await tokenStorage.deleteCredentials(selectedServer.name);
|
||||
debugLogger.info(
|
||||
`Cleared OAuth tokens for server '${selectedServer.name}'`,
|
||||
);
|
||||
|
||||
// Disconnect the server so it no longer appears as connected
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (toolRegistry) {
|
||||
await toolRegistry.disconnectServer(selectedServer.name);
|
||||
}
|
||||
|
||||
// Reload to update hasOAuthTokens flag and server status
|
||||
await reloadServers();
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Error clearing OAuth tokens for server '${selectedServer.name}':`,
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [config, selectedServer, reloadServers]);
|
||||
|
||||
// Reconnect server
|
||||
const handleReconnect = useCallback(async () => {
|
||||
if (!config || !selectedServer) return;
|
||||
|
|
@ -343,7 +378,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
|
||||
// Determine the scope based on server configuration location
|
||||
let targetScope: 'user' | 'workspace' = 'user';
|
||||
if (server.scope === 'extension') {
|
||||
if (server.source === 'extension') {
|
||||
// Extension servers should not be disabled through user/workspace settings
|
||||
// Show error message and return
|
||||
debugLogger.warn(
|
||||
|
|
@ -351,7 +386,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
} else if (server.scope === 'workspace') {
|
||||
} else if (server.source === 'project') {
|
||||
targetScope = 'workspace';
|
||||
}
|
||||
|
||||
|
|
@ -544,6 +579,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
onReconnect={handleReconnect}
|
||||
onDisable={handleDisable}
|
||||
onAuthenticate={handleAuthenticate}
|
||||
onClearAuth={handleClearAuth}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
|
@ -576,10 +612,10 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
return (
|
||||
<AuthenticateStep
|
||||
server={selectedServer}
|
||||
onSuccess={() => {
|
||||
onBack={() => {
|
||||
handleNavigateBack();
|
||||
void reloadServers();
|
||||
}}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -601,6 +637,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
handleReconnect,
|
||||
handleDisable,
|
||||
handleAuthenticate,
|
||||
handleClearAuth,
|
||||
handleNavigateBack,
|
||||
handleSelectTool,
|
||||
handleSelectDisableScope,
|
||||
|
|
|
|||
|
|
@ -16,13 +16,15 @@ import {
|
|||
MCPOAuthTokenStorage,
|
||||
getErrorMessage,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { OAuthDisplayPayload } from '@qwen-code/qwen-code-core';
|
||||
import { appEvents, AppEvent } from '../../../../utils/events.js';
|
||||
|
||||
type AuthState = 'idle' | 'authenticating' | 'success' | 'error';
|
||||
|
||||
const AUTO_BACK_DELAY_MS = 2000;
|
||||
|
||||
export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
|
||||
server,
|
||||
onSuccess,
|
||||
onBack,
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
|
|
@ -39,9 +41,12 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
|
|||
setMessages([]);
|
||||
setErrorMessage(null);
|
||||
|
||||
// Listen for OAuth display messages (same as mcpCommand.ts)
|
||||
const displayListener = (message: string) => {
|
||||
setMessages((prev) => [...prev, message]);
|
||||
// Listen for OAuth display messages - supports both plain strings and
|
||||
// structured i18n messages ({ key, params }) emitted by the core layer.
|
||||
const displayListener = (message: OAuthDisplayPayload) => {
|
||||
const text =
|
||||
typeof message === 'string' ? message : t(message.key, message.params);
|
||||
setMessages((prev) => [...prev, text]);
|
||||
};
|
||||
appEvents.on(AppEvent.OauthDisplayMessage, displayListener);
|
||||
|
||||
|
|
@ -83,6 +88,16 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
|
|||
}),
|
||||
]);
|
||||
await toolRegistry.discoverToolsForServer(server.name);
|
||||
|
||||
// Show discovered tool count
|
||||
const discoveredTools = toolRegistry.getToolsByServer(server.name);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
t("Discovered {{count}} tool(s) from '{{name}}'.", {
|
||||
count: String(discoveredTools.length),
|
||||
name: server.name,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
// Update the client with the new tools
|
||||
|
|
@ -91,8 +106,12 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
|
|||
await geminiClient.setTools();
|
||||
}
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
t('Authentication complete. Returning to server details...'),
|
||||
]);
|
||||
|
||||
setAuthState('success');
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
setErrorMessage(getErrorMessage(error));
|
||||
setAuthState('error');
|
||||
|
|
@ -100,13 +119,22 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
|
|||
isRunning.current = false;
|
||||
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
|
||||
}
|
||||
}, [server, config, onSuccess]);
|
||||
}, [server, config]);
|
||||
|
||||
useEffect(() => {
|
||||
runAuthentication();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Auto-navigate back after authentication succeeds
|
||||
useEffect(() => {
|
||||
if (authState !== 'success') return;
|
||||
const timer = setTimeout(() => {
|
||||
onBack();
|
||||
}, AUTO_BACK_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [authState, onBack]);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
|
|
@ -158,6 +186,11 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
|
|||
{t('Authenticating... Please complete the login in your browser.')}
|
||||
</Text>
|
||||
)}
|
||||
{authState === 'success' && (
|
||||
<Text color={theme.status.success}>
|
||||
{t('Authentication successful.')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ type ServerAction =
|
|||
| 'view-tools'
|
||||
| 'reconnect'
|
||||
| 'toggle-disable'
|
||||
| 'authenticate';
|
||||
| 'authenticate'
|
||||
| 'clear-auth';
|
||||
|
||||
export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
||||
server,
|
||||
|
|
@ -32,6 +33,7 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
|||
onReconnect,
|
||||
onDisable,
|
||||
onAuthenticate,
|
||||
onClearAuth,
|
||||
onBack,
|
||||
}) => {
|
||||
const statusColor = server
|
||||
|
|
@ -77,15 +79,24 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
|||
value: 'toggle-disable',
|
||||
});
|
||||
|
||||
// 待补充准确的认证判断方案,暂时全部开放
|
||||
// 已认证的服务器显示"重新认证",未认证的显示"认证"
|
||||
if (!server.isDisabled) {
|
||||
result.push({
|
||||
key: 'authenticate',
|
||||
label: t('Authenticate'),
|
||||
label: server.hasOAuthTokens ? t('Re-authenticate') : t('Authenticate'),
|
||||
value: 'authenticate',
|
||||
});
|
||||
}
|
||||
|
||||
// 只在存储有 OAuth 认证信息时显示“清空认证”选项
|
||||
if (!server.isDisabled && server.hasOAuthTokens) {
|
||||
result.push({
|
||||
key: 'clear-auth',
|
||||
label: t('Clear Authentication'),
|
||||
value: 'clear-auth',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [server]);
|
||||
|
||||
|
|
@ -136,9 +147,9 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
|||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
{server.scope === 'user'
|
||||
{server.source === 'user'
|
||||
? t('User Settings')
|
||||
: server.scope === 'workspace'
|
||||
: server.source === 'project'
|
||||
? t('Workspace Settings')
|
||||
: t('Extension')}
|
||||
</Text>
|
||||
|
|
@ -222,6 +233,9 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
|||
case 'authenticate':
|
||||
onAuthenticate?.();
|
||||
break;
|
||||
case 'clear-auth':
|
||||
onClearAuth?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,8 +34,6 @@ export interface MCPServerDisplayInfo {
|
|||
status: MCPServerStatus;
|
||||
/** 来源类型 */
|
||||
source: 'user' | 'project' | 'extension';
|
||||
/** 配置所在的 scope */
|
||||
scope: 'user' | 'workspace' | 'extension';
|
||||
/** 配置文件路径 */
|
||||
configPath?: string;
|
||||
/** 服务器配置 */
|
||||
|
|
@ -50,6 +48,8 @@ export interface MCPServerDisplayInfo {
|
|||
errorMessage?: string;
|
||||
/** 是否被禁用(在排除列表中) */
|
||||
isDisabled: boolean;
|
||||
/** 是否存储有 OAuth 认证信息 */
|
||||
hasOAuthTokens?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -134,6 +134,8 @@ export interface ServerDetailStepProps {
|
|||
onDisable?: () => void;
|
||||
/** OAuth 认证回调 */
|
||||
onAuthenticate?: () => void;
|
||||
/** 清空认证信息回调 */
|
||||
onClearAuth?: () => void;
|
||||
/** 返回回调 */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
|
@ -180,8 +182,6 @@ export interface ToolDetailStepProps {
|
|||
export interface AuthenticateStepProps {
|
||||
/** 服务器信息 */
|
||||
server: MCPServerDisplayInfo | null;
|
||||
/** 认证成功回调 */
|
||||
onSuccess?: () => void;
|
||||
/** 返回回调 */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ describe('MCP utils', () => {
|
|||
name: 'server1',
|
||||
status: MCPServerStatus.CONNECTED,
|
||||
source: 'user',
|
||||
scope: 'user',
|
||||
config: { command: 'cmd1' },
|
||||
toolCount: 1,
|
||||
promptCount: 0,
|
||||
|
|
@ -35,7 +34,6 @@ describe('MCP utils', () => {
|
|||
name: 'server2',
|
||||
status: MCPServerStatus.CONNECTED,
|
||||
source: 'extension',
|
||||
scope: 'extension',
|
||||
config: { command: 'cmd2' },
|
||||
toolCount: 2,
|
||||
promptCount: 0,
|
||||
|
|
|
|||
|
|
@ -174,33 +174,6 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
unmount();
|
||||
});
|
||||
|
||||
it('navigates down with arrow key and selects', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate down to "Blue"
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await wait();
|
||||
|
||||
// Press Enter
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
{ answers: { 0: 'Blue' } },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('navigates with number keys', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
|
@ -246,56 +219,6 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not navigate above first option', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Try to go up from first option
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
await wait();
|
||||
|
||||
// Should still select the first option
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
{ answers: { 0: 'Red' } },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not navigate below last option', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate way past the last option (3 options + 1 custom input = 4 total)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await wait();
|
||||
}
|
||||
|
||||
// Should still render without crashing
|
||||
expect(lastFrame()).toContain('What is your favorite color?');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-select interaction', () => {
|
||||
|
|
@ -321,128 +244,9 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
expect(lastFrame()).toContain('[✓]');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('submits multi-select with Space to toggle then Enter to confirm', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Space to toggle first option
|
||||
stdin.write(' ');
|
||||
await wait();
|
||||
|
||||
// Enter to confirm and submit
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
{ answers: { 0: 'Red' } },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows typed custom input text in frame for multi-select question', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Move to "Type something..." input
|
||||
for (let i = 0; i < 3; i++) {
|
||||
stdin.write('\u001B[B');
|
||||
await wait();
|
||||
}
|
||||
|
||||
stdin.write('Orange');
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('Orange');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple questions', () => {
|
||||
it('auto-advances to next question after selecting an option', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [
|
||||
createSingleQuestion({ header: 'Q1' }),
|
||||
createSingleQuestion({
|
||||
header: 'Q2',
|
||||
question: 'Second question?',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Select first option in Q1
|
||||
stdin.write('\r');
|
||||
await wait(200); // Wait for auto-advance timeout (150ms)
|
||||
|
||||
// Should now show Q2
|
||||
expect(lastFrame()).toContain('Second question?');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('navigates between tabs with left/right arrows', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [
|
||||
createSingleQuestion({ header: 'Q1' }),
|
||||
createSingleQuestion({
|
||||
header: 'Q2',
|
||||
question: 'Second question?',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate right to Q2
|
||||
stdin.write('\u001B[C'); // Right arrow
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('Second question?');
|
||||
|
||||
// Navigate left back to Q1
|
||||
stdin.write('\u001B[D'); // Left arrow
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('What is your favorite color?');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows Submit tab for multiple questions', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
|
|
@ -473,80 +277,6 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
unmount();
|
||||
});
|
||||
|
||||
it('submits all answers from Submit tab', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [
|
||||
createSingleQuestion({ header: 'Q1' }),
|
||||
createSingleQuestion({
|
||||
header: 'Q2',
|
||||
question: 'Second question?',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Answer Q1
|
||||
stdin.write('\r'); // Select Red
|
||||
await wait(200);
|
||||
|
||||
// Answer Q2
|
||||
stdin.write('\r'); // Select Red
|
||||
await wait(200);
|
||||
|
||||
// Now on Submit tab, press Enter to submit
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
{ answers: { 0: 'Red', 1: 'Red' } },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('cancels from Submit tab', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [
|
||||
createSingleQuestion({ header: 'Q1' }),
|
||||
createSingleQuestion({ header: 'Q2' }),
|
||||
],
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate to submit tab
|
||||
stdin.write('\u001B[C'); // Right
|
||||
await wait();
|
||||
stdin.write('\u001B[C'); // Right
|
||||
await wait();
|
||||
|
||||
// Navigate down to Cancel option
|
||||
stdin.write('\u001B[B'); // Down
|
||||
await wait();
|
||||
|
||||
// Press Enter
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows unanswered questions as (not answered) in Submit tab', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
|
|
@ -598,286 +328,4 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('escape from custom input', () => {
|
||||
it('cancels from custom input with Escape', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate to custom input (3 options, so index 3 is custom input)
|
||||
stdin.write('\u001B[B'); // Down
|
||||
await wait();
|
||||
stdin.write('\u001B[B'); // Down
|
||||
await wait();
|
||||
stdin.write('\u001B[B'); // Down - now at custom input
|
||||
await wait();
|
||||
|
||||
// Press Escape
|
||||
stdin.write('\u001B');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('answered question marker', () => {
|
||||
it('shows check mark on answered question tab', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [
|
||||
createSingleQuestion({ header: 'Q1' }),
|
||||
createSingleQuestion({ header: 'Q2' }),
|
||||
],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Answer Q1
|
||||
stdin.write('\r'); // Select Red
|
||||
await wait(200);
|
||||
|
||||
// Q2 is now active; check that Q1 shows ✓
|
||||
expect(lastFrame()).toContain('Q1');
|
||||
expect(lastFrame()).toContain('✓');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom input preserves state', () => {
|
||||
it('preserves typed text when navigating away and back', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate to custom input (3 options, index 3)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
stdin.write('\u001B[B'); // Down
|
||||
await wait();
|
||||
}
|
||||
|
||||
// Type something
|
||||
stdin.write('Purple');
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('Purple');
|
||||
|
||||
// Navigate away (up to first option)
|
||||
stdin.write('\u001B[A'); // Up
|
||||
await wait();
|
||||
stdin.write('\u001B[A'); // Up
|
||||
await wait();
|
||||
stdin.write('\u001B[A'); // Up
|
||||
await wait();
|
||||
|
||||
// Navigate back to custom input
|
||||
for (let i = 0; i < 3; i++) {
|
||||
stdin.write('\u001B[B'); // Down
|
||||
await wait();
|
||||
}
|
||||
|
||||
// Text should still be there
|
||||
expect(lastFrame()).toContain('Purple');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not auto-check custom input in multi-select when navigating back', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate to custom input (index 3)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
stdin.write('\u001B[B');
|
||||
await wait();
|
||||
}
|
||||
|
||||
// Type something - auto-checks
|
||||
stdin.write('Custom');
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('[✓]');
|
||||
|
||||
// Enter to toggle it off (since auto-check already checked it)
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
// Should be unchecked now - verify on the custom input line specifically
|
||||
const afterToggle = lastFrame()!;
|
||||
const toggledLine = afterToggle
|
||||
.split('\n')
|
||||
.find((l) => l.includes('Custom'));
|
||||
expect(toggledLine).toBeDefined();
|
||||
expect(toggledLine).toContain('[ ]');
|
||||
expect(toggledLine).not.toContain('[✓]');
|
||||
|
||||
// Navigate away
|
||||
stdin.write('\u001B[A'); // Up
|
||||
await wait();
|
||||
|
||||
// Navigate back to custom input
|
||||
stdin.write('\u001B[B'); // Down
|
||||
await wait();
|
||||
|
||||
// Should still be unchecked (not auto-checked on remount)
|
||||
const output = lastFrame()!;
|
||||
const lines = output.split('\n');
|
||||
const customLine = lines.find((l) => l.includes('Custom'));
|
||||
expect(customLine).toBeDefined();
|
||||
expect(customLine).toContain('[ ]');
|
||||
expect(customLine).not.toContain('[✓]');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('keeps custom input checked when navigating back if user checked it', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate to custom input (index 3)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
stdin.write('\u001B[B');
|
||||
await wait();
|
||||
}
|
||||
|
||||
// Type something - should auto-check
|
||||
stdin.write('Custom');
|
||||
await wait();
|
||||
|
||||
// Should already be checked (auto-checked on type)
|
||||
expect(lastFrame()).toContain('[✓]');
|
||||
|
||||
// Navigate away
|
||||
stdin.write('\u001B[A'); // Up
|
||||
await wait();
|
||||
|
||||
// Navigate back to custom input
|
||||
stdin.write('\u001B[B'); // Down
|
||||
await wait();
|
||||
|
||||
// Should still be checked
|
||||
const output = lastFrame()!;
|
||||
const lines = output.split('\n');
|
||||
const customLine = lines.find((l) => l.includes('Custom'));
|
||||
expect(customLine).toBeDefined();
|
||||
expect(customLine).toContain('[✓]');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('auto-checks custom input in multi-select when user types text', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate to custom input (index 3)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
stdin.write('\u001B[B');
|
||||
await wait();
|
||||
}
|
||||
|
||||
// Type something - should auto-check
|
||||
stdin.write('Hello');
|
||||
await wait();
|
||||
|
||||
const output = lastFrame()!;
|
||||
const lines = output.split('\n');
|
||||
const customLine = lines.find((l) => l.includes('Hello'));
|
||||
expect(customLine).toBeDefined();
|
||||
expect(customLine).toContain('[✓]');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('auto-unchecks custom input in multi-select when text is cleared', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate to custom input (index 3)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
stdin.write('\u001B[B');
|
||||
await wait();
|
||||
}
|
||||
|
||||
// Type something - should auto-check
|
||||
stdin.write('Hi');
|
||||
await wait();
|
||||
|
||||
// Verify auto-check on the custom input line
|
||||
const afterType = lastFrame()!;
|
||||
const typedLine = afterType.split('\n').find((l) => l.includes('Hi'));
|
||||
expect(typedLine).toBeDefined();
|
||||
expect(typedLine).toContain('[✓]');
|
||||
|
||||
// Delete all text (backspace twice)
|
||||
stdin.write('\x7f'); // backspace
|
||||
await wait();
|
||||
stdin.write('\x7f'); // backspace
|
||||
await wait();
|
||||
|
||||
// Should be unchecked now - check the custom input line (option 4)
|
||||
const afterClear = lastFrame()!;
|
||||
const clearedLine = afterClear.split('\n').find((l) => l.includes('4.'));
|
||||
expect(clearedLine).toBeDefined();
|
||||
expect(clearedLine).toContain('[ ]');
|
||||
expect(clearedLine).not.toContain('[✓]');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1840,7 +1840,7 @@ export function useTextBuffer({
|
|||
process.env['VISUAL'] ??
|
||||
process.env['EDITOR'] ??
|
||||
(process.platform === 'win32' ? 'notepad' : 'vi');
|
||||
const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-'));
|
||||
const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'qwen-edit-'));
|
||||
const filePath = pathMod.join(tmpDir, 'buffer.txt');
|
||||
fs.writeFileSync(filePath, text, 'utf8');
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue