mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 20:20:57 +00:00
Merge branch 'main' into feat/support-insight-command
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
commit
a172696b86
150 changed files with 9730 additions and 2047 deletions
86
packages/cli/src/ui/components/ApiKeyInput.tsx
Normal file
86
packages/cli/src/ui/components/ApiKeyInput.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { TextInput } from './shared/TextInput.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { CodingPlanRegion } from '../../constants/codingPlan.js';
|
||||
import Link from 'ink-link';
|
||||
|
||||
interface ApiKeyInputProps {
|
||||
onSubmit: (apiKey: string) => void;
|
||||
onCancel: () => void;
|
||||
region?: CodingPlanRegion;
|
||||
}
|
||||
|
||||
const CODING_PLAN_API_KEY_URL =
|
||||
'https://bailian.console.aliyun.com/?tab=model#/efm/coding_plan';
|
||||
|
||||
const CODING_PLAN_INTL_API_KEY_URL =
|
||||
'https://modelstudio.console.alibabacloud.com/?tab=dashboard#/efm/coding_plan';
|
||||
|
||||
export function ApiKeyInput({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
region = CodingPlanRegion.CHINA,
|
||||
}: ApiKeyInputProps): React.JSX.Element {
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const apiKeyUrl =
|
||||
region === CodingPlanRegion.GLOBAL
|
||||
? CODING_PLAN_INTL_API_KEY_URL
|
||||
: CODING_PLAN_API_KEY_URL;
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onCancel();
|
||||
} else if (key.name === 'return') {
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (!trimmedKey) {
|
||||
setError(t('API key cannot be empty.'));
|
||||
return;
|
||||
}
|
||||
onSubmit(trimmedKey);
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text>{t('Please enter your API key:')}</Text>
|
||||
</Box>
|
||||
<TextInput value={apiKey} onChange={setApiKey} placeholder="sk-sp-..." />
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.error}>{error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('You can get your exclusive Coding Plan API-KEY here:')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={0}>
|
||||
<Link url={apiKeyUrl} fallback={false}>
|
||||
<Text color={theme.status.success} underline>
|
||||
{apiKeyUrl}
|
||||
</Text>
|
||||
</Link>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('(Press Enter to submit, Escape to cancel)')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import { ThemeDialog } from './ThemeDialog.js';
|
|||
import { SettingsDialog } from './SettingsDialog.js';
|
||||
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
|
||||
import { AuthDialog } from '../auth/AuthDialog.js';
|
||||
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
|
||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
|
|
@ -56,16 +55,6 @@ export const DialogManager = ({
|
|||
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
|
||||
uiState;
|
||||
|
||||
const getDefaultOpenAIConfig = () => {
|
||||
const fromSettings = settings.merged.security?.auth;
|
||||
const modelSettings = settings.merged.model;
|
||||
return {
|
||||
apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '',
|
||||
baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '',
|
||||
model: modelSettings?.name || process.env['OPENAI_MODEL'] || '',
|
||||
};
|
||||
};
|
||||
|
||||
if (uiState.showWelcomeBackDialog && uiState.welcomeBackInfo?.hasHistory) {
|
||||
return (
|
||||
<WelcomeBackDialog
|
||||
|
|
@ -133,6 +122,15 @@ export const DialogManager = ({
|
|||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.codingPlanUpdateRequest) {
|
||||
return (
|
||||
<ConsentPrompt
|
||||
prompt={uiState.codingPlanUpdateRequest.prompt}
|
||||
onConfirm={uiState.codingPlanUpdateRequest.onConfirm}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.settingInputRequests.length > 0) {
|
||||
const request = uiState.settingInputRequests[0];
|
||||
// Use settingName as key to force re-mount when switching between different settings
|
||||
|
|
@ -251,28 +249,8 @@ export const DialogManager = ({
|
|||
}
|
||||
|
||||
if (uiState.isAuthenticating) {
|
||||
if (uiState.pendingAuthType === AuthType.USE_OPENAI) {
|
||||
const defaults = getDefaultOpenAIConfig();
|
||||
return (
|
||||
<OpenAIKeyPrompt
|
||||
onSubmit={(apiKey, baseUrl, model) => {
|
||||
uiActions.handleAuthSelect(AuthType.USE_OPENAI, {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
});
|
||||
}}
|
||||
onCancel={() => {
|
||||
uiActions.cancelAuthentication();
|
||||
uiActions.setAuthState(AuthState.Updating);
|
||||
}}
|
||||
defaultApiKey={defaults.apiKey}
|
||||
defaultBaseUrl={defaults.baseUrl}
|
||||
defaultModel={defaults.model}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// OpenAI authentication now handled through AuthDialog with coding-plan/custom sub-modes
|
||||
// Qwen OAuth remains as a separate flow
|
||||
if (uiState.pendingAuthType === AuthType.QWEN_OAUTH) {
|
||||
return (
|
||||
<QwenOAuthProgress
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { GeminiThoughtMessageContent } from './messages/GeminiThoughtMessageCont
|
|||
import { CompressionMessage } from './messages/CompressionMessage.js';
|
||||
import { SummaryMessage } from './messages/SummaryMessage.js';
|
||||
import { WarningMessage } from './messages/WarningMessage.js';
|
||||
import { RetryCountdownMessage } from './messages/RetryCountdownMessage.js';
|
||||
import { Box } from 'ink';
|
||||
import { AboutBox } from './AboutBox.js';
|
||||
import { StatsDisplay } from './StatsDisplay.js';
|
||||
|
|
@ -127,6 +128,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
{itemForDisplay.type === 'error' && (
|
||||
<ErrorMessage text={itemForDisplay.text} />
|
||||
)}
|
||||
{itemForDisplay.type === 'retry_countdown' && (
|
||||
<RetryCountdownMessage text={itemForDisplay.text} />
|
||||
)}
|
||||
{itemForDisplay.type === 'about' && (
|
||||
<AboutBox {...itemForDisplay.systemInfo} width={boxWidth} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -370,6 +370,8 @@ describe('InputPrompt', () => {
|
|||
});
|
||||
|
||||
describe('clipboard image paste', () => {
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
|
||||
|
|
@ -378,10 +380,37 @@ describe('InputPrompt', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should handle Ctrl+V when clipboard has an image', async () => {
|
||||
// Windows uses Alt+V (\x1Bv), non-Windows uses Ctrl+V (\x16)
|
||||
const describeConditional = isWindows ? it.skip : it;
|
||||
describeConditional(
|
||||
'should handle Ctrl+V when clipboard has an image',
|
||||
async () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
|
||||
'/Users/mochi/.qwen/tmp/clipboard-123.png',
|
||||
);
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Send Ctrl+V
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
await wait();
|
||||
|
||||
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
|
||||
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
|
||||
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled();
|
||||
// Note: The new implementation adds images as attachments rather than inserting into buffer
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('should handle Cmd+V when clipboard has an image', async () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
|
||||
'/test/.qwen-clipboard/clipboard-123.png',
|
||||
'/Users/mochi/.qwen/tmp/clipboard-456.png',
|
||||
);
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
|
|
@ -389,18 +418,15 @@ describe('InputPrompt', () => {
|
|||
);
|
||||
await wait();
|
||||
|
||||
// Send Ctrl+V
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
// Send Cmd+V (meta key) / Alt+V on Windows
|
||||
// In terminals, Cmd+V or Alt+V is typically sent as ESC followed by 'v'
|
||||
stdin.write('\x1Bv');
|
||||
await wait();
|
||||
|
||||
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
|
||||
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith(
|
||||
props.config.getTargetDir(),
|
||||
);
|
||||
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith(
|
||||
props.config.getTargetDir(),
|
||||
);
|
||||
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
|
||||
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
|
||||
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled();
|
||||
// Note: The new implementation adds images as attachments rather than inserting into buffer
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
|
@ -412,7 +438,8 @@ describe('InputPrompt', () => {
|
|||
);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
// Use platform-appropriate key combination
|
||||
stdin.write(isWindows ? '\x1Bv' : '\x16');
|
||||
await wait();
|
||||
|
||||
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
|
||||
|
|
@ -430,7 +457,8 @@ describe('InputPrompt', () => {
|
|||
);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
// Use platform-appropriate key combination
|
||||
stdin.write(isWindows ? '\x1Bv' : '\x16');
|
||||
await wait();
|
||||
|
||||
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
|
||||
|
|
@ -439,11 +467,7 @@ describe('InputPrompt', () => {
|
|||
});
|
||||
|
||||
it('should insert image path at cursor position with proper spacing', async () => {
|
||||
const imagePath = path.join(
|
||||
'test',
|
||||
'.qwen-clipboard',
|
||||
'clipboard-456.png',
|
||||
);
|
||||
const imagePath = '/Users/mochi/.qwen/tmp/clipboard-456.png';
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(imagePath);
|
||||
|
||||
|
|
@ -451,27 +475,20 @@ describe('InputPrompt', () => {
|
|||
mockBuffer.text = 'Hello world';
|
||||
mockBuffer.cursor = [0, 5]; // Cursor after "Hello"
|
||||
mockBuffer.lines = ['Hello world'];
|
||||
mockBuffer.replaceRangeByOffset = vi.fn();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
// Use platform-appropriate key combination
|
||||
stdin.write(isWindows ? '\x1Bv' : '\x16');
|
||||
await wait();
|
||||
|
||||
// Should insert at cursor position with spaces
|
||||
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
|
||||
|
||||
// Get the actual call to see what path was used
|
||||
const actualCall = vi.mocked(mockBuffer.replaceRangeByOffset).mock
|
||||
.calls[0];
|
||||
expect(actualCall[0]).toBe(5); // start offset
|
||||
expect(actualCall[1]).toBe(5); // end offset
|
||||
expect(actualCall[2]).toBe(
|
||||
' @' + path.relative(path.join('test', 'project', 'src'), imagePath),
|
||||
);
|
||||
// The new implementation adds images as attachments rather than inserting into buffer
|
||||
// So we verify that saveClipboardImage was called instead
|
||||
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
|
||||
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
|
@ -485,7 +502,8 @@ describe('InputPrompt', () => {
|
|||
);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
// Use platform-appropriate key combination
|
||||
stdin.write(isWindows ? '\x1Bv' : '\x16');
|
||||
await wait();
|
||||
|
||||
// Should not throw and should not set buffer text on error
|
||||
|
|
|
|||
|
|
@ -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, createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
ApprovalMode,
|
||||
Storage,
|
||||
createDebugLogger,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
parseInputForHighlighting,
|
||||
buildSegmentsForVisualSlice,
|
||||
|
|
@ -41,6 +45,15 @@ import { useUIActions } from '../contexts/UIActionsContext.js';
|
|||
import { useKeypressContext } from '../contexts/KeypressContext.js';
|
||||
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
|
||||
|
||||
/**
|
||||
* Represents an attachment (e.g., pasted image) displayed above the input prompt
|
||||
*/
|
||||
export interface Attachment {
|
||||
id: string; // Unique identifier (timestamp)
|
||||
path: string; // Full file path
|
||||
filename: string; // Filename only (for display)
|
||||
}
|
||||
|
||||
const debugLogger = createDebugLogger('INPUT_PROMPT');
|
||||
export interface InputPromptProps {
|
||||
buffer: TextBuffer;
|
||||
|
|
@ -126,6 +139,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
const [recentPasteTime, setRecentPasteTime] = useState<number | null>(null);
|
||||
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Attachment state for clipboard images
|
||||
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(),
|
||||
|
|
@ -281,10 +298,25 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
if (shellModeActive) {
|
||||
shellHistory.addCommandToHistory(finalValue);
|
||||
}
|
||||
|
||||
// Convert attachments to @references and prepend to the message
|
||||
if (attachments.length > 0) {
|
||||
const attachmentRefs = attachments
|
||||
.map((att) => `@${path.relative(config.getTargetDir(), att.path)}`)
|
||||
.join(' ');
|
||||
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(finalValue);
|
||||
|
||||
// Clear attachments after submit
|
||||
setAttachments([]);
|
||||
setIsAttachmentMode(false);
|
||||
setSelectedAttachmentIndex(-1);
|
||||
|
||||
resetCompletionState();
|
||||
resetReverseSearchCompletionState();
|
||||
},
|
||||
|
|
@ -295,6 +327,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
shellModeActive,
|
||||
shellHistory,
|
||||
resetReverseSearchCompletionState,
|
||||
attachments,
|
||||
config,
|
||||
pendingPastes,
|
||||
],
|
||||
);
|
||||
|
|
@ -336,52 +370,45 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
]);
|
||||
|
||||
// Handle clipboard image pasting with Ctrl+V
|
||||
const handleClipboardImage = useCallback(async () => {
|
||||
const handleClipboardImage = useCallback(async (validated = false) => {
|
||||
try {
|
||||
if (await clipboardHasImage()) {
|
||||
const imagePath = await saveClipboardImage(config.getTargetDir());
|
||||
const hasImage = validated || (await clipboardHasImage());
|
||||
if (hasImage) {
|
||||
const imagePath = await saveClipboardImage(Storage.getGlobalTempDir());
|
||||
if (imagePath) {
|
||||
// Clean up old images
|
||||
cleanupOldClipboardImages(config.getTargetDir()).catch(() => {
|
||||
cleanupOldClipboardImages(Storage.getGlobalTempDir()).catch(() => {
|
||||
// Ignore cleanup errors
|
||||
});
|
||||
|
||||
// Get relative path from current directory
|
||||
const relativePath = path.relative(config.getTargetDir(), imagePath);
|
||||
|
||||
// Insert @path reference at cursor position
|
||||
const insertText = `@${relativePath}`;
|
||||
const currentText = buffer.text;
|
||||
const [row, col] = buffer.cursor;
|
||||
|
||||
// Calculate offset from row/col
|
||||
let offset = 0;
|
||||
for (let i = 0; i < row; i++) {
|
||||
offset += buffer.lines[i].length + 1; // +1 for newline
|
||||
}
|
||||
offset += col;
|
||||
|
||||
// Add spaces around the path if needed
|
||||
let textToInsert = insertText;
|
||||
const charBefore = offset > 0 ? currentText[offset - 1] : '';
|
||||
const charAfter =
|
||||
offset < currentText.length ? currentText[offset] : '';
|
||||
|
||||
if (charBefore && charBefore !== ' ' && charBefore !== '\n') {
|
||||
textToInsert = ' ' + textToInsert;
|
||||
}
|
||||
if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) {
|
||||
textToInsert = textToInsert + ' ';
|
||||
}
|
||||
|
||||
// Insert at cursor position
|
||||
buffer.replaceRangeByOffset(offset, offset, textToInsert);
|
||||
// Add as attachment instead of inserting @reference into text
|
||||
const filename = path.basename(imagePath);
|
||||
const newAttachment: Attachment = {
|
||||
id: String(Date.now()),
|
||||
path: imagePath,
|
||||
filename,
|
||||
};
|
||||
setAttachments((prev) => [...prev, newAttachment]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error('Error handling clipboard image:', error);
|
||||
}
|
||||
}, [buffer, config]);
|
||||
}, []);
|
||||
|
||||
// Handle deletion of an attachment from the list
|
||||
const handleAttachmentDelete = useCallback((index: number) => {
|
||||
setAttachments((prev) => {
|
||||
const newList = prev.filter((_, i) => i !== index);
|
||||
if (newList.length === 0) {
|
||||
setIsAttachmentMode(false);
|
||||
setSelectedAttachmentIndex(-1);
|
||||
} else {
|
||||
setSelectedAttachmentIndex(Math.min(index, newList.length - 1));
|
||||
}
|
||||
return newList;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key) => {
|
||||
|
|
@ -412,7 +439,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
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;
|
||||
if (
|
||||
|
||||
// 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
|
||||
) {
|
||||
|
|
@ -666,6 +697,55 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// Attachment mode handling - process before history navigation
|
||||
if (isAttachmentMode && attachments.length > 0) {
|
||||
if (key.name === 'left') {
|
||||
setSelectedAttachmentIndex((i) => Math.max(0, i - 1));
|
||||
return;
|
||||
}
|
||||
if (key.name === 'right') {
|
||||
setSelectedAttachmentIndex((i) =>
|
||||
Math.min(attachments.length - 1, i + 1),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||
// Exit attachment mode and return to input
|
||||
setIsAttachmentMode(false);
|
||||
setSelectedAttachmentIndex(-1);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'backspace' || key.name === 'delete') {
|
||||
handleAttachmentDelete(selectedAttachmentIndex);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'return' || key.name === 'escape') {
|
||||
setIsAttachmentMode(false);
|
||||
setSelectedAttachmentIndex(-1);
|
||||
return;
|
||||
}
|
||||
// For other keys, exit attachment mode and let input handle them
|
||||
setIsAttachmentMode(false);
|
||||
setSelectedAttachmentIndex(-1);
|
||||
// Continue to process the key in input
|
||||
}
|
||||
|
||||
// Enter attachment mode when pressing up at the first line with attachments
|
||||
if (
|
||||
!isAttachmentMode &&
|
||||
attachments.length > 0 &&
|
||||
!shellModeActive &&
|
||||
!reverseSearchActive &&
|
||||
!commandSearchActive &&
|
||||
buffer.visualCursor[0] === 0 &&
|
||||
buffer.visualScrollRow === 0 &&
|
||||
keyMatchers[Command.NAVIGATION_UP](key)
|
||||
) {
|
||||
setIsAttachmentMode(true);
|
||||
setSelectedAttachmentIndex(attachments.length - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shellModeActive) {
|
||||
if (keyMatchers[Command.REVERSE_SEARCH](key)) {
|
||||
setCommandSearchActive(true);
|
||||
|
|
@ -864,6 +944,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
onToggleShortcuts,
|
||||
showShortcuts,
|
||||
uiState,
|
||||
isAttachmentMode,
|
||||
attachments,
|
||||
selectedAttachmentIndex,
|
||||
handleAttachmentDelete,
|
||||
uiActions,
|
||||
pasteWorkaround,
|
||||
nextLargePastePlaceholder,
|
||||
|
|
@ -921,6 +1005,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
{attachments.length > 0 && (
|
||||
<Box marginLeft={2} marginBottom={0}>
|
||||
<Text color={theme.text.secondary}>{t('Attachments: ')}</Text>
|
||||
{attachments.map((att, idx) => (
|
||||
<Text
|
||||
key={att.id}
|
||||
color={
|
||||
isAttachmentMode && idx === selectedAttachmentIndex
|
||||
? theme.status.success
|
||||
: theme.text.secondary
|
||||
}
|
||||
>
|
||||
[{att.filename}]{idx < attachments.length - 1 ? ' ' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderTop={true}
|
||||
|
|
@ -1077,6 +1178,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
/>
|
||||
</Box>
|
||||
)}
|
||||
{/* Attachment hints - show when there are attachments and no suggestions visible */}
|
||||
{attachments.length > 0 && !shouldShowSuggestions && (
|
||||
<Box marginLeft={2} marginRight={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{isAttachmentMode
|
||||
? t('← → select, Delete to remove, ↓ to exit')
|
||||
: t('↑ to manage attachments')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ interface Shortcut {
|
|||
// Platform-specific key mappings
|
||||
const getNewlineKey = () =>
|
||||
process.platform === 'win32' ? 'ctrl+enter' : 'ctrl+j';
|
||||
const getPasteKey = () => (process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v');
|
||||
const getPasteKey = () => {
|
||||
if (process.platform === 'win32') return 'alt+v';
|
||||
return process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v';
|
||||
};
|
||||
const getExternalEditorKey = () =>
|
||||
process.platform === 'darwin' ? 'ctrl+x' : 'ctrl+x';
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export const MainContent = () => {
|
|||
return (
|
||||
<>
|
||||
<Static
|
||||
key={uiState.historyRemountKey}
|
||||
key={`${uiState.historyRemountKey}-${uiState.currentModel}`}
|
||||
items={[
|
||||
<AppHeader key="app-header" version={version} />,
|
||||
<DebugModeNotification key="debug-notification" />,
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
|
||||
|
||||
// Mock useKeypress hook
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('OpenAIKeyPrompt', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
it('should render the prompt correctly', () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('OpenAI Configuration Required');
|
||||
expect(lastFrame()).toContain(
|
||||
'https://bailian.console.aliyun.com/?tab=model#/api-key',
|
||||
);
|
||||
expect(lastFrame()).toContain(
|
||||
'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show the component with proper styling', () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('OpenAI Configuration Required');
|
||||
expect(output).toContain('API Key:');
|
||||
expect(output).toContain('Base URL:');
|
||||
expect(output).toContain('Model:');
|
||||
expect(output).toContain(
|
||||
'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle paste with control characters', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { stdin } = render(
|
||||
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
|
||||
// Simulate paste with control characters
|
||||
const pasteWithControlChars = '\x1b[200~sk-test123\x1b[201~';
|
||||
stdin.write(pasteWithControlChars);
|
||||
|
||||
// Wait a bit for processing
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// The component should have filtered out the control characters
|
||||
// and only kept 'sk-test123'
|
||||
expect(onSubmit).not.toHaveBeenCalled(); // Should not submit yet
|
||||
});
|
||||
});
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface OpenAIKeyPromptProps {
|
||||
onSubmit: (apiKey: string, baseUrl: string, model: string) => void;
|
||||
onCancel: () => void;
|
||||
defaultApiKey?: string;
|
||||
defaultBaseUrl?: string;
|
||||
defaultModel?: string;
|
||||
}
|
||||
|
||||
export const credentialSchema = z.object({
|
||||
apiKey: z.string().min(1, 'API key is required'),
|
||||
baseUrl: z
|
||||
.union([z.string().url('Base URL must be a valid URL'), z.literal('')])
|
||||
.optional(),
|
||||
model: z.string().min(1, 'Model must be a non-empty string').optional(),
|
||||
});
|
||||
|
||||
export type OpenAICredentials = z.infer<typeof credentialSchema>;
|
||||
|
||||
export function OpenAIKeyPrompt({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
defaultApiKey,
|
||||
defaultBaseUrl,
|
||||
defaultModel,
|
||||
}: OpenAIKeyPromptProps): React.JSX.Element {
|
||||
const [apiKey, setApiKey] = useState(defaultApiKey || '');
|
||||
const [baseUrl, setBaseUrl] = useState(defaultBaseUrl || '');
|
||||
const [model, setModel] = useState(defaultModel || '');
|
||||
const [currentField, setCurrentField] = useState<
|
||||
'apiKey' | 'baseUrl' | 'model'
|
||||
>('apiKey');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const validateAndSubmit = () => {
|
||||
setValidationError(null);
|
||||
|
||||
try {
|
||||
const validated = credentialSchema.parse({
|
||||
apiKey: apiKey.trim(),
|
||||
baseUrl: baseUrl.trim() || undefined,
|
||||
model: model.trim() || undefined,
|
||||
});
|
||||
|
||||
onSubmit(
|
||||
validated.apiKey,
|
||||
validated.baseUrl === '' ? '' : validated.baseUrl || '',
|
||||
validated.model || '',
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const errorMessage = error.errors
|
||||
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
||||
.join(', ');
|
||||
setValidationError(
|
||||
t('Invalid credentials: {{errorMessage}}', { errorMessage }),
|
||||
);
|
||||
} else {
|
||||
setValidationError(t('Failed to validate credentials'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
// Handle escape
|
||||
if (key.name === 'escape') {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Enter key
|
||||
if (key.name === 'return') {
|
||||
if (currentField === 'apiKey') {
|
||||
// 允许空 API key 跳转到下一个字段,让用户稍后可以返回修改
|
||||
setCurrentField('baseUrl');
|
||||
return;
|
||||
} else if (currentField === 'baseUrl') {
|
||||
setCurrentField('model');
|
||||
return;
|
||||
} else if (currentField === 'model') {
|
||||
// 只有在提交时才检查 API key 是否为空
|
||||
if (apiKey.trim()) {
|
||||
validateAndSubmit();
|
||||
} else {
|
||||
// 如果 API key 为空,回到 API key 字段
|
||||
setCurrentField('apiKey');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Tab key for field navigation
|
||||
if (key.name === 'tab') {
|
||||
if (currentField === 'apiKey') {
|
||||
setCurrentField('baseUrl');
|
||||
} else if (currentField === 'baseUrl') {
|
||||
setCurrentField('model');
|
||||
} else if (currentField === 'model') {
|
||||
setCurrentField('apiKey');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle arrow keys for field navigation
|
||||
if (key.name === 'up') {
|
||||
if (currentField === 'baseUrl') {
|
||||
setCurrentField('apiKey');
|
||||
} else if (currentField === 'model') {
|
||||
setCurrentField('baseUrl');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'down') {
|
||||
if (currentField === 'apiKey') {
|
||||
setCurrentField('baseUrl');
|
||||
} else if (currentField === 'baseUrl') {
|
||||
setCurrentField('model');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle backspace/delete
|
||||
if (key.name === 'backspace' || key.name === 'delete') {
|
||||
if (currentField === 'apiKey') {
|
||||
setApiKey((prev) => prev.slice(0, -1));
|
||||
} else if (currentField === 'baseUrl') {
|
||||
setBaseUrl((prev) => prev.slice(0, -1));
|
||||
} else if (currentField === 'model') {
|
||||
setModel((prev) => prev.slice(0, -1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle paste mode - if it's a paste event with content
|
||||
if (key.paste && key.sequence) {
|
||||
// 过滤粘贴相关的控制序列
|
||||
let cleanInput = key.sequence
|
||||
// 过滤 ESC 开头的控制序列(如 \u001b[200~、\u001b[201~ 等)
|
||||
.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '') // eslint-disable-line no-control-regex
|
||||
// 过滤粘贴开始标记 [200~
|
||||
.replace(/\[200~/g, '')
|
||||
// 过滤粘贴结束标记 [201~
|
||||
.replace(/\[201~/g, '')
|
||||
// 过滤单独的 [ 和 ~ 字符(可能是粘贴标记的残留)
|
||||
.replace(/^\[|~$/g, '');
|
||||
|
||||
// 再过滤所有不可见字符(ASCII < 32,除了回车换行)
|
||||
cleanInput = cleanInput
|
||||
.split('')
|
||||
.filter((ch) => ch.charCodeAt(0) >= 32)
|
||||
.join('');
|
||||
|
||||
if (cleanInput.length > 0) {
|
||||
if (currentField === 'apiKey') {
|
||||
setApiKey((prev) => prev + cleanInput);
|
||||
} else if (currentField === 'baseUrl') {
|
||||
setBaseUrl((prev) => prev + cleanInput);
|
||||
} else if (currentField === 'model') {
|
||||
setModel((prev) => prev + cleanInput);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle regular character input
|
||||
if (key.sequence && !key.ctrl && !key.meta) {
|
||||
// Filter control characters
|
||||
const cleanInput = key.sequence
|
||||
.split('')
|
||||
.filter((ch) => ch.charCodeAt(0) >= 32)
|
||||
.join('');
|
||||
|
||||
if (cleanInput.length > 0) {
|
||||
if (currentField === 'apiKey') {
|
||||
setApiKey((prev) => prev + cleanInput);
|
||||
} else if (currentField === 'baseUrl') {
|
||||
setBaseUrl((prev) => prev + cleanInput);
|
||||
} else if (currentField === 'model') {
|
||||
setModel((prev) => prev + cleanInput);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentBlue}>
|
||||
{t('OpenAI Configuration Required')}
|
||||
</Text>
|
||||
{validationError && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentRed}>{validationError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
{t(
|
||||
'Please enter your OpenAI configuration. You can get an API key from',
|
||||
)}{' '}
|
||||
<Text color={Colors.AccentBlue}>
|
||||
https://bailian.console.aliyun.com/?tab=model#/api-key
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="row">
|
||||
<Box width={12}>
|
||||
<Text
|
||||
color={currentField === 'apiKey' ? Colors.AccentBlue : Colors.Gray}
|
||||
>
|
||||
{t('API Key:')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text>
|
||||
{currentField === 'apiKey' ? '> ' : ' '}
|
||||
{apiKey || ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="row">
|
||||
<Box width={12}>
|
||||
<Text
|
||||
color={currentField === 'baseUrl' ? Colors.AccentBlue : Colors.Gray}
|
||||
>
|
||||
{t('Base URL:')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text>
|
||||
{currentField === 'baseUrl' ? '> ' : ' '}
|
||||
{baseUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="row">
|
||||
<Box width={12}>
|
||||
<Text
|
||||
color={currentField === 'model' ? Colors.AccentBlue : Colors.Gray}
|
||||
>
|
||||
{t('Model:')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text>
|
||||
{currentField === 'model' ? '> ' : ' '}
|
||||
{model}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
{t('Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
interface RetryCountdownMessageProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a retry countdown message in a dimmed/secondary style
|
||||
* to visually distinguish it from error messages.
|
||||
*/
|
||||
export const RetryCountdownMessage: React.FC<RetryCountdownMessageProps> = ({
|
||||
text,
|
||||
}) => {
|
||||
if (!text || text.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prefix = '↻ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.text.secondary}>{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text wrap="wrap" color={theme.text.secondary}>
|
||||
{text}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -20,6 +20,7 @@ import type {
|
|||
PlanResultDisplay,
|
||||
AnsiOutput,
|
||||
Config,
|
||||
McpToolProgressData,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { AgentExecutionDisplay } from '../subagents/index.js';
|
||||
import { PlanSummaryDisplay } from '../PlanSummaryDisplay.js';
|
||||
|
|
@ -113,6 +114,22 @@ const useResultDisplayRenderer = (
|
|||
};
|
||||
}
|
||||
|
||||
// Check for McpToolProgressData
|
||||
if (
|
||||
typeof resultDisplay === 'object' &&
|
||||
resultDisplay !== null &&
|
||||
'type' in resultDisplay &&
|
||||
resultDisplay.type === 'mcp_tool_progress'
|
||||
) {
|
||||
const progress = resultDisplay as McpToolProgressData;
|
||||
const msg = progress.message ?? `Progress: ${progress.progress}`;
|
||||
const totalStr = progress.total != null ? `/${progress.total}` : '';
|
||||
return {
|
||||
type: 'string',
|
||||
data: `⏳ [${progress.progress}${totalStr}] ${msg}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for AnsiOutput
|
||||
if (
|
||||
typeof resultDisplay === 'object' &&
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue