From 29699274bb0e8f70b9bedad40ca2d03739318853 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Thu, 21 Aug 2025 16:43:56 -0700 Subject: [PATCH] feat(settings) support editing string settings. (#6732) --- .../src/ui/components/SettingsDialog.test.tsx | 302 +++++++++++++----- .../cli/src/ui/components/SettingsDialog.tsx | 113 ++++--- .../src/ui/components/shared/text-buffer.ts | 54 +--- packages/cli/src/ui/utils/textUtils.ts | 48 +++ 4 files changed, 361 insertions(+), 156 deletions(-) diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index 76a12e57..a1674661 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -27,11 +27,61 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SettingsDialog } from './SettingsDialog.js'; import { LoadedSettings } from '../../config/settings.js'; import { VimModeProvider } from '../contexts/VimModeContext.js'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; // Mock the VimModeContext const mockToggleVimEnabled = vi.fn(); const mockSetVimMode = vi.fn(); +const createMockSettings = ( + userSettings = {}, + systemSettings = {}, + workspaceSettings = {}, +) => + new LoadedSettings( + { + settings: { customThemes: {}, mcpServers: {}, ...systemSettings }, + path: '/system/settings.json', + }, + { + settings: { + customThemes: {}, + mcpServers: {}, + ...userSettings, + }, + path: '/user/settings.json', + }, + { + settings: { customThemes: {}, mcpServers: {}, ...workspaceSettings }, + path: '/workspace/settings.json', + }, + [], + true, + ); + +vi.mock('../contexts/SettingsContext.js', async () => { + const actual = await vi.importActual('../contexts/SettingsContext.js'); + let settings = createMockSettings({ 'a.string.setting': 'initial' }); + return { + ...actual, + useSettings: () => ({ + settings, + setSetting: (key: string, value: string) => { + settings = createMockSettings({ [key]: value }); + }, + getSettingDefinition: (key: string) => { + if (key === 'a.string.setting') { + return { + type: 'string', + description: 'A string setting', + }; + } + return undefined; + }, + }), + }; +}); + vi.mock('../contexts/VimModeContext.js', async () => { const actual = await vi.importActual('../contexts/VimModeContext.js'); return { @@ -53,28 +103,6 @@ vi.mock('../../utils/settingsUtils.js', async () => { }; }); -// Mock the useKeypress hook to avoid context issues -interface Key { - name: string; - ctrl: boolean; - meta: boolean; - shift: boolean; - paste: boolean; - sequence: string; -} - -// Variables for keypress simulation (not currently used) -// let currentKeypressHandler: ((key: Key) => void) | null = null; -// let isKeypressActive = false; - -vi.mock('../hooks/useKeypress.js', () => ({ - useKeypress: vi.fn( - (_handler: (key: Key) => void, _options: { isActive: boolean }) => { - // Mock implementation - simplified for test stability - }, - ), -})); - // Helper function to simulate key presses (commented out for now) // const simulateKeyPress = async (keyData: Partial & { name: string }) => { // if (currentKeypressHandler) { @@ -149,7 +177,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { lastFrame } = render( - , + + + , ); const output = lastFrame(); @@ -163,7 +193,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { lastFrame } = render( - , + + + , ); const output = lastFrame(); @@ -176,7 +208,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { lastFrame } = render( - , + + + , ); const output = lastFrame(); @@ -191,7 +225,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Press down arrow @@ -207,7 +243,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // First go down, then up @@ -224,7 +262,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Navigate with vim keys @@ -241,7 +281,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Try to go up from first item @@ -259,7 +301,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Press Enter to toggle current setting @@ -274,7 +318,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Press Space to toggle current setting @@ -289,7 +335,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Navigate to vim mode setting and toggle it @@ -308,7 +356,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Switch to scope focus @@ -327,7 +377,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { lastFrame, unmount } = render( - , + + + , ); // Wait for initial render @@ -352,11 +404,13 @@ describe('SettingsDialog', () => { const onRestartRequest = vi.fn(); const { unmount } = render( - {}} - onRestartRequest={onRestartRequest} - />, + + {}} + onRestartRequest={onRestartRequest} + /> + , ); // This test would need to trigger a restart-required setting change @@ -371,11 +425,13 @@ describe('SettingsDialog', () => { const onRestartRequest = vi.fn(); const { stdin, unmount } = render( - {}} - onRestartRequest={onRestartRequest} - />, + + {}} + onRestartRequest={onRestartRequest} + /> + , ); // Press 'r' key (this would only work if restart prompt is showing) @@ -393,7 +449,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { lastFrame, unmount } = render( - , + + + , ); // Wait for initial render @@ -418,7 +476,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Switch to scope selector @@ -442,7 +502,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { lastFrame } = render( - , + + + , ); // Should show user scope values initially @@ -459,7 +521,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Try to toggle a setting (this might trigger vim mode toggle) @@ -477,7 +541,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Toggle a setting @@ -499,7 +565,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Navigate down many times to test scrolling @@ -519,7 +587,9 @@ describe('SettingsDialog', () => { const { stdin, unmount } = render( - + + + , ); @@ -542,7 +612,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { lastFrame } = render( - , + + + , ); const output = lastFrame(); @@ -555,7 +627,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Toggle a non-restart-required setting (like hideTips) @@ -571,7 +645,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { lastFrame, unmount } = render( - , + + + , ); // This test would need to navigate to a specific restart-required setting @@ -591,7 +667,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { unmount } = render( - , + + + , ); // Restart prompt should be cleared when switching scopes @@ -609,7 +687,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { lastFrame } = render( - , + + + , ); const output = lastFrame(); @@ -626,7 +706,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { lastFrame } = render( - , + + + , ); const output = lastFrame(); @@ -641,7 +723,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Rapid navigation @@ -660,7 +744,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Press Ctrl+C to reset current setting to default @@ -676,7 +762,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Press Ctrl+L to reset current setting to default @@ -692,7 +780,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Try to navigate when potentially at bounds @@ -709,7 +799,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { lastFrame, unmount } = render( - , + + + , ); // Wait for initial render @@ -739,7 +831,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { lastFrame } = render( - , + + + , ); // Should still render without crashing @@ -752,7 +846,9 @@ describe('SettingsDialog', () => { // Should not crash even if some settings are missing definitions const { lastFrame } = render( - , + + + , ); expect(lastFrame()).toContain('Settings'); @@ -765,7 +861,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { lastFrame, unmount } = render( - , + + + , ); // Wait for initial render @@ -793,7 +891,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Toggle first setting (should require restart) @@ -822,7 +922,9 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount } = render( - , + + + , ); // Multiple scope changes @@ -846,11 +948,13 @@ describe('SettingsDialog', () => { const onRestartRequest = vi.fn(); const { stdin, unmount } = render( - {}} - onRestartRequest={onRestartRequest} - />, + + {}} + onRestartRequest={onRestartRequest} + /> + , ); // This would test the restart workflow if we could trigger it @@ -863,4 +967,58 @@ describe('SettingsDialog', () => { unmount(); }); }); + + describe('String Settings Editing', () => { + it('should allow editing and committing a string setting', async () => { + let settings = createMockSettings({ 'a.string.setting': 'initial' }); + const onSelect = vi.fn(); + + const { stdin, unmount, rerender } = render( + + + , + ); + + // Wait for the dialog to render + await wait(); + + // Navigate to the last setting + for (let i = 0; i < 20; i++) { + stdin.write('j'); // Down + await wait(10); + } + + // Press Enter to start editing + stdin.write('\r'); + await wait(); + + // Type a new value + stdin.write('new value'); + await wait(); + + // Press Enter to commit + stdin.write('\r'); + await wait(); + + settings = createMockSettings( + { 'a.string.setting': 'new value' }, + {}, + {}, + ); + rerender( + + + , + ); + await wait(); + + // Press Escape to exit + stdin.write('\u001B'); + await wait(); + + expect(onSelect).toHaveBeenCalledWith(undefined, 'User'); + + unmount(); + }); + }); }); diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 8fa689d0..c9685cd5 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -35,7 +35,7 @@ import { import { useVimMode } from '../contexts/VimModeContext.js'; import { useKeypress } from '../hooks/useKeypress.js'; import chalk from 'chalk'; -import { cpSlice, cpLen } from '../utils/textUtils.js'; +import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js'; interface SettingsDialogProps { settings: LoadedSettings; @@ -78,8 +78,8 @@ export function SettingsDialog({ new Set(), ); - // Preserve pending changes across scope switches (boolean and number values only) - type PendingValue = boolean | number; + // Preserve pending changes across scope switches + type PendingValue = boolean | number | string; const [globalPendingChanges, setGlobalPendingChanges] = useState< Map >(new Map()); @@ -99,7 +99,10 @@ export function SettingsDialog({ const def = getSettingDefinition(key); if (def?.type === 'boolean' && typeof value === 'boolean') { updated = setPendingSettingValue(key, value, updated); - } else if (def?.type === 'number' && typeof value === 'number') { + } else if ( + (def?.type === 'number' && typeof value === 'number') || + (def?.type === 'string' && typeof value === 'string') + ) { updated = setPendingSettingValueAny(key, value, updated); } newModified.add(key); @@ -123,7 +126,7 @@ export function SettingsDialog({ type: definition?.type, toggle: () => { if (definition?.type !== 'boolean') { - // For non-boolean (e.g., number) items, toggle will be handled via edit mode. + // For non-boolean items, toggle will be handled via edit mode. return; } const currentValue = getSettingValue(key, pendingSettings, {}); @@ -220,7 +223,7 @@ export function SettingsDialog({ const items = generateSettingsItems(); - // Number edit state + // Generic edit state const [editingKey, setEditingKey] = useState(null); const [editBuffer, setEditBuffer] = useState(''); const [editCursorPos, setEditCursorPos] = useState(0); // Cursor position within edit buffer @@ -235,28 +238,39 @@ export function SettingsDialog({ return () => clearInterval(id); }, [editingKey]); - const startEditingNumber = (key: string, initial?: string) => { + const startEditing = (key: string, initial?: string) => { setEditingKey(key); const initialValue = initial ?? ''; setEditBuffer(initialValue); setEditCursorPos(cpLen(initialValue)); // Position cursor at end of initial value }; - const commitNumberEdit = (key: string) => { - if (editBuffer.trim() === '') { - // Nothing entered; cancel edit + const commitEdit = (key: string) => { + const definition = getSettingDefinition(key); + const type = definition?.type; + + if (editBuffer.trim() === '' && type === 'number') { + // Nothing entered for a number; cancel edit setEditingKey(null); setEditBuffer(''); setEditCursorPos(0); return; } - const parsed = Number(editBuffer.trim()); - if (Number.isNaN(parsed)) { - // Invalid number; cancel edit - setEditingKey(null); - setEditBuffer(''); - setEditCursorPos(0); - return; + + let parsed: string | number; + if (type === 'number') { + const numParsed = Number(editBuffer.trim()); + if (Number.isNaN(numParsed)) { + // Invalid number; cancel edit + setEditingKey(null); + setEditBuffer(''); + setEditCursorPos(0); + return; + } + parsed = numParsed; + } else { + // For strings, use the buffer as is. + parsed = editBuffer; } // Update pending @@ -347,10 +361,16 @@ export function SettingsDialog({ setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings')); } if (focusSection === 'settings') { - // If editing a number, capture numeric input and control keys + // If editing, capture input and control keys if (editingKey) { + const definition = getSettingDefinition(editingKey); + const type = definition?.type; + if (key.paste && key.sequence) { - const pasted = key.sequence.replace(/[^0-9\-+.]/g, ''); + let pasted = key.sequence; + if (type === 'number') { + pasted = key.sequence.replace(/[^0-9\-+.]/g, ''); + } if (pasted) { setEditBuffer((b) => { const before = cpSlice(b, 0, editCursorPos); @@ -380,16 +400,27 @@ export function SettingsDialog({ return; } if (name === 'escape') { - commitNumberEdit(editingKey); + commitEdit(editingKey); return; } if (name === 'return') { - commitNumberEdit(editingKey); + commitEdit(editingKey); return; } - // Allow digits, minus, plus, and dot - const ch = key.sequence; - if (/[0-9\-+.]/.test(ch)) { + + let ch = key.sequence; + let isValidChar = false; + if (type === 'number') { + // Allow digits, minus, plus, and dot. + isValidChar = /[0-9\-+.]/.test(ch); + } else { + ch = stripUnsafeCharacters(ch); + // For strings, allow any single character that isn't a control + // sequence. + isValidChar = ch.length === 1; + } + + if (isValidChar) { setEditBuffer((currentBuffer) => { const beforeCursor = cpSlice(currentBuffer, 0, editCursorPos); const afterCursor = cpSlice(currentBuffer, editCursorPos); @@ -398,6 +429,7 @@ export function SettingsDialog({ setEditCursorPos((pos) => pos + 1); return; } + // Arrow key navigation if (name === 'left') { setEditCursorPos((pos) => Math.max(0, pos - 1)); @@ -422,7 +454,7 @@ export function SettingsDialog({ if (name === 'up' || name === 'k') { // If editing, commit first if (editingKey) { - commitNumberEdit(editingKey); + commitEdit(editingKey); } const newIndex = activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1; @@ -436,7 +468,7 @@ export function SettingsDialog({ } else if (name === 'down' || name === 'j') { // If editing, commit first if (editingKey) { - commitNumberEdit(editingKey); + commitEdit(editingKey); } const newIndex = activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0; @@ -449,15 +481,18 @@ export function SettingsDialog({ } } else if (name === 'return' || name === 'space') { const currentItem = items[activeSettingIndex]; - if (currentItem?.type === 'number') { - startEditingNumber(currentItem.value); + if ( + currentItem?.type === 'number' || + currentItem?.type === 'string' + ) { + startEditing(currentItem.value); } else { currentItem?.toggle(); } } else if (/^[0-9]$/.test(key.sequence || '') && !editingKey) { const currentItem = items[activeSettingIndex]; if (currentItem?.type === 'number') { - startEditingNumber(currentItem.value, key.sequence); + startEditing(currentItem.value, key.sequence); } } else if (ctrl && (name === 'c' || name === 'l')) { // Ctrl+C or Ctrl+L: Clear current setting and reset to default @@ -475,8 +510,11 @@ export function SettingsDialog({ prev, ), ); - } else if (defType === 'number') { - if (typeof defaultValue === 'number') { + } else if (defType === 'number' || defType === 'string') { + if ( + typeof defaultValue === 'number' || + typeof defaultValue === 'string' + ) { setPendingSettings((prev) => setPendingSettingValueAny( currentSetting.value, @@ -509,7 +547,8 @@ export function SettingsDialog({ ? typeof defaultValue === 'boolean' ? defaultValue : false - : typeof defaultValue === 'number' + : typeof defaultValue === 'number' || + typeof defaultValue === 'string' ? defaultValue : undefined; const immediateSettingsObject = @@ -541,7 +580,9 @@ export function SettingsDialog({ (currentSetting.type === 'boolean' && typeof defaultValue === 'boolean') || (currentSetting.type === 'number' && - typeof defaultValue === 'number') + typeof defaultValue === 'number') || + (currentSetting.type === 'string' && + typeof defaultValue === 'string') ) { setGlobalPendingChanges((prev) => { const next = new Map(prev); @@ -584,7 +625,7 @@ export function SettingsDialog({ } if (name === 'escape') { if (editingKey) { - commitNumberEdit(editingKey); + commitEdit(editingKey); } else { onSelect(undefined, selectedScope); } @@ -637,8 +678,8 @@ export function SettingsDialog({ // Cursor not visible displayValue = editBuffer; } - } else if (item.type === 'number') { - // For numbers, get the actual current value from pending settings + } else if (item.type === 'number' || item.type === 'string') { + // For numbers/strings, get the actual current value from pending settings const path = item.value.split('.'); const currentValue = getNestedValue(pendingSettings, path); diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 93f6e360..389a4799 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -4,8 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import stripAnsi from 'strip-ansi'; -import { stripVTControlCharacters } from 'util'; import { spawnSync } from 'child_process'; import fs from 'fs'; import os from 'os'; @@ -13,7 +11,12 @@ import pathMod from '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 } from '../../utils/textUtils.js'; +import { + toCodePoints, + cpLen, + cpSlice, + stripUnsafeCharacters, +} from '../../utils/textUtils.js'; import { handleVimAction, VimAction } from './vim-buffer-actions.js'; export type Direction = @@ -494,51 +497,6 @@ export const replaceRangeInternal = ( }; }; -/** - * Strip characters that can break terminal rendering. - * - * Uses Node.js built-in stripVTControlCharacters to handle VT sequences, - * then filters remaining control characters that can disrupt display. - * - * Characters stripped: - * - ANSI escape sequences (via strip-ansi) - * - VT control sequences (via Node.js util.stripVTControlCharacters) - * - C0 control chars (0x00-0x1F) except CR/LF which are handled elsewhere - * - C1 control chars (0x80-0x9F) that can cause display issues - * - * Characters preserved: - * - All printable Unicode including emojis - * - DEL (0x7F) - handled functionally by applyOperations, not a display issue - * - CR/LF (0x0D/0x0A) - needed for line breaks - */ -function stripUnsafeCharacters(str: string): string { - const strippedAnsi = stripAnsi(str); - const strippedVT = stripVTControlCharacters(strippedAnsi); - - return toCodePoints(strippedVT) - .filter((char) => { - const code = char.codePointAt(0); - if (code === undefined) return false; - - // Preserve CR/LF for line handling - if (code === 0x0a || code === 0x0d) return true; - - // Remove C0 control chars (except CR/LF) that can break display - // Examples: BELL(0x07) makes noise, BS(0x08) moves cursor, VT(0x0B), FF(0x0C) - if (code >= 0x00 && code <= 0x1f) return false; - - // Remove C1 control chars (0x80-0x9F) - legacy 8-bit control codes - if (code >= 0x80 && code <= 0x9f) return false; - - // Preserve DEL (0x7F) - it's handled functionally by applyOperations as backspace - // and doesn't cause rendering issues when displayed - - // Preserve all other characters including Unicode/emojis - return true; - }) - .join(''); -} - export interface Viewport { height: number; width: number; diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index e4d8ea58..7630f04d 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -4,6 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import stripAnsi from 'strip-ansi'; +import { stripVTControlCharacters } from 'util'; + /** * Calculates the maximum width of a multi-line ASCII art string. * @param asciiArt The ASCII art string. @@ -38,3 +41,48 @@ export function cpSlice(str: string, start: number, end?: number): string { const arr = toCodePoints(str).slice(start, end); return arr.join(''); } + +/** + * Strip characters that can break terminal rendering. + * + * Uses Node.js built-in stripVTControlCharacters to handle VT sequences, + * then filters remaining control characters that can disrupt display. + * + * Characters stripped: + * - ANSI escape sequences (via strip-ansi) + * - VT control sequences (via Node.js util.stripVTControlCharacters) + * - C0 control chars (0x00-0x1F) except CR/LF which are handled elsewhere + * - C1 control chars (0x80-0x9F) that can cause display issues + * + * Characters preserved: + * - All printable Unicode including emojis + * - DEL (0x7F) - handled functionally by applyOperations, not a display issue + * - CR/LF (0x0D/0x0A) - needed for line breaks + */ +export function stripUnsafeCharacters(str: string): string { + const strippedAnsi = stripAnsi(str); + const strippedVT = stripVTControlCharacters(strippedAnsi); + + return toCodePoints(strippedVT) + .filter((char) => { + const code = char.codePointAt(0); + if (code === undefined) return false; + + // Preserve CR/LF for line handling + if (code === 0x0a || code === 0x0d) return true; + + // Remove C0 control chars (except CR/LF) that can break display + // Examples: BELL(0x07) makes noise, BS(0x08) moves cursor, VT(0x0B), FF(0x0C) + if (code >= 0x00 && code <= 0x1f) return false; + + // Remove C1 control chars (0x80-0x9f) - legacy 8-bit control codes + if (code >= 0x80 && code <= 0x9f) return false; + + // Preserve DEL (0x7f) - it's handled functionally by applyOperations as backspace + // and doesn't cause rendering issues when displayed + + // Preserve all other characters including Unicode/emojis + return true; + }) + .join(''); +}