From 5b2176770e85acb16907059e318178be4ab3e139 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Wed, 10 Sep 2025 21:20:40 -0700 Subject: [PATCH] feat: add cached string width function for performance optimization (#7850) Co-authored-by: lifeloating --- .../ui/components/shared/text-buffer.test.ts | 63 +++ .../src/ui/components/shared/text-buffer.ts | 417 +++++++++++------- packages/cli/src/ui/utils/textUtils.ts | 66 ++- 3 files changed, 380 insertions(+), 166 deletions(-) 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 94d4ffb55b..2bd0296781 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -1393,6 +1393,69 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots expect(getBufferState(result).text).toBe('Pasted Text'); }); + it('should sanitize large text (>5000 chars) and strip unsafe characters', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => false }), + ); + const unsafeChars = '\x07\x08\x0B\x0C'; + const largeTextWithUnsafe = + 'safe text'.repeat(600) + unsafeChars + 'more safe text'; + + expect(largeTextWithUnsafe.length).toBeGreaterThan(5000); + + act(() => + result.current.handleInput({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: largeTextWithUnsafe, + }), + ); + + const resultText = getBufferState(result).text; + expect(resultText).not.toContain('\x07'); + expect(resultText).not.toContain('\x08'); + expect(resultText).not.toContain('\x0B'); + expect(resultText).not.toContain('\x0C'); + expect(resultText).toContain('safe text'); + expect(resultText).toContain('more safe text'); + }); + + it('should sanitize large ANSI text (>5000 chars) and strip escape codes', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => false }), + ); + const largeTextWithAnsi = + '\x1B[31m' + + 'red text'.repeat(800) + + '\x1B[0m' + + '\x1B[32m' + + 'green text'.repeat(200) + + '\x1B[0m'; + + expect(largeTextWithAnsi.length).toBeGreaterThan(5000); + + act(() => + result.current.handleInput({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: largeTextWithAnsi, + }), + ); + + const resultText = getBufferState(result).text; + expect(resultText).not.toContain('\x1B[31m'); + expect(resultText).not.toContain('\x1B[32m'); + expect(resultText).not.toContain('\x1B[0m'); + expect(resultText).toContain('red text'); + expect(resultText).toContain('green text'); + }); + it('should not strip popular emojis', () => { const { result } = renderHook(() => useTextBuffer({ viewport, isValidPath: () => false }), diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index bee0a2424d..b2d6f8b178 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -9,13 +9,13 @@ import fs from 'node:fs'; import os from 'node:os'; import pathMod from 'node:path'; import { useState, useCallback, useEffect, useMemo, useReducer } from 'react'; -import stringWidth from 'string-width'; import { unescapePath } from '@google/gemini-cli-core'; import { toCodePoints, cpLen, cpSlice, stripUnsafeCharacters, + getCachedStringWidth, } from '../../utils/textUtils.js'; import type { VimAction } from './vim-buffer-actions.js'; import { handleVimAction } from './vim-buffer-actions.js'; @@ -629,21 +629,23 @@ export function logicalPosToOffset( return offset; } -// Helper to calculate visual lines and map cursor positions -function calculateVisualLayout( - logicalLines: string[], - logicalCursor: [number, number], - viewportWidth: number, -): { +export interface VisualLayout { visualLines: string[]; - visualCursor: [number, number]; - logicalToVisualMap: Array>; // For each logical line, an array of [visualLineIndex, startColInLogical] - visualToLogicalMap: Array<[number, number]>; // For each visual line, its [logicalLineIndex, startColInLogical] -} { + // For each logical line, an array of [visualLineIndex, startColInLogical] + logicalToVisualMap: Array>; + // For each visual line, its [logicalLineIndex, startColInLogical] + visualToLogicalMap: Array<[number, number]>; +} + +// Calculates the visual wrapping of lines and the mapping between logical and visual coordinates. +// This is an expensive operation and should be memoized. +function calculateLayout( + logicalLines: string[], + viewportWidth: number, +): VisualLayout { const visualLines: string[] = []; const logicalToVisualMap: Array> = []; const visualToLogicalMap: Array<[number, number]> = []; - let currentVisualCursor: [number, number] = [0, 0]; logicalLines.forEach((logLine, logIndex) => { logicalToVisualMap[logIndex] = []; @@ -652,9 +654,6 @@ function calculateVisualLayout( logicalToVisualMap[logIndex].push([visualLines.length, 0]); visualToLogicalMap.push([logIndex, 0]); visualLines.push(''); - if (logIndex === logicalCursor[0] && logicalCursor[1] === 0) { - currentVisualCursor = [visualLines.length - 1, 0]; - } } else { // Non-empty logical line let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index) @@ -670,7 +669,7 @@ function calculateVisualLayout( // Iterate through code points to build the current visual line (chunk) for (let i = currentPosInLogLine; i < codePointsInLogLine.length; i++) { const char = codePointsInLogLine[i]; - const charVisualWidth = stringWidth(char); + const charVisualWidth = getCachedStringWidth(char); if (currentChunkVisualWidth + charVisualWidth > viewportWidth) { // Character would exceed viewport width @@ -754,30 +753,6 @@ function calculateVisualLayout( visualToLogicalMap.push([logIndex, currentPosInLogLine]); visualLines.push(currentChunk); - // Cursor mapping logic - // Note: currentPosInLogLine here is the start of the currentChunk within the logical line. - if (logIndex === logicalCursor[0]) { - const cursorLogCol = logicalCursor[1]; // This is a code point index - if ( - cursorLogCol >= currentPosInLogLine && - cursorLogCol < currentPosInLogLine + numCodePointsInChunk // Cursor is within this chunk - ) { - currentVisualCursor = [ - visualLines.length - 1, - cursorLogCol - currentPosInLogLine, // Visual col is also code point index within visual line - ]; - } else if ( - cursorLogCol === currentPosInLogLine + numCodePointsInChunk && - numCodePointsInChunk > 0 - ) { - // Cursor is exactly at the end of this non-empty chunk - currentVisualCursor = [ - visualLines.length - 1, - numCodePointsInChunk, - ]; - } - } - const logicalStartOfThisChunk = currentPosInLogLine; currentPosInLogLine += numCodePointsInChunk; @@ -793,23 +768,6 @@ function calculateVisualLayout( currentPosInLogLine++; } } - // After all chunks of a non-empty logical line are processed, - // if the cursor is at the very end of this logical line, update visual cursor. - if ( - logIndex === logicalCursor[0] && - logicalCursor[1] === codePointsInLogLine.length // Cursor at end of logical line - ) { - const lastVisualLineIdx = visualLines.length - 1; - if ( - lastVisualLineIdx >= 0 && - visualLines[lastVisualLineIdx] !== undefined - ) { - currentVisualCursor = [ - lastVisualLineIdx, - cpLen(visualLines[lastVisualLineIdx]), // Cursor at end of last visual line for this logical line - ]; - } - } } }); @@ -824,27 +782,67 @@ function calculateVisualLayout( logicalToVisualMap[0].push([0, 0]); visualToLogicalMap.push([0, 0]); } - currentVisualCursor = [0, 0]; - } - // Handle cursor at the very end of the text (after all processing) - // This case might be covered by the loop end condition now, but kept for safety. - else if ( - logicalCursor[0] === logicalLines.length - 1 && - logicalCursor[1] === cpLen(logicalLines[logicalLines.length - 1]) && - visualLines.length > 0 - ) { - const lastVisLineIdx = visualLines.length - 1; - currentVisualCursor = [lastVisLineIdx, cpLen(visualLines[lastVisLineIdx])]; } return { visualLines, - visualCursor: currentVisualCursor, logicalToVisualMap, visualToLogicalMap, }; } +// Calculates the visual cursor position based on a pre-calculated layout. +// This is a lightweight operation. +function calculateVisualCursorFromLayout( + layout: VisualLayout, + logicalCursor: [number, number], +): [number, number] { + const { logicalToVisualMap, visualLines } = layout; + const [logicalRow, logicalCol] = logicalCursor; + + const segmentsForLogicalLine = logicalToVisualMap[logicalRow]; + + if (!segmentsForLogicalLine || segmentsForLogicalLine.length === 0) { + // This can happen for an empty document. + return [0, 0]; + } + + // Find the segment where the logical column fits. + // The segments are sorted by startColInLogical. + let targetSegmentIndex = segmentsForLogicalLine.findIndex( + ([, startColInLogical], index) => { + const nextStartColInLogical = + index + 1 < segmentsForLogicalLine.length + ? segmentsForLogicalLine[index + 1][1] + : Infinity; + return ( + logicalCol >= startColInLogical && logicalCol < nextStartColInLogical + ); + }, + ); + + // If not found, it means the cursor is at the end of the logical line. + if (targetSegmentIndex === -1) { + if (logicalCol === 0) { + targetSegmentIndex = 0; + } else { + targetSegmentIndex = segmentsForLogicalLine.length - 1; + } + } + + const [visualRow, startColInLogical] = + segmentsForLogicalLine[targetSegmentIndex]; + const visualCol = logicalCol - startColInLogical; + + // The visual column should not exceed the length of the visual line. + const clampedVisualCol = Math.min( + visualCol, + cpLen(visualLines[visualRow] ?? ''), + ); + + return [visualRow, clampedVisualCol]; +} + // --- Start of reducer logic --- export interface TextBufferState { @@ -857,6 +855,8 @@ export interface TextBufferState { clipboard: string | null; selectionAnchor: [number, number] | null; viewportWidth: number; + viewportHeight: number; + visualLayout: VisualLayout; } const historyLimit = 100; @@ -884,6 +884,14 @@ export type TextBufferAction = dir: Direction; }; } + | { + type: 'set_cursor'; + payload: { + cursorRow: number; + cursorCol: number; + preferredCol: number | null; + }; + } | { type: 'delete' } | { type: 'delete_word_left' } | { type: 'delete_word_right' } @@ -903,7 +911,7 @@ export type TextBufferAction = } | { type: 'move_to_offset'; payload: { offset: number } } | { type: 'create_undo_snapshot' } - | { type: 'set_viewport_width'; payload: number } + | { type: 'set_viewport'; payload: { width: number; height: number } } | { type: 'vim_delete_word_forward'; payload: { count: number } } | { type: 'vim_delete_word_backward'; payload: { count: number } } | { type: 'vim_delete_word_end'; payload: { count: number } } @@ -941,7 +949,7 @@ export type TextBufferAction = | { type: 'vim_move_to_line'; payload: { lineNumber: number } } | { type: 'vim_escape_insert_mode' }; -export function textBufferReducer( +function textBufferReducerLogic( state: TextBufferState, action: TextBufferAction, ): TextBufferState { @@ -1047,80 +1055,120 @@ export function textBufferReducer( }; } - case 'set_viewport_width': { - if (action.payload === state.viewportWidth) { + case 'set_viewport': { + const { width, height } = action.payload; + if (width === state.viewportWidth && height === state.viewportHeight) { return state; } - return { ...state, viewportWidth: action.payload }; + return { + ...state, + viewportWidth: width, + viewportHeight: height, + }; } case 'move': { const { dir } = action.payload; - const { lines, cursorRow, cursorCol, viewportWidth } = state; - const visualLayout = calculateVisualLayout( - lines, - [cursorRow, cursorCol], - viewportWidth, - ); - const { visualLines, visualCursor, visualToLogicalMap } = visualLayout; + const { cursorRow, cursorCol, lines, visualLayout, preferredCol } = state; - let newVisualRow = visualCursor[0]; - let newVisualCol = visualCursor[1]; - let newPreferredCol = state.preferredCol; + // Visual movements + if ( + dir === 'left' || + dir === 'right' || + dir === 'up' || + dir === 'down' || + dir === 'home' || + dir === 'end' + ) { + const visualCursor = calculateVisualCursorFromLayout(visualLayout, [ + cursorRow, + cursorCol, + ]); + const { visualLines, visualToLogicalMap } = visualLayout; - const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? ''); + let newVisualRow = visualCursor[0]; + let newVisualCol = visualCursor[1]; + let newPreferredCol = preferredCol; - switch (dir) { - case 'left': - newPreferredCol = null; - if (newVisualCol > 0) { - newVisualCol--; - } else if (newVisualRow > 0) { - newVisualRow--; - newVisualCol = cpLen(visualLines[newVisualRow] ?? ''); - } - break; - case 'right': - newPreferredCol = null; - if (newVisualCol < currentVisLineLen) { - newVisualCol++; - } else if (newVisualRow < visualLines.length - 1) { - newVisualRow++; + const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? ''); + + switch (dir) { + case 'left': + newPreferredCol = null; + if (newVisualCol > 0) { + newVisualCol--; + } else if (newVisualRow > 0) { + newVisualRow--; + newVisualCol = cpLen(visualLines[newVisualRow] ?? ''); + } + break; + case 'right': + newPreferredCol = null; + if (newVisualCol < currentVisLineLen) { + newVisualCol++; + } else if (newVisualRow < visualLines.length - 1) { + newVisualRow++; + newVisualCol = 0; + } + break; + case 'up': + if (newVisualRow > 0) { + if (newPreferredCol === null) newPreferredCol = newVisualCol; + newVisualRow--; + newVisualCol = clamp( + newPreferredCol, + 0, + cpLen(visualLines[newVisualRow] ?? ''), + ); + } + break; + case 'down': + if (newVisualRow < visualLines.length - 1) { + if (newPreferredCol === null) newPreferredCol = newVisualCol; + newVisualRow++; + newVisualCol = clamp( + newPreferredCol, + 0, + cpLen(visualLines[newVisualRow] ?? ''), + ); + } + break; + case 'home': + newPreferredCol = null; newVisualCol = 0; - } - break; - case 'up': - if (newVisualRow > 0) { - if (newPreferredCol === null) newPreferredCol = newVisualCol; - newVisualRow--; - newVisualCol = clamp( - newPreferredCol, - 0, - cpLen(visualLines[newVisualRow] ?? ''), + break; + case 'end': + newPreferredCol = null; + newVisualCol = currentVisLineLen; + break; + default: { + const exhaustiveCheck: never = dir; + console.error( + `Unknown visual movement direction: ${exhaustiveCheck}`, ); + return state; } - break; - case 'down': - if (newVisualRow < visualLines.length - 1) { - if (newPreferredCol === null) newPreferredCol = newVisualCol; - newVisualRow++; - newVisualCol = clamp( - newPreferredCol, + } + + if (visualToLogicalMap[newVisualRow]) { + const [logRow, logStartCol] = visualToLogicalMap[newVisualRow]; + return { + ...state, + cursorRow: logRow, + cursorCol: clamp( + logStartCol + newVisualCol, 0, - cpLen(visualLines[newVisualRow] ?? ''), - ); - } - break; - case 'home': - newPreferredCol = null; - newVisualCol = 0; - break; - case 'end': - newPreferredCol = null; - newVisualCol = currentVisLineLen; - break; + cpLen(lines[logRow] ?? ''), + ), + preferredCol: newPreferredCol, + }; + } + return state; + } + + // Logical movements + switch (dir) { case 'wordLeft': { - const { cursorRow, cursorCol, lines } = state; if (cursorCol === 0 && cursorRow === 0) return state; let newCursorRow = cursorRow; @@ -1156,7 +1204,6 @@ export function textBufferReducer( }; } case 'wordRight': { - const { cursorRow, cursorCol, lines } = state; if ( cursorRow === lines.length - 1 && cursorCol === cpLen(lines[cursorRow] ?? '') @@ -1186,23 +1233,15 @@ export function textBufferReducer( }; } default: - break; + return state; } + } - if (visualToLogicalMap[newVisualRow]) { - const [logRow, logStartCol] = visualToLogicalMap[newVisualRow]; - return { - ...state, - cursorRow: logRow, - cursorCol: clamp( - logStartCol + newVisualCol, - 0, - cpLen(state.lines[logRow] ?? ''), - ), - preferredCol: newPreferredCol, - }; - } - return state; + case 'set_cursor': { + return { + ...state, + ...action.payload, + }; } case 'delete': { @@ -1214,14 +1253,22 @@ export function textBufferReducer( newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, cursorCol + 1); - return { ...nextState, lines: newLines, preferredCol: null }; + return { + ...nextState, + lines: newLines, + preferredCol: null, + }; } else if (cursorRow < lines.length - 1) { const nextState = pushUndoLocal(state); const nextLineContent = currentLine(cursorRow + 1); const newLines = [...nextState.lines]; newLines[cursorRow] = lineContent + nextLineContent; newLines.splice(cursorRow + 1, 1); - return { ...nextState, lines: newLines, preferredCol: null }; + return { + ...nextState, + lines: newLines, + preferredCol: null, + }; } return state; } @@ -1303,7 +1350,10 @@ export function textBufferReducer( const nextState = pushUndoLocal(state); const newLines = [...nextState.lines]; newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol); - return { ...nextState, lines: newLines }; + return { + ...nextState, + lines: newLines, + }; } else if (cursorRow < lines.length - 1) { // Act as a delete const nextState = pushUndoLocal(state); @@ -1311,7 +1361,11 @@ export function textBufferReducer( const newLines = [...nextState.lines]; newLines[cursorRow] = lineContent + nextLineContent; newLines.splice(cursorRow + 1, 1); - return { ...nextState, lines: newLines, preferredCol: null }; + return { + ...nextState, + lines: newLines, + preferredCol: null, + }; } return state; } @@ -1441,6 +1495,25 @@ export function textBufferReducer( } } +export function textBufferReducer( + state: TextBufferState, + action: TextBufferAction, +): TextBufferState { + const newState = textBufferReducerLogic(state, action); + + if ( + newState.lines !== state.lines || + newState.viewportWidth !== state.viewportWidth + ) { + return { + ...newState, + visualLayout: calculateLayout(newState.lines, newState.viewportWidth), + }; + } + + return newState; +} + // --- End of reducer logic --- export function useTextBuffer({ @@ -1459,6 +1532,10 @@ export function useTextBuffer({ lines.length === 0 ? [''] : lines, initialCursorOffset, ); + const visualLayout = calculateLayout( + lines.length === 0 ? [''] : lines, + viewport.width, + ); return { lines: lines.length === 0 ? [''] : lines, cursorRow: initialCursorRow, @@ -1469,21 +1546,29 @@ export function useTextBuffer({ clipboard: null, selectionAnchor: null, viewportWidth: viewport.width, + viewportHeight: viewport.height, + visualLayout, }; - }, [initialText, initialCursorOffset, viewport.width]); + }, [initialText, initialCursorOffset, viewport.width, viewport.height]); const [state, dispatch] = useReducer(textBufferReducer, initialState); - const { lines, cursorRow, cursorCol, preferredCol, selectionAnchor } = state; + const { + lines, + cursorRow, + cursorCol, + preferredCol, + selectionAnchor, + visualLayout, + } = state; const text = useMemo(() => lines.join('\n'), [lines]); - const visualLayout = useMemo( - () => - calculateVisualLayout(lines, [cursorRow, cursorCol], state.viewportWidth), - [lines, cursorRow, cursorCol, state.viewportWidth], + const visualCursor = useMemo( + () => calculateVisualCursorFromLayout(visualLayout, [cursorRow, cursorCol]), + [visualLayout, cursorRow, cursorCol], ); - const { visualLines, visualCursor } = visualLayout; + const { visualLines } = visualLayout; const [visualScrollRow, setVisualScrollRow] = useState(0); @@ -1494,8 +1579,11 @@ export function useTextBuffer({ }, [text, onChange]); useEffect(() => { - dispatch({ type: 'set_viewport_width', payload: viewport.width }); - }, [viewport.width]); + dispatch({ + type: 'set_viewport', + payload: { width: viewport.width, height: viewport.height }, + }); + }, [viewport.width, viewport.height]); // Update visual scroll (vertical) useEffect(() => { @@ -1568,9 +1656,12 @@ export function useTextBuffer({ dispatch({ type: 'delete' }); }, []); - const move = useCallback((dir: Direction): void => { - dispatch({ type: 'move', payload: { dir } }); - }, []); + const move = useCallback( + (dir: Direction): void => { + dispatch({ type: 'move', payload: { dir } }); + }, + [dispatch], + ); const undo = useCallback((): void => { dispatch({ type: 'undo' }); diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index ac3d3398fb..98f690eae3 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -6,6 +6,7 @@ import stripAnsi from 'strip-ansi'; import { stripVTControlCharacters } from 'node:util'; +import stringWidth from 'string-width'; /** * Calculates the maximum width of a multi-line ASCII art string. @@ -26,10 +27,39 @@ export const getAsciiArtWidth = (asciiArt: string): number => { * code units so that surrogate‑pair emoji count as one "column".) * ---------------------------------------------------------------------- */ +// Cache for code points to reduce GC pressure +const codePointsCache = new Map(); +const MAX_STRING_LENGTH_TO_CACHE = 1000; + export function toCodePoints(str: string): string[] { - // [...str] or Array.from both iterate by UTF‑32 code point, handling - // surrogate pairs correctly. - return Array.from(str); + // ASCII fast path - check if all chars are ASCII (0-127) + let isAscii = true; + for (let i = 0; i < str.length; i++) { + if (str.charCodeAt(i) > 127) { + isAscii = false; + break; + } + } + if (isAscii) { + return str.split(''); + } + + // Cache short strings + if (str.length <= MAX_STRING_LENGTH_TO_CACHE) { + const cached = codePointsCache.get(str); + if (cached) { + return cached; + } + } + + const result = Array.from(str); + + // Cache result (unlimited like Ink) + if (str.length <= MAX_STRING_LENGTH_TO_CACHE) { + codePointsCache.set(str, result); + } + + return result; } export function cpLen(str: string): number { @@ -86,3 +116,33 @@ export function stripUnsafeCharacters(str: string): string { }) .join(''); } + +// String width caching for performance optimization +const stringWidthCache = new Map(); + +/** + * Cached version of stringWidth function for better performance + * Follows Ink's approach with unlimited cache (no eviction) + */ +export const getCachedStringWidth = (str: string): number => { + // ASCII printable chars have width 1 + if (/^[\x20-\x7E]*$/.test(str)) { + return str.length; + } + + if (stringWidthCache.has(str)) { + return stringWidthCache.get(str)!; + } + + const width = stringWidth(str); + stringWidthCache.set(str, width); + + return width; +}; + +/** + * Clear the string width cache + */ +export const clearStringWidthCache = (): void => { + stringWidthCache.clear(); +};