feat(paste): add large paste placeholder and fix enter-submit on macOS

- Add large paste placeholder feature: when pasting text > 1000 chars,
  show a placeholder like '[Pasted Content 1500 chars]' instead of the
  full content. The full text is stored and expanded on submit.

- Fix enter-submit on macOS: only apply recentPasteTime protection when
  pasteWorkaround is enabled (Windows or Node < 20). On macOS/Linux with
  modern Node, bracketed paste markers work reliably so the protection is
  unnecessary and caused the first Enter after paste to be ignored.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
tanzhenxin 2026-02-04 15:18:20 +08:00
parent 2ed4ae773e
commit c7b681ef5d
2 changed files with 58 additions and 6 deletions

View file

@ -38,6 +38,7 @@ import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js'; import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js'; import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js';
import { useKeypressContext } from '../contexts/KeypressContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js'; import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
export interface InputPromptProps { export interface InputPromptProps {
buffer: TextBuffer; buffer: TextBuffer;
@ -111,6 +112,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const isShellFocused = useShellFocusState(); const isShellFocused = useShellFocusState();
const uiState = useUIState(); const uiState = useUIState();
const uiActions = useUIActions(); const uiActions = useUIActions();
const { pasteWorkaround } = useKeypressContext();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0); const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false); const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@ -118,6 +120,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const [recentPasteTime, setRecentPasteTime] = useState<number | null>(null); const [recentPasteTime, setRecentPasteTime] = useState<number | null>(null);
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null); const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Large paste placeholder handling
const LARGE_PASTE_CHAR_THRESHOLD = 1000;
const [pendingPastes, setPendingPastes] = useState<Map<string, string>>(
new Map(),
);
const largePasteCounters = useRef<Map<number, number>>(new Map());
const [dirs, setDirs] = useState<readonly string[]>( const [dirs, setDirs] = useState<readonly string[]>(
config.getWorkspaceContext().getDirectories(), config.getWorkspaceContext().getDirectories(),
); );
@ -186,6 +195,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} }
}, [showEscapePrompt, onEscapePromptChange]); }, [showEscapePrompt, onEscapePromptChange]);
// Helper to generate unique placeholder for large pastes
const nextLargePastePlaceholder = useCallback((charCount: number): string => {
const base = `[Pasted Content ${charCount} chars]`;
const currentCount = largePasteCounters.current.get(charCount) || 0;
const nextCount = currentCount + 1;
largePasteCounters.current.set(charCount, nextCount);
if (nextCount === 1) {
return base;
}
return `${base} #${nextCount}`;
}, []);
// Clear escape prompt timer on unmount // Clear escape prompt timer on unmount
useEffect( useEffect(
() => () => { () => () => {
@ -204,10 +225,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (shellModeActive) { if (shellModeActive) {
shellHistory.addCommandToHistory(submittedValue); shellHistory.addCommandToHistory(submittedValue);
} }
// Expand any large paste placeholders to their full content before submitting
let finalValue = submittedValue;
if (pendingPastes.size > 0) {
pendingPastes.forEach((fullContent, placeholder) => {
finalValue = finalValue.replace(placeholder, fullContent);
});
setPendingPastes(new Map());
}
// Clear the buffer *before* calling onSubmit to prevent potential re-submission // 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. // if onSubmit triggers a re-render while the buffer still holds the old value.
buffer.setText(''); buffer.setText('');
onSubmit(submittedValue); onSubmit(finalValue);
resetCompletionState(); resetCompletionState();
resetReverseSearchCompletionState(); resetReverseSearchCompletionState();
}, },
@ -218,6 +247,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
shellModeActive, shellModeActive,
shellHistory, shellHistory,
resetReverseSearchCompletionState, resetReverseSearchCompletionState,
pendingPastes,
], ],
); );
@ -330,8 +360,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
pasteTimeoutRef.current = null; pasteTimeoutRef.current = null;
}, 500); }, 500);
// Ensure we never accidentally interpret paste as regular input. // Handle large pastes by showing a placeholder
buffer.handleInput(key); const pasted = key.sequence.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const charCount = [...pasted].length; // Proper Unicode char count
if (charCount > LARGE_PASTE_CHAR_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; return;
} }
@ -619,8 +663,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (keyMatchers[Command.SUBMIT](key)) { if (keyMatchers[Command.SUBMIT](key)) {
if (buffer.text.trim()) { if (buffer.text.trim()) {
// Check if a paste operation occurred recently to prevent accidental auto-submission // Check if a paste operation occurred recently to prevent accidental auto-submission.
if (recentPasteTime !== null) { // 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 // Paste occurred recently, ignore this submit to prevent auto-execution
return; return;
} }
@ -719,6 +765,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
showShortcuts, showShortcuts,
uiState, uiState,
uiActions, uiActions,
pasteWorkaround,
LARGE_PASTE_CHAR_THRESHOLD,
nextLargePastePlaceholder,
], ],
); );

View file

@ -60,6 +60,7 @@ export type KeypressHandler = (key: Key) => void;
interface KeypressContextValue { interface KeypressContextValue {
subscribe: (handler: KeypressHandler) => void; subscribe: (handler: KeypressHandler) => void;
unsubscribe: (handler: KeypressHandler) => void; unsubscribe: (handler: KeypressHandler) => void;
pasteWorkaround: boolean;
} }
const KeypressContext = createContext<KeypressContextValue | undefined>( const KeypressContext = createContext<KeypressContextValue | undefined>(
@ -799,7 +800,9 @@ export function KeypressProvider({
]); ]);
return ( return (
<KeypressContext.Provider value={{ subscribe, unsubscribe }}> <KeypressContext.Provider
value={{ subscribe, unsubscribe, pasteWorkaround }}
>
{children} {children}
</KeypressContext.Provider> </KeypressContext.Provider>
); );