mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 19:52:02 +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
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue