From 75e4f492ab98c6e8d9dce8d979b3e8b6e94c7e5e Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Wed, 21 Jan 2026 21:09:24 -0500 Subject: [PATCH] feat: replace large text pastes with [Pasted Text: X lines] placeholder (#16422) --- .../src/ui/components/InputPrompt.test.tsx | 102 ++++++- .../cli/src/ui/components/InputPrompt.tsx | 25 +- .../ui/components/shared/text-buffer.test.ts | 232 +++++++++++++++- .../src/ui/components/shared/text-buffer.ts | 261 +++++++++++++++++- .../shared/vim-buffer-actions.test.ts | 5 +- packages/cli/src/ui/hooks/vim.test.tsx | 1 + packages/cli/src/ui/utils/highlight.ts | 21 +- 7 files changed, 619 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 7fa6476a49..7bed916f52 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -176,6 +176,8 @@ describe('InputPrompt', () => { visualToTransformedMap: [0], transformationsByLine: [], getOffset: vi.fn().mockReturnValue(0), + pastedContent: {}, + addPastedContent: vi.fn().mockReturnValue('[Pasted Text: 6 lines]'), } as unknown as TextBuffer; mockShellHistory = { @@ -248,6 +250,8 @@ describe('InputPrompt', () => { checking: false, }); + vi.mocked(clipboardy.read).mockResolvedValue(''); + props = { buffer: mockBuffer, onSubmit: vi.fn(), @@ -632,10 +636,9 @@ describe('InputPrompt', () => { await waitFor(() => { expect(clipboardy.read).toHaveBeenCalled(); - expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalledWith( - expect.any(Number), - expect.any(Number), + expect(mockBuffer.insert).toHaveBeenCalledWith( 'pasted text', + expect.objectContaining({ paste: true }), ); }); unmount(); @@ -1718,6 +1721,99 @@ describe('InputPrompt', () => { }); }); + describe('large paste placeholder', () => { + it('should handle large clipboard paste (lines > 5) by calling buffer.insert', async () => { + vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false); + const largeText = '1\n2\n3\n4\n5\n6'; + vi.mocked(clipboardy.read).mockResolvedValue(largeText); + + const { stdin, unmount } = renderWithProviders( + , + ); + + await act(async () => { + stdin.write('\x16'); // Ctrl+V + }); + + await waitFor(() => { + expect(mockBuffer.insert).toHaveBeenCalledWith( + largeText, + expect.objectContaining({ paste: true }), + ); + }); + + unmount(); + }); + + it('should handle large clipboard paste (chars > 500) by calling buffer.insert', async () => { + vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false); + const largeText = 'a'.repeat(501); + vi.mocked(clipboardy.read).mockResolvedValue(largeText); + + const { stdin, unmount } = renderWithProviders( + , + ); + + await act(async () => { + stdin.write('\x16'); // Ctrl+V + }); + + await waitFor(() => { + expect(mockBuffer.insert).toHaveBeenCalledWith( + largeText, + expect.objectContaining({ paste: true }), + ); + }); + + unmount(); + }); + + it('should handle normal clipboard paste by calling buffer.insert', async () => { + vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false); + const smallText = 'hello world'; + vi.mocked(clipboardy.read).mockResolvedValue(smallText); + + const { stdin, unmount } = renderWithProviders( + , + ); + + await act(async () => { + stdin.write('\x16'); // Ctrl+V + }); + + await waitFor(() => { + expect(mockBuffer.insert).toHaveBeenCalledWith( + smallText, + expect.objectContaining({ paste: true }), + ); + }); + + unmount(); + }); + + it('should replace placeholder with actual content on submit', async () => { + // Setup buffer to have the placeholder + const largeText = '1\n2\n3\n4\n5\n6'; + const id = '[Pasted Text: 6 lines]'; + mockBuffer.text = `Check this: ${id}`; + mockBuffer.pastedContent = { [id]: largeText }; + + const { stdin, unmount } = renderWithProviders( + , + ); + + await act(async () => { + stdin.write('\r'); // Enter + }); + + await waitFor(() => { + expect(props.onSubmit).toHaveBeenCalledWith(`Check this: ${largeText}`); + }); + + unmount(); + }); + }); + describe('paste auto-submission protection', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 064bb60d31..ea9d51824d 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -12,7 +12,10 @@ import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js'; import { theme } from '../semantic-colors.js'; import { useInputHistory } from '../hooks/useInputHistory.js'; import type { TextBuffer } from './shared/text-buffer.js'; -import { logicalPosToOffset } from './shared/text-buffer.js'; +import { + logicalPosToOffset, + PASTED_TEXT_PLACEHOLDER_REGEX, +} from './shared/text-buffer.js'; import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js'; import chalk from 'chalk'; import stringWidth from 'string-width'; @@ -221,13 +224,22 @@ export const InputPrompt: React.FC = ({ const handleSubmitAndClear = useCallback( (submittedValue: string) => { + let processedValue = submittedValue; + if (buffer.pastedContent) { + // Replace placeholders like [Pasted Text: 6 lines] with actual content + processedValue = processedValue.replace( + PASTED_TEXT_PLACEHOLDER_REGEX, + (match) => buffer.pastedContent[match] || match, + ); + } + if (shellModeActive) { - shellHistory.addCommandToHistory(submittedValue); + shellHistory.addCommandToHistory(processedValue); } // 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(processedValue); resetCompletionState(); resetReverseSearchCompletionState(); }, @@ -360,8 +372,7 @@ export const InputPrompt: React.FC = ({ stdout.write('\x1b]52;c;?\x07'); } else { const textToInsert = await clipboardy.read(); - const offset = buffer.getOffset(); - buffer.replaceRangeByOffset(offset, offset, textToInsert); + buffer.insert(textToInsert, { paste: true }); } } catch (error) { debugLogger.error('Error handling paste:', error); @@ -1191,7 +1202,9 @@ export const InputPrompt: React.FC = ({ } const color = - seg.type === 'command' || seg.type === 'file' + seg.type === 'command' || + seg.type === 'file' || + seg.type === 'paste' ? theme.text.accent : theme.text.primary; diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index 308e7ea89e..ad4832cb36 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -55,6 +55,7 @@ const initialState: TextBufferState = { viewportHeight: 24, transformationsByLine: [[]], visualLayout: defaultVisualLayout, + pastedContent: {}, }; describe('textBufferReducer', () => { @@ -153,6 +154,19 @@ describe('textBufferReducer', () => { }); }); + describe('add_pasted_content action', () => { + it('should add content to pastedContent Record', () => { + const action: TextBufferAction = { + type: 'add_pasted_content', + payload: { id: '[Pasted Text: 6 lines]', text: 'large content' }, + }; + const state = textBufferReducer(initialState, action); + expect(state.pastedContent).toEqual({ + '[Pasted Text: 6 lines]': 'large content', + }); + }); + }); + describe('backspace action', () => { it('should remove a character', () => { const stateWithText: TextBufferState = { @@ -184,6 +198,155 @@ describe('textBufferReducer', () => { }); }); + describe('atomic placeholder deletion', () => { + describe('paste placeholders', () => { + it('backspace at end of paste placeholder removes entire placeholder', () => { + const placeholder = '[Pasted Text: 6 lines]'; + const stateWithPlaceholder: TextBufferState = { + ...initialState, + lines: [placeholder], + cursorRow: 0, + cursorCol: placeholder.length, // cursor at end + pastedContent: { + [placeholder]: 'line1\nline2\nline3\nline4\nline5\nline6', + }, + }; + const action: TextBufferAction = { type: 'backspace' }; + const state = textBufferReducer(stateWithPlaceholder, action); + expect(state).toHaveOnlyValidCharacters(); + expect(state.lines).toEqual(['']); + expect(state.cursorCol).toBe(0); + // pastedContent should be cleaned up + expect(state.pastedContent[placeholder]).toBeUndefined(); + }); + + it('delete at start of paste placeholder removes entire placeholder', () => { + const placeholder = '[Pasted Text: 6 lines]'; + const stateWithPlaceholder: TextBufferState = { + ...initialState, + lines: [placeholder], + cursorRow: 0, + cursorCol: 0, // cursor at start + pastedContent: { + [placeholder]: 'line1\nline2\nline3\nline4\nline5\nline6', + }, + }; + const action: TextBufferAction = { type: 'delete' }; + const state = textBufferReducer(stateWithPlaceholder, action); + expect(state).toHaveOnlyValidCharacters(); + expect(state.lines).toEqual(['']); + expect(state.cursorCol).toBe(0); + // pastedContent should be cleaned up + expect(state.pastedContent[placeholder]).toBeUndefined(); + }); + + it('backspace inside paste placeholder does normal deletion', () => { + const placeholder = '[Pasted Text: 6 lines]'; + const stateWithPlaceholder: TextBufferState = { + ...initialState, + lines: [placeholder], + cursorRow: 0, + cursorCol: 10, // cursor in middle + pastedContent: { + [placeholder]: 'line1\nline2\nline3\nline4\nline5\nline6', + }, + }; + const action: TextBufferAction = { type: 'backspace' }; + const state = textBufferReducer(stateWithPlaceholder, action); + expect(state).toHaveOnlyValidCharacters(); + // Should only delete one character + expect(state.lines[0].length).toBe(placeholder.length - 1); + expect(state.cursorCol).toBe(9); + // pastedContent should NOT be cleaned up (placeholder is broken) + expect(state.pastedContent[placeholder]).toBeDefined(); + }); + }); + + describe('image placeholders', () => { + it('backspace at end of image path removes entire path', () => { + const imagePath = '@test.png'; + const transformations = calculateTransformationsForLine(imagePath); + const stateWithImage: TextBufferState = { + ...initialState, + lines: [imagePath], + cursorRow: 0, + cursorCol: imagePath.length, // cursor at end + transformationsByLine: [transformations], + }; + const action: TextBufferAction = { type: 'backspace' }; + const state = textBufferReducer(stateWithImage, action); + expect(state).toHaveOnlyValidCharacters(); + expect(state.lines).toEqual(['']); + expect(state.cursorCol).toBe(0); + }); + + it('delete at start of image path removes entire path', () => { + const imagePath = '@test.png'; + const transformations = calculateTransformationsForLine(imagePath); + const stateWithImage: TextBufferState = { + ...initialState, + lines: [imagePath], + cursorRow: 0, + cursorCol: 0, // cursor at start + transformationsByLine: [transformations], + }; + const action: TextBufferAction = { type: 'delete' }; + const state = textBufferReducer(stateWithImage, action); + expect(state).toHaveOnlyValidCharacters(); + expect(state.lines).toEqual(['']); + expect(state.cursorCol).toBe(0); + }); + + it('backspace inside image path does normal deletion', () => { + const imagePath = '@test.png'; + const transformations = calculateTransformationsForLine(imagePath); + const stateWithImage: TextBufferState = { + ...initialState, + lines: [imagePath], + cursorRow: 0, + cursorCol: 5, // cursor in middle + transformationsByLine: [transformations], + }; + const action: TextBufferAction = { type: 'backspace' }; + const state = textBufferReducer(stateWithImage, action); + expect(state).toHaveOnlyValidCharacters(); + // Should only delete one character + expect(state.lines[0].length).toBe(imagePath.length - 1); + expect(state.cursorCol).toBe(4); + }); + }); + + describe('undo behavior', () => { + it('undo after placeholder deletion restores everything', () => { + const placeholder = '[Pasted Text: 6 lines]'; + const pasteContent = 'line1\nline2\nline3\nline4\nline5\nline6'; + const stateWithPlaceholder: TextBufferState = { + ...initialState, + lines: [placeholder], + cursorRow: 0, + cursorCol: placeholder.length, + pastedContent: { [placeholder]: pasteContent }, + }; + + // Delete the placeholder + const deleteAction: TextBufferAction = { type: 'backspace' }; + const stateAfterDelete = textBufferReducer( + stateWithPlaceholder, + deleteAction, + ); + expect(stateAfterDelete.lines).toEqual(['']); + expect(stateAfterDelete.pastedContent[placeholder]).toBeUndefined(); + + // Undo should restore + const undoAction: TextBufferAction = { type: 'undo' }; + const stateAfterUndo = textBufferReducer(stateAfterDelete, undoAction); + expect(stateAfterUndo).toHaveOnlyValidCharacters(); + expect(stateAfterUndo.lines).toEqual([placeholder]); + expect(stateAfterUndo.pastedContent[placeholder]).toBe(pasteContent); + }); + }); + }); + describe('undo/redo actions', () => { it('should undo and redo a change', () => { // 1. Insert text @@ -548,6 +711,64 @@ describe('useTextBuffer', () => { expect(state.cursor).toEqual([0, 6]); }); + it('insert: should use placeholder for large text paste', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => false }), + ); + const largeText = '1\n2\n3\n4\n5\n6'; + act(() => result.current.insert(largeText, { paste: true })); + const state = getBufferState(result); + expect(state.text).toBe('[Pasted Text: 6 lines]'); + expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe( + largeText, + ); + }); + + it('insert: should NOT use placeholder for large text if NOT a paste', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => false }), + ); + const largeText = '1\n2\n3\n4\n5\n6'; + act(() => result.current.insert(largeText, { paste: false })); + const state = getBufferState(result); + expect(state.text).toBe(largeText); + }); + + it('insert: should clean up pastedContent when placeholder is deleted', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => false }), + ); + const largeText = '1\n2\n3\n4\n5\n6'; + act(() => result.current.insert(largeText, { paste: true })); + expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe( + largeText, + ); + + // Delete the placeholder using setText + act(() => result.current.setText('')); + expect(Object.keys(result.current.pastedContent)).toHaveLength(0); + }); + + it('insert: should clean up pastedContent when placeholder is removed via atomic backspace', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => false }), + ); + const largeText = '1\n2\n3\n4\n5\n6'; + act(() => result.current.insert(largeText, { paste: true })); + expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe( + largeText, + ); + + // Single backspace at end of placeholder removes entire placeholder + act(() => { + result.current.backspace(); + }); + + expect(getBufferState(result).text).toBe(''); + // pastedContent is cleaned up when placeholder is deleted atomically + expect(Object.keys(result.current.pastedContent)).toHaveLength(0); + }); + it('newline: should create a new line and move cursor', () => { const { result } = renderHook(() => useTextBuffer({ @@ -1350,9 +1571,14 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots }); const state = getBufferState(result); - // Check that the text is the result of three concatenations. - expect(state.lines).toStrictEqual( - (longText + longText + longText).split('\n'), + // Check that the text is the result of three concatenations of placeholders. + // All three use the same placeholder because React batches the state updates + // within the same act() block, so pastedContent isn't updated between inserts. + expect(state.lines).toStrictEqual([ + '[Pasted Text: 8 lines][Pasted Text: 8 lines][Pasted Text: 8 lines]', + ]); + expect(result.current.pastedContent['[Pasted Text: 8 lines]']).toBe( + longText, ); const expectedCursorPos = offsetToLogicalPos( state.text, diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 4c5174f438..6fd3b1810f 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -34,6 +34,13 @@ import type { VimAction } from './vim-buffer-actions.js'; import { handleVimAction } from './vim-buffer-actions.js'; import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js'; +const LARGE_PASTE_LINE_THRESHOLD = 5; +const LARGE_PASTE_CHAR_THRESHOLD = 500; + +// Regex to match paste placeholders like [Pasted Text: 6 lines] or [Pasted Text: 501 chars #2] +export const PASTED_TEXT_PLACEHOLDER_REGEX = + /\[Pasted Text: \d+ (?:lines|chars)(?: #\d+)?\]/g; + export type Direction = | 'left' | 'right' @@ -578,6 +585,7 @@ interface UndoHistoryEntry { lines: string[]; cursorRow: number; cursorCol: number; + pastedContent: Record; } function calculateInitialCursorPosition( @@ -784,6 +792,88 @@ export function getTransformUnderCursor( return null; } +/** + * Represents an atomic placeholder that should be deleted as a unit. + * Extensible to support future placeholder types. + */ +interface AtomicPlaceholder { + start: number; // Start position in logical text + end: number; // End position in logical text + type: 'paste' | 'image'; // Type for cleanup logic + id?: string; // For paste placeholders: the pastedContent key +} + +/** + * Find atomic placeholder at cursor for backspace (cursor at end). + * Checks all placeholder types in priority order. + */ +function findAtomicPlaceholderForBackspace( + line: string, + cursorCol: number, + transformations: Transformation[], +): AtomicPlaceholder | null { + // 1. Check paste placeholders (text-based) + const pasteRegex = new RegExp(PASTED_TEXT_PLACEHOLDER_REGEX.source, 'g'); + let match; + while ((match = pasteRegex.exec(line)) !== null) { + const start = match.index; + const end = start + match[0].length; + if (cursorCol === end) { + return { start, end, type: 'paste', id: match[0] }; + } + } + + // 2. Check image transformations (logical bounds) + for (const transform of transformations) { + if (cursorCol === transform.logEnd) { + return { + start: transform.logStart, + end: transform.logEnd, + type: 'image', + }; + } + } + + return null; +} + +/** + * Find atomic placeholder at cursor for delete (cursor at start). + */ +function findAtomicPlaceholderForDelete( + line: string, + cursorCol: number, + transformations: Transformation[], +): AtomicPlaceholder | null { + // 1. Check paste placeholders + const pasteRegex = new RegExp(PASTED_TEXT_PLACEHOLDER_REGEX.source, 'g'); + let match; + while ((match = pasteRegex.exec(line)) !== null) { + const start = match.index; + if (cursorCol === start) { + return { + start, + end: start + match[0].length, + type: 'paste', + id: match[0], + }; + } + } + + // 2. Check image transformations + for (const transform of transformations) { + if (cursorCol === transform.logStart) { + return { + start: transform.logStart, + end: transform.logEnd, + type: 'image', + }; + } + } + + return null; +} + export function calculateTransformedLine( logLine: string, logIndex: number, @@ -1184,6 +1274,7 @@ export interface TextBufferState { viewportWidth: number; viewportHeight: number; visualLayout: VisualLayout; + pastedContent: Record; } const historyLimit = 100; @@ -1193,6 +1284,7 @@ export const pushUndo = (currentState: TextBufferState): TextBufferState => { lines: [...currentState.lines], cursorRow: currentState.cursorRow, cursorCol: currentState.cursorCol, + pastedContent: { ...currentState.pastedContent }, }; const newStack = [...currentState.undoStack, snapshot]; if (newStack.length > historyLimit) { @@ -1204,6 +1296,7 @@ export const pushUndo = (currentState: TextBufferState): TextBufferState => { export type TextBufferAction = | { type: 'set_text'; payload: string; pushToUndo?: boolean } | { type: 'insert'; payload: string } + | { type: 'add_pasted_content'; payload: { id: string; text: string } } | { type: 'backspace' } | { type: 'move'; @@ -1308,6 +1401,7 @@ function textBufferReducerLogic( cursorRow: lastNewLineIndex, cursorCol: cpLen(lines[lastNewLineIndex] ?? ''), preferredCol: null, + pastedContent: action.payload === '' ? {} : nextState.pastedContent, }; } @@ -1365,7 +1459,62 @@ function textBufferReducerLogic( }; } + case 'add_pasted_content': { + const { id, text } = action.payload; + return { + ...state, + pastedContent: { + ...state.pastedContent, + [id]: text, + }, + }; + } + case 'backspace': { + const { cursorRow, cursorCol, lines, transformationsByLine } = state; + + // Early return if at start of buffer + if (cursorCol === 0 && cursorRow === 0) return state; + + // Check if cursor is at end of an atomic placeholder + const transformations = transformationsByLine[cursorRow] ?? []; + const placeholder = findAtomicPlaceholderForBackspace( + lines[cursorRow], + cursorCol, + transformations, + ); + + if (placeholder) { + const nextState = pushUndoLocal(state); + const newLines = [...nextState.lines]; + newLines[cursorRow] = + cpSlice(newLines[cursorRow], 0, placeholder.start) + + cpSlice(newLines[cursorRow], placeholder.end); + + // Recalculate transformations for the modified line + const newTransformations = [...nextState.transformationsByLine]; + newTransformations[cursorRow] = calculateTransformationsForLine( + newLines[cursorRow], + ); + + // Clean up pastedContent if this was a paste placeholder + let newPastedContent = nextState.pastedContent; + if (placeholder.type === 'paste' && placeholder.id) { + const { [placeholder.id]: _, ...remaining } = nextState.pastedContent; + newPastedContent = remaining; + } + + return { + ...nextState, + lines: newLines, + cursorCol: placeholder.start, + preferredCol: null, + transformationsByLine: newTransformations, + pastedContent: newPastedContent, + }; + } + + // Standard backspace logic const nextState = pushUndoLocal(state); const newLines = [...nextState.lines]; let newCursorRow = nextState.cursorRow; @@ -1373,8 +1522,6 @@ function textBufferReducerLogic( const currentLine = (r: number) => newLines[r] ?? ''; - if (newCursorCol === 0 && newCursorRow === 0) return state; - if (newCursorCol > 0) { const lineContent = currentLine(newCursorRow); newLines[newCursorRow] = @@ -1584,7 +1731,47 @@ function textBufferReducerLogic( } case 'delete': { - const { cursorRow, cursorCol, lines } = state; + const { cursorRow, cursorCol, lines, transformationsByLine } = state; + + // Check if cursor is at start of an atomic placeholder + const transformations = transformationsByLine[cursorRow] ?? []; + const placeholder = findAtomicPlaceholderForDelete( + lines[cursorRow], + cursorCol, + transformations, + ); + + if (placeholder) { + const nextState = pushUndoLocal(state); + const newLines = [...nextState.lines]; + newLines[cursorRow] = + cpSlice(newLines[cursorRow], 0, placeholder.start) + + cpSlice(newLines[cursorRow], placeholder.end); + + // Recalculate transformations for the modified line + const newTransformations = [...nextState.transformationsByLine]; + newTransformations[cursorRow] = calculateTransformationsForLine( + newLines[cursorRow], + ); + + // Clean up pastedContent if this was a paste placeholder + let newPastedContent = nextState.pastedContent; + if (placeholder.type === 'paste' && placeholder.id) { + const { [placeholder.id]: _, ...remaining } = nextState.pastedContent; + newPastedContent = remaining; + } + + return { + ...nextState, + lines: newLines, + // cursorCol stays the same + preferredCol: null, + transformationsByLine: newTransformations, + pastedContent: newPastedContent, + }; + } + + // Standard delete logic const lineContent = currentLine(cursorRow); if (cursorCol < currentLineLen(cursorRow)) { const nextState = pushUndoLocal(state); @@ -1734,6 +1921,7 @@ function textBufferReducerLogic( lines: [...state.lines], cursorRow: state.cursorRow, cursorCol: state.cursorCol, + pastedContent: { ...state.pastedContent }, }; return { ...state, @@ -1751,6 +1939,7 @@ function textBufferReducerLogic( lines: [...state.lines], cursorRow: state.cursorRow, cursorCol: state.cursorCol, + pastedContent: { ...state.pastedContent }, }; return { ...state, @@ -1926,6 +2115,7 @@ export function useTextBuffer({ viewportWidth: viewport.width, viewportHeight: viewport.height, visualLayout, + pastedContent: {}, }; }, [initialText, initialCursorOffset, viewport.width, viewport.height]); @@ -1942,6 +2132,7 @@ export function useTextBuffer({ selectionAnchor, visualLayout, transformationsByLine, + pastedContent, } = state; const text = useMemo(() => lines.join('\n'), [lines]); @@ -1995,20 +2186,64 @@ export function useTextBuffer({ } }, [visualCursor, visualScrollRow, viewport, visualLines.length]); + const addPastedContent = useCallback( + (content: string, lineCount: number): string => { + // content is already normalized by the caller + const base = + lineCount > LARGE_PASTE_LINE_THRESHOLD + ? `[Pasted Text: ${lineCount} lines]` + : `[Pasted Text: ${content.length} chars]`; + + let id = base; + let suffix = 2; + while (pastedContent[id]) { + id = base.replace(']', ` #${suffix}]`); + suffix++; + } + + dispatch({ + type: 'add_pasted_content', + payload: { id, text: content }, + }); + return id; + }, + [pastedContent], + ); + const insert = useCallback( (ch: string, { paste = false }: { paste?: boolean } = {}): void => { - if (!singleLine && /[\n\r]/.test(ch)) { - dispatch({ type: 'insert', payload: ch }); + if (typeof ch !== 'string') { return; } + // Normalize line endings once at the entry point for pastes + const text = paste ? ch.replace(/\r\n|\r/g, '\n') : ch; + + if (paste) { + const lineCount = text.split('\n').length; + if ( + lineCount > LARGE_PASTE_LINE_THRESHOLD || + text.length > LARGE_PASTE_CHAR_THRESHOLD + ) { + const id = addPastedContent(text, lineCount); + dispatch({ type: 'insert', payload: id }); + return; + } + } + + if (!singleLine && /[\n\r]/.test(text)) { + dispatch({ type: 'insert', payload: text }); + return; + } + + let textToInsert = text; const minLengthToInferAsDragDrop = 3; if ( - ch.length >= minLengthToInferAsDragDrop && + text.length >= minLengthToInferAsDragDrop && !shellModeActive && paste ) { - let potentialPath = ch.trim(); + let potentialPath = text.trim(); const quoteMatch = potentialPath.match(/^'(.*)'$/); if (quoteMatch) { potentialPath = quoteMatch[1]; @@ -2018,12 +2253,12 @@ export function useTextBuffer({ const processed = parsePastedPaths(potentialPath, isValidPath); if (processed) { - ch = processed; + textToInsert = processed; } } let currentText = ''; - for (const char of toCodePoints(ch)) { + for (const char of toCodePoints(textToInsert)) { if (char.codePointAt(0) === 127) { if (currentText.length > 0) { dispatch({ type: 'insert', payload: currentText }); @@ -2038,7 +2273,7 @@ export function useTextBuffer({ dispatch({ type: 'insert', payload: currentText }); } }, - [isValidPath, shellModeActive, singleLine], + [isValidPath, shellModeActive, singleLine, addPastedContent], ); const newline = useCallback((): void => { @@ -2435,6 +2670,7 @@ export function useTextBuffer({ cursor: [cursorRow, cursorCol], preferredCol, selectionAnchor, + pastedContent, allVisualLines: visualLines, viewportVisualLines: renderedVisualLines, @@ -2447,6 +2683,7 @@ export function useTextBuffer({ visualLayout, setText, insert, + addPastedContent, newline, backspace, del, @@ -2506,6 +2743,7 @@ export function useTextBuffer({ cursorCol, preferredCol, selectionAnchor, + pastedContent, visualLines, renderedVisualLines, visualCursor, @@ -2517,6 +2755,7 @@ export function useTextBuffer({ visualLayout, setText, insert, + addPastedContent, newline, backspace, del, @@ -2584,6 +2823,7 @@ export interface TextBuffer { */ preferredCol: number | null; // Preferred visual column selectionAnchor: [number, number] | null; // Logical selection anchor + pastedContent: Record; // Visual state (handles wrapping) allVisualLines: string[]; // All visual lines for the current text and viewport width. @@ -2621,6 +2861,7 @@ export interface TextBuffer { * Insert a single character or string without newlines. */ insert: (ch: string, opts?: { paste?: boolean }) => void; + addPastedContent: (text: string, lineCount: number) => string; newline: () => void; backspace: () => void; del: () => void; diff --git a/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts b/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts index d6778c16a3..d258b06cc9 100644 --- a/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts +++ b/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts @@ -34,6 +34,7 @@ const createTestState = ( viewportHeight: 24, transformationsByLine: [[]], visualLayout: defaultVisualLayout, + pastedContent: {}, }); describe('vim-buffer-actions', () => { @@ -904,7 +905,9 @@ describe('vim-buffer-actions', () => { it('should preserve undo stack in operations', () => { const state = createTestState(['hello'], 0, 0); - state.undoStack = [{ lines: ['previous'], cursorRow: 0, cursorCol: 0 }]; + state.undoStack = [ + { lines: ['previous'], cursorRow: 0, cursorCol: 0, pastedContent: {} }, + ]; const action = { type: 'vim_delete_char' as const, diff --git a/packages/cli/src/ui/hooks/vim.test.tsx b/packages/cli/src/ui/hooks/vim.test.tsx index 88b7ebb415..372f5f03e4 100644 --- a/packages/cli/src/ui/hooks/vim.test.tsx +++ b/packages/cli/src/ui/hooks/vim.test.tsx @@ -67,6 +67,7 @@ const createMockTextBufferState = ( transformedToLogicalMaps: lines.map(() => []), visualToTransformedMap: [], }, + pastedContent: {}, ...partial, }; }; diff --git a/packages/cli/src/ui/utils/highlight.ts b/packages/cli/src/ui/utils/highlight.ts index 4d827ee192..a6166204b0 100644 --- a/packages/cli/src/ui/utils/highlight.ts +++ b/packages/cli/src/ui/utils/highlight.ts @@ -4,21 +4,28 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + type Transformation, + PASTED_TEXT_PLACEHOLDER_REGEX, +} from '../components/shared/text-buffer.js'; import { LRUCache } from 'mnemonist'; -import type { Transformation } from '../components/shared/text-buffer.js'; import { cpLen, cpSlice } from './textUtils.js'; import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../constants.js'; export type HighlightToken = { text: string; - type: 'default' | 'command' | 'file'; + type: 'default' | 'command' | 'file' | 'paste'; }; -// Matches slash commands (e.g., /help) and @ references (files or MCP resource URIs). +// Matches slash commands (e.g., /help), @ references (files or MCP resource URIs), +// and large paste placeholders (e.g., [Pasted Text: 6 lines]). // The @ pattern uses a negated character class to support URIs like `@file:///example.txt` // which contain colons. It matches any character except delimiters: comma, whitespace, // semicolon, common punctuation, and brackets. -const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[^,\s;!?()[\]{}])+)/g; +const HIGHLIGHT_REGEX = new RegExp( + `(^/[a-zA-Z0-9_-]+|@(?:\\\\ |[^,\\s;!?()\\[\\]{}])+|${PASTED_TEXT_PLACEHOLDER_REGEX.source})`, + 'g', +); const highlightCache = new LRUCache( LRU_BUFFER_PERF_CACHE_LIMIT, @@ -66,7 +73,11 @@ export function parseInputForHighlighting( tokens.push({ text: text.slice(last, matchIndex), type: 'default' }); } - const type = fullMatch.startsWith('/') ? 'command' : 'file'; + const type = fullMatch.startsWith('/') + ? 'command' + : fullMatch.startsWith('@') + ? 'file' + : 'paste'; if (type === 'command' && index !== 0) { tokens.push({ text: fullMatch, type: 'default' }); } else {