From 29699274bb0e8f70b9bedad40ca2d03739318853 Mon Sep 17 00:00:00 2001
From: Jacob Richman <jacob314@gmail.com>
Date: Thu, 21 Aug 2025 16:43:56 -0700
Subject: feat(settings) support editing string settings. (#6732)

---
 packages/cli/src/ui/components/SettingsDialog.tsx | 113 +++++++++++++++-------
 1 file changed, 77 insertions(+), 36 deletions(-)

(limited to 'packages/cli/src/ui/components/SettingsDialog.tsx')

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);
 
-- 
cgit v1.2.3

