@evolve.labs/devflow 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.claude/commands/agents/architect.md +1162 -0
  2. package/.claude/commands/agents/architect.meta.yaml +124 -0
  3. package/.claude/commands/agents/builder.md +1432 -0
  4. package/.claude/commands/agents/builder.meta.yaml +117 -0
  5. package/.claude/commands/agents/chronicler.md +633 -0
  6. package/.claude/commands/agents/chronicler.meta.yaml +217 -0
  7. package/.claude/commands/agents/guardian.md +456 -0
  8. package/.claude/commands/agents/guardian.meta.yaml +127 -0
  9. package/.claude/commands/agents/strategist.md +483 -0
  10. package/.claude/commands/agents/strategist.meta.yaml +158 -0
  11. package/.claude/commands/agents/system-designer.md +1137 -0
  12. package/.claude/commands/agents/system-designer.meta.yaml +156 -0
  13. package/.claude/commands/devflow-help.md +93 -0
  14. package/.claude/commands/devflow-status.md +60 -0
  15. package/.claude/commands/quick/create-adr.md +82 -0
  16. package/.claude/commands/quick/new-feature.md +57 -0
  17. package/.claude/commands/quick/security-check.md +54 -0
  18. package/.claude/commands/quick/system-design.md +58 -0
  19. package/.claude_project +52 -0
  20. package/.devflow/agents/architect.meta.yaml +122 -0
  21. package/.devflow/agents/builder.meta.yaml +116 -0
  22. package/.devflow/agents/chronicler.meta.yaml +222 -0
  23. package/.devflow/agents/guardian.meta.yaml +127 -0
  24. package/.devflow/agents/strategist.meta.yaml +158 -0
  25. package/.devflow/agents/system-designer.meta.yaml +265 -0
  26. package/.devflow/project.yaml +242 -0
  27. package/.gitignore-template +84 -0
  28. package/LICENSE +21 -0
  29. package/README.md +249 -0
  30. package/bin/devflow.js +54 -0
  31. package/lib/autopilot.js +235 -0
  32. package/lib/autopilotConstants.js +213 -0
  33. package/lib/constants.js +95 -0
  34. package/lib/init.js +200 -0
  35. package/lib/update.js +181 -0
  36. package/lib/utils.js +157 -0
  37. package/lib/web.js +119 -0
  38. package/package.json +57 -0
  39. package/web/CHANGELOG.md +192 -0
  40. package/web/README.md +156 -0
  41. package/web/app/api/autopilot/execute/route.ts +102 -0
  42. package/web/app/api/autopilot/terminal-execute/route.ts +124 -0
  43. package/web/app/api/files/route.ts +280 -0
  44. package/web/app/api/files/tree/route.ts +160 -0
  45. package/web/app/api/git/route.ts +201 -0
  46. package/web/app/api/health/route.ts +94 -0
  47. package/web/app/api/project/open/route.ts +134 -0
  48. package/web/app/api/search/route.ts +247 -0
  49. package/web/app/api/specs/route.ts +405 -0
  50. package/web/app/api/terminal/route.ts +222 -0
  51. package/web/app/globals.css +160 -0
  52. package/web/app/ide/layout.tsx +43 -0
  53. package/web/app/ide/page.tsx +216 -0
  54. package/web/app/layout.tsx +34 -0
  55. package/web/app/page.tsx +303 -0
  56. package/web/components/agents/AgentIcons.tsx +281 -0
  57. package/web/components/autopilot/AutopilotConfigModal.tsx +245 -0
  58. package/web/components/autopilot/AutopilotPanel.tsx +299 -0
  59. package/web/components/dashboard/DashboardPanel.tsx +393 -0
  60. package/web/components/editor/Breadcrumbs.tsx +134 -0
  61. package/web/components/editor/EditorPanel.tsx +120 -0
  62. package/web/components/editor/EditorTabs.tsx +229 -0
  63. package/web/components/editor/MarkdownPreview.tsx +154 -0
  64. package/web/components/editor/MermaidDiagram.tsx +113 -0
  65. package/web/components/editor/MonacoEditor.tsx +177 -0
  66. package/web/components/editor/TabContextMenu.tsx +207 -0
  67. package/web/components/git/GitPanel.tsx +534 -0
  68. package/web/components/layout/Shell.tsx +15 -0
  69. package/web/components/layout/StatusBar.tsx +100 -0
  70. package/web/components/modals/CommandPalette.tsx +393 -0
  71. package/web/components/modals/GlobalSearch.tsx +348 -0
  72. package/web/components/modals/QuickOpen.tsx +241 -0
  73. package/web/components/modals/RecentFiles.tsx +208 -0
  74. package/web/components/projects/ProjectSelector.tsx +147 -0
  75. package/web/components/settings/SettingItem.tsx +150 -0
  76. package/web/components/settings/SettingsPanel.tsx +323 -0
  77. package/web/components/specs/SpecsPanel.tsx +1091 -0
  78. package/web/components/terminal/TerminalPanel.tsx +683 -0
  79. package/web/components/ui/ContextMenu.tsx +182 -0
  80. package/web/components/ui/LoadingSpinner.tsx +66 -0
  81. package/web/components/ui/ResizeHandle.tsx +110 -0
  82. package/web/components/ui/Skeleton.tsx +108 -0
  83. package/web/components/ui/SkipLinks.tsx +37 -0
  84. package/web/components/ui/Toaster.tsx +57 -0
  85. package/web/hooks/useFocusTrap.ts +141 -0
  86. package/web/hooks/useKeyboardShortcuts.ts +169 -0
  87. package/web/hooks/useListNavigation.ts +237 -0
  88. package/web/lib/autopilotConstants.ts +213 -0
  89. package/web/lib/constants/agents.ts +67 -0
  90. package/web/lib/git.ts +339 -0
  91. package/web/lib/ptyManager.ts +191 -0
  92. package/web/lib/specsParser.ts +299 -0
  93. package/web/lib/stores/autopilotStore.ts +288 -0
  94. package/web/lib/stores/fileStore.ts +550 -0
  95. package/web/lib/stores/gitStore.ts +386 -0
  96. package/web/lib/stores/projectStore.ts +196 -0
  97. package/web/lib/stores/settingsStore.ts +126 -0
  98. package/web/lib/stores/specsStore.ts +297 -0
  99. package/web/lib/stores/uiStore.ts +175 -0
  100. package/web/lib/types/index.ts +177 -0
  101. package/web/lib/utils.ts +98 -0
  102. package/web/next.config.js +50 -0
  103. package/web/package.json +54 -0
  104. package/web/postcss.config.js +6 -0
  105. package/web/tailwind.config.ts +68 -0
  106. package/web/tsconfig.json +41 -0
