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 56f8e240..c5295fc1 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -585,6 +585,68 @@ describe('useTextBuffer', () => { expect(getBufferState(result).text).toBe(''); }); + it('should handle multiple delete characters in one input', () => { + const { result } = renderHook(() => + useTextBuffer({ + initialText: 'abcde', + viewport, + isValidPath: () => false, + }), + ); + act(() => result.current.move('end')); // cursor at the end + expect(getBufferState(result).cursor).toEqual([0, 5]); + + act(() => { + result.current.applyOperations([ + { type: 'backspace' }, + { type: 'backspace' }, + { type: 'backspace' }, + ]); + }); + expect(getBufferState(result).text).toBe('ab'); + expect(getBufferState(result).cursor).toEqual([0, 2]); + }); + + it('should handle inserts that contain delete characters ', () => { + const { result } = renderHook(() => + useTextBuffer({ + initialText: 'abcde', + viewport, + isValidPath: () => false, + }), + ); + act(() => result.current.move('end')); // cursor at the end + expect(getBufferState(result).cursor).toEqual([0, 5]); + + act(() => { + result.current.applyOperations([ + { type: 'insert', payload: '\x7f\x7f\x7f' }, + ]); + }); + expect(getBufferState(result).text).toBe('ab'); + expect(getBufferState(result).cursor).toEqual([0, 2]); + }); + + it('should handle inserts with a mix of regular and delete characters ', () => { + const { result } = renderHook(() => + useTextBuffer({ + initialText: 'abcde', + viewport, + isValidPath: () => false, + }), + ); + act(() => result.current.move('end')); // cursor at the end + expect(getBufferState(result).cursor).toEqual([0, 5]); + + act(() => { + result.current.applyOperations([ + { type: 'insert', payload: '\x7fI\x7f\x7fNEW' }, + ]); + }); + expect(getBufferState(result).text).toBe('abcNEW'); + expect(getBufferState(result).cursor).toEqual([0, 6]); + }); + it('should handle arrow keys for movement', () => { const { result } = renderHook(() => useTextBuffer({ @@ -632,9 +694,13 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots ); // Simulate pasting the long text multiple times - act(() => result.current.insertStr(longText)); - act(() => result.current.insertStr(longText)); - act(() => result.current.insertStr(longText)); + act(() => { + result.current.applyOperations([ + { type: 'insert', payload: longText }, + { type: 'insert', payload: longText }, + { type: 'insert', payload: longText }, + ]); + }); const state = getBufferState(result); // Check that the text is the result of three concatenations. @@ -792,6 +858,53 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots expect(state.cursor).toEqual([0, 3]); // After 'X' }); }); + + describe('Input Sanitization', () => { + it('should strip ANSI escape codes from input', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => false }), + ); + const textWithAnsi = '\x1B[31mHello\x1B[0m'; + act(() => result.current.handleInput(textWithAnsi, {})); + expect(getBufferState(result).text).toBe('Hello'); + }); + + it('should strip control characters from input', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => false }), + ); + const textWithControlChars = 'H\x07e\x08l\x0Bl\x0Co'; // BELL, BACKSPACE, VT, FF + act(() => result.current.handleInput(textWithControlChars, {})); + expect(getBufferState(result).text).toBe('Hello'); + }); + + it('should strip mixed ANSI and control characters from input', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => false }), + ); + const textWithMixed = '\u001B[4mH\u001B[0mello'; + act(() => result.current.handleInput(textWithMixed, {})); + expect(getBufferState(result).text).toBe('Hello'); + }); + + it('should not strip standard characters or newlines', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => false }), + ); + const validText = 'Hello World\nThis is a test.'; + act(() => result.current.handleInput(validText, {})); + expect(getBufferState(result).text).toBe(validText); + }); + + it('should sanitize pasted text via handleInput', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => false }), + ); + const pastedText = '\u001B[4mPasted\u001B[4m Text'; + act(() => result.current.handleInput(pastedText, {})); + expect(getBufferState(result).text).toBe('Pasted Text'); + }); + }); }); describe('offsetToLogicalPos', () => { diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index e5957c7d..ef21d00a 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -23,6 +23,13 @@ export type Direction = | 'home' | 'end'; +// TODO(jacob314): refactor so all edit operations to be part of this list. +// This makes it robust for clients to apply multiple edit operations without +// having to carefully reason about how React manages state. +type UpdateOperation = + | { type: 'insert'; payload: string } + | { type: 'backspace' }; + // Simple helper for word‑wise ops. function isWordChar(ch: string | undefined): boolean { if (ch === undefined) { @@ -31,6 +38,28 @@ function isWordChar(ch: string | undefined): boolean { return !/[\s,.;!?]/.test(ch); } +/** + * Strip characters that can break terminal rendering. + * + * Strip ANSI escape codes and control characters except for line breaks. + * Control characters such as delete break terminal UI rendering. + */ +function stripUnsafeCharacters(str: string): string { + const stripped = stripAnsi(str); + return toCodePoints(stripAnsi(stripped)) + .filter((char) => { + if (char.length > 1) return false; + const code = char.codePointAt(0); + if (code === undefined) { + return false; + } + const isUnsafe = + code === 127 || (code <= 31 && code !== 13 && code !== 10); + return !isUnsafe; + }) + .join(''); +} + export interface Viewport { height: number; width: number; @@ -524,14 +553,15 @@ export function useTextBuffer({ if (str === '') return false; pushUndo(); - const normalised = str.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + let normalised = str.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + normalised = stripUnsafeCharacters(normalised); + const parts = normalised.split('\n'); const newLines = [...lines]; const lineContent = currentLine(cursorRow); const before = cpSlice(lineContent, 0, cursorCol); const after = cpSlice(lineContent, cursorCol); - newLines[cursorRow] = before + parts[0]; if (parts.length > 1) { @@ -556,6 +586,101 @@ export function useTextBuffer({ [pushUndo, cursorRow, cursorCol, lines, currentLine, setPreferredCol], ); + const applyOperations = useCallback( + (ops: UpdateOperation[]) => { + if (ops.length === 0) return; + + const expandedOps: UpdateOperation[] = []; + for (const op of ops) { + if (op.type === 'insert') { + let currentText = ''; + for (const char of toCodePoints(op.payload)) { + if (char.codePointAt(0) === 127) { + // \x7f + if (currentText.length > 0) { + expandedOps.push({ type: 'insert', payload: currentText }); + currentText = ''; + } + expandedOps.push({ type: 'backspace' }); + } else { + currentText += char; + } + } + if (currentText.length > 0) { + expandedOps.push({ type: 'insert', payload: currentText }); + } + } else { + expandedOps.push(op); + } + } + + if (expandedOps.length === 0) { + return; + } + + pushUndo(); // Snapshot before applying batch of updates + + const newLines = [...lines]; + let newCursorRow = cursorRow; + let newCursorCol = cursorCol; + + const currentLine = (r: number) => newLines[r] ?? ''; + + for (const op of expandedOps) { + if (op.type === 'insert') { + const str = stripUnsafeCharacters( + op.payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), + ); + const parts = str.split('\n'); + const lineContent = currentLine(newCursorRow); + const before = cpSlice(lineContent, 0, newCursorCol); + const after = cpSlice(lineContent, newCursorCol); + newLines[newCursorRow] = before + parts[0]; + + if (parts.length > 1) { + const remainingParts = parts.slice(1); + const lastPartOriginal = remainingParts.pop() ?? ''; + newLines.splice(newCursorRow + 1, 0, ...remainingParts); + newLines.splice( + newCursorRow + parts.length - 1, + 0, + lastPartOriginal + after, + ); + newCursorRow = newCursorRow + parts.length - 1; + newCursorCol = cpLen(lastPartOriginal); + } else { + newCursorCol = cpLen(before) + cpLen(parts[0]); + } + } else if (op.type === 'backspace') { + if (newCursorCol === 0 && newCursorRow === 0) continue; + + if (newCursorCol > 0) { + const lineContent = currentLine(newCursorRow); + newLines[newCursorRow] = + cpSlice(lineContent, 0, newCursorCol - 1) + + cpSlice(lineContent, newCursorCol); + newCursorCol--; + } else if (newCursorRow > 0) { + const prevLineContent = currentLine(newCursorRow - 1); + const currentLineContentVal = currentLine(newCursorRow); + const newCol = cpLen(prevLineContent); + newLines[newCursorRow - 1] = + prevLineContent + currentLineContentVal; + newLines.splice(newCursorRow, 1); + newCursorRow--; + newCursorCol = newCol; + } + } + } + + setLines(newLines); + setCursorRow(newCursorRow); + setCursorCol(newCursorCol); + setPreferredCol(null); + }, + [lines, cursorRow, cursorCol, pushUndo, setPreferredCol], + ); + const insert = useCallback( (ch: string): void => { if (/[\n\r]/.test(ch)) { @@ -563,7 +688,8 @@ export function useTextBuffer({ return; } dbg('insert', { ch, beforeCursor: [cursorRow, cursorCol] }); - pushUndo(); + + ch = stripUnsafeCharacters(ch); // Arbitrary threshold to avoid false positives on normal key presses // while still detecting virtually all reasonable length file paths. @@ -585,77 +711,21 @@ export function useTextBuffer({ ch = `@${potentialPath}`; } } - - setLines((prevLines) => { - const newLines = [...prevLines]; - const lineContent = currentLine(cursorRow); - newLines[cursorRow] = - cpSlice(lineContent, 0, cursorCol) + - ch + - cpSlice(lineContent, cursorCol); - return newLines; - }); - setCursorCol((prev) => prev + cpLen(ch)); // Use cpLen for character length - setPreferredCol(null); + applyOperations([{ type: 'insert', payload: ch }]); }, - [ - pushUndo, - cursorRow, - cursorCol, - currentLine, - insertStr, - setPreferredCol, - isValidPath, - ], + [applyOperations, cursorRow, cursorCol, isValidPath, insertStr], ); const newline = useCallback((): void => { dbg('newline', { beforeCursor: [cursorRow, cursorCol] }); - pushUndo(); - setLines((prevLines) => { - const newLines = [...prevLines]; - const l = currentLine(cursorRow); - const before = cpSlice(l, 0, cursorCol); - const after = cpSlice(l, cursorCol); - newLines[cursorRow] = before; - newLines.splice(cursorRow + 1, 0, after); - return newLines; - }); - setCursorRow((prev) => prev + 1); - setCursorCol(0); - setPreferredCol(null); - }, [pushUndo, cursorRow, cursorCol, currentLine, setPreferredCol]); + applyOperations([{ type: 'insert', payload: '\n' }]); + }, [applyOperations, cursorRow, cursorCol]); const backspace = useCallback((): void => { dbg('backspace', { beforeCursor: [cursorRow, cursorCol] }); if (cursorCol === 0 && cursorRow === 0) return; - - pushUndo(); - if (cursorCol > 0) { - setLines((prevLines) => { - const newLines = [...prevLines]; - const lineContent = currentLine(cursorRow); - newLines[cursorRow] = - cpSlice(lineContent, 0, cursorCol - 1) + - cpSlice(lineContent, cursorCol); - return newLines; - }); - setCursorCol((prev) => prev - 1); - } else if (cursorRow > 0) { - const prevLineContent = currentLine(cursorRow - 1); - const currentLineContentVal = currentLine(cursorRow); - const newCol = cpLen(prevLineContent); - setLines((prevLines) => { - const newLines = [...prevLines]; - newLines[cursorRow - 1] = prevLineContent + currentLineContentVal; - newLines.splice(cursorRow, 1); - return newLines; - }); - setCursorRow((prev) => prev - 1); - setCursorCol(newCol); - } - setPreferredCol(null); - }, [pushUndo, cursorRow, cursorCol, currentLine, setPreferredCol]); + applyOperations([{ type: 'backspace' }]); + }, [applyOperations, cursorRow, cursorCol]); const del = useCallback((): void => { dbg('delete', { beforeCursor: [cursorRow, cursorCol] }); @@ -1224,10 +1294,7 @@ export function useTextBuffer({ backspace(); else if (key['delete'] || (key['ctrl'] && input === 'd')) del(); else if (input && !key['ctrl'] && !key['meta']) { - // Heuristic for paste: if input is longer than 1 char (potential paste) - // strip ANSI escape codes. - const cleanedInput = input.length > 1 ? stripAnsi(input) : input; - insert(cleanedInput); + insert(input); } const textChanged = text !== beforeText; @@ -1306,7 +1373,6 @@ export function useTextBuffer({ setText, insert, - insertStr, newline, backspace, del, @@ -1323,6 +1389,8 @@ export function useTextBuffer({ handleInput, openInExternalEditor, + applyOperations, + copy: useCallback(() => { if (!selectionAnchor) return null; const [ar, ac] = selectionAnchor; @@ -1386,7 +1454,6 @@ export interface TextBuffer { * Insert a single character or string without newlines. */ insert: (ch: string) => void; - insertStr: (str: string) => boolean; newline: () => void; backspace: () => void; del: () => void; @@ -1466,4 +1533,7 @@ export interface TextBuffer { replacementText: string, ) => boolean; moveToOffset(offset: number): void; + + // Batch updates + applyOperations: (ops: UpdateOperation[]) => void; }