Merge branch 'main' into feat/hook_sessionstart_sessionend

This commit is contained in:
DennisYu07 2026-03-17 20:34:13 -07:00
commit b236e4152f
195 changed files with 7605 additions and 3975 deletions

View file

@ -345,7 +345,7 @@ export function AuthDialog(): React.JSX.Element {
return (
<Box
borderStyle="round"
borderStyle="single"
borderColor={theme?.border?.default}
flexDirection="column"
padding={1}

View file

@ -41,7 +41,7 @@ export function AuthInProgress({
return (
<Box
borderStyle="round"
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
padding={1}

View file

@ -19,14 +19,14 @@ import {
} from '../utils/export/index.js';
const mockSessionServiceMocks = vi.hoisted(() => ({
loadLastSession: vi.fn(),
loadSession: vi.fn(),
}));
vi.mock('@qwen-code/qwen-code-core', () => {
class SessionService {
constructor(_cwd: string) {}
async loadLastSession() {
return mockSessionServiceMocks.loadLastSession();
async loadSession(_sessionId: string) {
return mockSessionServiceMocks.loadSession();
}
}
@ -68,13 +68,14 @@ describe('exportCommand', () => {
beforeEach(() => {
vi.clearAllMocks();
mockSessionServiceMocks.loadLastSession.mockResolvedValue(mockSessionData);
mockSessionServiceMocks.loadSession.mockResolvedValue(mockSessionData);
mockContext = createMockCommandContext({
services: {
config: {
getWorkingDir: vi.fn().mockReturnValue('/test/dir'),
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
},
},
});
@ -132,7 +133,7 @@ describe('exportCommand', () => {
content: expect.stringContaining('export-2025-01-01T00-00-00-000Z.md'),
});
expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled();
expect(mockSessionServiceMocks.loadSession).toHaveBeenCalled();
expect(collectSessionData).toHaveBeenCalledWith(
mockSessionData.conversation,
expect.anything(),
@ -191,7 +192,7 @@ describe('exportCommand', () => {
});
it('should return error when no session is found', async () => {
mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined);
mockSessionServiceMocks.loadSession.mockResolvedValue(undefined);
const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md');
if (!mdCommand?.action) {
@ -260,7 +261,7 @@ describe('exportCommand', () => {
),
});
expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled();
expect(mockSessionServiceMocks.loadSession).toHaveBeenCalled();
expect(collectSessionData).toHaveBeenCalledWith(
mockSessionData.conversation,
expect.anything(),
@ -323,7 +324,7 @@ describe('exportCommand', () => {
});
it('should return error when no session is found', async () => {
mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined);
mockSessionServiceMocks.loadSession.mockResolvedValue(undefined);
const htmlCommand = exportCommand.subCommands?.find(
(c) => c.name === 'html',

View file

@ -22,6 +22,7 @@ import {
toJsonl,
generateExportFilename,
} from '../utils/export/index.js';
import { t } from '../../i18n/index.js';
/**
* Action for the 'md' subcommand - exports session to markdown.
@ -50,9 +51,10 @@ async function exportMarkdownAction(
}
try {
// Load the current session
// Load the current session using the current session ID
const sessionService = new SessionService(cwd);
const sessionData = await sessionService.loadLastSession();
const sessionId = config.getSessionId();
const sessionData = await sessionService.loadSession(sessionId);
if (!sessionData) {
return {
@ -122,9 +124,10 @@ async function exportHtmlAction(
}
try {
// Load the current session
// Load the current session using the current session ID
const sessionService = new SessionService(cwd);
const sessionData = await sessionService.loadLastSession();
const sessionId = config.getSessionId();
const sessionData = await sessionService.loadSession(sessionId);
if (!sessionData) {
return {
@ -194,9 +197,10 @@ async function exportJsonAction(
}
try {
// Load the current session
// Load the current session using the current session ID
const sessionService = new SessionService(cwd);
const sessionData = await sessionService.loadLastSession();
const sessionId = config.getSessionId();
const sessionData = await sessionService.loadSession(sessionId);
if (!sessionData) {
return {
@ -266,9 +270,10 @@ async function exportJsonlAction(
}
try {
// Load the current session
// Load the current session using the current session ID
const sessionService = new SessionService(cwd);
const sessionData = await sessionService.loadLastSession();
const sessionId = config.getSessionId();
const sessionData = await sessionService.loadSession(sessionId);
if (!sessionData) {
return {
@ -316,30 +321,40 @@ async function exportJsonlAction(
*/
export const exportCommand: SlashCommand = {
name: 'export',
description: 'Export current session message history to a file',
get description() {
return t('Export current session message history to a file');
},
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'html',
description: 'Export session to HTML format',
get description() {
return t('Export session to HTML format');
},
kind: CommandKind.BUILT_IN,
action: exportHtmlAction,
},
{
name: 'md',
description: 'Export session to markdown format',
get description() {
return t('Export session to markdown format');
},
kind: CommandKind.BUILT_IN,
action: exportMarkdownAction,
},
{
name: 'json',
description: 'Export session to JSON format',
get description() {
return t('Export session to JSON format');
},
kind: CommandKind.BUILT_IN,
action: exportJsonAction,
},
{
name: 'jsonl',
description: 'Export session to JSONL format (one message per line)',
get description() {
return t('Export session to JSONL format (one message per line)');
},
kind: CommandKind.BUILT_IN,
action: exportJsonlAction,
},

View file

@ -13,6 +13,7 @@ import {
CommandKind,
} from './types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
async function restoreAction(
context: CommandContext,
@ -144,8 +145,11 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => {
return {
name: 'restore',
description:
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
get description() {
return t(
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
);
},
kind: CommandKind.BUILT_IN,
action: restoreAction,
completion,

View file

@ -211,6 +211,7 @@ export enum CommandKind {
BUILT_IN = 'built-in',
FILE = 'file',
MCP_PROMPT = 'mcp-prompt',
SKILL = 'skill',
}
export interface CommandCompletionItem {

View file

@ -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('');
});
});

View file

@ -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}

View file

@ -21,12 +21,13 @@ export const PlanSummaryDisplay: React.FC<PlanSummaryDisplayProps> = ({
availableHeight,
childWidth,
}) => {
const { message, plan } = data;
const { message, plan, rejected } = data;
const messageColor = rejected ? Colors.AccentYellow : Colors.AccentGreen;
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text color={Colors.AccentGreen} wrap="wrap">
<Text color={messageColor} wrap="wrap">
{message}
</Text>
</Box>

View file

@ -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');
});
});

View file

@ -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>
);
}

View file

@ -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;

View file

@ -25,6 +25,7 @@ import { useConfig } from '../../contexts/ConfigContext.js';
import {
getMCPServerStatus,
DiscoveredMCPTool,
MCPOAuthTokenStorage,
type MCPServerConfig,
type AnyDeclarativeTool,
type DiscoveredMCPPrompt,
@ -109,6 +110,16 @@ 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,
@ -118,6 +129,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
invalidToolCount,
promptCount: serverPrompts.length,
isDisabled,
hasOAuthTokens,
});
}
@ -249,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;
@ -537,6 +579,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
onReconnect={handleReconnect}
onDisable={handleDisable}
onAuthenticate={handleAuthenticate}
onClearAuth={handleClearAuth}
onBack={handleNavigateBack}
/>
);
@ -569,10 +612,10 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
return (
<AuthenticateStep
server={selectedServer}
onSuccess={() => {
onBack={() => {
handleNavigateBack();
void reloadServers();
}}
onBack={handleNavigateBack}
/>
);
@ -594,6 +637,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
handleReconnect,
handleDisable,
handleAuthenticate,
handleClearAuth,
handleNavigateBack,
handleSelectTool,
handleSelectDisableScope,

View file

@ -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>
);

View file

@ -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]);
@ -222,6 +233,9 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
case 'authenticate':
onAuthenticate?.();
break;
case 'clear-auth':
onClearAuth?.();
break;
default:
break;
}

View file

@ -48,6 +48,8 @@ export interface MCPServerDisplayInfo {
errorMessage?: string;
/** 是否被禁用(在排除列表中) */
isDisabled: boolean;
/** 是否存储有 OAuth 认证信息 */
hasOAuthTokens?: boolean;
}
/**
@ -132,6 +134,8 @@ export interface ServerDetailStepProps {
onDisable?: () => void;
/** OAuth 认证回调 */
onAuthenticate?: () => void;
/** 清空认证信息回调 */
onClearAuth?: () => void;
/** 返回回调 */
onBack: () => void;
}
@ -178,8 +182,6 @@ export interface ToolDetailStepProps {
export interface AuthenticateStepProps {
/** 服务器信息 */
server: MCPServerDisplayInfo | null;
/** 认证成功回调 */
onSuccess?: () => void;
/** 返回回调 */
onBack: () => void;
}

View file

@ -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();
@ -271,72 +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();
});
});
describe('multiple questions', () => {
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({
@ -367,41 +277,6 @@ describe('<AskUserQuestionDialog />', () => {
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({

View file

@ -6,7 +6,7 @@
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { Box } from 'ink';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
@ -136,13 +136,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
contentWidth={innerWidth}
/>
)}
{tool.outputFile && (
<Box marginX={1}>
<Text color={theme.text.primary}>
Output too long and was saved to: {tool.outputFile}
</Text>
</Box>
)}
</Box>
);
})}

View file

@ -300,4 +300,55 @@ describe('<ToolMessage />', () => {
);
expect(lastFrame()).toContain('MockAnsiOutput:hello');
});
it('renders rejected plan content with plan text still visible', () => {
const planResultDisplay = {
type: 'plan_summary' as const,
message: 'Plan was rejected. Remaining in plan mode.',
plan: '# My Plan\n- Step 1: Do something\n- Step 2: Do another thing',
rejected: true,
};
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="ExitPlanMode"
description="Plan:"
status={ToolCallStatus.Canceled}
resultDisplay={planResultDisplay}
/>,
StreamingState.Idle,
);
const output = lastFrame();
expect(output).toContain('Plan was rejected. Remaining in plan mode.');
expect(output).toContain('MockMarkdown:# My Plan');
expect(output).toContain('- Step 1: Do something');
expect(output).toContain('- Step 2: Do another thing');
});
it('renders approved plan content with approval message', () => {
const planResultDisplay = {
type: 'plan_summary' as const,
message: 'User approved the plan.',
plan: '# My Plan\n- Step 1\n- Step 2',
};
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="ExitPlanMode"
description="Plan:"
status={ToolCallStatus.Success}
resultDisplay={planResultDisplay}
/>,
StreamingState.Idle,
);
const output = lastFrame();
expect(output).toContain('User approved the plan.');
expect(output).toContain('MockMarkdown:# My Plan');
expect(output).toContain('- Step 1');
expect(output).toContain('- Step 2');
});
});

