Merge branch 'main' into feat/image-attachment

This commit is contained in:
LaZzyMan 2026-02-10 14:16:21 +08:00
commit 56030f9291
609 changed files with 26677 additions and 12343 deletions

View file

@ -42,6 +42,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
branchName: 'main',
nightly: false,
debugMessage: '',
currentModel: 'gemini-pro',
sessionStats: {
lastPromptTokenCount: 0,
},

View file

@ -9,6 +9,7 @@ import { Header } from './Header.js';
import { Tips } from './Tips.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
interface AppHeaderProps {
version: string;
@ -17,10 +18,11 @@ interface AppHeaderProps {
export const AppHeader = ({ version }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
const uiState = useUIState();
const contentGeneratorConfig = config.getContentGeneratorConfig();
const authType = contentGeneratorConfig?.authType;
const model = config.getModel();
const model = uiState.currentModel;
const targetDir = config.getTargetDir();
const showBanner = !config.getScreenReader();
const showTips = !(settings.merged.ui?.hideTips || config.getScreenReader());

View file

@ -43,10 +43,6 @@ vi.mock('./ShellModeIndicator.js', () => ({
ShellModeIndicator: () => <Text>ShellModeIndicator</Text>,
}));
vi.mock('./DetailedMessagesDisplay.js', () => ({
DetailedMessagesDisplay: () => <Text>DetailedMessagesDisplay</Text>,
}));
vi.mock('./InputPrompt.js', () => ({
InputPrompt: () => <Text>InputPrompt</Text>,
calculatePromptWidths: vi.fn(() => ({
@ -60,10 +56,6 @@ vi.mock('./Footer.js', () => ({
Footer: () => <Text>Footer</Text>,
}));
vi.mock('./ShowMoreLines.js', () => ({
ShowMoreLines: () => <Text>ShowMoreLines</Text>,
}));
vi.mock('./QueuedMessageDisplay.js', () => ({
QueuedMessageDisplay: ({ messageQueue }: { messageQueue: string[] }) => {
if (messageQueue.length === 0) {
@ -91,7 +83,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
contextFileNames: [],
showAutoAcceptIndicator: ApprovalMode.DEFAULT,
messageQueue: [],
showErrorDetails: false,
constrainHeight: false,
isInputActive: true,
buffer: '',
@ -111,7 +102,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
ideContextState: null,
geminiMdFileCount: 0,
showToolDescriptions: false,
filteredConsoleMessages: [],
sessionStats: {
lastPromptTokenCount: 0,
sessionTokenCount: 0,
@ -119,7 +109,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
},
branchName: 'main',
debugMessage: '',
errorCount: 0,
nightly: false,
isTrustedFolder: true,
...overrides,
@ -354,31 +343,4 @@ describe('Composer', () => {
expect(lastFrame()).toContain('Footer');
});
});
describe('Error Details Display', () => {
it('shows DetailedMessagesDisplay when showErrorDetails is true', () => {
const uiState = createMockUIState({
showErrorDetails: true,
filteredConsoleMessages: [
{ level: 'error', message: 'Test error', timestamp: new Date() },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('DetailedMessagesDisplay');
expect(lastFrame()).toContain('ShowMoreLines');
});
it('does not show error details when showErrorDetails is false', () => {
const uiState = createMockUIState({
showErrorDetails: false,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).not.toContain('DetailedMessagesDisplay');
});
});
});

View file

@ -5,15 +5,12 @@
*/
import { Box, useIsScreenReaderEnabled } from 'ink';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useState } from 'react';
import { LoadingIndicator } from './LoadingIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { InputPrompt, calculatePromptWidths } from './InputPrompt.js';
import { InputPrompt } from './InputPrompt.js';
import { Footer } from './Footer.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
import { KeyboardShortcuts } from './KeyboardShortcuts.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
@ -29,8 +26,6 @@ export const Composer = () => {
const uiState = useUIState();
const uiActions = useUIActions();
const { vimEnabled } = useVimMode();
const terminalWidth = process.stdout.columns;
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
const { showAutoAcceptIndicator } = uiState;
@ -42,28 +37,29 @@ export const Composer = () => {
// State for suggestions visibility
const [showSuggestions, setShowSuggestions] = useState(false);
const handleSuggestionsVisibilityChange = useCallback((visible: boolean) => {
setShowSuggestions(visible);
}, []);
// Use the container width of InputPrompt for width of DetailedMessagesDisplay
const { containerWidth } = useMemo(
() => calculatePromptWidths(uiState.terminalWidth),
[uiState.terminalWidth],
const handleSuggestionsVisibilityChange = useCallback(
(visible: boolean) => {
setShowSuggestions(visible);
// Also notify AppContainer for Tab key handling
uiActions.onSuggestionsVisibilityChange(visible);
},
[uiActions],
);
return (
<Box flexDirection="column" marginTop={1}>
{!uiState.embeddedShellFocused && (
<LoadingIndicator
// Hide loading phrases when enableLoadingPhrases is explicitly false.
// Using === false ensures phrases show by default when undefined.
thought={
uiState.streamingState === StreamingState.WaitingForConfirmation ||
config.getAccessibility()?.disableLoadingPhrases
config.getAccessibility()?.enableLoadingPhrases === false
? undefined
: uiState.thought
}
currentLoadingPhrase={
config.getAccessibility()?.disableLoadingPhrases
config.getAccessibility()?.enableLoadingPhrases === false
? undefined
: uiState.currentLoadingPhrase
}
@ -75,21 +71,6 @@ export const Composer = () => {
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
{uiState.showErrorDetails && (
<OverflowProvider>
<Box flexDirection="column">
<DetailedMessagesDisplay
messages={uiState.filteredConsoleMessages}
maxHeight={
uiState.constrainHeight ? debugConsoleMaxHeight : undefined
}
width={containerWidth}
/>
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>
</OverflowProvider>
)}
{uiState.isFeedbackDialogOpen && <FeedbackDialog />}
{uiState.isInputActive && (

View file

@ -1,35 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
interface ConsoleSummaryDisplayProps {
errorCount: number;
// logCount is not currently in the plan to be displayed in summary
}
export const ConsoleSummaryDisplay: React.FC<ConsoleSummaryDisplayProps> = ({
errorCount,
}) => {
if (errorCount === 0) {
return null;
}
const errorIcon = '\u2716'; // Heavy multiplication x (✖)
return (
<Box>
{errorCount > 0 && (
<Text color={theme.status.error}>
{errorIcon} {errorCount} error{errorCount > 1 ? 's' : ''}{' '}
<Text color={theme.text.secondary}>(ctrl+o for details)</Text>
</Text>
)}
</Box>
);
};

View file

@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { Storage, isDebugLoggingDegraded } from '@qwen-code/qwen-code-core';
import { useConfig } from '../contexts/ConfigContext.js';
import { theme } from '../semantic-colors.js';
/**
* Displays debug mode status and log file path when debug mode is enabled.
*/
export const DebugModeNotification = () => {
const config = useConfig();
if (!config.getDebugMode()) {
return null;
}
const logPath = Storage.getDebugLogPath(config.getSessionId());
const isDegraded = isDebugLoggingDegraded();
return (
<Box paddingX={1} marginTop={1} flexDirection="column">
<Text color={theme.status.warning}>Debug mode enabled</Text>
<Text dimColor>Logging to: {logPath}</Text>
{isDegraded && (
<Text dimColor>
Warning: Debug logging is degraded (write failures occurred)
</Text>
)}
</Box>
);
};

View file

@ -1,83 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type { ConsoleMessageItem } from '../types.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
interface DetailedMessagesDisplayProps {
messages: ConsoleMessageItem[];
maxHeight: number | undefined;
width: number;
// debugMode is not needed here if App.tsx filters debug messages before passing them.
// If DetailedMessagesDisplay should handle filtering, add debugMode prop.
}
export const DetailedMessagesDisplay: React.FC<
DetailedMessagesDisplayProps
> = ({ messages, maxHeight, width }) => {
if (messages.length === 0) {
return null; // Don't render anything if there are no messages
}
const borderAndPadding = 4;
return (
<Box
flexDirection="column"
marginTop={1}
borderStyle="round"
borderColor={theme.border.default}
paddingX={1}
width={width}
>
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
Debug Console{' '}
<Text color={theme.text.secondary}>(ctrl+o to close)</Text>
</Text>
</Box>
<MaxSizedBox maxHeight={maxHeight} maxWidth={width - borderAndPadding}>
{messages.map((msg, index) => {
let textColor = theme.text.primary;
let icon = '\u2139'; // Information source ()
switch (msg.type) {
case 'warn':
textColor = theme.status.warning;
icon = '\u26A0'; // Warning sign (⚠)
break;
case 'error':
textColor = theme.status.error;
icon = '\u2716'; // Heavy multiplication x (✖)
break;
case 'debug':
textColor = theme.text.secondary; // Or theme.text.secondary
icon = '\u{1F50D}'; // Left-pointing magnifying glass (🔍)
break;
case 'log':
default:
// Default textColor and icon are already set
break;
}
return (
<Box key={index} flexDirection="row">
<Text color={textColor}>{icon} </Text>
<Text color={textColor} wrap="wrap">
{msg.content}
{msg.count && msg.count > 1 && (
<Text color={theme.text.secondary}> (x{msg.count})</Text>
)}
</Text>
</Box>
);
})}
</MaxSizedBox>
</Box>
);
};

View file

@ -18,10 +18,15 @@ import { ScopeSelector } from './shared/ScopeSelector.js';
import type { LoadedSettings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import type { EditorType } from '@qwen-code/qwen-code-core';
import { isEditorAvailable } from '@qwen-code/qwen-code-core';
import {
createDebugLogger,
isEditorAvailable,
} from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { t } from '../../i18n/index.js';
const debugLogger = createDebugLogger('EDITOR_SETTINGS_DIALOG');
interface EditorDialogProps {
onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void;
settings: LoadedSettings;
@ -61,7 +66,7 @@ export function EditorSettingsDialog({
)
: 0;
if (editorIndex === -1) {
console.error(`Editor is not supported: ${currentPreference}`);
debugLogger.error(`Editor is not supported: ${currentPreference}`);
editorIndex = 0;
}

View file

@ -7,7 +7,6 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
@ -25,20 +24,11 @@ export const Footer: React.FC = () => {
const config = useConfig();
const { vimEnabled, vimMode } = useVimMode();
const {
errorCount,
showErrorDetails,
promptTokenCount,
showAutoAcceptIndicator,
} = {
errorCount: uiState.errorCount,
showErrorDetails: uiState.showErrorDetails,
const { promptTokenCount, showAutoAcceptIndicator } = {
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
showAutoAcceptIndicator: uiState.showAutoAcceptIndicator,
};
const showErrorIndicator = !showErrorDetails && errorCount > 0;
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
@ -103,13 +93,6 @@ export const Footer: React.FC = () => {
),
});
}
if (showErrorIndicator) {
rightItems.push({
key: 'errors',
node: <ConsoleSummaryDisplay errorCount={errorCount} />,
});
}
return (
<Box
justifyContent="space-between"

View file

@ -106,10 +106,11 @@ export const Header: React.FC<HeaderProps> = ({
availableInfoPanelWidth - infoPanelChromeWidth,
);
const authModelText = `${formattedAuthType} | ${model}`;
const authHintText = ' (/auth to change)';
const showAuthHint =
const modelHintText = ' (/model to change)';
const showModelHint =
infoPanelContentWidth > 0 &&
getCachedStringWidth(authModelText + authHintText) <= infoPanelContentWidth;
getCachedStringWidth(authModelText + modelHintText) <=
infoPanelContentWidth;
// Now shorten the path to fit the available space
const tildeifiedPath = tildeifyPath(workingDirectory);
@ -169,8 +170,8 @@ export const Header: React.FC<HeaderProps> = ({
{/* Auth and Model line */}
<Text>
<Text color={theme.text.secondary}>{authModelText}</Text>
{showAuthHint && (
<Text color={theme.text.secondary}>{authHintText}</Text>
{showModelHint && (
<Text color={theme.text.secondary}>{modelHintText}</Text>
)}
</Text>
{/* Directory line */}

View file

@ -38,19 +38,13 @@ describe('IdeTrustChangeDialog', () => {
expect(frameText).toContain("Press 'r' to restart Gemini");
});
it('renders a generic message and logs an error for NONE reason', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
it('renders a generic message for NONE reason', () => {
const { lastFrame } = renderWithProviders(
<IdeTrustChangeDialog reason="NONE" />,
);
const frameText = lastFrame();
expect(frameText).toContain('Workspace trust has changed.');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'IdeTrustChangeDialog rendered with unexpected reason "NONE"',
);
});
it('calls relaunchApp when "r" is pressed', () => {

View file

@ -9,11 +9,14 @@ import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { relaunchApp } from '../../utils/processUtils.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
interface IdeTrustChangeDialogProps {
reason: RestartReason;
}
const debugLogger = createDebugLogger('IDE_TRUST_DIALOG');
export const IdeTrustChangeDialog = ({ reason }: IdeTrustChangeDialogProps) => {
useKeypress(
(key) => {
@ -27,7 +30,7 @@ export const IdeTrustChangeDialog = ({ reason }: IdeTrustChangeDialogProps) => {
let message = 'Workspace trust has changed.';
if (reason === 'NONE') {
// This should not happen, but provides a fallback and a debug log.
console.error(
debugLogger.error(
'IdeTrustChangeDialog rendered with unexpected reason "NONE"',
);
} else if (reason === 'CONNECTION_CHANGE') {

View file

@ -492,10 +492,7 @@ describe('InputPrompt', () => {
unmount();
});
it('should handle errors during clipboard operations', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
it('should handle errors during clipboard operations gracefully', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockRejectedValue(
new Error('Clipboard error'),
);
@ -509,13 +506,9 @@ describe('InputPrompt', () => {
stdin.write(isWindows ? '\x1Bv' : '\x16');
await wait();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error handling clipboard image:',
expect.any(Error),
);
// Should not throw and should not set buffer text on error
expect(mockBuffer.setText).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
unmount();
});
});
@ -2158,6 +2151,291 @@ describe('InputPrompt', () => {
expect(mockBuffer.handleInput).toHaveBeenCalled();
unmount();
});
describe('large paste placeholder', () => {
it('should create placeholder for paste > 1000 characters', async () => {
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// Create a paste with 1001 characters
const largeContent = 'x'.repeat(1001);
// Simulate bracketed paste
stdin.write(`\x1b[200~${largeContent}\x1b[201~`);
await wait();
// Verify placeholder was inserted, not the full content
expect(mockBuffer.insert).toHaveBeenCalledWith(
'[Pasted Content 1001 chars]',
{ paste: false },
);
expect(mockBuffer.insert).toHaveBeenCalledTimes(1);
unmount();
});
it('should create placeholder for paste > 10 lines', async () => {
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// Create a paste with 11 lines (each line is short)
const multiLineContent = Array(11).fill('line').join('\n');
// Simulate bracketed paste
stdin.write(`\x1b[200~${multiLineContent}\x1b[201~`);
await wait();
// Verify placeholder was inserted
expect(mockBuffer.insert).toHaveBeenCalledWith(
expect.stringMatching(/\[Pasted Content \d+ chars\]/),
{ paste: false },
);
unmount();
});
it('should use sequential IDs for multiple pastes of same size', async () => {
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
const largeContent = 'x'.repeat(1001);
// First paste
stdin.write(`\x1b[200~${largeContent}\x1b[201~`);
await wait();
// Second paste
stdin.write(`\x1b[200~${largeContent}\x1b[201~`);
await wait();
// Verify both placeholders were created with correct IDs
expect(mockBuffer.insert).toHaveBeenCalledWith(
'[Pasted Content 1001 chars]',
{ paste: false },
);
expect(mockBuffer.insert).toHaveBeenCalledWith(
'[Pasted Content 1001 chars] #2',
{ paste: false },
);
unmount();
});
it('should expand placeholder to full content on submit', async () => {
const largeContent = 'x'.repeat(1001);
mockBuffer.text = '[Pasted Content 1001 chars]';
mockBuffer.lines = [mockBuffer.text];
mockBuffer.cursor = [0, mockBuffer.text.length];
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// First paste to set up the placeholder
stdin.write(`\x1b[200~${largeContent}\x1b[201~`);
await wait();
// Wait for paste protection to expire
await new Promise((resolve) => setTimeout(resolve, 600));
// Submit the input
stdin.write('\r');
await wait();
// Verify onSubmit was called with expanded content
expect(props.onSubmit).toHaveBeenCalledWith(largeContent);
unmount();
});
it('should expand same-size placeholders correctly when #2 appears first', async () => {
const firstPaste = 'x'.repeat(1001);
const secondPaste = 'y'.repeat(1001);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write(`\x1b[200~${firstPaste}\x1b[201~`);
await wait();
stdin.write(`\x1b[200~${secondPaste}\x1b[201~`);
await wait();
mockBuffer.text =
'[Pasted Content 1001 chars] #2\n[Pasted Content 1001 chars]';
mockBuffer.lines = mockBuffer.text.split('\n');
mockBuffer.cursor = [1, '[Pasted Content 1001 chars]'.length];
// Wait for paste protection to expire
await new Promise((resolve) => setTimeout(resolve, 600));
stdin.write('\r');
await wait();
expect(props.onSubmit).toHaveBeenCalledWith(
`${secondPaste}\n${firstPaste}`,
);
unmount();
});
it('should write expanded placeholder content to shell history', async () => {
props.shellModeActive = true;
const largeContent = 'x'.repeat(1001);
mockBuffer.text = '[Pasted Content 1001 chars]';
mockBuffer.lines = [mockBuffer.text];
mockBuffer.cursor = [0, mockBuffer.text.length];
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write(`\x1b[200~${largeContent}\x1b[201~`);
await wait();
// Wait for paste protection to expire
await new Promise((resolve) => setTimeout(resolve, 600));
stdin.write('\r');
await wait();
expect(mockShellHistory.addCommandToHistory).toHaveBeenCalledWith(
largeContent,
);
expect(props.onSubmit).toHaveBeenCalledWith(largeContent);
unmount();
});
it('should delete entire placeholder on backspace', async () => {
const placeholderText = '[Pasted Content 1001 chars]';
mockBuffer.text = placeholderText;
mockBuffer.lines = [placeholderText];
mockBuffer.cursor = [0, placeholderText.length];
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// First set up a placeholder via paste
const largeContent = 'x'.repeat(1001);
stdin.write(`\x1b[200~${largeContent}\x1b[201~`);
await wait();
// Press backspace to delete the placeholder
stdin.write('\x7f'); // backspace character
await wait();
// Verify replaceRangeByOffset was called to delete entire placeholder
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalledWith(
0,
placeholderText.length,
'',
);
unmount();
});
it('should reuse placeholder ID after deletion', async () => {
// Set up mocks that actually update buffer state
vi.mocked(mockBuffer.insert).mockImplementation((text: string) => {
mockBuffer.text += text;
mockBuffer.lines = [mockBuffer.text];
mockBuffer.cursor = [0, mockBuffer.text.length];
});
vi.mocked(mockBuffer.replaceRangeByOffset).mockImplementation(
(start: number, end: number, replacement: string) => {
mockBuffer.text =
mockBuffer.text.slice(0, start) +
replacement +
mockBuffer.text.slice(end);
mockBuffer.lines = [mockBuffer.text];
mockBuffer.cursor = [0, start];
},
);
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
const largeContent = 'x'.repeat(1001);
// First paste - gets ID 1
stdin.write(`\x1b[200~${largeContent}\x1b[201~`);
await wait();
// Verify first placeholder was inserted
expect(mockBuffer.text).toBe('[Pasted Content 1001 chars]');
// Press backspace to delete the placeholder (cursor is at end of placeholder)
stdin.write('\x7f');
await wait();
// Verify the placeholder was deleted (buffer is now empty)
expect(mockBuffer.text).toBe('');
// Second paste - should reuse ID 1 since the first was deleted
stdin.write(`\x1b[200~${largeContent}\x1b[201~`);
await wait();
// Verify the ID was reused (no #2 suffix)
const insertCalls = vi.mocked(mockBuffer.insert).mock.calls;
const lastCall = insertCalls[insertCalls.length - 1];
expect(lastCall[0]).toBe('[Pasted Content 1001 chars]');
unmount();
});
it('should handle mixed pastes with different character counts', async () => {
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
const content1001 = 'x'.repeat(1001);
const content1500 = 'y'.repeat(1500);
// Paste 1001 chars
stdin.write(`\x1b[200~${content1001}\x1b[201~`);
await wait();
// Paste 1500 chars
stdin.write(`\x1b[200~${content1500}\x1b[201~`);
await wait();
// Paste 1001 chars again (should get ID #2 for 1001)
stdin.write(`\x1b[200~${content1001}\x1b[201~`);
await wait();
// Verify placeholders with correct IDs
expect(mockBuffer.insert).toHaveBeenCalledWith(
'[Pasted Content 1001 chars]',
{ paste: false },
);
expect(mockBuffer.insert).toHaveBeenCalledWith(
'[Pasted Content 1500 chars]',
{ paste: false },
);
expect(mockBuffer.insert).toHaveBeenCalledWith(
'[Pasted Content 1001 chars] #2',
{ paste: false },
);
unmount();
});
});
});
function clean(str: string | undefined): string {
if (!str) return '';

View file

@ -22,7 +22,11 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { ApprovalMode, Storage } from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
Storage,
createDebugLogger,
} from '@qwen-code/qwen-code-core';
import {
parseInputForHighlighting,
buildSegmentsForVisualSlice,
@ -38,6 +42,7 @@ import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useKeypressContext } from '../contexts/KeypressContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
/**
@ -49,6 +54,7 @@ export interface Attachment {
filename: string; // Filename only (for display)
}
const debugLogger = createDebugLogger('INPUT_PROMPT');
export interface InputPromptProps {
buffer: TextBuffer;
onSubmit: (value: string) => void;
@ -97,6 +103,10 @@ export const calculatePromptWidths = (terminalWidth: number) => {
} as const;
};
// Large paste placeholder thresholds
const LARGE_PASTE_CHAR_THRESHOLD = 1000;
const LARGE_PASTE_LINE_THRESHOLD = 10;
export const InputPrompt: React.FC<InputPromptProps> = ({
buffer,
onSubmit,
@ -121,6 +131,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const isShellFocused = useShellFocusState();
const uiState = useUIState();
const uiActions = useUIActions();
const { pasteWorkaround } = useKeypressContext();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@ -132,6 +143,37 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [isAttachmentMode, setIsAttachmentMode] = useState(false);
const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState(-1);
// Large paste placeholder handling
const [pendingPastes, setPendingPastes] = useState<Map<string, string>>(
new Map(),
);
// Track active placeholder IDs for each charCount to enable reuse
const activePlaceholderIds = useRef<Map<number, Set<number>>>(new Map());
// Parse placeholder to extract charCount and ID
const parsePlaceholder = useCallback(
(placeholder: string): { charCount: number; id: number } | null => {
const match = placeholder.match(
/^\[Pasted Content (\d+) chars\](?: #(\d+))?$/,
);
if (!match) return null;
const charCount = parseInt(match[1], 10);
const id = match[2] ? parseInt(match[2], 10) : 1;
return { charCount, id };
},
[],
);
// Free a placeholder ID when deleted so it can be reused
const freePlaceholderId = useCallback((charCount: number, id: number) => {
const activeIds = activePlaceholderIds.current.get(charCount);
if (activeIds) {
activeIds.delete(id);
if (activeIds.size === 0) {
activePlaceholderIds.current.delete(charCount);
}
}
}, []);
const [dirs, setDirs] = useState<readonly string[]>(
config.getWorkspaceContext().getDirectories(),
@ -201,6 +243,25 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}, [showEscapePrompt, onEscapePromptChange]);
// Helper to generate unique placeholder for large pastes
// Reuses IDs that have been freed up from deleted placeholders
const nextLargePastePlaceholder = useCallback((charCount: number): string => {
const activeIds = activePlaceholderIds.current.get(charCount) || new Set();
// Find smallest available ID (starting from 1)
let id = 1;
while (activeIds.has(id)) {
id++;
}
// Mark as active
activeIds.add(id);
activePlaceholderIds.current.set(charCount, activeIds);
const base = `[Pasted Content ${charCount} chars]`;
return id === 1 ? base : `${base} #${id}`;
}, []);
// Clear escape prompt timer on unmount
useEffect(
() => () => {
@ -216,23 +277,40 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const handleSubmitAndClear = useCallback(
(submittedValue: string) => {
// Expand any large paste placeholders to their full content before submitting
let finalValue = submittedValue;
if (pendingPastes.size > 0) {
const placeholders = Array.from(pendingPastes.keys()).sort(
(a, b) => b.length - a.length,
);
const escapedPlaceholders = placeholders.map((placeholderValue) =>
placeholderValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
);
const placeholderRegex = new RegExp(escapedPlaceholders.join('|'), 'g');
finalValue = finalValue.replace(
placeholderRegex,
(matchedPlaceholder) =>
pendingPastes.get(matchedPlaceholder) ?? matchedPlaceholder,
);
setPendingPastes(new Map());
activePlaceholderIds.current.clear();
}
if (shellModeActive) {
shellHistory.addCommandToHistory(submittedValue);
shellHistory.addCommandToHistory(finalValue);
}
// Convert attachments to @references and prepend to the message
let finalMessage = submittedValue;
if (attachments.length > 0) {
const attachmentRefs = attachments
.map((att) => `@${path.relative(config.getTargetDir(), att.path)}`)
.join(' ');
finalMessage = `${attachmentRefs}\n\n${submittedValue.trim()}`;
finalValue = `${attachmentRefs}\n\n${finalValue.trim()}`;
}
// Clear the buffer *before* calling onSubmit to prevent potential re-submission
// if onSubmit triggers a re-render while the buffer still holds the old value.
buffer.setText('');
onSubmit(finalMessage);
onSubmit(finalValue);
// Clear attachments after submit
setAttachments([]);
@ -251,6 +329,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetReverseSearchCompletionState,
attachments,
config,
pendingPastes,
],
);
@ -313,7 +392,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}
} catch (error) {
console.error('Error handling clipboard image:', error);
debugLogger.error('Error handling clipboard image:', error);
}
}, []);
@ -356,10 +435,28 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
pasteTimeoutRef.current = null;
}, 500);
// Handle large pastes by showing a placeholder
const pasted = key.sequence.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const charCount = [...pasted].length; // Proper Unicode char count
const lineCount = pasted.split('\n').length;
// Ensure we never accidentally interpret paste as regular input.
if (key.pasteImage) {
handleClipboardImage(true);
} else if (
charCount > LARGE_PASTE_CHAR_THRESHOLD ||
lineCount > LARGE_PASTE_LINE_THRESHOLD
) {
const placeholder = nextLargePastePlaceholder(charCount);
setPendingPastes((prev) => {
const next = new Map(prev);
next.set(placeholder, pasted);
return next;
});
// Insert the placeholder as regular text
buffer.insert(placeholder, { paste: false });
} else {
// Normal paste handling for small content
buffer.handleInput(key);
}
return;
@ -698,8 +795,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (keyMatchers[Command.SUBMIT](key)) {
if (buffer.text.trim()) {
// Check if a paste operation occurred recently to prevent accidental auto-submission
if (recentPasteTime !== null) {
// Check if a paste operation occurred recently to prevent accidental auto-submission.
// Only applies when pasteWorkaround is enabled (Windows or Node < 20), where bracketed
// paste markers may not work reliably and Enter key events can leak from pasted text.
if (pasteWorkaround && recentPasteTime !== null) {
// Paste occurred recently, ignore this submit to prevent auto-execution
return;
}
@ -768,6 +867,54 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Handle backspace with placeholder-aware deletion
if (
key.name === 'backspace' ||
key.sequence === '\x7f' ||
(key.ctrl && key.name === 'h')
) {
const text = buffer.text;
const [row, col] = buffer.cursor;
// Calculate the offset where the cursor is
let offset = 0;
for (let i = 0; i < row; i++) {
offset += buffer.lines[i].length + 1; // +1 for newline
}
offset += col;
// Check if we're at the end of any placeholder
let placeholderDeleted = false;
for (const placeholder of pendingPastes.keys()) {
const placeholderStart = offset - placeholder.length;
if (
placeholderStart >= 0 &&
text.slice(placeholderStart, offset) === placeholder
) {
// Delete the entire placeholder
buffer.replaceRangeByOffset(placeholderStart, offset, '');
// Remove from pendingPastes and free the ID for reuse
setPendingPastes((prev) => {
const next = new Map(prev);
next.delete(placeholder);
return next;
});
const parsed = parsePlaceholder(placeholder);
if (parsed) {
freePlaceholderId(parsed.charCount, parsed.id);
}
placeholderDeleted = true;
break;
}
}
if (!placeholderDeleted) {
// Normal backspace behavior
buffer.backspace();
}
return;
}
// Fall back to the text buffer's default input handling for all other keys
buffer.handleInput(key);
},
@ -802,6 +949,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
selectedAttachmentIndex,
handleAttachmentDelete,
uiActions,
pasteWorkaround,
nextLargePastePlaceholder,
pendingPastes,
parsePlaceholder,
freePlaceholderId,
],
);

View file

@ -7,10 +7,12 @@
import { Box, Static } from 'ink';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { Notifications } from './Notifications.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
import { DebugModeNotification } from './DebugModeNotification.js';
// Limit Gemini messages to a very high number of lines to mitigate performance
// issues in the worst case if we somehow get an enormous response from Gemini.
@ -32,9 +34,11 @@ export const MainContent = () => {
return (
<>
<Static
key={uiState.historyRemountKey}
key={`${uiState.historyRemountKey}-${uiState.currentModel}`}
items={[
<AppHeader key="app-header" version={version} />,
<DebugModeNotification key="debug-notification" />,
<Notifications key="notifications" />,
...uiState.history.map((h) => (
<HistoryItemDisplay
terminalWidth={terminalWidth}

View file

@ -47,30 +47,35 @@ const renderComponent = (
setValue: vi.fn(),
} as unknown as LoadedSettings;
const mockConfig = contextValue
? ({
// --- Functions used by ModelDialog ---
getModel: vi.fn(() => MAINLINE_CODER),
setModel: vi.fn().mockResolvedValue(undefined),
switchModel: vi.fn().mockResolvedValue(undefined),
getAuthType: vi.fn(() => 'qwen-oauth'),
const mockConfig = {
// --- Functions used by ModelDialog ---
getModel: vi.fn(() => MAINLINE_CODER),
setModel: vi.fn().mockResolvedValue(undefined),
switchModel: vi.fn().mockResolvedValue(undefined),
getAuthType: vi.fn(() => 'qwen-oauth'),
getAllConfiguredModels: vi.fn(() =>
AVAILABLE_MODELS_QWEN.map((m) => ({
id: m.id,
label: m.label,
description: m.description || '',
authType: AuthType.QWEN_OAUTH,
})),
),
// --- Functions used by ClearcutLogger ---
getUsageStatisticsEnabled: vi.fn(() => true),
getSessionId: vi.fn(() => 'mock-session-id'),
getDebugMode: vi.fn(() => false),
getContentGeneratorConfig: vi.fn(() => ({
authType: AuthType.QWEN_OAUTH,
model: MAINLINE_CODER,
})),
getUseSmartEdit: vi.fn(() => false),
getUseModelRouter: vi.fn(() => false),
getProxy: vi.fn(() => undefined),
// --- Functions used by ClearcutLogger ---
getUsageStatisticsEnabled: vi.fn(() => true),
getSessionId: vi.fn(() => 'mock-session-id'),
getDebugMode: vi.fn(() => false),
getContentGeneratorConfig: vi.fn(() => ({
authType: AuthType.QWEN_OAUTH,
model: MAINLINE_CODER,
})),
getUseModelRouter: vi.fn(() => false),
getProxy: vi.fn(() => undefined),
// --- Spread test-specific overrides ---
...contextValue,
} as unknown as Config)
: undefined;
// --- Spread test-specific overrides ---
...(contextValue ?? {}),
} as unknown as Config;
const renderResult = render(
<SettingsContext.Provider value={mockSettings}>
@ -176,10 +181,6 @@ describe('<ModelDialog />', () => {
AuthType.QWEN_OAUTH,
MAINLINE_CODER,
undefined,
{
reason: 'user_manual',
context: 'Model switched via /model dialog',
},
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@ -236,10 +237,6 @@ describe('<ModelDialog />', () => {
AuthType.QWEN_OAUTH,
MAINLINE_CODER,
{ requireCachedCredentials: true },
{
reason: 'user_manual',
context: 'AuthType+model switched via /model dialog',
},
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@ -308,6 +305,14 @@ describe('<ModelDialog />', () => {
{
getModel: mockGetModel,
getAuthType: mockGetAuthType,
getAllConfiguredModels: vi.fn(() =>
AVAILABLE_MODELS_QWEN.map((m) => ({
id: m.id,
label: m.label,
description: m.description || '',
authType: AuthType.QWEN_OAUTH,
})),
),
} as unknown as Config
}
>
@ -322,6 +327,14 @@ describe('<ModelDialog />', () => {
const newMockConfig = {
getModel: mockGetModel,
getAuthType: mockGetAuthType,
getAllConfiguredModels: vi.fn(() =>
AVAILABLE_MODELS_QWEN.map((m) => ({
id: m.id,
label: m.label,
description: m.description || '',
authType: AuthType.QWEN_OAUTH,
})),
),
} as unknown as Config;
rerender(

View file

@ -11,6 +11,7 @@ import {
AuthType,
ModelSlashCommandEvent,
logModelSlashCommand,
type AvailableModel as CoreAvailableModel,
type ContentGeneratorConfig,
type ContentGeneratorConfigSource,
type ContentGeneratorConfigSources,
@ -19,12 +20,9 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import {
getAvailableModelsForAuthType,
MAINLINE_CODER,
} from '../models/availableModels.js';
import { MAINLINE_CODER } from '../models/availableModels.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import { t } from '../../i18n/index.js';
@ -105,6 +103,46 @@ function persistAuthTypeSelection(
settings.setValue(scope, 'security.auth.selectedType', authType);
}
interface HandleModelSwitchSuccessParams {
settings: ReturnType<typeof useSettings>;
uiState: UIState | null;
after: ContentGeneratorConfig | undefined;
effectiveAuthType: AuthType | undefined;
effectiveModelId: string;
isRuntime: boolean;
}
function handleModelSwitchSuccess({
settings,
uiState,
after,
effectiveAuthType,
effectiveModelId,
isRuntime,
}: HandleModelSwitchSuccessParams): void {
persistModelSelection(settings, effectiveModelId);
if (effectiveAuthType) {
persistAuthTypeSelection(settings, effectiveAuthType);
}
const baseUrl = after?.baseUrl ?? t('(default)');
const maskedKey = maskApiKey(after?.apiKey);
uiState?.historyManager.addItem(
{
type: 'info',
text:
`authType: ${effectiveAuthType ?? '(none)'}` +
`\n` +
`Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}` +
`\n` +
`Base URL: ${baseUrl}` +
`\n` +
`API key: ${maskedKey}`,
},
Date.now(),
);
}
function ConfigRow({
label,
value,
@ -154,13 +192,21 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const sources = readSourcesFromConfig(config);
const availableModelEntries = useMemo(() => {
const allAuthTypes = Object.values(AuthType) as AuthType[];
const modelsByAuthType = allAuthTypes
.map((t) => ({
authType: t,
models: getAvailableModelsForAuthType(t, config ?? undefined),
}))
.filter((x) => x.models.length > 0);
const allModels = config ? config.getAllConfiguredModels() : [];
// Separate runtime models from registry models
const runtimeModels = allModels.filter((m) => m.isRuntimeModel);
const registryModels = allModels.filter((m) => !m.isRuntimeModel);
// Group registry models by authType
const modelsByAuthTypeMap = new Map<AuthType, CoreAvailableModel[]>();
for (const model of registryModels) {
const authType = model.authType;
if (!modelsByAuthTypeMap.has(authType)) {
modelsByAuthTypeMap.set(authType, []);
}
modelsByAuthTypeMap.get(authType)!.push(model);
}
// Fixed order: qwen-oauth first, then others in a stable order
const authTypeOrder: AuthType[] = [
@ -171,44 +217,91 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
AuthType.USE_VERTEX_AI,
];
// Filter to only include authTypes that have models
const availableAuthTypes = new Set(modelsByAuthType.map((x) => x.authType));
// Filter to only include authTypes that have registry models and maintain order
const availableAuthTypes = new Set(modelsByAuthTypeMap.keys());
const orderedAuthTypes = authTypeOrder.filter((t) =>
availableAuthTypes.has(t),
);
return orderedAuthTypes.flatMap((t) => {
const models =
modelsByAuthType.find((x) => x.authType === t)?.models ?? [];
return models.map((m) => ({ authType: t, model: m }));
});
// Build ordered list: runtime models first, then registry models grouped by authType
const result: Array<{
authType: AuthType;
model: CoreAvailableModel;
isRuntime?: boolean;
snapshotId?: string;
}> = [];
// Add all runtime models first
for (const runtimeModel of runtimeModels) {
result.push({
authType: runtimeModel.authType,
model: runtimeModel,
isRuntime: true,
snapshotId: runtimeModel.runtimeSnapshotId,
});
}
// Add registry models grouped by authType
for (const t of orderedAuthTypes) {
for (const model of modelsByAuthTypeMap.get(t) ?? []) {
result.push({ authType: t, model, isRuntime: false });
}
}
return result;
}, [config]);
const MODEL_OPTIONS = useMemo(
() =>
availableModelEntries.map(({ authType: t2, model }) => {
const value = `${t2}::${model.id}`;
const title = (
<Text>
<Text bold color={theme.text.accent}>
[{t2}]
availableModelEntries.map(
({ authType: t2, model, isRuntime, snapshotId }) => {
// Runtime models use snapshotId directly (format: $runtime|${authType}|${modelId})
const value =
isRuntime && snapshotId ? snapshotId : `${t2}::${model.id}`;
const title = (
<Text>
<Text
bold
color={isRuntime ? theme.status.warning : theme.text.accent}
>
[{t2}]
</Text>
<Text>{` ${model.label}`}</Text>
{isRuntime && (
<Text color={theme.status.warning}> (Runtime)</Text>
)}
</Text>
<Text>{` ${model.label}`}</Text>
</Text>
);
const description = model.description || '';
return {
value,
title,
description,
key: value,
};
}),
);
// Include runtime indicator in description
let description = model.description || '';
if (isRuntime) {
description = description
? `${description} (Runtime)`
: 'Runtime model';
}
return {
value,
title,
description,
key: value,
};
},
),
[availableModelEntries],
);
const preferredModelId = config?.getModel() || MAINLINE_CODER;
const preferredKey = authType ? `${authType}::${preferredModelId}` : '';
// Check if current model is a runtime model
// Runtime snapshot ID is already in $runtime|${authType}|${modelId} format
const activeRuntimeSnapshot = config?.getActiveRuntimeModelSnapshot?.();
const preferredKey = activeRuntimeSnapshot
? activeRuntimeSnapshot.id
: authType
? `${authType}::${preferredModelId}`
: '';
useKeypress(
(key) => {
@ -228,67 +321,81 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const handleSelect = useCallback(
async (selected: string) => {
// Clear any previous error
setErrorMessage(null);
const sep = '::';
const idx = selected.indexOf(sep);
const selectedAuthType = (
idx >= 0 ? selected.slice(0, idx) : authType
) as AuthType;
const modelId = idx >= 0 ? selected.slice(idx + sep.length) : selected;
let after: ContentGeneratorConfig | undefined;
let effectiveAuthType: AuthType | undefined;
let effectiveModelId = selected;
let isRuntime = false;
if (config) {
try {
await config.switchModel(
selectedAuthType,
modelId,
selectedAuthType !== authType &&
selectedAuthType === AuthType.QWEN_OAUTH
? { requireCachedCredentials: true }
: undefined,
{
reason: 'user_manual',
context:
selectedAuthType === authType
? 'Model switched via /model dialog'
: 'AuthType+model switched via /model dialog',
},
);
} catch (e) {
const baseErrorMessage = e instanceof Error ? e.message : String(e);
setErrorMessage(
`Failed to switch model to '${modelId}'.\n\n${baseErrorMessage}`,
);
return;
if (!config) {
onClose();
return;
}
try {
// Determine if this is a runtime model selection
// Runtime model format: $runtime|${authType}|${modelId}
isRuntime = selected.startsWith('$runtime|');
let selectedAuthType: AuthType;
let modelId: string;
if (isRuntime) {
// For runtime models, extract authType from the snapshot ID
// Format: $runtime|${authType}|${modelId}
const parts = selected.split('|');
if (parts.length >= 2 && parts[0] === '$runtime') {
selectedAuthType = parts[1] as AuthType;
} else {
selectedAuthType = authType as AuthType;
}
modelId = selected; // Pass the full snapshot ID to switchModel
} else {
const sep = '::';
const idx = selected.indexOf(sep);
selectedAuthType = (
idx >= 0 ? selected.slice(0, idx) : authType
) as AuthType;
modelId = idx >= 0 ? selected.slice(idx + sep.length) : selected;
}
const event = new ModelSlashCommandEvent(modelId);
logModelSlashCommand(config, event);
const after = config.getContentGeneratorConfig?.() as
await config.switchModel(
selectedAuthType,
modelId,
selectedAuthType !== authType &&
selectedAuthType === AuthType.QWEN_OAUTH
? { requireCachedCredentials: true }
: undefined,
);
if (!isRuntime) {
const event = new ModelSlashCommandEvent(modelId);
logModelSlashCommand(config, event);
}
after = config.getContentGeneratorConfig?.() as
| ContentGeneratorConfig
| undefined;
const effectiveAuthType =
after?.authType ?? selectedAuthType ?? authType;
const effectiveModelId = after?.model ?? modelId;
persistModelSelection(settings, effectiveModelId);
persistAuthTypeSelection(settings, effectiveAuthType);
const baseUrl = after?.baseUrl ?? t('(default)');
const maskedKey = maskApiKey(after?.apiKey);
uiState?.historyManager.addItem(
{
type: 'info',
text:
`authType: ${effectiveAuthType}\n` +
`Using model: ${effectiveModelId}\n` +
`Base URL: ${baseUrl}\n` +
`API key: ${maskedKey}`,
},
Date.now(),
);
effectiveAuthType = after?.authType ?? selectedAuthType ?? authType;
effectiveModelId = after?.model ?? modelId;
} catch (e) {
const baseErrorMessage = e instanceof Error ? e.message : String(e);
const errorPrefix = isRuntime
? 'Failed to switch to runtime model.'
: `Failed to switch model to '${effectiveModelId ?? selected}'.`;
setErrorMessage(`${errorPrefix}\n\n${baseErrorMessage}`);
return;
}
handleModelSwitchSuccess({
settings,
uiState,
after,
effectiveAuthType,
effectiveModelId,
isRuntime,
});
onClose();
},
[authType, config, onClose, settings, uiState, setErrorMessage],

View file

@ -19,10 +19,6 @@ export const Notifications = () => {
const showInitError =
initError && streamingState !== StreamingState.Responding;
if (!showStartupWarnings && !showInitError && !updateInfo) {
return null;
}
return (
<>
{updateInfo && <UpdateNotification message={updateInfo.message} />}

View file

@ -394,10 +394,6 @@ describe('QwenOAuthProgress', () => {
it('should handle QR code generation errors gracefully', async () => {
const qrcode = await import('qrcode-terminal');
const mockGenerate = vi.mocked(qrcode.default.generate);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
mockGenerate.mockImplementation(() => {
throw new Error('QR Code generation failed');
});
@ -413,12 +409,6 @@ describe('QwenOAuthProgress', () => {
// 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:');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to generate QR code:',
expect.any(Error),
);
consoleErrorSpy.mockRestore();
});
it('should not generate QR code when deviceAuth is null', async () => {

View file

@ -12,6 +12,7 @@ import Link from 'ink-link';
import qrcode from 'qrcode-terminal';
import { Colors } from '../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';
@ -29,6 +30,8 @@ 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
@ -161,7 +164,7 @@ export function QwenOAuthProgress({
},
);
} catch (error) {
console.error('Failed to generate QR code:', error);
debugLogger.error('Failed to generate QR code:', error);
setQrCodeData(null);
}
};

View file

@ -29,7 +29,7 @@ import {
} from '../../utils/settingsUtils.js';
import { updateOutputLanguageFile } from '../../utils/languageUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { type Config } from '@qwen-code/qwen-code-core';
import { createDebugLogger, type Config } from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js';
import chalk from 'chalk';
import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
@ -46,6 +46,8 @@ interface SettingsDialogProps {
config?: Config;
}
const debugLogger = createDebugLogger('SETTINGS_DIALOG');
const maxItemsToShow = 8;
export function SettingsDialog({
@ -162,7 +164,7 @@ export function SettingsDialog({
{} as Settings,
);
console.log(
debugLogger.debug(
`[DEBUG SettingsDialog] Saving ${key} immediately with value:`,
newValue,
);
@ -177,7 +179,7 @@ export function SettingsDialog({
if (key === 'general.vimMode' && newValue !== vimEnabled) {
// Call toggleVimEnabled to sync the VimModeContext local state
toggleVimEnabled().catch((error) => {
console.error('Failed to toggle vim mode:', error);
debugLogger.error('Failed to toggle vim mode:', error);
});
}
@ -189,7 +191,7 @@ export function SettingsDialog({
try {
config?.setApprovalMode(settings.merged.tools.approvalMode);
} catch (error) {
console.error(
debugLogger.error(
'Failed to apply approval mode to current session:',
error,
);
@ -663,7 +665,7 @@ export function SettingsDialog({
try {
config?.setApprovalMode(settings.merged.tools.approvalMode);
} catch (error) {
console.error(
debugLogger.error(
'Failed to apply approval mode to current session:',
error,
);

View file

@ -9,6 +9,7 @@ import { render, Box, useApp } from 'ink';
import { getGitBranch, SessionService } from '@qwen-code/qwen-code-core';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { SessionPicker } from './SessionPicker.js';
import { writeStdoutLine } from '../../utils/stdioHelpers.js';
interface StandalonePickerScreenProps {
sessionService: SessionService;
@ -70,7 +71,7 @@ export async function showResumeSessionPicker(
const sessionService = new SessionService(cwd);
const hasSession = await sessionService.loadLastSession();
if (!hasSession) {
console.log('No sessions found. Start a new session with `qwen`.');
writeStdoutLine('No sessions found. Start a new session with `qwen`.');
return undefined;
}

View file

@ -10,8 +10,10 @@ import stringWidth from 'string-width';
import { theme } from '../../semantic-colors.js';
import { toCodePoints } from '../../utils/textUtils.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
let enableDebugLog = false;
const debugLogger = createDebugLogger('MAX_SIZED_BOX');
/**
* Minimum height for the MaxSizedBox component.
@ -28,7 +30,7 @@ function debugReportError(message: string, element: React.ReactNode) {
if (!enableDebugLog) return;
if (!React.isValidElement(element)) {
console.error(
debugLogger.error(
message,
`Invalid element: '${String(element)}' typeof=${typeof element}`,
);
@ -44,10 +46,13 @@ function debugReportError(message: string, element: React.ReactNode) {
const lineNumber = elementWithSource._source?.lineNumber;
sourceMessage = fileName ? `${fileName}:${lineNumber}` : '<Unknown file>';
} catch (error) {
console.error('Error while trying to get file name:', error);
debugLogger.error('Error while trying to get file name:', error);
}
console.error(message, `${String(element.type)}. Source: ${sourceMessage}`);
debugLogger.error(
message,
`${String(element.type)}. Source: ${sourceMessage}`,
);
}
interface MaxSizedBoxProps {
children?: React.ReactNode;

View file

@ -9,7 +9,7 @@ import fs from 'node:fs';
import os from 'node:os';
import pathMod from 'node:path';
import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
import { unescapePath } from '@qwen-code/qwen-code-core';
import { createDebugLogger, unescapePath } from '@qwen-code/qwen-code-core';
import {
toCodePoints,
cpLen,
@ -20,6 +20,8 @@ import {
import type { VimAction } from './vim-buffer-actions.js';
import { handleVimAction } from './vim-buffer-actions.js';
const debugLogger = createDebugLogger('TEXT_BUFFER');
export type Direction =
| 'left'
| 'right'
@ -1143,7 +1145,7 @@ function textBufferReducerLogic(
break;
default: {
const exhaustiveCheck: never = dir;
console.error(
debugLogger.error(
`Unknown visual movement direction: ${exhaustiveCheck}`,
);
return state;
@ -1489,7 +1491,7 @@ function textBufferReducerLogic(
default: {
const exhaustiveCheck: never = action;
console.error(`Unknown action encountered: ${exhaustiveCheck}`);
debugLogger.error(`Unknown action encountered: ${exhaustiveCheck}`);
return state;
}
}
@ -1858,7 +1860,7 @@ export function useTextBuffer({
newText = newText.replace(/\r\n?/g, '\n');
dispatch({ type: 'set_text', payload: newText, pushToUndo: false });
} catch (err) {
console.error('[useTextBuffer] external editor error', err);
debugLogger.error('[useTextBuffer] external editor error', err);
} finally {
if (wasRaw) setRawMode?.(true);
try {

View file

@ -11,12 +11,15 @@ import type {
SubagentManager,
SubagentConfig,
} from '@qwen-code/qwen-code-core';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import { theme } from '../../../semantic-colors.js';
import { shouldShowColor, getColorForDisplay } from '../utils.js';
import { useLaunchEditor } from '../../../hooks/useLaunchEditor.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { t } from '../../../../i18n/index.js';
const debugLogger = createDebugLogger('SUBAGENT_CREATION_SUMMARY');
/**
* Step 6: Final confirmation and actions.
*/
@ -87,7 +90,7 @@ export function CreationSummary({
}
} catch (error) {
// Silently handle errors in warning checks
console.warn('Error checking subagent name availability:', error);
debugLogger.warn('Error checking subagent name availability:', error);
}
// Check length warnings

View file

@ -6,6 +6,7 @@
import { Box, Text } from 'ink';
import { type SubagentConfig } from '@qwen-code/qwen-code-core';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import type { StepNavigationProps } from '../types.js';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
@ -16,6 +17,8 @@ interface AgentDeleteStepProps extends StepNavigationProps {
onDelete: (agent: SubagentConfig) => Promise<void>;
}
const debugLogger = createDebugLogger('AGENT_DELETE_STEP');
export function AgentDeleteStep({
selectedAgent,
onDelete,
@ -30,7 +33,7 @@ export function AgentDeleteStep({
await onDelete(selectedAgent);
// Navigation will be handled by the parent component after successful deletion
} catch (error) {
console.error('Failed to delete agent:', error);
debugLogger.error('Failed to delete agent:', error);
}
} else if (key.name === 'n') {
onNavigateBack();

View file

@ -17,6 +17,7 @@ import { MANAGEMENT_STEPS } from '../types.js';
import { theme } from '../../../semantic-colors.js';
import { getColorForDisplay, shouldShowColor } from '../utils.js';
import type { SubagentConfig, Config } 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';
@ -25,6 +26,8 @@ interface AgentsManagerDialogProps {
config: Config | null;
}
const debugLogger = createDebugLogger('AGENTS_MANAGER_DIALOG');
/**
* Main orchestrator component for the agents management dialog.
*/
@ -108,7 +111,7 @@ export function AgentsManagerDialog({
setNavigationStack([MANAGEMENT_STEPS.AGENT_SELECTION]);
setSelectedAgentIndex(-1);
} catch (error) {
console.error('Failed to delete agent:', error);
debugLogger.error('Failed to delete agent:', error);
throw error; // Re-throw to let the component handle the error state
}
},
@ -253,7 +256,7 @@ export function AgentsManagerDialog({
await loadAgents();
handleNavigateBack();
} catch (error) {
console.error('Failed to save agent changes:', error);
debugLogger.error('Failed to save agent changes:', error);
}
}
}}
@ -282,7 +285,7 @@ export function AgentsManagerDialog({
await loadAgents();
handleNavigateBack();
} catch (error) {
console.error('Failed to save color changes:', error);
debugLogger.error('Failed to save color changes:', error);
}
}
}}

View file

@ -7,6 +7,9 @@
import { Box, Text } from 'ink';
import { useUIState } from '../../contexts/UIStateContext.js';
import { ExtensionUpdateState } from '../../state/extensions.js';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
const debugLogger = createDebugLogger('EXTENSIONS_LIST');
export const ExtensionsList = () => {
const { extensionsUpdateState, commandContext } = useUIState();
@ -47,7 +50,7 @@ export const ExtensionsList = () => {
stateColor = 'green';
break;
default:
console.error(`Unhandled ExtensionUpdateState ${state}`);
debugLogger.error(`Unhandled ExtensionUpdateState ${state}`);
break;
}

View file

@ -49,13 +49,6 @@ export const McpStatus: React.FC<McpStatusProps> = ({
return (
<Box flexDirection="column">
<Text>{t('No MCP servers configured.')}</Text>
<Text>
{t('Please view MCP documentation in your browser:')}{' '}
<Text color={theme.text.link}>
https://goo.gle/gemini-cli-docs-mcp
</Text>{' '}
{t('or use the cli /docs command')}
</Text>
</Box>
);
}