@@ -0,0 +1,208 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useMemo } from 'react';
4
+ import { useUIStore } from '@/lib/stores/uiStore';
5
+ import { useFileStore } from '@/lib/stores/fileStore';
6
+ import { useProjectStore } from '@/lib/stores/projectStore';
7
+ import { useFocusTrap } from '@/hooks/useFocusTrap';
8
+ import { useListNavigation } from '@/hooks/useListNavigation';
9
+ import { File, FileText, FileCode, FileJson, Clock, Pin } from 'lucide-react';
10
+ import { cn, getFileName } from '@/lib/utils';
11
+
12
+ interface RecentFileItem {
13
+ path: string;
14
+ name: string;
15
+ relativePath: string;
16
+ extension: string;
17
+ isOpen: boolean;
18
+ isPinned: boolean;
19
+ }
20
+
21
+ const getFileIcon = (extension: string) => {
22
+ switch (extension) {
23
+ case 'md':
24
+ return <FileText className="w-4 h-4 text-blue-400" aria-hidden="true" />;
25
+ case 'json':
26
+ case 'yaml':
27
+ case 'yml':
28
+ return <FileJson className="w-4 h-4 text-yellow-400" aria-hidden="true" />;
29
+ case 'ts':
30
+ case 'tsx':
31
+ case 'js':
32
+ case 'jsx':
33
+ return <FileCode className="w-4 h-4 text-green-400" aria-hidden="true" />;
34
+ default:
35
+ return <File className="w-4 h-4 text-gray-400" aria-hidden="true" />;
36
+ }
37
+ };
38
+
39
+ /**
40
+ * Recent Files modal (Ctrl+Tab).
41
+ * Shows recently opened files for quick navigation.
42
+ */
43
+ export function RecentFiles() {
44
+ const modalRef = useRef<HTMLDivElement>(null);
45
+ const listRef = useRef<HTMLDivElement>(null);
46
+
47
+ const { activeModal, closeModal } = useUIStore();
48
+ const { recentFiles, openFiles, pinnedFiles, openFile, activeFile } = useFileStore();
49
+ const { currentProject } = useProjectStore();
50
+
51
+ const isOpen = activeModal === 'recentFiles';
52
+
53
+ // Build list of recent files with metadata
54
+ const fileItems = useMemo((): RecentFileItem[] => {
55
+ const projectPath = currentProject?.path || '';
56
+
57
+ return recentFiles.map((path) => {
58
+ let relativePath = path;
59
+ if (projectPath && path.startsWith(projectPath)) {
60
+ relativePath = path.slice(projectPath.length);
61
+ if (relativePath.startsWith('/')) {
62
+ relativePath = relativePath.slice(1);
63
+ }
64
+ }
65
+
66
+ const name = getFileName(path);
67
+ const extension = name.includes('.') ? name.split('.').pop() || '' : '';
68
+
69
+ return {
70
+ path,
71
+ name,
72
+ relativePath,
73
+ extension,
74
+ isOpen: openFiles.some((f) => f.path === path),
75
+ isPinned: pinnedFiles.includes(path),
76
+ };
77
+ });
78
+ }, [recentFiles, openFiles, pinnedFiles, currentProject]);
79
+
80
+ // Keyboard navigation
81
+ const { selectedIndex, setSelectedIndex, handleKeyDown, isSelected } = useListNavigation({
82
+ items: fileItems,
83
+ onSelect: (item) => {
84
+ openFile(item.path);
85
+ closeModal();
86
+ },
87
+ onEscape: closeModal,
88
+ });
89
+
90
+ // Focus trap
91
+ useFocusTrap(modalRef, isOpen, {
92
+ onEscape: closeModal,
93
+ });
94
+
95
+ // Reset selection when opened
96
+ useEffect(() => {
97
+ if (isOpen) {
98
+ // Find current active file in the list and select it, or default to 0
99
+ const activeIndex = fileItems.findIndex((f) => f.path === activeFile);
100
+ setSelectedIndex(activeIndex >= 0 ? activeIndex : 0);
101
+ }
102
+ }, [isOpen, fileItems, activeFile, setSelectedIndex]);
103
+
104
+ // Scroll selected into view
105
+ useEffect(() => {
106
+ if (!listRef.current || !isOpen) return;
107
+ const selectedEl = listRef.current.children[selectedIndex] as HTMLElement;
108
+ selectedEl?.scrollIntoView({ block: 'nearest' });
109
+ }, [selectedIndex, isOpen]);
110
+
111
+ if (!isOpen) return null;
112
+
113
+ return (
114
+ <div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]">
115
+ {/* Backdrop */}
116
+ <div
117
+ className="absolute inset-0 bg-black/60 backdrop-blur-sm"
118
+ onClick={closeModal}
119
+ aria-hidden="true"
120
+ />
121
+
122
+ {/* Modal */}
123
+ <div
124
+ ref={modalRef}
125
+ className={cn(
126
+ 'relative w-full max-w-lg bg-[#12121a] border border-white/10 rounded-xl shadow-2xl overflow-hidden',
127
+ 'animate-in fade-in-0 zoom-in-95 duration-150'
128
+ )}
129
+ role="dialog"
130
+ aria-modal="true"
131
+ aria-label="Recent files"
132
+ onKeyDown={handleKeyDown}
133
+ >
134
+ {/* Header */}
135
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-white/10">
136
+ <Clock className="w-5 h-5 text-purple-400" aria-hidden="true" />
137
+ <h2 className="text-sm font-medium text-white">Recent Files</h2>
138
+ <div className="flex-1" />
139
+ <div className="text-xs text-gray-600 border border-white/10 px-1.5 py-0.5 rounded">
140
+ esc
141
+ </div>
142
+ </div>
143
+
144
+ {/* File List */}
145
+ <div
146
+ ref={listRef}
147
+ className="max-h-[50vh] overflow-y-auto"
148
+ role="listbox"
149
+ aria-label="Recent files list"
150
+ >
151
+ {fileItems.length === 0 ? (
152
+ <div className="px-4 py-8 text-center text-gray-500 text-sm">
153
+ No recent files
154
+ </div>
155
+ ) : (
156
+ fileItems.map((file, index) => (
157
+ <button
158
+ key={file.path}
159
+ onClick={() => {
160
+ openFile(file.path);
161
+ closeModal();
162
+ }}
163
+ onMouseEnter={() => setSelectedIndex(index)}
164
+ className={cn(
165
+ 'w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors',
166
+ isSelected(index)
167
+ ? 'bg-purple-500/20 border-l-2 border-purple-500'
168
+ : 'hover:bg-white/5 border-l-2 border-transparent'
169
+ )}
170
+ role="option"
171
+ aria-selected={isSelected(index)}
172
+ tabIndex={isSelected(index) ? 0 : -1}
173
+ >
174
+ {getFileIcon(file.extension)}
175
+
176
+ <div className="flex-1 min-w-0">
177
+ <div className="flex items-center gap-2">
178
+ <span className="text-sm text-white truncate">{file.name}</span>
179
+ {file.isPinned && (
180
+ <Pin className="w-3 h-3 text-purple-400 flex-shrink-0" aria-label="Pinned" />
181
+ )}
182
+ {file.isOpen && (
183
+ <span className="text-xs text-gray-500 flex-shrink-0">• open</span>
184
+ )}
185
+ </div>
186
+ <div className="text-xs text-gray-500 truncate">{file.relativePath}</div>
187
+ </div>
188
+
189
+ {file.path === activeFile && (
190
+ <span className="text-xs text-purple-400 flex-shrink-0">current</span>
191
+ )}
192
+ </button>
193
+ ))
194
+ )}
195
+ </div>
196
+
197
+ {/* Footer */}
198
+ <div className="flex items-center justify-between px-4 py-2 border-t border-white/10 text-xs text-gray-500">
199
+ <div className="flex items-center gap-4">
200
+ <span>↑↓ navigate</span>
201
+ <span>↵ open</span>
202
+ </div>
203
+ <div>{fileItems.length} files</div>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ );
208
+ }
@@ -0,0 +1,147 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect } from 'react';
4
+ import { FolderPlus, X, Check, ChevronDown } from 'lucide-react';
5
+ import { cn } from '@/lib/utils';
6
+ import { useProjectStore } from '@/lib/stores/projectStore';
7
+
8
+ export function ProjectSelector() {
9
+ const { projects, activeProjectPath, addProject, removeProject, setActiveProject } = useProjectStore();
10
+ const [isOpen, setIsOpen] = useState(false);
11
+ const [showAddInput, setShowAddInput] = useState(false);
12
+ const [newPath, setNewPath] = useState('');
13
+ const [isAdding, setIsAdding] = useState(false);
14
+ const dropdownRef = useRef<HTMLDivElement>(null);
15
+ const inputRef = useRef<HTMLInputElement>(null);
16
+
17
+ const activeProject = projects.find(p => p.path === activeProjectPath) || projects[0];
18
+
19
+ useEffect(() => {
20
+ if (showAddInput && inputRef.current) {
21
+ inputRef.current.focus();
22
+ }
23
+ }, [showAddInput]);
24
+
25
+ useEffect(() => {
26
+ const handleClickOutside = (e: MouseEvent) => {
27
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
28
+ setIsOpen(false);
29
+ setShowAddInput(false);
30
+ }
31
+ };
32
+ document.addEventListener('mousedown', handleClickOutside);
33
+ return () => document.removeEventListener('mousedown', handleClickOutside);
34
+ }, []);
35
+
36
+ const handleAddProject = async () => {
37
+ if (!newPath.trim()) return;
38
+ setIsAdding(true);
39
+ await addProject(newPath.trim());
40
+ setIsAdding(false);
41
+ setNewPath('');
42
+ setShowAddInput(false);
43
+ };
44
+
45
+ const getProjectName = (path: string) => path.split('/').pop() || path;
46
+
47
+ if (projects.length === 0) {
48
+ return null;
49
+ }
50
+
51
+ return (
52
+ <div className="relative" ref={dropdownRef}>
53
+ <button
54
+ onClick={() => setIsOpen(!isOpen)}
55
+ className="flex items-center gap-2 px-3 py-1.5 bg-white/5 hover:bg-white/10 rounded-lg transition-colors text-sm w-full"
56
+ >
57
+ <div className="w-2 h-2 rounded-full bg-green-400 flex-shrink-0" />
58
+ <span className="truncate text-white font-medium">
59
+ {activeProject ? getProjectName(activeProject.path) : 'No project'}
60
+ </span>
61
+ {projects.length > 1 && (
62
+ <span className="text-[10px] text-gray-500 flex-shrink-0">+{projects.length - 1}</span>
63
+ )}
64
+ <ChevronDown className={cn('w-3.5 h-3.5 text-gray-400 ml-auto flex-shrink-0 transition-transform', isOpen && 'rotate-180')} />
65
+ </button>
66
+
67
+ {isOpen && (
68
+ <div className="absolute top-full left-0 right-0 mt-1 bg-[#1a1a24] border border-white/10 rounded-lg shadow-xl z-50 overflow-hidden">
69
+ {/* Project list */}
70
+ <div className="max-h-48 overflow-y-auto">
71
+ {projects.map((project) => (
72
+ <div
73
+ key={project.path}
74
+ className={cn(
75
+ 'flex items-center gap-2 px-3 py-2 hover:bg-white/5 cursor-pointer group',
76
+ project.path === activeProjectPath && 'bg-purple-500/10'
77
+ )}
78
+ onClick={() => {
79
+ setActiveProject(project.path);
80
+ setIsOpen(false);
81
+ }}
82
+ >
83
+ {project.path === activeProjectPath ? (
84
+ <Check className="w-3.5 h-3.5 text-purple-400 flex-shrink-0" />
85
+ ) : (
86
+ <div className="w-3.5 h-3.5 flex-shrink-0" />
87
+ )}
88
+ <div className="flex-1 min-w-0">
89
+ <div className="text-sm text-white truncate">{getProjectName(project.path)}</div>
90
+ <div className="text-[10px] text-gray-500 truncate">{project.path}</div>
91
+ </div>
92
+ {projects.length > 1 && (
93
+ <button
94
+ onClick={(e) => {
95
+ e.stopPropagation();
96
+ removeProject(project.path);
97
+ }}
98
+ className="p-1 opacity-0 group-hover:opacity-100 hover:bg-white/10 rounded transition-all text-gray-400 hover:text-red-400"
99
+ title="Remove project"
100
+ >
101
+ <X className="w-3 h-3" />
102
+ </button>
103
+ )}
104
+ </div>
105
+ ))}
106
+ </div>
107
+
108
+ {/* Add project */}
109
+ <div className="border-t border-white/10">
110
+ {showAddInput ? (
111
+ <div className="p-2 flex gap-1">
112
+ <input
113
+ ref={inputRef}
114
+ type="text"
115
+ value={newPath}
116
+ onChange={(e) => setNewPath(e.target.value)}
117
+ onKeyDown={(e) => {
118
+ if (e.key === 'Enter') handleAddProject();
119
+ if (e.key === 'Escape') setShowAddInput(false);
120
+ }}
121
+ placeholder="/path/to/project"
122
+ className="flex-1 px-2 py-1.5 bg-white/5 border border-white/10 rounded text-sm text-white placeholder-gray-500 focus:outline-none focus:border-purple-500"
123
+ disabled={isAdding}
124
+ />
125
+ <button
126
+ onClick={handleAddProject}
127
+ disabled={isAdding || !newPath.trim()}
128
+ className="px-2 py-1.5 bg-purple-600 hover:bg-purple-500 text-white text-xs rounded transition-colors disabled:opacity-50"
129
+ >
130
+ {isAdding ? '...' : 'Add'}
131
+ </button>
132
+ </div>
133
+ ) : (
134
+ <button
135
+ onClick={() => setShowAddInput(true)}
136
+ className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:text-white hover:bg-white/5 transition-colors"
137
+ >
138
+ <FolderPlus className="w-3.5 h-3.5" />
139
+ Add Project
140
+ </button>
141
+ )}
142
+ </div>
143
+ </div>
144
+ )}
145
+ </div>
146
+ );
147
+ }
@@ -0,0 +1,150 @@
1
+ 'use client';
2
+
3
+ import { cn } from '@/lib/utils';
4
+
5
+ interface BaseSettingProps {
6
+ label: string;
7
+ description?: string;
8
+ }
9
+
10
+ interface ToggleSettingProps extends BaseSettingProps {
11
+ type: 'toggle';
12
+ value: boolean;
13
+ onChange: (value: boolean) => void;
14
+ }
15
+
16
+ interface SliderSettingProps extends BaseSettingProps {
17
+ type: 'slider';
18
+ value: number;
19
+ onChange: (value: number) => void;
20
+ min: number;
21
+ max: number;
22
+ step?: number;
23
+ unit?: string;
24
+ }
25
+
26
+ interface SelectSettingProps<T extends string | number> extends BaseSettingProps {
27
+ type: 'select';
28
+ value: T;
29
+ onChange: (value: T) => void;
30
+ options: { label: string; value: T; disabled?: boolean }[];
31
+ }
32
+
33
+ type SettingItemProps =
34
+ | ToggleSettingProps
35
+ | SliderSettingProps
36
+ | SelectSettingProps<string | number>;
37
+
38
+ export function SettingItem(props: SettingItemProps) {
39
+ const { label, description, type } = props;
40
+
41
+ return (
42
+ <div className="flex items-center justify-between py-3 border-b border-white/5 last:border-0">
43
+ <div className="flex-1 pr-4">
44
+ <h4 className="text-sm font-medium text-white">{label}</h4>
45
+ {description && (
46
+ <p className="text-xs text-gray-400 mt-0.5">{description}</p>
47
+ )}
48
+ </div>
49
+
50
+ <div className="flex-shrink-0">
51
+ {type === 'toggle' && <ToggleControl {...props} />}
52
+ {type === 'slider' && <SliderControl {...props} />}
53
+ {type === 'select' && <SelectControl {...props} />}
54
+ </div>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ function ToggleControl({ value, onChange }: ToggleSettingProps) {
60
+ return (
61
+ <button
62
+ onClick={() => onChange(!value)}
63
+ className={cn(
64
+ 'relative w-10 h-5 rounded-full transition-colors duration-200',
65
+ value ? 'bg-purple-500' : 'bg-gray-600'
66
+ )}
67
+ >
68
+ <span
69
+ className={cn(
70
+ 'absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform duration-200',
71
+ value && 'translate-x-5'
72
+ )}
73
+ />
74
+ </button>
75
+ );
76
+ }
77
+
78
+ function SliderControl({ value, onChange, min, max, step = 1, unit }: SliderSettingProps) {
79
+ return (
80
+ <div className="flex items-center gap-3">
81
+ <input
82
+ type="range"
83
+ value={value}
84
+ onChange={(e) => onChange(Number(e.target.value))}
85
+ min={min}
86
+ max={max}
87
+ step={step}
88
+ className="w-24 h-1.5 bg-gray-600 rounded-full appearance-none cursor-pointer
89
+ [&::-webkit-slider-thumb]:appearance-none
90
+ [&::-webkit-slider-thumb]:w-3.5
91
+ [&::-webkit-slider-thumb]:h-3.5
92
+ [&::-webkit-slider-thumb]:rounded-full
93
+ [&::-webkit-slider-thumb]:bg-purple-500
94
+ [&::-webkit-slider-thumb]:cursor-pointer
95
+ [&::-webkit-slider-thumb]:transition-transform
96
+ [&::-webkit-slider-thumb]:hover:scale-110"
97
+ />
98
+ <span className="text-xs text-gray-400 w-12 text-right tabular-nums">
99
+ {value}{unit}
100
+ </span>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ function SelectControl<T extends string | number>({
106
+ value,
107
+ onChange,
108
+ options,
109
+ }: SelectSettingProps<T>) {
110
+ return (
111
+ <select
112
+ value={value}
113
+ onChange={(e) => {
114
+ const newValue = typeof value === 'number'
115
+ ? Number(e.target.value)
116
+ : e.target.value;
117
+ onChange(newValue as T);
118
+ }}
119
+ className="bg-[#1a1a24] border border-white/10 rounded px-2 py-1 text-sm text-white
120
+ focus:outline-none focus:ring-1 focus:ring-purple-500 cursor-pointer
121
+ [&>option]:bg-[#1a1a24] [&>option]:text-white"
122
+ >
123
+ {options.map((opt) => (
124
+ <option key={String(opt.value)} value={opt.value} disabled={opt.disabled}>
125
+ {opt.label}
126
+ </option>
127
+ ))}
128
+ </select>
129
+ );
130
+ }
131
+
132
+ // Section header component
133
+ export function SettingSection({
134
+ title,
135
+ children,
136
+ }: {
137
+ title: string;
138
+ children: React.ReactNode;
139
+ }) {
140
+ return (
141
+ <div className="mb-6">
142
+ <h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">
143
+ {title}
144
+ </h3>
145
+ <div className="bg-[#12121a] rounded-lg p-4 border border-white/5">
146
+ {children}
147
+ </div>
148
+ </div>
149
+ );
150
+ }