From 92bb4624c4ce191ed5dd60aaca4e2786039b3b2b Mon Sep 17 00:00:00 2001
From: Ali Al Jufairi <ali.redha.ali@gmail.com>
Date: Tue, 19 Aug 2025 11:28:45 +0900
Subject: feat(settings): enhance settings management with generic setter and
 display hel… (#6202)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-authored-by: Jacob Richman <jacob314@gmail.com>
---
 packages/cli/src/ui/components/SettingsDialog.tsx | 487 +++++++++++++++++-----
 1 file changed, 385 insertions(+), 102 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 a09cd76a..8fa689d0 100644
--- a/packages/cli/src/ui/components/SettingsDialog.tsx
+++ b/packages/cli/src/ui/components/SettingsDialog.tsx
@@ -29,9 +29,13 @@ import {
   requiresRestart,
   getRestartRequiredFromModified,
   getDefaultValue,
+  setPendingSettingValueAny,
+  getNestedValue,
 } from '../../utils/settingsUtils.js';
 import { useVimMode } from '../contexts/VimModeContext.js';
 import { useKeypress } from '../hooks/useKeypress.js';
+import chalk from 'chalk';
+import { cpSlice, cpLen } from '../utils/textUtils.js';
 
 interface SettingsDialogProps {
   settings: LoadedSettings;
@@ -74,61 +78,55 @@ export function SettingsDialog({
     new Set(),
   );
 
-  // Track the intended values for modified settings
-  const [modifiedValues, setModifiedValues] = useState<Map<string, boolean>>(
-    new Map(),
-  );
+  // Preserve pending changes across scope switches (boolean and number values only)
+  type PendingValue = boolean | number;
+  const [globalPendingChanges, setGlobalPendingChanges] = useState<
+    Map<string, PendingValue>
+  >(new Map());
 
   // Track restart-required settings across scope changes
-  const [restartRequiredSettings, setRestartRequiredSettings] = useState<
+  const [_restartRequiredSettings, setRestartRequiredSettings] = useState<
     Set<string>
   >(new Set());
 
   useEffect(() => {
-    setPendingSettings(
-      structuredClone(settings.forScope(selectedScope).settings),
-    );
-    // Don't reset modifiedSettings when scope changes - preserve user's pending changes
-    if (restartRequiredSettings.size === 0) {
-      setShowRestartPrompt(false);
-    }
-  }, [selectedScope, settings, restartRequiredSettings]);
-
-  // Preserve pending changes when scope changes
-  useEffect(() => {
-    if (modifiedSettings.size > 0) {
-      setPendingSettings((prevPending) => {
-        let updatedPending = { ...prevPending };
-
-        // Reapply all modified settings to the new pending settings using stored values
-        modifiedSettings.forEach((key) => {
-          const storedValue = modifiedValues.get(key);
-          if (storedValue !== undefined) {
-            updatedPending = setPendingSettingValue(
-              key,
-              storedValue,
-              updatedPending,
-            );
-          }
-        });
-
-        return updatedPending;
-      });
+    // Base settings for selected scope
+    let updated = structuredClone(settings.forScope(selectedScope).settings);
+    // Overlay globally pending (unsaved) changes so user sees their modifications in any scope
+    const newModified = new Set<string>();
+    const newRestartRequired = new Set<string>();
+    for (const [key, value] of globalPendingChanges.entries()) {
+      const def = getSettingDefinition(key);
+      if (def?.type === 'boolean' && typeof value === 'boolean') {
+        updated = setPendingSettingValue(key, value, updated);
+      } else if (def?.type === 'number' && typeof value === 'number') {
+        updated = setPendingSettingValueAny(key, value, updated);
+      }
+      newModified.add(key);
+      if (requiresRestart(key)) newRestartRequired.add(key);
     }
-  }, [selectedScope, modifiedSettings, modifiedValues, settings]);
+    setPendingSettings(updated);
+    setModifiedSettings(newModified);
+    setRestartRequiredSettings(newRestartRequired);
+    setShowRestartPrompt(newRestartRequired.size > 0);
+  }, [selectedScope, settings, globalPendingChanges]);
 
   const generateSettingsItems = () => {
     const settingKeys = getDialogSettingKeys();
 
     return settingKeys.map((key: string) => {
-      const currentValue = getSettingValue(key, pendingSettings, {});
       const definition = getSettingDefinition(key);
 
       return {
         label: definition?.label || key,
         value: key,
-        checked: currentValue,
+        type: definition?.type,
         toggle: () => {
+          if (definition?.type !== 'boolean') {
+            // For non-boolean (e.g., number) items, toggle will be handled via edit mode.
+            return;
+          }
+          const currentValue = getSettingValue(key, pendingSettings, {});
           const newValue = !currentValue;
 
           setPendingSettings((prev) =>
@@ -137,10 +135,10 @@ export function SettingsDialog({
 
           if (!requiresRestart(key)) {
             const immediateSettings = new Set([key]);
-            const immediateSettingsObject = setPendingSettingValue(
+            const immediateSettingsObject = setPendingSettingValueAny(
               key,
               newValue,
-              {},
+              {} as Settings,
             );
 
             console.log(
@@ -162,23 +160,13 @@ export function SettingsDialog({
               });
             }
 
-            // Capture the current modified settings before updating state
-            const currentModifiedSettings = new Set(modifiedSettings);
-
-            // Remove the saved setting from modifiedSettings since it's now saved
+            // Remove from modifiedSettings since it's now saved
             setModifiedSettings((prev) => {
               const updated = new Set(prev);
               updated.delete(key);
               return updated;
             });
 
-            // Remove from modifiedValues as well
-            setModifiedValues((prev) => {
-              const updated = new Map(prev);
-              updated.delete(key);
-              return updated;
-            });
-
             // Also remove from restart-required settings if it was there
             setRestartRequiredSettings((prev) => {
               const updated = new Set(prev);
@@ -186,34 +174,20 @@ export function SettingsDialog({
               return updated;
             });
 
-            setPendingSettings((_prevPending) => {
-              let updatedPending = structuredClone(
-                settings.forScope(selectedScope).settings,
-              );
-
-              currentModifiedSettings.forEach((modifiedKey) => {
-                if (modifiedKey !== key) {
-                  const modifiedValue = modifiedValues.get(modifiedKey);
-                  if (modifiedValue !== undefined) {
-                    updatedPending = setPendingSettingValue(
-                      modifiedKey,
-                      modifiedValue,
-                      updatedPending,
-                    );
-                  }
-                }
-              });
-
-              return updatedPending;
-            });
-          } else {
-            // For restart-required settings, store the actual value
-            setModifiedValues((prev) => {
-              const updated = new Map(prev);
-              updated.set(key, newValue);
-              return updated;
+            // Remove from global pending changes if present
+            setGlobalPendingChanges((prev) => {
+              if (!prev.has(key)) return prev;
+              const next = new Map(prev);
+              next.delete(key);
+              return next;
             });
 
+            // Refresh pending settings from the saved state
+            setPendingSettings(
+              structuredClone(settings.forScope(selectedScope).settings),
+            );
+          } else {
+            // For restart-required settings, track as modified
             setModifiedSettings((prev) => {
               const updated = new Set(prev).add(key);
               const needsRestart = hasRestartRequiredSettings(updated);
@@ -231,6 +205,13 @@ export function SettingsDialog({
               }
               return updated;
             });
+
+            // Add/update pending change globally so it persists across scopes
+            setGlobalPendingChanges((prev) => {
+              const next = new Map(prev);
+              next.set(key, newValue as PendingValue);
+              return next;
+            });
           }
         },
       };
@@ -239,6 +220,108 @@ export function SettingsDialog({
 
   const items = generateSettingsItems();
 
+  // Number 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
+  const [cursorVisible, setCursorVisible] = useState<boolean>(true);
+
+  useEffect(() => {
+    if (!editingKey) {
+      setCursorVisible(true);
+      return;
+    }
+    const id = setInterval(() => setCursorVisible((v) => !v), 500);
+    return () => clearInterval(id);
+  }, [editingKey]);
+
+  const startEditingNumber = (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
+      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;
+    }
+
+    // Update pending
+    setPendingSettings((prev) => setPendingSettingValueAny(key, parsed, prev));
+
+    if (!requiresRestart(key)) {
+      const immediateSettings = new Set([key]);
+      const immediateSettingsObject = setPendingSettingValueAny(
+        key,
+        parsed,
+        {} as Settings,
+      );
+      saveModifiedSettings(
+        immediateSettings,
+        immediateSettingsObject,
+        settings,
+        selectedScope,
+      );
+
+      // Remove from modified sets if present
+      setModifiedSettings((prev) => {
+        const updated = new Set(prev);
+        updated.delete(key);
+        return updated;
+      });
+      setRestartRequiredSettings((prev) => {
+        const updated = new Set(prev);
+        updated.delete(key);
+        return updated;
+      });
+
+      // Remove from global pending since it's immediately saved
+      setGlobalPendingChanges((prev) => {
+        if (!prev.has(key)) return prev;
+        const next = new Map(prev);
+        next.delete(key);
+        return next;
+      });
+    } else {
+      // Mark as modified and needing restart
+      setModifiedSettings((prev) => {
+        const updated = new Set(prev).add(key);
+        const needsRestart = hasRestartRequiredSettings(updated);
+        if (needsRestart) {
+          setShowRestartPrompt(true);
+          setRestartRequiredSettings((prevRestart) =>
+            new Set(prevRestart).add(key),
+          );
+        }
+        return updated;
+      });
+
+      // Record pending change globally for persistence across scopes
+      setGlobalPendingChanges((prev) => {
+        const next = new Map(prev);
+        next.set(key, parsed as PendingValue);
+        return next;
+      });
+    }
+
+    setEditingKey(null);
+    setEditBuffer('');
+    setEditCursorPos(0);
+  };
+
   // Scope selector items
   const scopeItems = getScopeItems();
 
@@ -264,7 +347,83 @@ export function SettingsDialog({
         setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
       }
       if (focusSection === 'settings') {
+        // If editing a number, capture numeric input and control keys
+        if (editingKey) {
+          if (key.paste && key.sequence) {
+            const pasted = key.sequence.replace(/[^0-9\-+.]/g, '');
+            if (pasted) {
+              setEditBuffer((b) => {
+                const before = cpSlice(b, 0, editCursorPos);
+                const after = cpSlice(b, editCursorPos);
+                return before + pasted + after;
+              });
+              setEditCursorPos((pos) => pos + cpLen(pasted));
+            }
+            return;
+          }
+          if (name === 'backspace' || name === 'delete') {
+            if (name === 'backspace' && editCursorPos > 0) {
+              setEditBuffer((b) => {
+                const before = cpSlice(b, 0, editCursorPos - 1);
+                const after = cpSlice(b, editCursorPos);
+                return before + after;
+              });
+              setEditCursorPos((pos) => pos - 1);
+            } else if (name === 'delete' && editCursorPos < cpLen(editBuffer)) {
+              setEditBuffer((b) => {
+                const before = cpSlice(b, 0, editCursorPos);
+                const after = cpSlice(b, editCursorPos + 1);
+                return before + after;
+              });
+              // Cursor position stays the same for delete
+            }
+            return;
+          }
+          if (name === 'escape') {
+            commitNumberEdit(editingKey);
+            return;
+          }
+          if (name === 'return') {
+            commitNumberEdit(editingKey);
+            return;
+          }
+          // Allow digits, minus, plus, and dot
+          const ch = key.sequence;
+          if (/[0-9\-+.]/.test(ch)) {
+            setEditBuffer((currentBuffer) => {
+              const beforeCursor = cpSlice(currentBuffer, 0, editCursorPos);
+              const afterCursor = cpSlice(currentBuffer, editCursorPos);
+              return beforeCursor + ch + afterCursor;
+            });
+            setEditCursorPos((pos) => pos + 1);
+            return;
+          }
+          // Arrow key navigation
+          if (name === 'left') {
+            setEditCursorPos((pos) => Math.max(0, pos - 1));
+            return;
+          }
+          if (name === 'right') {
+            setEditCursorPos((pos) => Math.min(cpLen(editBuffer), pos + 1));
+            return;
+          }
+          // Home and End keys
+          if (name === 'home') {
+            setEditCursorPos(0);
+            return;
+          }
+          if (name === 'end') {
+            setEditCursorPos(cpLen(editBuffer));
+            return;
+          }
+          // Block other keys while editing
+          return;
+        }
         if (name === 'up' || name === 'k') {
+          // If editing, commit first
+          if (editingKey) {
+            commitNumberEdit(editingKey);
+          }
           const newIndex =
             activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
           setActiveSettingIndex(newIndex);
@@ -275,6 +434,10 @@ export function SettingsDialog({
             setScrollOffset(newIndex);
           }
         } else if (name === 'down' || name === 'j') {
+          // If editing, commit first
+          if (editingKey) {
+            commitNumberEdit(editingKey);
+          }
           const newIndex =
             activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
           setActiveSettingIndex(newIndex);
@@ -285,24 +448,44 @@ export function SettingsDialog({
             setScrollOffset(newIndex - maxItemsToShow + 1);
           }
         } else if (name === 'return' || name === 'space') {
-          items[activeSettingIndex]?.toggle();
+          const currentItem = items[activeSettingIndex];
+          if (currentItem?.type === 'number') {
+            startEditingNumber(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);
+          }
         } else if (ctrl && (name === 'c' || name === 'l')) {
           // Ctrl+C or Ctrl+L: Clear current setting and reset to default
           const currentSetting = items[activeSettingIndex];
           if (currentSetting) {
             const defaultValue = getDefaultValue(currentSetting.value);
-            // Ensure defaultValue is a boolean for setPendingSettingValue
-            const booleanDefaultValue =
-              typeof defaultValue === 'boolean' ? defaultValue : false;
-
-            // Update pending settings to default value
-            setPendingSettings((prev) =>
-              setPendingSettingValue(
-                currentSetting.value,
-                booleanDefaultValue,
-                prev,
-              ),
-            );
+            const defType = currentSetting.type;
+            if (defType === 'boolean') {
+              const booleanDefaultValue =
+                typeof defaultValue === 'boolean' ? defaultValue : false;
+              setPendingSettings((prev) =>
+                setPendingSettingValue(
+                  currentSetting.value,
+                  booleanDefaultValue,
+                  prev,
+                ),
+              );
+            } else if (defType === 'number') {
+              if (typeof defaultValue === 'number') {
+                setPendingSettings((prev) =>
+                  setPendingSettingValueAny(
+                    currentSetting.value,
+                    defaultValue,
+                    prev,
+                  ),
+                );
+              }
+            }
 
             // Remove from modified settings since it's now at default
             setModifiedSettings((prev) => {
@@ -321,11 +504,22 @@ export function SettingsDialog({
             // If this setting doesn't require restart, save it immediately
             if (!requiresRestart(currentSetting.value)) {
               const immediateSettings = new Set([currentSetting.value]);
-              const immediateSettingsObject = setPendingSettingValue(
-                currentSetting.value,
-                booleanDefaultValue,
-                {},
-              );
+              const toSaveValue =
+                currentSetting.type === 'boolean'
+                  ? typeof defaultValue === 'boolean'
+                    ? defaultValue
+                    : false
+                  : typeof defaultValue === 'number'
+                    ? defaultValue
+                    : undefined;
+              const immediateSettingsObject =
+                toSaveValue !== undefined
+                  ? setPendingSettingValueAny(
+                      currentSetting.value,
+                      toSaveValue,
+                      {} as Settings,
+                    )
+                  : ({} as Settings);
 
               saveModifiedSettings(
                 immediateSettings,
@@ -333,6 +527,28 @@ export function SettingsDialog({
                 settings,
                 selectedScope,
               );
+
+              // Remove from global pending changes if present
+              setGlobalPendingChanges((prev) => {
+                if (!prev.has(currentSetting.value)) return prev;
+                const next = new Map(prev);
+                next.delete(currentSetting.value);
+                return next;
+              });
+            } else {
+              // Track default reset as a pending change if restart required
+              if (
+                (currentSetting.type === 'boolean' &&
+                  typeof defaultValue === 'boolean') ||
+                (currentSetting.type === 'number' &&
+                  typeof defaultValue === 'number')
+              ) {
+                setGlobalPendingChanges((prev) => {
+                  const next = new Map(prev);
+                  next.set(currentSetting.value, defaultValue as PendingValue);
+                  return next;
+                });
+              }
             }
           }
         }
@@ -350,6 +566,16 @@ export function SettingsDialog({
             settings,
             selectedScope,
           );
+
+          // Remove saved keys from global pending changes
+          setGlobalPendingChanges((prev) => {
+            if (prev.size === 0) return prev;
+            const next = new Map(prev);
+            for (const key of restartRequiredSet) {
+              next.delete(key);
+            }
+            return next;
+          });
         }
 
         setShowRestartPrompt(false);
@@ -357,7 +583,11 @@ export function SettingsDialog({
         if (onRestartRequest) onRestartRequest();
       }
       if (name === 'escape') {
-        onSelect(undefined, selectedScope);
+        if (editingKey) {
+          commitNumberEdit(editingKey);
+        } else {
+          onSelect(undefined, selectedScope);
+        }
       }
     },
     { isActive: true },
@@ -385,13 +615,66 @@ export function SettingsDialog({
 
           const scopeSettings = settings.forScope(selectedScope).settings;
           const mergedSettings = settings.merged;
-          const displayValue = getDisplayValue(
-            item.value,
-            scopeSettings,
-            mergedSettings,
-            modifiedSettings,
-            pendingSettings,
-          );
+
+          let displayValue: string;
+          if (editingKey === item.value) {
+            // Show edit buffer with advanced cursor highlighting
+            if (cursorVisible && editCursorPos < cpLen(editBuffer)) {
+              // Cursor is in the middle or at start of text
+              const beforeCursor = cpSlice(editBuffer, 0, editCursorPos);
+              const atCursor = cpSlice(
+                editBuffer,
+                editCursorPos,
+                editCursorPos + 1,
+              );
+              const afterCursor = cpSlice(editBuffer, editCursorPos + 1);
+              displayValue =
+                beforeCursor + chalk.inverse(atCursor) + afterCursor;
+            } else if (cursorVisible && editCursorPos >= cpLen(editBuffer)) {
+              // Cursor is at the end - show inverted space
+              displayValue = editBuffer + chalk.inverse(' ');
+            } else {
+              // Cursor not visible
+              displayValue = editBuffer;
+            }
+          } else if (item.type === 'number') {
+            // For numbers, get the actual current value from pending settings
+            const path = item.value.split('.');
+            const currentValue = getNestedValue(pendingSettings, path);
+
+            const defaultValue = getDefaultValue(item.value);
+
+            if (currentValue !== undefined && currentValue !== null) {
+              displayValue = String(currentValue);
+            } else {
+              displayValue =
+                defaultValue !== undefined && defaultValue !== null
+                  ? String(defaultValue)
+                  : '';
+            }
+
+            // Add * if value differs from default OR if currently being modified
+            const isModified = modifiedSettings.has(item.value);
+            const effectiveCurrentValue =
+              currentValue !== undefined && currentValue !== null
+                ? currentValue
+                : defaultValue;
+            const isDifferentFromDefault =
+              effectiveCurrentValue !== defaultValue;
+
+            if (isDifferentFromDefault || isModified) {
+              displayValue += '*';
+            }
+          } else {
+            // For booleans and other types, use existing logic
+            displayValue = getDisplayValue(
+              item.value,
+              scopeSettings,
+              mergedSettings,
+              modifiedSettings,
+              pendingSettings,
+            );
+          }
           const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings);
 
           // Generate scope message for this setting
-- 
cgit v1.2.3

