summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJacob Richman <[email protected]>2025-08-21 16:43:56 -0700
committerGitHub <[email protected]>2025-08-21 23:43:56 +0000
commit29699274bb0e8f70b9bedad40ca2d03739318853 (patch)
tree17ff82d54991e72deba93eb40e4b7d068b881cb5
parent10286934e6a549dcad557adecfc087552e13c983 (diff)
feat(settings) support editing string settings. (#6732)
-rw-r--r--packages/cli/src/ui/components/SettingsDialog.test.tsx302
-rw-r--r--packages/cli/src/ui/components/SettingsDialog.tsx113
-rw-r--r--packages/cli/src/ui/components/shared/text-buffer.ts54
-rw-r--r--packages/cli/src/ui/utils/textUtils.ts48
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<Key> & { name: string }) => {
// if (currentKeypressHandler) {
@@ -149,7 +177,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
const output = lastFrame();
@@ -163,7 +193,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
const output = lastFrame();
@@ -176,7 +208,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
const output = lastFrame();
@@ -191,7 +225,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
// Press down arrow
@@ -207,7 +243,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
// First go down, then up
@@ -224,7 +262,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
// Navigate with vim keys
@@ -241,7 +281,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
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
@@ -259,7 +301,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
// Press Enter to toggle current setting
@@ -274,7 +318,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
// Press Space to toggle current setting
@@ -289,7 +335,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
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
@@ -308,7 +356,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
// Switch to scope focus
@@ -327,7 +377,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame, unmount } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
// Wait for initial render
@@ -352,11 +404,13 @@ describe('SettingsDialog', () => {
const onRestartRequest = vi.fn();
const { unmount } = render(
- <SettingsDialog
- settings={settings}
- onSelect={() => {}}
- onRestartRequest={onRestartRequest}
- />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog
+ settings={settings}
+ onSelect={() => {}}
+ onRestartRequest={onRestartRequest}
+ />
+ </KeypressProvider>,
);
// 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(
- <SettingsDialog
- settings={settings}
- onSelect={() => {}}
- onRestartRequest={onRestartRequest}
- />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog
+ settings={settings}
+ onSelect={() => {}}
+ onRestartRequest={onRestartRequest}
+ />
+ </KeypressProvider>,
);
// 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(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
// Wait for initial render
@@ -418,7 +476,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
// Switch to scope selector
@@ -442,7 +502,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
// Should show user scope values initially
@@ -459,7 +521,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
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)
@@ -477,7 +541,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
// Toggle a setting
@@ -499,7 +565,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
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
@@ -519,7 +587,9 @@ describe('SettingsDialog', () => {
const { stdin, unmount } = render(
<VimModeProvider settings={settings}>
- <SettingsDialog settings={settings} onSelect={onSelect} />
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>
</VimModeProvider>,
);
@@ -542,7 +612,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
const output = lastFrame();
@@ -555,7 +627,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
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)
@@ -571,7 +645,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
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
@@ -591,7 +667,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
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
@@ -609,7 +687,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
const output = lastFrame();
@@ -626,7 +706,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
const output = lastFrame();
@@ -641,7 +723,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
// Rapid navigation
@@ -660,7 +744,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
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
@@ -676,7 +762,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
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
@@ -692,7 +780,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
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
@@ -709,7 +799,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame, unmount } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
// Wait for initial render
@@ -739,7 +831,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
// Should still render without crashing
@@ -752,7 +846,9 @@ describe('SettingsDialog', () => {
// Should not crash even if some settings are missing definitions
const { lastFrame } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
expect(lastFrame()).toContain('Settings');
@@ -765,7 +861,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame, unmount } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
// Wait for initial render
@@ -793,7 +891,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
// Toggle first setting (should require restart)
@@ -822,7 +922,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
- <SettingsDialog settings={settings} onSelect={onSelect} />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </KeypressProvider>,
);
// Multiple scope changes
@@ -846,11 +948,13 @@ describe('SettingsDialog', () => {
const onRestartRequest = vi.fn();
const { stdin, unmount } = render(
- <SettingsDialog
- settings={settings}
- onSelect={() => {}}
- onRestartRequest={onRestartRequest}
- />,
+ <KeypressProvider kittyProtocolEnabled={false}>
+ <SettingsDialog
+ settings={settings}
+ onSelect={() => {}}
+ onRestartRequest={onRestartRequest}
+ />
+ </KeypressProvider>,
);
// 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(
+ <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();
+ });
+ });
});
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<string, PendingValue>
>(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<string | null>(null);
const [editBuffer, setEditBuffer] = useState<string>('');
const [editCursorPos, setEditCursorPos] = useState<number>(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('');
+}