Merge pull request #1713 from QwenLM/fix/enter-submit

feat(paste): add large paste placeholder and fix enter-submit on macOS
This commit is contained in:
pomelo 2026-02-06 17:44:14 +08:00 committed by GitHub
commit a6885ccb4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 444 additions and 7 deletions

View file

@ -2133,6 +2133,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 '';

View file

@ -38,6 +38,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';
const debugLogger = createDebugLogger('INPUT_PROMPT');
@ -89,6 +90,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,
@ -113,6 +118,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);
@ -120,6 +126,38 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const [recentPasteTime, setRecentPasteTime] = useState<number | null>(null);
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 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(),
);
@ -188,6 +226,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(
() => () => {
@ -203,13 +260,31 @@ 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);
}
// 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(submittedValue);
onSubmit(finalValue);
resetCompletionState();
resetReverseSearchCompletionState();
},
@ -220,6 +295,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
shellModeActive,
shellHistory,
resetReverseSearchCompletionState,
pendingPastes,
],
);
@ -332,8 +408,26 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
pasteTimeoutRef.current = null;
}, 500);
// Ensure we never accidentally interpret paste as regular input.
buffer.handleInput(key);
// 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;
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;
}
@ -621,8 +715,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;
}
@ -691,6 +787,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);
},
@ -721,6 +865,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
showShortcuts,
uiState,
uiActions,
pasteWorkaround,
nextLargePastePlaceholder,
pendingPastes,
parsePlaceholder,
freePlaceholderId,
],
);