View file

@ -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');

View file

@ -94,7 +94,7 @@ export function CreationSummary({
}
// Check length warnings
if (state.generatedDescription.length > 300) {
if (state.generatedDescription.length > 1000) {
allWarnings.push(
t('Description is over {{length}} characters', {
length: state.generatedDescription.length.toString(),

View file

@ -31,6 +31,7 @@ import type { LoadedSettings } from '../../config/settings.js';
import { type CommandContext, type SlashCommand } from '../commands/types.js';
import { CommandService } from '../../services/CommandService.js';
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
import { BundledSkillLoader } from '../../services/BundledSkillLoader.js';
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import { parseSlashCommand } from '../../utils/commands.js';
@ -311,6 +312,7 @@ export const useSlashCommandProcessor = (
const loaders = [
new McpPromptLoader(config),
new BuiltinCommandLoader(config),
new BundledSkillLoader(config),
new FileCommandLoader(config),
];
const commandService = await CommandService.create(

View file

@ -28,6 +28,7 @@ import {
ApprovalMode,
AuthType,
GeminiEventType as ServerGeminiEventType,
SendMessageType,
ToolErrorType,
ToolConfirmationOutcome,
} from '@qwen-code/qwen-code-core';
@ -482,7 +483,7 @@ describe('useGeminiStream', () => {
expectedMergedResponse,
expect.any(AbortSignal),
'prompt-id-2',
{ isContinuation: true },
{ type: SendMessageType.ToolResult },
);
});
@ -806,7 +807,7 @@ describe('useGeminiStream', () => {
toolCallResponseParts,
expect.any(AbortSignal),
'prompt-id-4',
{ isContinuation: true },
{ type: SendMessageType.ToolResult },
);
});
@ -1122,7 +1123,7 @@ describe('useGeminiStream', () => {
'This is the actual prompt from the command file.',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
expect(mockScheduleToolCalls).not.toHaveBeenCalled();
@ -1149,7 +1150,7 @@ describe('useGeminiStream', () => {
'',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
});
});
@ -1168,7 +1169,7 @@ describe('useGeminiStream', () => {
'// This is a line comment',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
});
});
@ -1187,7 +1188,7 @@ describe('useGeminiStream', () => {
'/* This is a block comment */',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
});
});
@ -2091,7 +2092,7 @@ describe('useGeminiStream', () => {
processedQueryParts, // Argument 1: The parts array directly
expect.any(AbortSignal), // Argument 2: An AbortSignal
expect.any(String), // Argument 3: The prompt_id string
undefined, // Argument 4: Options (undefined for normal prompts)
{ type: SendMessageType.UserQuery }, // Argument 4: The options
);
});
@ -2244,6 +2245,7 @@ describe('useGeminiStream', () => {
it('should show a retry countdown and update pending history over time', async () => {
vi.useFakeTimers();
try {
let continueToRetryAttempt: (() => void) | undefined;
let resolveStream: (() => void) | undefined;
mockSendMessageStream.mockReturnValue(
(async function* () {
@ -2256,6 +2258,9 @@ describe('useGeminiStream', () => {
delayMs: 3000,
},
};
await new Promise<void>((resolve) => {
continueToRetryAttempt = resolve;
});
yield {
type: ServerGeminiEventType.Retry,
};
@ -2330,6 +2335,12 @@ describe('useGeminiStream', () => {
'2s',
);
continueToRetryAttempt?.();
await act(async () => {
await Promise.resolve();
});
resolveStream?.();
await act(async () => {
@ -2347,6 +2358,103 @@ describe('useGeminiStream', () => {
}
});
it('should clear retry errors after auto-retry succeeds once the countdown has elapsed', async () => {
vi.useFakeTimers();
try {
let continueAfterCountdown: (() => void) | undefined;
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Retry,
retryInfo: {
message: '[API Error: Rate limit exceeded]',
attempt: 1,
maxRetries: 3,
delayMs: 1000,
},
};
await new Promise<void>((resolve) => {
continueAfterCountdown = resolve;
});
yield {
type: ServerGeminiEventType.Retry,
};
yield {
type: ServerGeminiEventType.Text,
value: 'Success after retry',
};
yield {
type: ServerGeminiEventType.Finished,
value: { reason: 'STOP', usageMetadata: undefined },
};
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
() => {},
80,
24,
),
);
act(() => {
void result.current.submitQuery('Trigger retry after countdown');
});
let errorItem = result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
) as { hint?: string } | undefined;
for (let attempts = 0; attempts < 5 && !errorItem; attempts++) {
await act(async () => {
await Promise.resolve();
});
errorItem = result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
) as { hint?: string } | undefined;
}
expect(errorItem?.hint).toContain('1s');
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
const staleErrorBeforeRetryCompletes =
result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
) as { hint?: string } | undefined;
expect(staleErrorBeforeRetryCompletes?.hint).toContain('0s');
await act(async () => {
continueAfterCountdown?.();
await Promise.resolve();
await Promise.resolve();
});
const remainingError = result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
);
expect(remainingError).toBeUndefined();
} finally {
vi.useRealTimers();
}
});
it('should memoize pendingHistoryItems', () => {
mockUseReactToolScheduler.mockReturnValue([
[],
@ -2669,7 +2777,7 @@ describe('useGeminiStream', () => {
'First query',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
// Verify only the first query was added to history
@ -2721,14 +2829,14 @@ describe('useGeminiStream', () => {
'First query',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
expect(mockSendMessageStream).toHaveBeenNthCalledWith(
2,
'Second query',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
});
@ -2751,7 +2859,7 @@ describe('useGeminiStream', () => {
'Second query',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
});
});

View file

@ -19,14 +19,17 @@ import type {
} from '@qwen-code/qwen-code-core';
import {
GeminiEventType as ServerGeminiEventType,
SendMessageType,
createDebugLogger,
getErrorMessage,
isNodeError,
MessageSenderType,
logUserPrompt,
logUserRetry,
GitService,
UnauthorizedError,
UserPromptEvent,
UserRetryEvent,
logConversationFinishedEvent,
ConversationFinishedEvent,
ApprovalMode,
@ -1034,7 +1037,8 @@ export const useGeminiStream = (
// Show retry info if available (rate-limit / throttling errors)
if (event.retryInfo) {
startRetryCountdown(event.retryInfo);
} else if (!pendingRetryCountdownItemRef.current) {
} else {
// The retry attempt is starting now, so any prior retry UI is stale.
clearRetryCountdown();
}
break;
@ -1075,26 +1079,28 @@ export const useGeminiStream = (
setThought,
pendingHistoryItemRef,
setPendingHistoryItem,
pendingRetryCountdownItemRef,
],
);
const submitQuery = useCallback(
async (
query: PartListUnion,
options?: { isContinuation: boolean; skipPreparation?: boolean },
submitType: SendMessageType = SendMessageType.UserQuery,
prompt_id?: string,
) => {
// Prevent concurrent executions of submitQuery, but allow continuations
// which are part of the same logical flow (tool responses)
if (isSubmittingQueryRef.current && !options?.isContinuation) {
if (
isSubmittingQueryRef.current &&
submitType !== SendMessageType.ToolResult
) {
return;
}
if (
(streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation) &&
!options?.isContinuation
submitType !== SendMessageType.ToolResult
)
return;
@ -1104,7 +1110,7 @@ export const useGeminiStream = (
const userMessageTimestamp = Date.now();
// Reset quota error flag when starting a new query (not a continuation)
if (!options?.isContinuation) {
if (submitType !== SendMessageType.ToolResult) {
setModelSwitchedFromQuotaError(false);
// Commit any pending retry error to history (without hint) since the
// user is starting a new conversation turn.
@ -1127,14 +1133,15 @@ export const useGeminiStream = (
}
return promptIdContext.run(prompt_id, async () => {
const { queryToSend, shouldProceed } = options?.skipPreparation
? { queryToSend: query, shouldProceed: true }
: await prepareQueryForGemini(
query,
userMessageTimestamp,
abortSignal,
prompt_id!,
);
const { queryToSend, shouldProceed } =
submitType === SendMessageType.Retry
? { queryToSend: query, shouldProceed: true }
: await prepareQueryForGemini(
query,
userMessageTimestamp,
abortSignal,
prompt_id!,
);
if (!shouldProceed || queryToSend === null) {
isSubmittingQueryRef.current = false;
@ -1142,7 +1149,7 @@ export const useGeminiStream = (
}
// Check image format support for non-continuations
if (!options?.isContinuation) {
if (submitType === SendMessageType.UserQuery) {
const formatCheck = checkImageFormatsSupport(queryToSend);
if (formatCheck.hasUnsupportedFormats) {
addItem(
@ -1159,7 +1166,7 @@ export const useGeminiStream = (
lastPromptRef.current = finalQueryToSend;
lastPromptErroredRef.current = false;
if (!options?.isContinuation) {
if (submitType === SendMessageType.UserQuery) {
// trigger new prompt event for session stats in CLI
startNewPrompt();
@ -1180,6 +1187,10 @@ export const useGeminiStream = (
setThought(null);
}
if (submitType === SendMessageType.Retry) {
logUserRetry(config, new UserRetryEvent(prompt_id));
}
setIsResponding(true);
setInitError(null);
@ -1188,7 +1199,7 @@ export const useGeminiStream = (
finalQueryToSend,
abortSignal,
prompt_id!,
options,
{ type: submitType },
);
const processingStatus = await processGeminiStreamEvents(
@ -1276,7 +1287,7 @@ export const useGeminiStream = (
*
* When conditions are met:
* - Clears any pending auto-retry countdown to avoid duplicate retries
* - Re-submits the last query with skipPreparation: true for faster retry
* - Re-submits the last query with isRetry: true, reusing the same prompt_id
*
* This function is exposed via UIActionsContext and triggered by InputPrompt
* when the user presses Ctrl+Y (bound to Command.RETRY_LAST in keyBindings.ts).
@ -1301,24 +1312,10 @@ export const useGeminiStream = (
return;
}
// Commit the error to history (without hint) before clearing
const errorItem = pendingRetryErrorItemRef.current;
if (errorItem) {
addItem({ type: errorItem.type, text: errorItem.text }, Date.now());
}
clearRetryCountdown();
await submitQuery(lastPrompt, {
isContinuation: false,
skipPreparation: true,
});
}, [
streamingState,
addItem,
clearRetryCountdown,
submitQuery,
pendingRetryErrorItemRef,
]);
await submitQuery(lastPrompt, SendMessageType.Retry);
}, [streamingState, addItem, clearRetryCountdown, submitQuery]);
const handleApprovalModeChange = useCallback(
async (newApprovalMode: ApprovalMode) => {
@ -1463,13 +1460,7 @@ export const useGeminiStream = (
return;
}
submitQuery(
responsesToSend,
{
isContinuation: true,
},
prompt_ids[0],
);
submitQuery(responsesToSend, SendMessageType.ToolResult, prompt_ids[0]);
},
[
isResponding,

View file

@ -252,7 +252,6 @@ export function mapToDisplay(
status: mapCoreStatusToDisplayStatus(trackedCall.status),
resultDisplay: trackedCall.response.resultDisplay,
confirmationDetails: undefined,
outputFile: trackedCall.response.outputFile,
};
case 'error':
return {

View file

@ -68,7 +68,6 @@ export interface IndividualToolCallDisplay {
confirmationDetails: ToolCallConfirmationDetails | undefined;
renderOutputAsMarkdown?: boolean;
ptyId?: number;
outputFile?: string;
}
export interface CompressionProps {