feat(settings) support editing string settings. (#6732)

This commit is contained in:
Jacob Richman 2025-08-21 16:43:56 -07:00 committed by GitHub
parent 10286934e6
commit 29699274bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 361 additions and 156 deletions

View File

@ -27,11 +27,61 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SettingsDialog } from './SettingsDialog.js'; import { SettingsDialog } from './SettingsDialog.js';
import { LoadedSettings } from '../../config/settings.js'; import { LoadedSettings } from '../../config/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js'; import { VimModeProvider } from '../contexts/VimModeContext.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
// Mock the VimModeContext // Mock the VimModeContext
const mockToggleVimEnabled = vi.fn(); const mockToggleVimEnabled = vi.fn();
const mockSetVimMode = 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 () => { vi.mock('../contexts/VimModeContext.js', async () => {
const actual = await vi.importActual('../contexts/VimModeContext.js'); const actual = await vi.importActual('../contexts/VimModeContext.js');
return { 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) // Helper function to simulate key presses (commented out for now)
// const simulateKeyPress = async (keyData: Partial<Key> & { name: string }) => { // const simulateKeyPress = async (keyData: Partial<Key> & { name: string }) => {
// if (currentKeypressHandler) { // if (currentKeypressHandler) {
@ -149,7 +177,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
const output = lastFrame(); const output = lastFrame();
@ -163,7 +193,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
const output = lastFrame(); const output = lastFrame();
@ -176,7 +208,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
const output = lastFrame(); const output = lastFrame();
@ -191,7 +225,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Press down arrow // Press down arrow
@ -207,7 +243,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// First go down, then up // First go down, then up
@ -224,7 +262,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Navigate with vim keys // Navigate with vim keys
@ -241,7 +281,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Try to go up from first item // Try to go up from first item
@ -259,7 +301,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Press Enter to toggle current setting // Press Enter to toggle current setting
@ -274,7 +318,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Press Space to toggle current setting // Press Space to toggle current setting
@ -289,7 +335,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Navigate to vim mode setting and toggle it // Navigate to vim mode setting and toggle it
@ -308,7 +356,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Switch to scope focus // Switch to scope focus
@ -327,7 +377,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame, unmount } = render( const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Wait for initial render // Wait for initial render
@ -352,11 +404,13 @@ describe('SettingsDialog', () => {
const onRestartRequest = vi.fn(); const onRestartRequest = vi.fn();
const { unmount } = render( const { unmount } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog <SettingsDialog
settings={settings} settings={settings}
onSelect={() => {}} onSelect={() => {}}
onRestartRequest={onRestartRequest} onRestartRequest={onRestartRequest}
/>, />
</KeypressProvider>,
); );
// This test would need to trigger a restart-required setting change // This test would need to trigger a restart-required setting change
@ -371,11 +425,13 @@ describe('SettingsDialog', () => {
const onRestartRequest = vi.fn(); const onRestartRequest = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog <SettingsDialog
settings={settings} settings={settings}
onSelect={() => {}} onSelect={() => {}}
onRestartRequest={onRestartRequest} onRestartRequest={onRestartRequest}
/>, />
</KeypressProvider>,
); );
// Press 'r' key (this would only work if restart prompt is showing) // Press 'r' key (this would only work if restart prompt is showing)
@ -393,7 +449,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame, unmount } = render( const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Wait for initial render // Wait for initial render
@ -418,7 +476,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Switch to scope selector // Switch to scope selector
@ -442,7 +502,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Should show user scope values initially // Should show user scope values initially
@ -459,7 +521,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Try to toggle a setting (this might trigger vim mode toggle) // Try to toggle a setting (this might trigger vim mode toggle)
@ -477,7 +541,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Toggle a setting // Toggle a setting
@ -499,7 +565,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Navigate down many times to test scrolling // Navigate down many times to test scrolling
@ -519,7 +587,9 @@ describe('SettingsDialog', () => {
const { stdin, unmount } = render( const { stdin, unmount } = render(
<VimModeProvider settings={settings}> <VimModeProvider settings={settings}>
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} /> <SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>
</VimModeProvider>, </VimModeProvider>,
); );
@ -542,7 +612,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
const output = lastFrame(); const output = lastFrame();
@ -555,7 +627,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Toggle a non-restart-required setting (like hideTips) // Toggle a non-restart-required setting (like hideTips)
@ -571,7 +645,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame, unmount } = render( const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// This test would need to navigate to a specific restart-required setting // This test would need to navigate to a specific restart-required setting
@ -591,7 +667,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { unmount } = render( const { unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Restart prompt should be cleared when switching scopes // Restart prompt should be cleared when switching scopes
@ -609,7 +687,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
const output = lastFrame(); const output = lastFrame();
@ -626,7 +706,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
const output = lastFrame(); const output = lastFrame();
@ -641,7 +723,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Rapid navigation // Rapid navigation
@ -660,7 +744,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Press Ctrl+C to reset current setting to default // Press Ctrl+C to reset current setting to default
@ -676,7 +762,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Press Ctrl+L to reset current setting to default // Press Ctrl+L to reset current setting to default
@ -692,7 +780,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Try to navigate when potentially at bounds // Try to navigate when potentially at bounds
@ -709,7 +799,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame, unmount } = render( const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Wait for initial render // Wait for initial render
@ -739,7 +831,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Should still render without crashing // Should still render without crashing
@ -752,7 +846,9 @@ describe('SettingsDialog', () => {
// Should not crash even if some settings are missing definitions // Should not crash even if some settings are missing definitions
const { lastFrame } = render( const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
expect(lastFrame()).toContain('Settings'); expect(lastFrame()).toContain('Settings');
@ -765,7 +861,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame, unmount } = render( const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Wait for initial render // Wait for initial render
@ -793,7 +891,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Toggle first setting (should require restart) // Toggle first setting (should require restart)
@ -822,7 +922,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />, <KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
); );
// Multiple scope changes // Multiple scope changes
@ -846,11 +948,13 @@ describe('SettingsDialog', () => {
const onRestartRequest = vi.fn(); const onRestartRequest = vi.fn();
const { stdin, unmount } = render( const { stdin, unmount } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog <SettingsDialog
settings={settings} settings={settings}
onSelect={() => {}} onSelect={() => {}}
onRestartRequest={onRestartRequest} onRestartRequest={onRestartRequest}
/>, />
</KeypressProvider>,
); );
// This would test the restart workflow if we could trigger it // This would test the restart workflow if we could trigger it
@ -863,4 +967,58 @@ describe('SettingsDialog', () => {
unmount(); 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(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// 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(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
await wait();
// Press Escape to exit
stdin.write('\u001B');
await wait();
expect(onSelect).toHaveBeenCalledWith(undefined, 'User');
unmount();
});
});
}); });

View File

@ -35,7 +35,7 @@ import {
import { useVimMode } from '../contexts/VimModeContext.js'; import { useVimMode } from '../contexts/VimModeContext.js';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
import chalk from 'chalk'; import chalk from 'chalk';
import { cpSlice, cpLen } from '../utils/textUtils.js'; import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
interface SettingsDialogProps { interface SettingsDialogProps {
settings: LoadedSettings; settings: LoadedSettings;
@ -78,8 +78,8 @@ export function SettingsDialog({
new Set(), new Set(),
); );
// Preserve pending changes across scope switches (boolean and number values only) // Preserve pending changes across scope switches
type PendingValue = boolean | number; type PendingValue = boolean | number | string;
const [globalPendingChanges, setGlobalPendingChanges] = useState< const [globalPendingChanges, setGlobalPendingChanges] = useState<
Map<string, PendingValue> Map<string, PendingValue>
>(new Map()); >(new Map());
@ -99,7 +99,10 @@ export function SettingsDialog({
const def = getSettingDefinition(key); const def = getSettingDefinition(key);
if (def?.type === 'boolean' && typeof value === 'boolean') { if (def?.type === 'boolean' && typeof value === 'boolean') {
updated = setPendingSettingValue(key, value, updated); 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); updated = setPendingSettingValueAny(key, value, updated);
} }
newModified.add(key); newModified.add(key);
@ -123,7 +126,7 @@ export function SettingsDialog({
type: definition?.type, type: definition?.type,
toggle: () => { toggle: () => {
if (definition?.type !== 'boolean') { 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; return;
} }
const currentValue = getSettingValue(key, pendingSettings, {}); const currentValue = getSettingValue(key, pendingSettings, {});
@ -220,7 +223,7 @@ export function SettingsDialog({
const items = generateSettingsItems(); const items = generateSettingsItems();
// Number edit state // Generic edit state
const [editingKey, setEditingKey] = useState<string | null>(null); const [editingKey, setEditingKey] = useState<string | null>(null);
const [editBuffer, setEditBuffer] = useState<string>(''); const [editBuffer, setEditBuffer] = useState<string>('');
const [editCursorPos, setEditCursorPos] = useState<number>(0); // Cursor position within edit buffer const [editCursorPos, setEditCursorPos] = useState<number>(0); // Cursor position within edit buffer
@ -235,29 +238,40 @@ export function SettingsDialog({
return () => clearInterval(id); return () => clearInterval(id);
}, [editingKey]); }, [editingKey]);
const startEditingNumber = (key: string, initial?: string) => { const startEditing = (key: string, initial?: string) => {
setEditingKey(key); setEditingKey(key);
const initialValue = initial ?? ''; const initialValue = initial ?? '';
setEditBuffer(initialValue); setEditBuffer(initialValue);
setEditCursorPos(cpLen(initialValue)); // Position cursor at end of initial value setEditCursorPos(cpLen(initialValue)); // Position cursor at end of initial value
}; };
const commitNumberEdit = (key: string) => { const commitEdit = (key: string) => {
if (editBuffer.trim() === '') { const definition = getSettingDefinition(key);
// Nothing entered; cancel edit const type = definition?.type;
if (editBuffer.trim() === '' && type === 'number') {
// Nothing entered for a number; cancel edit
setEditingKey(null); setEditingKey(null);
setEditBuffer(''); setEditBuffer('');
setEditCursorPos(0); setEditCursorPos(0);
return; return;
} }
const parsed = Number(editBuffer.trim());
if (Number.isNaN(parsed)) { let parsed: string | number;
if (type === 'number') {
const numParsed = Number(editBuffer.trim());
if (Number.isNaN(numParsed)) {
// Invalid number; cancel edit // Invalid number; cancel edit
setEditingKey(null); setEditingKey(null);
setEditBuffer(''); setEditBuffer('');
setEditCursorPos(0); setEditCursorPos(0);
return; return;
} }
parsed = numParsed;
} else {
// For strings, use the buffer as is.
parsed = editBuffer;
}
// Update pending // Update pending
setPendingSettings((prev) => setPendingSettingValueAny(key, parsed, prev)); setPendingSettings((prev) => setPendingSettingValueAny(key, parsed, prev));
@ -347,10 +361,16 @@ export function SettingsDialog({
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings')); setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
} }
if (focusSection === 'settings') { if (focusSection === 'settings') {
// If editing a number, capture numeric input and control keys // If editing, capture input and control keys
if (editingKey) { if (editingKey) {
const definition = getSettingDefinition(editingKey);
const type = definition?.type;
if (key.paste && key.sequence) { 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) { if (pasted) {
setEditBuffer((b) => { setEditBuffer((b) => {
const before = cpSlice(b, 0, editCursorPos); const before = cpSlice(b, 0, editCursorPos);
@ -380,16 +400,27 @@ export function SettingsDialog({
return; return;
} }
if (name === 'escape') { if (name === 'escape') {
commitNumberEdit(editingKey); commitEdit(editingKey);
return; return;
} }
if (name === 'return') { if (name === 'return') {
commitNumberEdit(editingKey); commitEdit(editingKey);
return; return;
} }
// Allow digits, minus, plus, and dot
const ch = key.sequence; let ch = key.sequence;
if (/[0-9\-+.]/.test(ch)) { 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) => { setEditBuffer((currentBuffer) => {
const beforeCursor = cpSlice(currentBuffer, 0, editCursorPos); const beforeCursor = cpSlice(currentBuffer, 0, editCursorPos);
const afterCursor = cpSlice(currentBuffer, editCursorPos); const afterCursor = cpSlice(currentBuffer, editCursorPos);
@ -398,6 +429,7 @@ export function SettingsDialog({
setEditCursorPos((pos) => pos + 1); setEditCursorPos((pos) => pos + 1);
return; return;
} }
// Arrow key navigation // Arrow key navigation
if (name === 'left') { if (name === 'left') {
setEditCursorPos((pos) => Math.max(0, pos - 1)); setEditCursorPos((pos) => Math.max(0, pos - 1));
@ -422,7 +454,7 @@ export function SettingsDialog({
if (name === 'up' || name === 'k') { if (name === 'up' || name === 'k') {
// If editing, commit first // If editing, commit first
if (editingKey) { if (editingKey) {
commitNumberEdit(editingKey); commitEdit(editingKey);
} }
const newIndex = const newIndex =
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1; activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
@ -436,7 +468,7 @@ export function SettingsDialog({
} else if (name === 'down' || name === 'j') { } else if (name === 'down' || name === 'j') {
// If editing, commit first // If editing, commit first
if (editingKey) { if (editingKey) {
commitNumberEdit(editingKey); commitEdit(editingKey);
} }
const newIndex = const newIndex =
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0; activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
@ -449,15 +481,18 @@ export function SettingsDialog({
} }
} else if (name === 'return' || name === 'space') { } else if (name === 'return' || name === 'space') {
const currentItem = items[activeSettingIndex]; const currentItem = items[activeSettingIndex];
if (currentItem?.type === 'number') { if (
startEditingNumber(currentItem.value); currentItem?.type === 'number' ||
currentItem?.type === 'string'
) {
startEditing(currentItem.value);
} else { } else {
currentItem?.toggle(); currentItem?.toggle();
} }
} else if (/^[0-9]$/.test(key.sequence || '') && !editingKey) { } else if (/^[0-9]$/.test(key.sequence || '') && !editingKey) {
const currentItem = items[activeSettingIndex]; const currentItem = items[activeSettingIndex];
if (currentItem?.type === 'number') { if (currentItem?.type === 'number') {
startEditingNumber(currentItem.value, key.sequence); startEditing(currentItem.value, key.sequence);
} }
} else if (ctrl && (name === 'c' || name === 'l')) { } else if (ctrl && (name === 'c' || name === 'l')) {
// Ctrl+C or Ctrl+L: Clear current setting and reset to default // Ctrl+C or Ctrl+L: Clear current setting and reset to default
@ -475,8 +510,11 @@ export function SettingsDialog({
prev, prev,
), ),
); );
} else if (defType === 'number') { } else if (defType === 'number' || defType === 'string') {
if (typeof defaultValue === 'number') { if (
typeof defaultValue === 'number' ||
typeof defaultValue === 'string'
) {
setPendingSettings((prev) => setPendingSettings((prev) =>
setPendingSettingValueAny( setPendingSettingValueAny(
currentSetting.value, currentSetting.value,
@ -509,7 +547,8 @@ export function SettingsDialog({
? typeof defaultValue === 'boolean' ? typeof defaultValue === 'boolean'
? defaultValue ? defaultValue
: false : false
: typeof defaultValue === 'number' : typeof defaultValue === 'number' ||
typeof defaultValue === 'string'
? defaultValue ? defaultValue
: undefined; : undefined;
const immediateSettingsObject = const immediateSettingsObject =
@ -541,7 +580,9 @@ export function SettingsDialog({
(currentSetting.type === 'boolean' && (currentSetting.type === 'boolean' &&
typeof defaultValue === 'boolean') || typeof defaultValue === 'boolean') ||
(currentSetting.type === 'number' && (currentSetting.type === 'number' &&
typeof defaultValue === 'number') typeof defaultValue === 'number') ||
(currentSetting.type === 'string' &&
typeof defaultValue === 'string')
) { ) {
setGlobalPendingChanges((prev) => { setGlobalPendingChanges((prev) => {
const next = new Map(prev); const next = new Map(prev);
@ -584,7 +625,7 @@ export function SettingsDialog({
} }
if (name === 'escape') { if (name === 'escape') {
if (editingKey) { if (editingKey) {
commitNumberEdit(editingKey); commitEdit(editingKey);
} else { } else {
onSelect(undefined, selectedScope); onSelect(undefined, selectedScope);
} }
@ -637,8 +678,8 @@ export function SettingsDialog({
// Cursor not visible // Cursor not visible
displayValue = editBuffer; displayValue = editBuffer;
} }
} else if (item.type === 'number') { } else if (item.type === 'number' || item.type === 'string') {
// For numbers, get the actual current value from pending settings // For numbers/strings, get the actual current value from pending settings
const path = item.value.split('.'); const path = item.value.split('.');
const currentValue = getNestedValue(pendingSettings, path); const currentValue = getNestedValue(pendingSettings, path);

View File

@ -4,8 +4,6 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import stripAnsi from 'strip-ansi';
import { stripVTControlCharacters } from 'util';
import { spawnSync } from 'child_process'; import { spawnSync } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
@ -13,7 +11,12 @@ import pathMod from 'path';
import { useState, useCallback, useEffect, useMemo, useReducer } from 'react'; import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
import stringWidth from 'string-width'; import stringWidth from 'string-width';
import { unescapePath } from '@google/gemini-cli-core'; 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'; import { handleVimAction, VimAction } from './vim-buffer-actions.js';
export type Direction = 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 { export interface Viewport {
height: number; height: number;
width: number; width: number;

View File

@ -4,6 +4,9 @@
* SPDX-License-Identifier: Apache-2.0 * 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. * Calculates the maximum width of a multi-line ASCII art string.
* @param asciiArt The 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); const arr = toCodePoints(str).slice(start, end);
return arr.join(''); 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('');
}