Merge branch 'main' into feature/arena-agent-collaboration

This commit is contained in:
tanzhenxin 2026-02-28 22:01:10 +08:00
commit 74b342623c
172 changed files with 12390 additions and 3258 deletions

View file

@ -22,7 +22,11 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { ApprovalMode, 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,
@ -923,6 +1007,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}
@ -1079,6 +1180,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>
)}
</>
);
};