better-codex 0.1.3 → 0.2.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.
@@ -0,0 +1,1208 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import { hubClient, type McpServerConfig } from '../../services/hub-client'
3
+ import { useAppStore } from '../../store'
4
+ import { Button, Icons, Input, Select, type SelectOption } from '../ui'
5
+
6
+ type EnvEntry = { key: string; value: string }
7
+
8
+ type McpServerDraft = {
9
+ name: string
10
+ transport: 'stdio' | 'http'
11
+ command: string
12
+ args: string
13
+ env: EnvEntry[]
14
+ env_vars: string
15
+ cwd: string
16
+ url: string
17
+ bearer_token_env_var: string
18
+ http_headers: EnvEntry[]
19
+ env_http_headers: EnvEntry[]
20
+ enabled: boolean
21
+ startup_timeout_sec: string
22
+ startup_timeout_ms: string
23
+ tool_timeout_sec: string
24
+ enabled_tools: string
25
+ disabled_tools: string
26
+ }
27
+
28
+ type StatusMessage = { type: 'error' | 'success'; message: string }
29
+
30
+ type McpTemplate = {
31
+ id: string
32
+ label: string
33
+ description: string
34
+ icon: 'terminal' | 'globe' | 'bolt'
35
+ preset: Partial<McpServerDraft>
36
+ }
37
+
38
+ type ActiveTab = 'servers' | 'config'
39
+
40
+ const transportOptions: SelectOption[] = [
41
+ { value: 'stdio', label: 'Local (stdio)', description: 'Launches a local MCP server command.' },
42
+ { value: 'http', label: 'Remote (HTTP)', description: 'Connects to a streamable HTTP MCP server.' },
43
+ ]
44
+
45
+ const mcpTemplates: McpTemplate[] = [
46
+ {
47
+ id: 'shell-tool',
48
+ label: 'Shell Tool',
49
+ description: 'Sandbox-aware shell commands',
50
+ icon: 'terminal',
51
+ preset: {
52
+ name: 'shell-tool',
53
+ transport: 'stdio',
54
+ command: 'npx',
55
+ args: '-y, @openai/codex-shell-tool-mcp',
56
+ },
57
+ },
58
+ {
59
+ id: 'playwright',
60
+ label: 'Playwright',
61
+ description: 'Browser automation',
62
+ icon: 'globe',
63
+ preset: {
64
+ name: 'playwright',
65
+ transport: 'stdio',
66
+ command: 'npx',
67
+ args: '-y, @playwright/mcp',
68
+ },
69
+ },
70
+ {
71
+ id: 'http',
72
+ label: 'HTTP Server',
73
+ description: 'Remote MCP endpoint',
74
+ icon: 'bolt',
75
+ preset: {
76
+ name: 'remote-mcp',
77
+ transport: 'http',
78
+ url: 'https://mcp.example.com/mcp',
79
+ bearer_token_env_var: 'MCP_TOKEN',
80
+ },
81
+ },
82
+ ]
83
+
84
+ type ConfigSnippet = {
85
+ id: string
86
+ label: string
87
+ description: string
88
+ content: string
89
+ }
90
+
91
+ const configSnippets: ConfigSnippet[] = [
92
+ {
93
+ id: 'mcp-stdio',
94
+ label: 'MCP stdio server',
95
+ description: 'Local command-based MCP server.',
96
+ content: `[mcp_servers.docs]
97
+ command = "npx"
98
+ args = ["-y", "mcp-server"]
99
+ `,
100
+ },
101
+ {
102
+ id: 'mcp-http',
103
+ label: 'MCP HTTP server',
104
+ description: 'Streamable HTTP MCP with bearer token.',
105
+ content: `[mcp_servers.remote]
106
+ url = "https://mcp.example.com/mcp"
107
+ bearer_token_env_var = "MCP_TOKEN"
108
+ `,
109
+ },
110
+ {
111
+ id: 'sandbox',
112
+ label: 'Sandbox + approvals',
113
+ description: 'Approval policy and sandbox preset.',
114
+ content: `approval_policy = "on-request"
115
+ sandbox_mode = "workspace-write"
116
+
117
+ [sandbox_workspace_write]
118
+ writable_roots = ["/path/to/workspace"]
119
+ network_access = false
120
+ `,
121
+ },
122
+ {
123
+ id: 'features',
124
+ label: 'Feature flags',
125
+ description: 'Centralized feature toggles.',
126
+ content: `[features]
127
+ unified_exec = false
128
+ apply_patch_freeform = false
129
+ view_image_tool = true
130
+ web_search_request = false
131
+ `,
132
+ },
133
+ {
134
+ id: 'provider',
135
+ label: 'Model provider',
136
+ description: 'Override a model provider endpoint.',
137
+ content: `[model_providers.acme]
138
+ name = "Acme"
139
+ base_url = "https://api.acme.example/v1"
140
+ env_key = "ACME_API_KEY"
141
+ wire_api = "responses"
142
+ `,
143
+ },
144
+ ]
145
+
146
+ const configSnippetOptions: SelectOption[] = configSnippets.map((snippet) => ({
147
+ value: snippet.id,
148
+ label: snippet.label,
149
+ description: snippet.description,
150
+ }))
151
+
152
+ // Field label component for consistent styling
153
+ function FieldLabel({ children, hint }: { children: React.ReactNode; hint?: string }) {
154
+ return (
155
+ <div className="flex items-center gap-2 mb-1.5">
156
+ <span className="text-xs font-medium text-text-secondary">{children}</span>
157
+ {hint && <span className="text-[10px] text-text-muted">({hint})</span>}
158
+ </div>
159
+ )
160
+ }
161
+
162
+ // Card component for server entries
163
+ function ServerCard({
164
+ server,
165
+ onUpdate,
166
+ onRemove,
167
+ disabled,
168
+ }: {
169
+ server: McpServerDraft
170
+ index: number
171
+ onUpdate: (updater: (prev: McpServerDraft) => McpServerDraft) => void
172
+ onRemove: () => void
173
+ disabled?: boolean
174
+ }) {
175
+ const [isExpanded, setIsExpanded] = useState(false)
176
+
177
+ return (
178
+ <div className={`group relative rounded-xl border transition-all duration-200 ${
179
+ server.enabled
180
+ ? 'bg-bg-secondary border-border hover:border-text-muted/40'
181
+ : 'bg-bg-primary border-border/50 opacity-60'
182
+ }`}>
183
+ {/* Header */}
184
+ <div className="flex items-center gap-3 p-4">
185
+ <div className={`w-2 h-2 rounded-full transition-colors ${
186
+ server.enabled ? 'bg-accent-green' : 'bg-text-muted'
187
+ }`} />
188
+
189
+ <div className="flex-1 min-w-0">
190
+ <div className="flex items-center gap-2">
191
+ <input
192
+ type="text"
193
+ value={server.name}
194
+ placeholder="server_name"
195
+ disabled={disabled}
196
+ onChange={(e) => onUpdate((prev) => ({ ...prev, name: e.target.value }))}
197
+ className="bg-transparent text-sm font-medium text-text-primary placeholder:text-text-muted outline-none flex-1 min-w-0"
198
+ />
199
+ <span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
200
+ server.transport === 'http'
201
+ ? 'bg-accent-blue/15 text-accent-blue'
202
+ : 'bg-accent-green/15 text-accent-green'
203
+ }`}>
204
+ {server.transport.toUpperCase()}
205
+ </span>
206
+ </div>
207
+ <p className="text-xs text-text-muted mt-0.5 truncate">
208
+ {server.transport === 'http'
209
+ ? server.url || 'No URL configured'
210
+ : server.command ? `${server.command} ${server.args}` : 'No command configured'
211
+ }
212
+ </p>
213
+ </div>
214
+
215
+ <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
216
+ <button
217
+ type="button"
218
+ onClick={() => onUpdate((prev) => ({ ...prev, enabled: !prev.enabled }))}
219
+ disabled={disabled}
220
+ className="p-1.5 rounded-lg hover:bg-bg-hover text-text-muted hover:text-text-primary transition-colors"
221
+ title={server.enabled ? 'Disable server' : 'Enable server'}
222
+ >
223
+ {server.enabled ? (
224
+ <Icons.Check className="w-4 h-4" />
225
+ ) : (
226
+ <Icons.X className="w-4 h-4" />
227
+ )}
228
+ </button>
229
+ <button
230
+ type="button"
231
+ onClick={() => setIsExpanded(!isExpanded)}
232
+ disabled={disabled}
233
+ className="p-1.5 rounded-lg hover:bg-bg-hover text-text-muted hover:text-text-primary transition-colors"
234
+ >
235
+ <Icons.Settings className="w-4 h-4" />
236
+ </button>
237
+ <button
238
+ type="button"
239
+ onClick={onRemove}
240
+ disabled={disabled}
241
+ className="p-1.5 rounded-lg hover:bg-accent-red/10 text-text-muted hover:text-accent-red transition-colors"
242
+ >
243
+ <Icons.Trash className="w-4 h-4" />
244
+ </button>
245
+ </div>
246
+ </div>
247
+
248
+ {/* Expanded content */}
249
+ {isExpanded && (
250
+ <div className="border-t border-border/60 p-4 space-y-4 bg-bg-primary/50 rounded-b-xl">
251
+ {/* Transport selection */}
252
+ <div>
253
+ <FieldLabel>Transport Type</FieldLabel>
254
+ <div className="flex gap-2">
255
+ {transportOptions.map((opt) => (
256
+ <button
257
+ key={opt.value}
258
+ type="button"
259
+ disabled={disabled}
260
+ onClick={() => onUpdate((prev) => ({ ...prev, transport: opt.value as 'stdio' | 'http' }))}
261
+ className={`flex-1 px-3 py-2 rounded-lg text-xs font-medium transition-all ${
262
+ server.transport === opt.value
263
+ ? 'bg-text-primary text-bg-primary'
264
+ : 'bg-bg-tertiary text-text-secondary hover:bg-bg-hover'
265
+ }`}
266
+ >
267
+ {opt.label}
268
+ </button>
269
+ ))}
270
+ </div>
271
+ </div>
272
+
273
+ {/* Transport-specific fields */}
274
+ {server.transport === 'stdio' ? (
275
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
276
+ <div>
277
+ <FieldLabel>Command</FieldLabel>
278
+ <Input
279
+ value={server.command}
280
+ placeholder="npx, node, python..."
281
+ onChange={(value) => onUpdate((prev) => ({ ...prev, command: value }))}
282
+ />
283
+ </div>
284
+ <div>
285
+ <FieldLabel hint="comma separated">Arguments</FieldLabel>
286
+ <Input
287
+ value={server.args}
288
+ placeholder="-y, @package/name"
289
+ onChange={(value) => onUpdate((prev) => ({ ...prev, args: value }))}
290
+ />
291
+ </div>
292
+ <div className="md:col-span-2">
293
+ <FieldLabel>Working Directory</FieldLabel>
294
+ <Input
295
+ value={server.cwd}
296
+ placeholder="/path/to/working/directory"
297
+ onChange={(value) => onUpdate((prev) => ({ ...prev, cwd: value }))}
298
+ />
299
+ </div>
300
+ </div>
301
+ ) : (
302
+ <div className="space-y-4">
303
+ <div>
304
+ <FieldLabel>Server URL</FieldLabel>
305
+ <Input
306
+ value={server.url}
307
+ placeholder="https://mcp.example.com/mcp"
308
+ onChange={(value) => onUpdate((prev) => ({ ...prev, url: value }))}
309
+ />
310
+ </div>
311
+ <div>
312
+ <FieldLabel hint="environment variable name">Bearer Token</FieldLabel>
313
+ <Input
314
+ value={server.bearer_token_env_var}
315
+ placeholder="MCP_TOKEN"
316
+ onChange={(value) => onUpdate((prev) => ({ ...prev, bearer_token_env_var: value }))}
317
+ />
318
+ </div>
319
+ </div>
320
+ )}
321
+
322
+ {/* Environment variables for stdio */}
323
+ {server.transport === 'stdio' && (
324
+ <div>
325
+ <div className="flex items-center justify-between mb-2">
326
+ <FieldLabel>Environment Variables</FieldLabel>
327
+ <button
328
+ type="button"
329
+ disabled={disabled}
330
+ onClick={() => onUpdate((prev) => ({ ...prev, env: [...prev.env, { key: '', value: '' }] }))}
331
+ className="text-xs text-text-muted hover:text-text-primary transition-colors"
332
+ >
333
+ + Add variable
334
+ </button>
335
+ </div>
336
+ {server.env.length === 0 ? (
337
+ <p className="text-xs text-text-muted italic">No environment variables</p>
338
+ ) : (
339
+ <div className="space-y-2">
340
+ {server.env.map((entry, envIndex) => (
341
+ <div key={envIndex} className="flex items-center gap-2">
342
+ <Input
343
+ value={entry.key}
344
+ placeholder="KEY"
345
+ onChange={(value) => onUpdate((prev) => ({
346
+ ...prev,
347
+ env: prev.env.map((item, idx) => idx === envIndex ? { ...item, key: value } : item),
348
+ }))}
349
+ className="flex-1"
350
+ />
351
+ <span className="text-text-muted">=</span>
352
+ <Input
353
+ value={entry.value}
354
+ placeholder="value"
355
+ onChange={(value) => onUpdate((prev) => ({
356
+ ...prev,
357
+ env: prev.env.map((item, idx) => idx === envIndex ? { ...item, value } : item),
358
+ }))}
359
+ className="flex-1"
360
+ />
361
+ <button
362
+ type="button"
363
+ onClick={() => onUpdate((prev) => ({
364
+ ...prev,
365
+ env: prev.env.filter((_, idx) => idx !== envIndex),
366
+ }))}
367
+ className="p-1.5 rounded hover:bg-accent-red/10 text-text-muted hover:text-accent-red transition-colors"
368
+ >
369
+ <Icons.X className="w-3.5 h-3.5" />
370
+ </button>
371
+ </div>
372
+ ))}
373
+ </div>
374
+ )}
375
+ </div>
376
+ )}
377
+
378
+ {/* HTTP Headers for http transport */}
379
+ {server.transport === 'http' && (
380
+ <div>
381
+ <div className="flex items-center justify-between mb-2">
382
+ <FieldLabel>HTTP Headers</FieldLabel>
383
+ <button
384
+ type="button"
385
+ disabled={disabled}
386
+ onClick={() => onUpdate((prev) => ({ ...prev, http_headers: [...prev.http_headers, { key: '', value: '' }] }))}
387
+ className="text-xs text-text-muted hover:text-text-primary transition-colors"
388
+ >
389
+ + Add header
390
+ </button>
391
+ </div>
392
+ {server.http_headers.length === 0 ? (
393
+ <p className="text-xs text-text-muted italic">No custom headers</p>
394
+ ) : (
395
+ <div className="space-y-2">
396
+ {server.http_headers.map((entry, headerIndex) => (
397
+ <div key={headerIndex} className="flex items-center gap-2">
398
+ <Input
399
+ value={entry.key}
400
+ placeholder="Header-Name"
401
+ onChange={(value) => onUpdate((prev) => ({
402
+ ...prev,
403
+ http_headers: prev.http_headers.map((item, idx) => idx === headerIndex ? { ...item, key: value } : item),
404
+ }))}
405
+ className="flex-1"
406
+ />
407
+ <span className="text-text-muted">:</span>
408
+ <Input
409
+ value={entry.value}
410
+ placeholder="value"
411
+ onChange={(value) => onUpdate((prev) => ({
412
+ ...prev,
413
+ http_headers: prev.http_headers.map((item, idx) => idx === headerIndex ? { ...item, value } : item),
414
+ }))}
415
+ className="flex-1"
416
+ />
417
+ <button
418
+ type="button"
419
+ onClick={() => onUpdate((prev) => ({
420
+ ...prev,
421
+ http_headers: prev.http_headers.filter((_, idx) => idx !== headerIndex),
422
+ }))}
423
+ className="p-1.5 rounded hover:bg-accent-red/10 text-text-muted hover:text-accent-red transition-colors"
424
+ >
425
+ <Icons.X className="w-3.5 h-3.5" />
426
+ </button>
427
+ </div>
428
+ ))}
429
+ </div>
430
+ )}
431
+ </div>
432
+ )}
433
+
434
+ {/* Advanced section */}
435
+ <details className="group/advanced">
436
+ <summary className="text-xs text-text-muted cursor-pointer hover:text-text-secondary transition-colors list-none flex items-center gap-1">
437
+ <Icons.ChevronRight className="w-3 h-3 transition-transform group-open/advanced:rotate-90" />
438
+ Advanced options
439
+ </summary>
440
+ <div className="mt-3 grid grid-cols-1 md:grid-cols-3 gap-3">
441
+ <div>
442
+ <FieldLabel hint="seconds">Startup Timeout</FieldLabel>
443
+ <Input
444
+ value={server.startup_timeout_sec}
445
+ placeholder="30"
446
+ onChange={(value) => onUpdate((prev) => ({ ...prev, startup_timeout_sec: value }))}
447
+ />
448
+ </div>
449
+ <div>
450
+ <FieldLabel hint="seconds">Tool Timeout</FieldLabel>
451
+ <Input
452
+ value={server.tool_timeout_sec}
453
+ placeholder="60"
454
+ onChange={(value) => onUpdate((prev) => ({ ...prev, tool_timeout_sec: value }))}
455
+ />
456
+ </div>
457
+ <div>
458
+ <FieldLabel hint="comma separated">Enabled Tools</FieldLabel>
459
+ <Input
460
+ value={server.enabled_tools}
461
+ placeholder="tool1, tool2"
462
+ onChange={(value) => onUpdate((prev) => ({ ...prev, enabled_tools: value }))}
463
+ />
464
+ </div>
465
+ </div>
466
+ </details>
467
+ </div>
468
+ )}
469
+ </div>
470
+ )
471
+ }
472
+
473
+ const splitList = (value: string) =>
474
+ value
475
+ .split(/[\n,]/u)
476
+ .map((item) => item.trim())
477
+ .filter(Boolean)
478
+
479
+ const toEntryList = (record: Record<string, string> | undefined): EnvEntry[] =>
480
+ Object.entries(record ?? {}).map(([key, value]) => ({ key, value }))
481
+
482
+ const toRecord = (entries: EnvEntry[]): Record<string, string> | undefined => {
483
+ const filtered = entries
484
+ .map((entry) => ({ key: entry.key.trim(), value: entry.value }))
485
+ .filter((entry) => entry.key.length > 0)
486
+ if (filtered.length === 0) {
487
+ return undefined
488
+ }
489
+ return Object.fromEntries(filtered.map((entry) => [entry.key, entry.value]))
490
+ }
491
+
492
+ const listToString = (items: string[] | undefined) => (items ?? []).join(', ')
493
+
494
+ const toDraft = (server: McpServerConfig): McpServerDraft => ({
495
+ name: server.name,
496
+ transport: server.url ? 'http' : 'stdio',
497
+ command: server.command ?? '',
498
+ args: listToString(server.args),
499
+ env: toEntryList(server.env),
500
+ env_vars: listToString(server.env_vars),
501
+ cwd: server.cwd ?? '',
502
+ url: server.url ?? '',
503
+ bearer_token_env_var: server.bearer_token_env_var ?? '',
504
+ http_headers: toEntryList(server.http_headers),
505
+ env_http_headers: toEntryList(server.env_http_headers),
506
+ enabled: server.enabled ?? true,
507
+ startup_timeout_sec:
508
+ server.startup_timeout_ms !== undefined
509
+ ? ''
510
+ : server.startup_timeout_sec !== undefined
511
+ ? String(server.startup_timeout_sec)
512
+ : '',
513
+ startup_timeout_ms: server.startup_timeout_ms !== undefined ? String(server.startup_timeout_ms) : '',
514
+ tool_timeout_sec: server.tool_timeout_sec !== undefined ? String(server.tool_timeout_sec) : '',
515
+ enabled_tools: listToString(server.enabled_tools),
516
+ disabled_tools: listToString(server.disabled_tools),
517
+ })
518
+
519
+ const toConfig = (draft: McpServerDraft): McpServerConfig => {
520
+ const env = toRecord(draft.env)
521
+ const httpHeaders = toRecord(draft.http_headers)
522
+ const envHttpHeaders = toRecord(draft.env_http_headers)
523
+
524
+ const listOrUndefined = (value: string) => {
525
+ const items = splitList(value)
526
+ return items.length ? items : undefined
527
+ }
528
+
529
+ const numberOrUndefined = (value: string) => {
530
+ if (!value.trim()) {
531
+ return undefined
532
+ }
533
+ const parsed = Number(value)
534
+ return Number.isFinite(parsed) ? parsed : undefined
535
+ }
536
+
537
+ const startupTimeoutMs = numberOrUndefined(draft.startup_timeout_ms)
538
+ const startupTimeoutSec = startupTimeoutMs === undefined ? numberOrUndefined(draft.startup_timeout_sec) : undefined
539
+
540
+ const base: McpServerConfig = {
541
+ name: draft.name.trim(),
542
+ enabled: draft.enabled ? undefined : false,
543
+ startup_timeout_sec: startupTimeoutSec,
544
+ startup_timeout_ms: startupTimeoutMs,
545
+ tool_timeout_sec: numberOrUndefined(draft.tool_timeout_sec),
546
+ enabled_tools: listOrUndefined(draft.enabled_tools),
547
+ disabled_tools: listOrUndefined(draft.disabled_tools),
548
+ }
549
+
550
+ if (draft.transport === 'http') {
551
+ return {
552
+ ...base,
553
+ url: draft.url.trim() || undefined,
554
+ bearer_token_env_var: draft.bearer_token_env_var.trim() || undefined,
555
+ http_headers: httpHeaders,
556
+ env_http_headers: envHttpHeaders,
557
+ }
558
+ }
559
+
560
+ return {
561
+ ...base,
562
+ command: draft.command.trim() || undefined,
563
+ args: listOrUndefined(draft.args),
564
+ env,
565
+ env_vars: listOrUndefined(draft.env_vars),
566
+ cwd: draft.cwd.trim() || undefined,
567
+ }
568
+ }
569
+
570
+ export function CodexSettings() {
571
+ const { accounts, selectedAccountId, connectionStatus } = useAppStore()
572
+ const profileOptions = useMemo<SelectOption[]>(
573
+ () =>
574
+ accounts.map((account) => ({
575
+ value: account.id,
576
+ label: account.name,
577
+ })),
578
+ [accounts]
579
+ )
580
+ const [profileId, setProfileId] = useState<string | null>(
581
+ selectedAccountId ?? accounts[0]?.id ?? null
582
+ )
583
+ const [configPath, setConfigPath] = useState('')
584
+ const [configSaved, setConfigSaved] = useState('')
585
+ const [configDraft, setConfigDraft] = useState('')
586
+ const [mcpDraft, setMcpDraft] = useState<McpServerDraft[]>([])
587
+ const [mcpBaseline, setMcpBaseline] = useState('')
588
+ const [loading, setLoading] = useState(false)
589
+ const [savingConfig, setSavingConfig] = useState(false)
590
+ const [savingMcp, setSavingMcp] = useState(false)
591
+ const [status, setStatus] = useState<StatusMessage | null>(null)
592
+ const [snippetId, setSnippetId] = useState(configSnippets[0]?.id ?? '')
593
+
594
+ const configDirty = configDraft !== configSaved
595
+ const mcpDirty = mcpBaseline !== JSON.stringify(mcpDraft)
596
+ const selectedSnippet = configSnippets.find((snippet) => snippet.id === snippetId) ?? null
597
+
598
+ useEffect(() => {
599
+ if (selectedAccountId) {
600
+ setProfileId(selectedAccountId)
601
+ } else if (!profileId && accounts.length > 0) {
602
+ setProfileId(accounts[0].id)
603
+ }
604
+ }, [accounts, profileId, selectedAccountId])
605
+
606
+ useEffect(() => {
607
+ if (!profileId) {
608
+ return
609
+ }
610
+ let active = true
611
+ setLoading(true)
612
+ setStatus(null)
613
+ hubClient
614
+ .getProfileConfig(profileId)
615
+ .then((snapshot) => {
616
+ if (!active) return
617
+ setConfigPath(snapshot.path)
618
+ setConfigSaved(snapshot.content)
619
+ setConfigDraft(snapshot.content)
620
+ const nextDraft = snapshot.mcpServers.map(toDraft)
621
+ setMcpDraft(nextDraft)
622
+ setMcpBaseline(JSON.stringify(nextDraft))
623
+ })
624
+ .catch(() => {
625
+ if (!active) return
626
+ setStatus({ type: 'error', message: 'Failed to load config for this profile.' })
627
+ })
628
+ .finally(() => {
629
+ if (!active) return
630
+ setLoading(false)
631
+ })
632
+
633
+ return () => {
634
+ active = false
635
+ }
636
+ }, [profileId])
637
+
638
+ const updateServer = (index: number, updater: (server: McpServerDraft) => McpServerDraft) => {
639
+ setMcpDraft((prev) => prev.map((server, idx) => (idx === index ? updater(server) : server)))
640
+ }
641
+
642
+ const createEmptyServer = (): McpServerDraft => ({
643
+ name: '',
644
+ transport: 'stdio',
645
+ command: '',
646
+ args: '',
647
+ env: [],
648
+ env_vars: '',
649
+ cwd: '',
650
+ url: '',
651
+ bearer_token_env_var: '',
652
+ http_headers: [],
653
+ env_http_headers: [],
654
+ enabled: true,
655
+ startup_timeout_sec: '',
656
+ startup_timeout_ms: '',
657
+ tool_timeout_sec: '',
658
+ enabled_tools: '',
659
+ disabled_tools: '',
660
+ })
661
+
662
+ const addServer = (preset?: Partial<McpServerDraft>) => {
663
+ setMcpDraft((prev) => [
664
+ ...prev,
665
+ {
666
+ ...createEmptyServer(),
667
+ ...preset,
668
+ },
669
+ ])
670
+ }
671
+
672
+ const removeServer = (index: number) => {
673
+ setMcpDraft((prev) => prev.filter((_, idx) => idx !== index))
674
+ }
675
+
676
+ const validateServers = () => {
677
+ const seenNames = new Set<string>()
678
+ for (const server of mcpDraft) {
679
+ const name = server.name.trim()
680
+ if (!name) {
681
+ return 'Every MCP server needs a name.'
682
+ }
683
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
684
+ return `Invalid MCP server name: "${name}".`
685
+ }
686
+ if (seenNames.has(name)) {
687
+ return `Duplicate MCP server name: "${name}".`
688
+ }
689
+ seenNames.add(name)
690
+ if (server.transport === 'http' && !server.url.trim()) {
691
+ return `Server "${name}" needs a URL.`
692
+ }
693
+ if (server.transport === 'stdio' && !server.command.trim()) {
694
+ return `Server "${name}" needs a command.`
695
+ }
696
+ }
697
+ return null
698
+ }
699
+
700
+ const insertSnippet = (snippet: string) => {
701
+ if (!snippet.trim()) {
702
+ return
703
+ }
704
+
705
+ const snippetLines = snippet.split(/\r?\n/u)
706
+ const isTableHeader = (line: string) => /^\s*\[[^\]]+\]\s*$/u.test(line)
707
+ const hasRootAssignment = () => {
708
+ for (const line of snippetLines) {
709
+ const trimmed = line.trim()
710
+ if (!trimmed || trimmed.startsWith('#')) {
711
+ continue
712
+ }
713
+ if (isTableHeader(trimmed)) {
714
+ return false
715
+ }
716
+ if (trimmed.includes('=')) {
717
+ return true
718
+ }
719
+ }
720
+ return false
721
+ }
722
+
723
+ setConfigDraft((prev) => {
724
+ const trimmedSnippet = snippet.trimEnd()
725
+ const lines = prev.split(/\r?\n/u)
726
+ const firstTableIndex = lines.findIndex((line) => isTableHeader(line.trim()))
727
+
728
+ if (!hasRootAssignment() || firstTableIndex === -1) {
729
+ const trimmed = prev.trimEnd()
730
+ if (!trimmed) {
731
+ return `${trimmedSnippet}\n`
732
+ }
733
+ return `${trimmed}\n\n${trimmedSnippet}\n`
734
+ }
735
+
736
+ const before = lines.slice(0, firstTableIndex).join('\n').trimEnd()
737
+ const after = lines.slice(firstTableIndex).join('\n').trimStart()
738
+ const parts = []
739
+ if (before) parts.push(before)
740
+ parts.push(trimmedSnippet)
741
+ if (after) parts.push(after)
742
+ return `${parts.join('\n\n')}\n`
743
+ })
744
+ }
745
+
746
+ const handleCopyPath = async () => {
747
+ if (!configPath || !navigator?.clipboard) {
748
+ return
749
+ }
750
+ try {
751
+ await navigator.clipboard.writeText(configPath)
752
+ setStatus({ type: 'success', message: 'Config path copied to clipboard.' })
753
+ } catch {
754
+ setStatus({ type: 'error', message: 'Unable to copy config path.' })
755
+ }
756
+ }
757
+
758
+ const handleReload = async () => {
759
+ if (!profileId) return
760
+ setLoading(true)
761
+ setStatus(null)
762
+ try {
763
+ const snapshot = await hubClient.getProfileConfig(profileId)
764
+ setConfigPath(snapshot.path)
765
+ setConfigSaved(snapshot.content)
766
+ setConfigDraft(snapshot.content)
767
+ const nextDraft = snapshot.mcpServers.map(toDraft)
768
+ setMcpDraft(nextDraft)
769
+ setMcpBaseline(JSON.stringify(nextDraft))
770
+ } catch {
771
+ setStatus({ type: 'error', message: 'Failed to reload config.' })
772
+ } finally {
773
+ setLoading(false)
774
+ }
775
+ }
776
+
777
+ const handleSaveConfig = async () => {
778
+ if (!profileId) return
779
+ setSavingConfig(true)
780
+ setStatus(null)
781
+ try {
782
+ const snapshot = await hubClient.saveProfileConfig(profileId, configDraft)
783
+ setConfigPath(snapshot.path)
784
+ setConfigSaved(snapshot.content)
785
+ setConfigDraft(snapshot.content)
786
+ const nextDraft = snapshot.mcpServers.map(toDraft)
787
+ setMcpDraft(nextDraft)
788
+ setMcpBaseline(JSON.stringify(nextDraft))
789
+ setStatus({ type: 'success', message: 'Config saved.' })
790
+ } catch {
791
+ setStatus({ type: 'error', message: 'Failed to save config.' })
792
+ } finally {
793
+ setSavingConfig(false)
794
+ }
795
+ }
796
+
797
+ const handleResetConfig = async () => {
798
+ if (!profileId) return
799
+ const confirmed = window.confirm(
800
+ 'Reset config.toml to defaults? This clears the file and removes any MCP server settings.'
801
+ )
802
+ if (!confirmed) {
803
+ return
804
+ }
805
+ setSavingConfig(true)
806
+ setStatus(null)
807
+ try {
808
+ const snapshot = await hubClient.saveProfileConfig(profileId, '')
809
+ setConfigPath(snapshot.path)
810
+ setConfigSaved(snapshot.content)
811
+ setConfigDraft(snapshot.content)
812
+ const nextDraft = snapshot.mcpServers.map(toDraft)
813
+ setMcpDraft(nextDraft)
814
+ setMcpBaseline(JSON.stringify(nextDraft))
815
+ setStatus({ type: 'success', message: 'Config reset to defaults.' })
816
+ } catch {
817
+ setStatus({ type: 'error', message: 'Failed to reset config.' })
818
+ } finally {
819
+ setSavingConfig(false)
820
+ }
821
+ }
822
+
823
+ const handleSaveMcp = async () => {
824
+ if (!profileId) return
825
+ if (configDirty) {
826
+ setStatus({ type: 'error', message: 'Save or discard raw config changes before saving MCP servers.' })
827
+ return
828
+ }
829
+ const validationError = validateServers()
830
+ if (validationError) {
831
+ setStatus({ type: 'error', message: validationError })
832
+ return
833
+ }
834
+ setSavingMcp(true)
835
+ setStatus(null)
836
+ try {
837
+ const snapshot = await hubClient.saveMcpServers(profileId, mcpDraft.map(toConfig))
838
+ setConfigPath(snapshot.path)
839
+ setConfigSaved(snapshot.content)
840
+ setConfigDraft(snapshot.content)
841
+ const nextDraft = snapshot.mcpServers.map(toDraft)
842
+ setMcpDraft(nextDraft)
843
+ setMcpBaseline(JSON.stringify(nextDraft))
844
+ setStatus({ type: 'success', message: 'MCP servers saved.' })
845
+ } catch {
846
+ setStatus({ type: 'error', message: 'Failed to save MCP servers.' })
847
+ } finally {
848
+ setSavingMcp(false)
849
+ }
850
+ }
851
+
852
+ const [activeTab, setActiveTab] = useState<ActiveTab>('servers')
853
+
854
+ if (!profileId) {
855
+ return (
856
+ <div className="h-full flex items-center justify-center p-6">
857
+ <div className="text-center max-w-sm">
858
+ <div className="w-12 h-12 rounded-full bg-bg-tertiary flex items-center justify-center mx-auto mb-4">
859
+ <Icons.Settings className="w-6 h-6 text-text-muted" />
860
+ </div>
861
+ <h3 className="text-lg font-semibold text-text-primary mb-2">No Profile Selected</h3>
862
+ <p className="text-sm text-text-muted">
863
+ Create or select a profile to configure Codex settings and MCP servers.
864
+ </p>
865
+ </div>
866
+ </div>
867
+ )
868
+ }
869
+
870
+ const isDisabled = loading || connectionStatus !== 'connected'
871
+
872
+ return (
873
+ <div className="h-full flex flex-col">
874
+ {/* Header */}
875
+ <div className="shrink-0 border-b border-border bg-bg-secondary/50">
876
+ <div className="px-6 py-4">
877
+ <div className="flex items-center justify-between mb-4">
878
+ <div>
879
+ <h2 className="text-lg font-semibold text-text-primary">Configuration</h2>
880
+ <p className="text-xs text-text-muted mt-0.5">
881
+ Manage MCP servers and Codex settings
882
+ </p>
883
+ </div>
884
+
885
+ {connectionStatus !== 'connected' && (
886
+ <div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-accent-red/10 border border-accent-red/20">
887
+ <div className="w-2 h-2 rounded-full bg-accent-red animate-pulse" />
888
+ <span className="text-xs font-medium text-accent-red">Backend Offline</span>
889
+ </div>
890
+ )}
891
+ </div>
892
+
893
+ {/* Profile selector and actions */}
894
+ <div className="flex items-center gap-3">
895
+ <div className="flex-1 max-w-[240px]">
896
+ <Select
897
+ options={profileOptions}
898
+ value={profileId}
899
+ onChange={setProfileId}
900
+ placeholder="Select profile"
901
+ size="md"
902
+ />
903
+ </div>
904
+
905
+ <div className="h-5 w-px bg-border" />
906
+
907
+ <button
908
+ type="button"
909
+ onClick={handleReload}
910
+ disabled={loading}
911
+ className="p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-bg-hover transition-colors disabled:opacity-50"
912
+ title="Reload configuration"
913
+ >
914
+ <Icons.Loader className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
915
+ </button>
916
+
917
+ <button
918
+ type="button"
919
+ onClick={handleCopyPath}
920
+ disabled={!configPath}
921
+ className="p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-bg-hover transition-colors disabled:opacity-50"
922
+ title="Copy config path"
923
+ >
924
+ <Icons.Copy className="w-4 h-4" />
925
+ </button>
926
+
927
+ <a
928
+ href="https://github.com/openai/codex/blob/main/docs/config.md"
929
+ target="_blank"
930
+ rel="noreferrer"
931
+ className="p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-bg-hover transition-colors"
932
+ title="View documentation"
933
+ >
934
+ <Icons.Help className="w-4 h-4" />
935
+ </a>
936
+ </div>
937
+
938
+ {/* Config path */}
939
+ {configPath && (
940
+ <div className="mt-3 text-[11px] text-text-muted font-mono bg-bg-primary/50 px-2 py-1 rounded inline-block">
941
+ {configPath}
942
+ </div>
943
+ )}
944
+ </div>
945
+
946
+ {/* Tabs */}
947
+ <div className="px-6 flex gap-1">
948
+ <button
949
+ type="button"
950
+ onClick={() => setActiveTab('servers')}
951
+ className={`px-4 py-2.5 text-sm font-medium rounded-t-lg transition-colors relative ${
952
+ activeTab === 'servers'
953
+ ? 'text-text-primary bg-bg-primary'
954
+ : 'text-text-muted hover:text-text-secondary'
955
+ }`}
956
+ >
957
+ MCP Servers
958
+ {mcpDraft.length > 0 && (
959
+ <span className={`ml-2 text-xs px-1.5 py-0.5 rounded-full ${
960
+ activeTab === 'servers' ? 'bg-bg-tertiary' : 'bg-bg-tertiary/50'
961
+ }`}>
962
+ {mcpDraft.length}
963
+ </span>
964
+ )}
965
+ {mcpDirty && (
966
+ <span className="absolute top-2 right-1 w-1.5 h-1.5 rounded-full bg-accent-blue" />
967
+ )}
968
+ </button>
969
+ <button
970
+ type="button"
971
+ onClick={() => setActiveTab('config')}
972
+ className={`px-4 py-2.5 text-sm font-medium rounded-t-lg transition-colors relative ${
973
+ activeTab === 'config'
974
+ ? 'text-text-primary bg-bg-primary'
975
+ : 'text-text-muted hover:text-text-secondary'
976
+ }`}
977
+ >
978
+ Raw Config
979
+ {configDirty && (
980
+ <span className="absolute top-2 right-1 w-1.5 h-1.5 rounded-full bg-accent-blue" />
981
+ )}
982
+ </button>
983
+ </div>
984
+ </div>
985
+
986
+ {/* Status message */}
987
+ {status && (
988
+ <div className={`mx-6 mt-4 px-4 py-3 rounded-lg text-sm flex items-center gap-2 ${
989
+ status.type === 'error'
990
+ ? 'bg-accent-red/10 text-accent-red border border-accent-red/20'
991
+ : 'bg-accent-green/10 text-accent-green border border-accent-green/20'
992
+ }`}>
993
+ {status.type === 'error' ? (
994
+ <Icons.Warning className="w-4 h-4 shrink-0" />
995
+ ) : (
996
+ <Icons.Check className="w-4 h-4 shrink-0" />
997
+ )}
998
+ {status.message}
999
+ </div>
1000
+ )}
1001
+
1002
+ {/* Content */}
1003
+ <div className="flex-1 overflow-y-auto p-6">
1004
+ {activeTab === 'servers' ? (
1005
+ <div className="max-w-3xl space-y-6">
1006
+ {/* Quick add templates */}
1007
+ <div>
1008
+ <h4 className="text-xs font-medium text-text-muted uppercase tracking-wide mb-3">
1009
+ Quick Add
1010
+ </h4>
1011
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
1012
+ {mcpTemplates.map((template) => {
1013
+ const IconComponent = template.icon === 'terminal' ? Icons.Terminal
1014
+ : template.icon === 'globe' ? Icons.Globe
1015
+ : Icons.Bolt
1016
+ return (
1017
+ <button
1018
+ key={template.id}
1019
+ type="button"
1020
+ disabled={isDisabled}
1021
+ onClick={() => addServer(template.preset)}
1022
+ className="group flex items-start gap-3 p-4 rounded-xl border border-border bg-bg-secondary hover:bg-bg-hover hover:border-text-muted/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed text-left"
1023
+ >
1024
+ <div className="w-9 h-9 rounded-lg bg-bg-tertiary group-hover:bg-bg-primary flex items-center justify-center shrink-0 transition-colors">
1025
+ <IconComponent className="w-4 h-4 text-text-muted group-hover:text-text-primary transition-colors" />
1026
+ </div>
1027
+ <div className="min-w-0">
1028
+ <div className="text-sm font-medium text-text-primary">{template.label}</div>
1029
+ <div className="text-xs text-text-muted mt-0.5">{template.description}</div>
1030
+ </div>
1031
+ </button>
1032
+ )
1033
+ })}
1034
+ </div>
1035
+ </div>
1036
+
1037
+ {/* Server list */}
1038
+ <div>
1039
+ <div className="flex items-center justify-between mb-3">
1040
+ <h4 className="text-xs font-medium text-text-muted uppercase tracking-wide">
1041
+ Configured Servers
1042
+ </h4>
1043
+ <div className="flex items-center gap-2">
1044
+ <button
1045
+ type="button"
1046
+ onClick={() => addServer()}
1047
+ disabled={isDisabled}
1048
+ className="text-xs text-text-muted hover:text-text-primary transition-colors disabled:opacity-50 flex items-center gap-1"
1049
+ >
1050
+ <Icons.Plus className="w-3.5 h-3.5" />
1051
+ Add empty
1052
+ </button>
1053
+ </div>
1054
+ </div>
1055
+
1056
+ {mcpDraft.length === 0 ? (
1057
+ <div className="rounded-xl border border-dashed border-border bg-bg-secondary/30 p-8 text-center">
1058
+ <div className="w-10 h-10 rounded-full bg-bg-tertiary flex items-center justify-center mx-auto mb-3">
1059
+ <Icons.Bolt className="w-5 h-5 text-text-muted" />
1060
+ </div>
1061
+ <p className="text-sm text-text-muted mb-1">No MCP servers configured</p>
1062
+ <p className="text-xs text-text-muted/70">
1063
+ Add a server template above or create a custom one
1064
+ </p>
1065
+ </div>
1066
+ ) : (
1067
+ <div className="space-y-3">
1068
+ {mcpDraft.map((server, index) => (
1069
+ <ServerCard
1070
+ key={`${server.name}-${index}`}
1071
+ server={server}
1072
+ index={index}
1073
+ onUpdate={(updater) => updateServer(index, updater)}
1074
+ onRemove={() => removeServer(index)}
1075
+ disabled={isDisabled}
1076
+ />
1077
+ ))}
1078
+ </div>
1079
+ )}
1080
+ </div>
1081
+
1082
+ {/* Save button - sticky at bottom when there are changes */}
1083
+ {mcpDirty && (
1084
+ <div className="sticky bottom-0 pt-4 pb-2 bg-gradient-to-t from-bg-primary via-bg-primary to-transparent">
1085
+ <div className="flex items-center justify-between p-4 rounded-xl bg-bg-secondary border border-border">
1086
+ <div className="text-sm text-text-muted">
1087
+ {configDirty ? (
1088
+ <span className="text-accent-red">Save raw config first before saving MCP servers</span>
1089
+ ) : (
1090
+ 'You have unsaved changes'
1091
+ )}
1092
+ </div>
1093
+ <Button
1094
+ variant="primary"
1095
+ size="sm"
1096
+ onClick={handleSaveMcp}
1097
+ disabled={isDisabled || savingMcp || configDirty}
1098
+ >
1099
+ {savingMcp ? 'Saving...' : 'Save MCP Servers'}
1100
+ </Button>
1101
+ </div>
1102
+ </div>
1103
+ )}
1104
+ </div>
1105
+ ) : (
1106
+ <div className="max-w-3xl space-y-6">
1107
+ {/* Snippets */}
1108
+ <div>
1109
+ <h4 className="text-xs font-medium text-text-muted uppercase tracking-wide mb-3">
1110
+ Insert Snippet
1111
+ </h4>
1112
+ <div className="flex items-center gap-3">
1113
+ <div className="flex-1 max-w-[280px]">
1114
+ <Select
1115
+ options={configSnippetOptions}
1116
+ value={snippetId}
1117
+ onChange={setSnippetId}
1118
+ placeholder="Choose a snippet"
1119
+ size="md"
1120
+ />
1121
+ </div>
1122
+ <Button
1123
+ variant="ghost"
1124
+ size="sm"
1125
+ disabled={!selectedSnippet || isDisabled}
1126
+ onClick={() => {
1127
+ if (selectedSnippet) {
1128
+ insertSnippet(selectedSnippet.content)
1129
+ }
1130
+ }}
1131
+ >
1132
+ Insert
1133
+ </Button>
1134
+ {selectedSnippet && (
1135
+ <span className="text-xs text-text-muted">
1136
+ {selectedSnippet.description}
1137
+ </span>
1138
+ )}
1139
+ </div>
1140
+ </div>
1141
+
1142
+ {/* Editor */}
1143
+ <div>
1144
+ <div className="flex items-center justify-between mb-3">
1145
+ <h4 className="text-xs font-medium text-text-muted uppercase tracking-wide">
1146
+ config.toml
1147
+ </h4>
1148
+ <div className="flex items-center gap-2">
1149
+ <button
1150
+ type="button"
1151
+ onClick={() => setConfigDraft(configSaved)}
1152
+ disabled={!configDirty || isDisabled}
1153
+ className="text-xs text-text-muted hover:text-text-primary transition-colors disabled:opacity-50"
1154
+ >
1155
+ Discard changes
1156
+ </button>
1157
+ <button
1158
+ type="button"
1159
+ onClick={handleResetConfig}
1160
+ disabled={isDisabled || savingConfig}
1161
+ className="text-xs text-accent-red/70 hover:text-accent-red transition-colors disabled:opacity-50"
1162
+ >
1163
+ Reset to defaults
1164
+ </button>
1165
+ </div>
1166
+ </div>
1167
+
1168
+ <div className="relative">
1169
+ <textarea
1170
+ value={configDraft}
1171
+ onChange={(event) => setConfigDraft(event.target.value)}
1172
+ placeholder="# config.toml&#10;&#10;# Add your configuration here..."
1173
+ className="w-full min-h-[400px] bg-bg-secondary border border-border rounded-xl p-4 text-sm text-text-primary font-mono outline-none focus:border-text-muted/50 transition-colors resize-y"
1174
+ spellCheck={false}
1175
+ disabled={isDisabled}
1176
+ />
1177
+ {configDirty && (
1178
+ <div className="absolute top-3 right-3 text-[10px] px-2 py-1 rounded bg-accent-blue/15 text-accent-blue font-medium">
1179
+ Modified
1180
+ </div>
1181
+ )}
1182
+ </div>
1183
+ </div>
1184
+
1185
+ {/* Save button */}
1186
+ {configDirty && (
1187
+ <div className="sticky bottom-0 pt-4 pb-2 bg-gradient-to-t from-bg-primary via-bg-primary to-transparent">
1188
+ <div className="flex items-center justify-between p-4 rounded-xl bg-bg-secondary border border-border">
1189
+ <div className="text-sm text-text-muted">
1190
+ You have unsaved config changes
1191
+ </div>
1192
+ <Button
1193
+ variant="primary"
1194
+ size="sm"
1195
+ onClick={handleSaveConfig}
1196
+ disabled={isDisabled || savingConfig}
1197
+ >
1198
+ {savingConfig ? 'Saving...' : 'Save Config'}
1199
+ </Button>
1200
+ </div>
1201
+ </div>
1202
+ )}
1203
+ </div>
1204
+ )}
1205
+ </div>
1206
+ </div>
1207
+ )
1208
+ }