mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 20:20:57 +00:00
Merge branch 'main' into feat/image-attachment
This commit is contained in:
commit
56030f9291
609 changed files with 26677 additions and 12343 deletions
|
|
@ -42,6 +42,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
|||
branchName: 'main',
|
||||
nightly: false,
|
||||
debugMessage: '',
|
||||
currentModel: 'gemini-pro',
|
||||
sessionStats: {
|
||||
lastPromptTokenCount: 0,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
36
packages/cli/src/ui/components/DebugModeNotification.tsx
Normal file
36
packages/cli/src/ui/components/DebugModeNotification.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue