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 {