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.
- package/apps/backend/README.md +43 -2
- package/apps/backend/src/core/app-server.ts +68 -2
- package/apps/backend/src/server.ts +157 -1
- package/apps/backend/src/services/codex-config.ts +561 -0
- package/apps/backend/src/thread-activity/service.ts +47 -0
- package/apps/web/README.md +18 -2
- package/apps/web/src/components/layout/codex-settings.tsx +1208 -0
- package/apps/web/src/components/layout/settings-dialog.tsx +9 -1
- package/apps/web/src/components/layout/virtualized-message-list.tsx +581 -86
- package/apps/web/src/config.ts +24 -0
- package/apps/web/src/hooks/use-hub-connection.ts +21 -3
- package/apps/web/src/hooks/use-thread-history.ts +94 -5
- package/apps/web/src/services/hub-client.ts +103 -5
- package/apps/web/src/types/index.ts +24 -0
- package/apps/web/src/utils/item-format.ts +55 -9
- package/package.json +1 -1
|
@@ -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 # 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
|
+
}
|