mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 07:12:18 +00:00
feat(codex): add dashboard control center
- add guided editors for top-level settings, trust, profiles, providers, MCP, and features - refresh raw snapshots after patch saves to avoid stale mtime conflicts - block structured saves while raw TOML is dirty and add route plus hook coverage
This commit is contained in:
1 parent
3c52b1ab6d
commit
b47aa0d28d
18 files changed
+2959
-57
No files matched your search
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface CodexConfigCardShellProps {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
badge?: string;
|
||||
description?: string;
|
||||
disabledReason?: string | null;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function CodexConfigCardShell({
|
||||
title,
|
||||
icon,
|
||||
badge,
|
||||
description,
|
||||
disabledReason,
|
||||
children,
|
||||
}: CodexConfigCardShellProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{icon}
|
||||
{title}
|
||||
{badge ? (
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
{badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{disabledReason ? <p className="text-xs text-amber-600">{disabledReason}</p> : null}
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { CodexFeatureCatalogEntry } from '@/lib/codex-config';
|
||||
import { CodexConfigCardShell } from './codex-config-card-shell';
|
||||
|
||||
interface CodexFeaturesCardProps {
|
||||
catalog: CodexFeatureCatalogEntry[];
|
||||
state: Record<string, boolean | null>;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string | null;
|
||||
onToggle: (feature: string, enabled: boolean | null) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function CodexFeaturesCard({
|
||||
catalog,
|
||||
state,
|
||||
disabled = false,
|
||||
disabledReason,
|
||||
onToggle,
|
||||
}: CodexFeaturesCardProps) {
|
||||
const knownFeatureNames = new Set(catalog.map((feature) => feature.name));
|
||||
const configOnlyFeatures = Object.entries(state)
|
||||
.filter(([name]) => !knownFeatureNames.has(name))
|
||||
.sort(([left], [right]) => left.localeCompare(right));
|
||||
|
||||
return (
|
||||
<CodexConfigCardShell
|
||||
title="Features"
|
||||
badge="features"
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
description="Toggle the supported Codex feature flags CCS can safely manage."
|
||||
disabledReason={disabledReason}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{catalog.map((feature) => {
|
||||
const current = state[feature.name] ?? null;
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{feature.label}</p>
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
{feature.name}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{feature.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{current !== null ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onToggle(feature.name, null)}
|
||||
disabled={disabled}
|
||||
>
|
||||
Use default
|
||||
</Button>
|
||||
) : null}
|
||||
<Switch
|
||||
checked={current === true}
|
||||
onCheckedChange={(next) => onToggle(feature.name, next)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{configOnlyFeatures.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Existing config-only flags
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These feature keys already exist in your `config.toml`, so CCS can surface them
|
||||
without claiming full catalog coverage.
|
||||
</p>
|
||||
</div>
|
||||
{configOnlyFeatures.map(([name, current]) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-dashed px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{name}</p>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
existing
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{current === null
|
||||
? 'Stored in a non-boolean form. Use raw TOML if you need to edit it.'
|
||||
: "Discovered from the current file instead of CCS's built-in catalog."}
|
||||
</p>
|
||||
</div>
|
||||
{current === null ? (
|
||||
<Badge variant="outline">Raw only</Badge>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onToggle(name, null)}
|
||||
disabled={disabled}
|
||||
>
|
||||
Use default
|
||||
</Button>
|
||||
<Switch
|
||||
checked={current === true}
|
||||
onCheckedChange={(next) => onToggle(name, next)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</CodexConfigCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Loader2, PlugZap, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { CodexMcpServerPatchValues } from '@/hooks/use-codex-types';
|
||||
import type { CodexMcpServerEntry } from '@/lib/codex-config';
|
||||
import { CodexConfigCardShell } from './codex-config-card-shell';
|
||||
|
||||
interface CodexMcpServersCardProps {
|
||||
entries: CodexMcpServerEntry[];
|
||||
disabled?: boolean;
|
||||
disabledReason?: string | null;
|
||||
saving?: boolean;
|
||||
onSave: (name: string, values: CodexMcpServerPatchValues) => Promise<void> | void;
|
||||
onDelete: (name: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const EMPTY_MCP_SERVER_DRAFT: CodexMcpServerEntry = {
|
||||
name: '',
|
||||
transport: 'stdio',
|
||||
command: null,
|
||||
args: [],
|
||||
url: null,
|
||||
enabled: true,
|
||||
required: false,
|
||||
startupTimeoutSec: null,
|
||||
toolTimeoutSec: null,
|
||||
enabledTools: [],
|
||||
disabledTools: [],
|
||||
};
|
||||
|
||||
function toCsv(value: string[]) {
|
||||
return value.join(', ');
|
||||
}
|
||||
|
||||
function fromCsv(value: string) {
|
||||
return value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
interface McpServerEditorProps {
|
||||
initialDraft: CodexMcpServerEntry;
|
||||
isNew: boolean;
|
||||
disabled: boolean;
|
||||
saving: boolean;
|
||||
canDelete: boolean;
|
||||
onSave: (name: string, values: CodexMcpServerPatchValues) => Promise<void> | void;
|
||||
onDelete: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
function McpServerEditor({
|
||||
initialDraft,
|
||||
isNew,
|
||||
disabled,
|
||||
saving,
|
||||
canDelete,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: McpServerEditorProps) {
|
||||
const [draft, setDraft] = useState<CodexMcpServerEntry>(initialDraft);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="playwright"
|
||||
disabled={disabled || !isNew}
|
||||
/>
|
||||
<Select
|
||||
value={draft.transport}
|
||||
onValueChange={(next) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
transport: next as CodexMcpServerEntry['transport'],
|
||||
}))
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">stdio</SelectItem>
|
||||
<SelectItem value="streamable-http">streamable-http</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{draft.transport === 'stdio' ? (
|
||||
<>
|
||||
<Input
|
||||
value={draft.command ?? ''}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, command: event.target.value || null }))
|
||||
}
|
||||
placeholder="npx"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
value={toCsv(draft.args)}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, args: fromCsv(event.target.value) }))
|
||||
}
|
||||
placeholder="@playwright/mcp@latest, --flag"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
className="sm:col-span-2"
|
||||
value={draft.url ?? ''}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, url: event.target.value || null }))
|
||||
}
|
||||
placeholder="https://example.test/mcp"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft.startupTimeoutSec ?? ''}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
startupTimeoutSec: event.target.value ? Number(event.target.value) : null,
|
||||
}))
|
||||
}
|
||||
placeholder="Startup timeout (sec)"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft.toolTimeoutSec ?? ''}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
toolTimeoutSec: event.target.value ? Number(event.target.value) : null,
|
||||
}))
|
||||
}
|
||||
placeholder="Tool timeout (sec)"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
value={toCsv(draft.enabledTools)}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, enabledTools: fromCsv(event.target.value) }))
|
||||
}
|
||||
placeholder="enabled_tools"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
value={toCsv(draft.disabledTools)}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, disabledTools: fromCsv(event.target.value) }))
|
||||
}
|
||||
placeholder="disabled_tools"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
|
||||
Enabled
|
||||
<Switch
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(next) => setDraft((current) => ({ ...current, enabled: next }))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
|
||||
Required
|
||||
<Switch
|
||||
checked={draft.required}
|
||||
onCheckedChange={(next) => setDraft((current) => ({ ...current, required: next }))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<Button variant="outline" onClick={onDelete} disabled={disabled || saving || !canDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onSave(draft.name, {
|
||||
transport: draft.transport,
|
||||
command: draft.command,
|
||||
args: draft.args,
|
||||
url: draft.url,
|
||||
enabled: draft.enabled,
|
||||
required: draft.required,
|
||||
startupTimeoutSec: draft.startupTimeoutSec,
|
||||
toolTimeoutSec: draft.toolTimeoutSec,
|
||||
enabledTools: draft.enabledTools,
|
||||
disabledTools: draft.disabledTools,
|
||||
})
|
||||
}
|
||||
disabled={disabled || saving || draft.name.trim().length === 0}
|
||||
>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Save MCP server
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodexMcpServersCard({
|
||||
entries,
|
||||
disabled = false,
|
||||
disabledReason,
|
||||
saving = false,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: CodexMcpServersCardProps) {
|
||||
const [selectedName, setSelectedName] = useState('new');
|
||||
const selectedEntry = useMemo(
|
||||
() => entries.find((entry) => entry.name === selectedName) ?? null,
|
||||
[entries, selectedName]
|
||||
);
|
||||
const draftSeed = selectedEntry ?? EMPTY_MCP_SERVER_DRAFT;
|
||||
const draftKey = JSON.stringify(draftSeed);
|
||||
|
||||
return (
|
||||
<CodexConfigCardShell
|
||||
title="MCP servers"
|
||||
badge="mcp_servers"
|
||||
icon={<PlugZap className="h-4 w-4" />}
|
||||
description="Manage the safe MCP transport fields. Keep auth headers and bearer tokens in raw TOML."
|
||||
disabledReason={disabledReason}
|
||||
>
|
||||
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select MCP server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="new">Create new MCP server</SelectItem>
|
||||
{entries.map((entry) => (
|
||||
<SelectItem key={entry.name} value={entry.name}>
|
||||
{entry.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<McpServerEditor
|
||||
key={draftKey}
|
||||
initialDraft={draftSeed}
|
||||
isNew={selectedName === 'new'}
|
||||
disabled={disabled}
|
||||
saving={saving}
|
||||
canDelete={selectedEntry !== null}
|
||||
onDelete={async () => {
|
||||
if (!selectedEntry) return;
|
||||
await onDelete(selectedEntry.name);
|
||||
setSelectedName('new');
|
||||
}}
|
||||
onSave={async (name, values) => {
|
||||
await onSave(name, values);
|
||||
setSelectedName(name);
|
||||
}}
|
||||
/>
|
||||
</CodexConfigCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { KeyRound, Loader2, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { CodexModelProviderPatchValues } from '@/hooks/use-codex-types';
|
||||
import type { CodexModelProviderEntry } from '@/lib/codex-config';
|
||||
import { CodexConfigCardShell } from './codex-config-card-shell';
|
||||
|
||||
interface CodexModelProvidersCardProps {
|
||||
entries: CodexModelProviderEntry[];
|
||||
disabled?: boolean;
|
||||
disabledReason?: string | null;
|
||||
saving?: boolean;
|
||||
onSave: (name: string, values: CodexModelProviderPatchValues) => Promise<void> | void;
|
||||
onDelete: (name: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const EMPTY_MODEL_PROVIDER_DRAFT: CodexModelProviderEntry = {
|
||||
name: '',
|
||||
displayName: null,
|
||||
baseUrl: null,
|
||||
envKey: null,
|
||||
wireApi: 'responses',
|
||||
requiresOpenaiAuth: false,
|
||||
supportsWebsockets: false,
|
||||
};
|
||||
|
||||
interface ModelProviderEditorProps {
|
||||
initialDraft: CodexModelProviderEntry;
|
||||
isNew: boolean;
|
||||
disabled: boolean;
|
||||
saving: boolean;
|
||||
canDelete: boolean;
|
||||
onSave: (name: string, values: CodexModelProviderPatchValues) => Promise<void> | void;
|
||||
onDelete: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
function ModelProviderEditor({
|
||||
initialDraft,
|
||||
isNew,
|
||||
disabled,
|
||||
saving,
|
||||
canDelete,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: ModelProviderEditorProps) {
|
||||
const [draft, setDraft] = useState<CodexModelProviderEntry>(initialDraft);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="Provider id"
|
||||
disabled={disabled || !isNew}
|
||||
/>
|
||||
<Input
|
||||
value={draft.displayName ?? ''}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, displayName: event.target.value || null }))
|
||||
}
|
||||
placeholder="Display name"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
value={draft.baseUrl ?? ''}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, baseUrl: event.target.value || null }))
|
||||
}
|
||||
placeholder="http://127.0.0.1:8317/api/provider/codex"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
value={draft.envKey ?? ''}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, envKey: event.target.value || null }))
|
||||
}
|
||||
placeholder="CLIPROXY_API_KEY"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Select
|
||||
value={draft.wireApi ?? 'responses'}
|
||||
onValueChange={(next) => setDraft((current) => ({ ...current, wireApi: next }))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="responses">responses</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
|
||||
Requires OpenAI auth
|
||||
<Switch
|
||||
checked={draft.requiresOpenaiAuth}
|
||||
onCheckedChange={(next) =>
|
||||
setDraft((current) => ({ ...current, requiresOpenaiAuth: next }))
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
|
||||
Supports websockets
|
||||
<Switch
|
||||
checked={draft.supportsWebsockets}
|
||||
onCheckedChange={(next) =>
|
||||
setDraft((current) => ({ ...current, supportsWebsockets: next }))
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<Button variant="outline" onClick={onDelete} disabled={disabled || saving || !canDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onSave(draft.name, {
|
||||
displayName: draft.displayName,
|
||||
baseUrl: draft.baseUrl,
|
||||
envKey: draft.envKey,
|
||||
wireApi: draft.wireApi,
|
||||
requiresOpenaiAuth: draft.requiresOpenaiAuth,
|
||||
supportsWebsockets: draft.supportsWebsockets,
|
||||
})
|
||||
}
|
||||
disabled={disabled || saving || draft.name.trim().length === 0}
|
||||
>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Save provider
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodexModelProvidersCard({
|
||||
entries,
|
||||
disabled = false,
|
||||
disabledReason,
|
||||
saving = false,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: CodexModelProvidersCardProps) {
|
||||
const [selectedName, setSelectedName] = useState<string>('new');
|
||||
const selectedEntry = useMemo(
|
||||
() => entries.find((entry) => entry.name === selectedName) ?? null,
|
||||
[entries, selectedName]
|
||||
);
|
||||
const draftSeed = selectedEntry ?? EMPTY_MODEL_PROVIDER_DRAFT;
|
||||
const draftKey = JSON.stringify(draftSeed);
|
||||
|
||||
return (
|
||||
<CodexConfigCardShell
|
||||
title="Model providers"
|
||||
badge="model_providers"
|
||||
icon={<KeyRound className="h-4 w-4" />}
|
||||
description="Edit the common provider fields CCS can support safely. Keep secret migration and inline bearer tokens in raw TOML."
|
||||
disabledReason={disabledReason}
|
||||
>
|
||||
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="new">Create new provider</SelectItem>
|
||||
{entries.map((entry) => (
|
||||
<SelectItem key={entry.name} value={entry.name}>
|
||||
{entry.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ModelProviderEditor
|
||||
key={draftKey}
|
||||
initialDraft={draftSeed}
|
||||
isNew={selectedName === 'new'}
|
||||
disabled={disabled}
|
||||
saving={saving}
|
||||
canDelete={selectedEntry !== null}
|
||||
onDelete={async () => {
|
||||
if (!selectedEntry) return;
|
||||
await onDelete(selectedEntry.name);
|
||||
setSelectedName('new');
|
||||
}}
|
||||
onSave={async (name, values) => {
|
||||
await onSave(name, values);
|
||||
setSelectedName(name);
|
||||
}}
|
||||
/>
|
||||
</CodexConfigCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Layers3, Loader2, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { CodexProfilePatchValues } from '@/hooks/use-codex-types';
|
||||
import type { CodexProfileEntry } from '@/lib/codex-config';
|
||||
import { CodexConfigCardShell } from './codex-config-card-shell';
|
||||
|
||||
interface CodexProfilesCardProps {
|
||||
activeProfile: string | null;
|
||||
entries: CodexProfileEntry[];
|
||||
providerNames: string[];
|
||||
disabled?: boolean;
|
||||
disabledReason?: string | null;
|
||||
saving?: boolean;
|
||||
onSave: (
|
||||
name: string,
|
||||
values: CodexProfilePatchValues,
|
||||
setAsActive: boolean
|
||||
) => Promise<void> | void;
|
||||
onDelete: (name: string) => Promise<void> | void;
|
||||
onSetActive: (name: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface ProfileEditorProps {
|
||||
initialName: string;
|
||||
initialModel: string | null;
|
||||
initialProvider: string | null;
|
||||
initialEffort: string | null;
|
||||
providerNames: string[];
|
||||
activeProfile: string | null;
|
||||
selectedEntryName: string | null;
|
||||
disabled: boolean;
|
||||
saving: boolean;
|
||||
onSave: (
|
||||
name: string,
|
||||
values: CodexProfilePatchValues,
|
||||
setAsActive: boolean
|
||||
) => Promise<void> | void;
|
||||
onDelete: () => Promise<void> | void;
|
||||
onSetActive: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
function ProfileEditor({
|
||||
initialName,
|
||||
initialModel,
|
||||
initialProvider,
|
||||
initialEffort,
|
||||
providerNames,
|
||||
activeProfile,
|
||||
selectedEntryName,
|
||||
disabled,
|
||||
saving,
|
||||
onSave,
|
||||
onDelete,
|
||||
onSetActive,
|
||||
}: ProfileEditorProps) {
|
||||
const [nameDraft, setNameDraft] = useState(initialName);
|
||||
const [modelDraft, setModelDraft] = useState<string | null>(initialModel);
|
||||
const [providerDraft, setProviderDraft] = useState<string | null>(initialProvider);
|
||||
const [effortDraft, setEffortDraft] = useState<string | null>(initialEffort);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Input
|
||||
value={nameDraft}
|
||||
onChange={(event) => setNameDraft(event.target.value)}
|
||||
placeholder="deep-review"
|
||||
disabled={disabled || selectedEntryName !== null}
|
||||
/>
|
||||
<Input
|
||||
value={modelDraft ?? ''}
|
||||
onChange={(event) => setModelDraft(event.target.value || null)}
|
||||
placeholder="gpt-5.4"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
value={providerDraft ?? '__unset__'}
|
||||
onValueChange={(next) => setProviderDraft(next === '__unset__' ? null : next)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Use global provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unset__">Use global provider</SelectItem>
|
||||
{providerNames.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={effortDraft ?? '__unset__'}
|
||||
onValueChange={(next) => setEffortDraft(next === '__unset__' ? null : next)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Use global effort" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unset__">Use global effort</SelectItem>
|
||||
{['minimal', 'low', 'medium', 'high', 'xhigh'].map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDelete}
|
||||
disabled={disabled || saving || !selectedEntryName}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onSetActive}
|
||||
disabled={
|
||||
disabled || saving || !selectedEntryName || selectedEntryName === activeProfile
|
||||
}
|
||||
>
|
||||
Set active
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onSave(
|
||||
nameDraft,
|
||||
{
|
||||
model: modelDraft,
|
||||
modelProvider: providerDraft,
|
||||
modelReasoningEffort: effortDraft,
|
||||
},
|
||||
false
|
||||
)
|
||||
}
|
||||
disabled={disabled || saving || nameDraft.trim().length === 0}
|
||||
>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Save profile
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onSave(
|
||||
nameDraft,
|
||||
{
|
||||
model: modelDraft,
|
||||
modelProvider: providerDraft,
|
||||
modelReasoningEffort: effortDraft,
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
disabled={disabled || saving || nameDraft.trim().length === 0}
|
||||
>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Save + activate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodexProfilesCard({
|
||||
activeProfile,
|
||||
entries,
|
||||
providerNames,
|
||||
disabled = false,
|
||||
disabledReason,
|
||||
saving = false,
|
||||
onSave,
|
||||
onDelete,
|
||||
onSetActive,
|
||||
}: CodexProfilesCardProps) {
|
||||
const [selectedName, setSelectedName] = useState('new');
|
||||
const selectedEntry = useMemo(
|
||||
() => entries.find((entry) => entry.name === selectedName) ?? null,
|
||||
[entries, selectedName]
|
||||
);
|
||||
const draftKey = JSON.stringify(selectedEntry ?? { name: '', values: {} });
|
||||
|
||||
return (
|
||||
<CodexConfigCardShell
|
||||
title="Profiles"
|
||||
badge="profiles"
|
||||
icon={<Layers3 className="h-4 w-4" />}
|
||||
description="Create reusable Codex overlays and set the active default profile."
|
||||
disabledReason={disabledReason}
|
||||
>
|
||||
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select profile" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="new">Create new profile</SelectItem>
|
||||
{entries.map((entry) => (
|
||||
<SelectItem key={entry.name} value={entry.name}>
|
||||
{entry.name}
|
||||
{entry.name === activeProfile ? ' (active)' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ProfileEditor
|
||||
key={draftKey}
|
||||
initialName={selectedEntry?.name ?? ''}
|
||||
initialModel={selectedEntry?.values.model ?? null}
|
||||
initialProvider={selectedEntry?.values.modelProvider ?? null}
|
||||
initialEffort={selectedEntry?.values.modelReasoningEffort ?? null}
|
||||
providerNames={providerNames}
|
||||
activeProfile={activeProfile}
|
||||
selectedEntryName={selectedEntry?.name ?? null}
|
||||
disabled={disabled}
|
||||
saving={saving}
|
||||
onDelete={async () => {
|
||||
if (!selectedEntry) return;
|
||||
await onDelete(selectedEntry.name);
|
||||
setSelectedName('new');
|
||||
}}
|
||||
onSetActive={async () => {
|
||||
if (!selectedEntry) return;
|
||||
await onSetActive(selectedEntry.name);
|
||||
}}
|
||||
onSave={async (name, values, setAsActive) => {
|
||||
await onSave(name, values, setAsActive);
|
||||
setSelectedName(name);
|
||||
}}
|
||||
/>
|
||||
</CodexConfigCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
import { FolderCheck, Loader2, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { CodexProjectTrustEntry } from '@/lib/codex-config';
|
||||
import { CodexConfigCardShell } from './codex-config-card-shell';
|
||||
|
||||
interface CodexProjectTrustCardProps {
|
||||
workspacePath: string;
|
||||
entries: CodexProjectTrustEntry[];
|
||||
disabled?: boolean;
|
||||
disabledReason?: string | null;
|
||||
saving?: boolean;
|
||||
onSave: (path: string, trustLevel: string | null) => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface ProjectTrustComposerProps {
|
||||
workspacePath: string;
|
||||
disabled: boolean;
|
||||
saving: boolean;
|
||||
onSave: (path: string, trustLevel: string | null) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function ProjectTrustComposer({
|
||||
workspacePath,
|
||||
disabled,
|
||||
saving,
|
||||
onSave,
|
||||
}: ProjectTrustComposerProps) {
|
||||
const [pathDraft, setPathDraft] = useState(workspacePath);
|
||||
const [trustLevel, setTrustLevel] = useState('trusted');
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-[1fr_160px_auto]">
|
||||
<Input
|
||||
value={pathDraft}
|
||||
onChange={(event) => setPathDraft(event.target.value)}
|
||||
placeholder="~/repo or /absolute/path"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select value={trustLevel} onValueChange={setTrustLevel} disabled={disabled}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="trusted">trusted</SelectItem>
|
||||
<SelectItem value="ask">ask</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => onSave(pathDraft, trustLevel)} disabled={disabled || saving}>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Save trust
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodexProjectTrustCard({
|
||||
workspacePath,
|
||||
entries,
|
||||
disabled = false,
|
||||
disabledReason,
|
||||
saving = false,
|
||||
onSave,
|
||||
}: CodexProjectTrustCardProps) {
|
||||
return (
|
||||
<CodexConfigCardShell
|
||||
title="Project trust"
|
||||
badge="projects"
|
||||
icon={<FolderCheck className="h-4 w-4" />}
|
||||
description="Trust current workspaces or remove stale trust entries without opening raw TOML."
|
||||
disabledReason={disabledReason}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paths must be absolute or start with <code>~/</code>. Relative paths are rejected so CCS
|
||||
does not trust the wrong folder.
|
||||
</p>
|
||||
<ProjectTrustComposer
|
||||
key={workspacePath}
|
||||
workspacePath={workspacePath}
|
||||
disabled={disabled}
|
||||
saving={saving}
|
||||
onSave={onSave}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => onSave(workspacePath, 'trusted')}
|
||||
disabled={disabled || saving}
|
||||
>
|
||||
Trust current workspace
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No explicit project trust entries saved.</p>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{entry.path}</p>
|
||||
<p className="text-xs text-muted-foreground">trust_level = {entry.trustLevel}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onSave(entry.path, entry.trustLevel === 'trusted' ? 'ask' : 'trusted')
|
||||
}
|
||||
disabled={disabled || saving}
|
||||
>
|
||||
Toggle
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onSave(entry.path, null)}
|
||||
disabled={disabled || saving}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CodexConfigCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, SlidersHorizontal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { CodexTopLevelSettingsPatch } from '@/hooks/use-codex-types';
|
||||
import type { CodexTopLevelSettingsView } from '@/lib/codex-config';
|
||||
import { CodexConfigCardShell } from './codex-config-card-shell';
|
||||
|
||||
const UNSET = '__unset__';
|
||||
|
||||
interface CodexTopLevelControlsCardProps {
|
||||
values: CodexTopLevelSettingsView;
|
||||
providerNames: string[];
|
||||
disabled?: boolean;
|
||||
disabledReason?: string | null;
|
||||
saving?: boolean;
|
||||
onSave: (values: CodexTopLevelSettingsPatch) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function toSelectValue(value: string | null | undefined) {
|
||||
return value ?? UNSET;
|
||||
}
|
||||
|
||||
function withCurrentValue(options: string[], current: string | null | undefined) {
|
||||
return current && !options.includes(current) ? [current, ...options] : options;
|
||||
}
|
||||
|
||||
interface TopLevelControlsFormProps {
|
||||
initialValues: CodexTopLevelSettingsView;
|
||||
providerNames: string[];
|
||||
disabled: boolean;
|
||||
saving: boolean;
|
||||
onSave: (values: CodexTopLevelSettingsPatch) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function TopLevelControlsForm({
|
||||
initialValues,
|
||||
providerNames,
|
||||
disabled,
|
||||
saving,
|
||||
onSave,
|
||||
}: TopLevelControlsFormProps) {
|
||||
const [draft, setDraft] = useState<CodexTopLevelSettingsView>(initialValues);
|
||||
const reasoningOptions = withCurrentValue(
|
||||
['minimal', 'low', 'medium', 'high', 'xhigh'],
|
||||
draft.modelReasoningEffort
|
||||
);
|
||||
const providerOptions = withCurrentValue(providerNames, draft.modelProvider);
|
||||
const approvalOptions = withCurrentValue(
|
||||
['on-request', 'never', 'untrusted'],
|
||||
draft.approvalPolicy
|
||||
);
|
||||
const sandboxOptions = withCurrentValue(
|
||||
['read-only', 'workspace-write', 'danger-full-access'],
|
||||
draft.sandboxMode
|
||||
);
|
||||
const webSearchOptions = withCurrentValue(['cached', 'live', 'disabled'], draft.webSearch);
|
||||
const personalityOptions = withCurrentValue(
|
||||
['default', 'pragmatic', 'concise', 'direct'],
|
||||
draft.personality
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Model</p>
|
||||
<Input
|
||||
value={draft.model ?? ''}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, model: event.target.value || null }))
|
||||
}
|
||||
placeholder="gpt-5.4"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Reasoning effort</p>
|
||||
<Select
|
||||
value={toSelectValue(draft.modelReasoningEffort)}
|
||||
onValueChange={(next) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
modelReasoningEffort: next === UNSET ? null : next,
|
||||
}))
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Use default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNSET}>Use default</SelectItem>
|
||||
{reasoningOptions.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Default provider</p>
|
||||
<Select
|
||||
value={toSelectValue(draft.modelProvider)}
|
||||
onValueChange={(next) =>
|
||||
setDraft((current) => ({ ...current, modelProvider: next === UNSET ? null : next }))
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Use Codex default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNSET}>Use Codex default</SelectItem>
|
||||
{providerOptions.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Approval policy</p>
|
||||
<Select
|
||||
value={toSelectValue(draft.approvalPolicy)}
|
||||
onValueChange={(next) =>
|
||||
setDraft((current) => ({ ...current, approvalPolicy: next === UNSET ? null : next }))
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Use default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNSET}>Use default</SelectItem>
|
||||
{approvalOptions.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Sandbox mode</p>
|
||||
<Select
|
||||
value={toSelectValue(draft.sandboxMode)}
|
||||
onValueChange={(next) =>
|
||||
setDraft((current) => ({ ...current, sandboxMode: next === UNSET ? null : next }))
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Use default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNSET}>Use default</SelectItem>
|
||||
{sandboxOptions.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Web search</p>
|
||||
<Select
|
||||
value={toSelectValue(draft.webSearch)}
|
||||
onValueChange={(next) =>
|
||||
setDraft((current) => ({ ...current, webSearch: next === UNSET ? null : next }))
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Use default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNSET}>Use default</SelectItem>
|
||||
{webSearchOptions.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Tool output token limit</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft.toolOutputTokenLimit ?? ''}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
toolOutputTokenLimit: event.target.value ? Number(event.target.value) : null,
|
||||
}))
|
||||
}
|
||||
placeholder="25000"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Personality</p>
|
||||
<Select
|
||||
value={toSelectValue(draft.personality)}
|
||||
onValueChange={(next) =>
|
||||
setDraft((current) => ({ ...current, personality: next === UNSET ? null : next }))
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Use default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNSET}>Use default</SelectItem>
|
||||
{personalityOptions.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => onSave(draft)} disabled={disabled || saving}>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Save top-level settings
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodexTopLevelControlsCard({
|
||||
values,
|
||||
providerNames,
|
||||
disabled = false,
|
||||
disabledReason,
|
||||
saving = false,
|
||||
onSave,
|
||||
}: CodexTopLevelControlsCardProps) {
|
||||
return (
|
||||
<CodexConfigCardShell
|
||||
title="Top-level controls"
|
||||
badge="config.toml"
|
||||
icon={<SlidersHorizontal className="h-4 w-4" />}
|
||||
description="Structured controls for the stable top-level Codex settings users touch most often."
|
||||
disabledReason={disabledReason}
|
||||
>
|
||||
<TopLevelControlsForm
|
||||
key={JSON.stringify(values)}
|
||||
initialValues={values}
|
||||
providerNames={providerNames}
|
||||
disabled={disabled}
|
||||
saving={saving}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</CodexConfigCardShell>
|
||||
);
|
||||
}
|
||||
@@ -88,11 +88,14 @@ export interface CodexSupportMatrixEntry {
|
||||
|
||||
export interface CodexUserConfigDiagnostics {
|
||||
model: string | null;
|
||||
modelReasoningEffort: string | null;
|
||||
modelProvider: string | null;
|
||||
activeProfile: string | null;
|
||||
approvalPolicy: string | null;
|
||||
sandboxMode: string | null;
|
||||
webSearch: string | null;
|
||||
toolOutputTokenLimit: number | null;
|
||||
personality: string | null;
|
||||
topLevelKeys: string[];
|
||||
profileCount: number;
|
||||
profileNames: string[];
|
||||
@@ -111,6 +114,7 @@ export interface CodexUserConfigDiagnostics {
|
||||
export interface CodexDashboardDiagnostics {
|
||||
binary: CodexBinaryDiagnostics;
|
||||
file: CodexConfigFileDiagnostics;
|
||||
workspacePath: string;
|
||||
config: CodexUserConfigDiagnostics;
|
||||
supportMatrix: CodexSupportMatrixEntry[];
|
||||
warnings: string[];
|
||||
@@ -126,3 +130,83 @@ export interface CodexRawConfigResponse {
|
||||
config: Record<string, unknown> | null;
|
||||
parseError: string | null;
|
||||
}
|
||||
|
||||
export interface CodexTopLevelSettingsPatch {
|
||||
model?: string | null;
|
||||
modelReasoningEffort?: string | null;
|
||||
modelProvider?: string | null;
|
||||
approvalPolicy?: string | null;
|
||||
sandboxMode?: string | null;
|
||||
webSearch?: string | null;
|
||||
toolOutputTokenLimit?: number | null;
|
||||
personality?: string | null;
|
||||
}
|
||||
|
||||
export type CodexProfilePatchValues = CodexTopLevelSettingsPatch;
|
||||
|
||||
export interface CodexModelProviderPatchValues {
|
||||
displayName?: string | null;
|
||||
baseUrl?: string | null;
|
||||
envKey?: string | null;
|
||||
wireApi?: string | null;
|
||||
requiresOpenaiAuth?: boolean | null;
|
||||
supportsWebsockets?: boolean | null;
|
||||
}
|
||||
|
||||
export interface CodexMcpServerPatchValues {
|
||||
transport: 'stdio' | 'streamable-http';
|
||||
command?: string | null;
|
||||
args?: string[] | null;
|
||||
url?: string | null;
|
||||
enabled?: boolean | null;
|
||||
required?: boolean | null;
|
||||
startupTimeoutSec?: number | null;
|
||||
toolTimeoutSec?: number | null;
|
||||
enabledTools?: string[] | null;
|
||||
disabledTools?: string[] | null;
|
||||
}
|
||||
|
||||
export type CodexConfigPatchInput =
|
||||
| {
|
||||
kind: 'top-level';
|
||||
expectedMtime?: number;
|
||||
values: CodexTopLevelSettingsPatch;
|
||||
}
|
||||
| {
|
||||
kind: 'project-trust';
|
||||
expectedMtime?: number;
|
||||
path: string;
|
||||
trustLevel: string | null;
|
||||
}
|
||||
| {
|
||||
kind: 'feature';
|
||||
expectedMtime?: number;
|
||||
feature: string;
|
||||
enabled: boolean | null;
|
||||
}
|
||||
| {
|
||||
kind: 'profile';
|
||||
expectedMtime?: number;
|
||||
action: 'set-active' | 'upsert' | 'delete';
|
||||
name: string;
|
||||
values?: CodexProfilePatchValues;
|
||||
setAsActive?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'model-provider';
|
||||
expectedMtime?: number;
|
||||
action: 'upsert' | 'delete';
|
||||
name: string;
|
||||
values?: CodexModelProviderPatchValues;
|
||||
}
|
||||
| {
|
||||
kind: 'mcp-server';
|
||||
expectedMtime?: number;
|
||||
action: 'upsert' | 'delete';
|
||||
name: string;
|
||||
values?: CodexMcpServerPatchValues;
|
||||
};
|
||||
|
||||
export interface CodexConfigPatchResult extends CodexRawConfigResponse {
|
||||
success: true;
|
||||
}
|
||||
@@ -2,7 +2,12 @@ import { useMemo } from 'react';
|
||||
import { parse as parseToml } from 'smol-toml';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ApiConflictError, withApiBase } from '@/lib/api-client';
|
||||
import type { CodexDashboardDiagnostics, CodexRawConfigResponse } from './use-codex-types';
|
||||
import type {
|
||||
CodexConfigPatchInput,
|
||||
CodexConfigPatchResult,
|
||||
CodexDashboardDiagnostics,
|
||||
CodexRawConfigResponse,
|
||||
} from './use-codex-types';
|
||||
|
||||
type CodexRawConfig = CodexRawConfigResponse;
|
||||
|
||||
@@ -16,6 +21,8 @@ interface SaveCodexRawConfigResponse {
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
type PatchCodexConfigResponse = CodexConfigPatchResult;
|
||||
|
||||
function parseCodexRawConfigText(rawText: string): {
|
||||
config: Record<string, unknown> | null;
|
||||
parseError: string | null;
|
||||
@@ -69,6 +76,21 @@ async function saveCodexRawConfig(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function patchCodexConfig(data: CodexConfigPatchInput): Promise<PatchCodexConfigResponse> {
|
||||
const res = await fetch(withApiBase('/codex/config/patch'), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.status === 409) throw new ApiConflictError('Codex config changed externally');
|
||||
|
||||
if (!res.ok) {
|
||||
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(payload?.error || 'Failed to patch Codex config');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useCodex() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -105,6 +127,14 @@ export function useCodex() {
|
||||
},
|
||||
});
|
||||
|
||||
const patchConfigMutation = useMutation({
|
||||
mutationFn: patchCodexConfig,
|
||||
onSuccess: (result) => {
|
||||
queryClient.setQueryData<CodexRawConfig>(['codex-raw-config'], result);
|
||||
queryClient.invalidateQueries({ queryKey: ['codex-diagnostics'] });
|
||||
},
|
||||
});
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
diagnostics: diagnosticsQuery.data,
|
||||
@@ -120,6 +150,9 @@ export function useCodex() {
|
||||
saveRawConfig: saveRawConfigMutation.mutate,
|
||||
saveRawConfigAsync: saveRawConfigMutation.mutateAsync,
|
||||
isSavingRawConfig: saveRawConfigMutation.isPending,
|
||||
patchConfig: patchConfigMutation.mutate,
|
||||
patchConfigAsync: patchConfigMutation.mutateAsync,
|
||||
isPatchingConfig: patchConfigMutation.isPending,
|
||||
}),
|
||||
[
|
||||
diagnosticsQuery.data,
|
||||
@@ -133,6 +166,9 @@ export function useCodex() {
|
||||
saveRawConfigMutation.mutate,
|
||||
saveRawConfigMutation.mutateAsync,
|
||||
saveRawConfigMutation.isPending,
|
||||
patchConfigMutation.mutate,
|
||||
patchConfigMutation.mutateAsync,
|
||||
patchConfigMutation.isPending,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
export interface CodexTopLevelSettingsView {
|
||||
model: string | null;
|
||||
modelReasoningEffort: string | null;
|
||||
modelProvider: string | null;
|
||||
approvalPolicy: string | null;
|
||||
sandboxMode: string | null;
|
||||
webSearch: string | null;
|
||||
toolOutputTokenLimit: number | null;
|
||||
personality: string | null;
|
||||
}
|
||||
|
||||
export interface CodexProjectTrustEntry {
|
||||
path: string;
|
||||
trustLevel: string;
|
||||
}
|
||||
|
||||
export interface CodexProfileEntry {
|
||||
name: string;
|
||||
values: CodexTopLevelSettingsView;
|
||||
}
|
||||
|
||||
export interface CodexModelProviderEntry {
|
||||
name: string;
|
||||
displayName: string | null;
|
||||
baseUrl: string | null;
|
||||
envKey: string | null;
|
||||
wireApi: string | null;
|
||||
requiresOpenaiAuth: boolean;
|
||||
supportsWebsockets: boolean;
|
||||
}
|
||||
|
||||
export interface CodexMcpServerEntry {
|
||||
name: string;
|
||||
transport: 'stdio' | 'streamable-http';
|
||||
command: string | null;
|
||||
args: string[];
|
||||
url: string | null;
|
||||
enabled: boolean;
|
||||
required: boolean;
|
||||
startupTimeoutSec: number | null;
|
||||
toolTimeoutSec: number | null;
|
||||
enabledTools: string[];
|
||||
disabledTools: string[];
|
||||
}
|
||||
|
||||
export interface CodexFeatureCatalogEntry {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const KNOWN_CODEX_FEATURES: CodexFeatureCatalogEntry[] = [
|
||||
{
|
||||
name: 'multi_agent',
|
||||
label: 'Multi-agent',
|
||||
description: 'Enable subagent collaboration tools.',
|
||||
},
|
||||
{
|
||||
name: 'unified_exec',
|
||||
label: 'Unified exec',
|
||||
description: 'Use the PTY-backed unified exec tool.',
|
||||
},
|
||||
{
|
||||
name: 'shell_snapshot',
|
||||
label: 'Shell snapshot',
|
||||
description: 'Reuse shell environment snapshots.',
|
||||
},
|
||||
{
|
||||
name: 'apply_patch_freeform',
|
||||
label: 'Apply patch',
|
||||
description: 'Enable freeform apply_patch edits.',
|
||||
},
|
||||
{ name: 'js_repl', label: 'JS REPL', description: 'Enable the Node-backed JavaScript REPL.' },
|
||||
{
|
||||
name: 'runtime_metrics',
|
||||
label: 'Runtime metrics',
|
||||
description: 'Collect Codex runtime metrics.',
|
||||
},
|
||||
{
|
||||
name: 'prevent_idle_sleep',
|
||||
label: 'Prevent idle sleep',
|
||||
description: 'Keep the machine awake while active.',
|
||||
},
|
||||
{ name: 'fast_mode', label: 'Fast mode', description: 'Allow the fast service tier path.' },
|
||||
{ name: 'apps', label: 'Apps', description: 'Enable ChatGPT Apps and connectors support.' },
|
||||
{
|
||||
name: 'smart_approvals',
|
||||
label: 'Smart approvals',
|
||||
description: 'Route eligible approvals through the guardian flow.',
|
||||
},
|
||||
];
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
: [];
|
||||
}
|
||||
|
||||
export function readCodexTopLevelSettings(
|
||||
config: Record<string, unknown> | null
|
||||
): CodexTopLevelSettingsView {
|
||||
return {
|
||||
model: asString(config?.model),
|
||||
modelReasoningEffort: asString(config?.model_reasoning_effort),
|
||||
modelProvider: asString(config?.model_provider),
|
||||
approvalPolicy: asString(config?.approval_policy),
|
||||
sandboxMode: asString(config?.sandbox_mode),
|
||||
webSearch: asString(config?.web_search),
|
||||
toolOutputTokenLimit: asNumber(config?.tool_output_token_limit),
|
||||
personality: asString(config?.personality),
|
||||
};
|
||||
}
|
||||
|
||||
export function readCodexProjectTrust(
|
||||
config: Record<string, unknown> | null
|
||||
): CodexProjectTrustEntry[] {
|
||||
const projects = asObject(config?.projects);
|
||||
if (!projects) return [];
|
||||
|
||||
return Object.entries(projects)
|
||||
.map(([projectPath, value]) => {
|
||||
const trustLevel = asString(asObject(value)?.trust_level);
|
||||
return trustLevel ? { path: projectPath, trustLevel } : null;
|
||||
})
|
||||
.filter((entry): entry is CodexProjectTrustEntry => entry !== null)
|
||||
.sort((left, right) => left.path.localeCompare(right.path));
|
||||
}
|
||||
|
||||
export function readCodexProfiles(config: Record<string, unknown> | null): CodexProfileEntry[] {
|
||||
const profiles = asObject(config?.profiles);
|
||||
if (!profiles) return [];
|
||||
|
||||
return Object.entries(profiles)
|
||||
.map(([name, value]) => ({ name, values: readCodexTopLevelSettings(asObject(value)) }))
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
export function readCodexModelProviders(
|
||||
config: Record<string, unknown> | null
|
||||
): CodexModelProviderEntry[] {
|
||||
const providers = asObject(config?.model_providers);
|
||||
if (!providers) return [];
|
||||
|
||||
return Object.entries(providers)
|
||||
.map(([name, value]) => {
|
||||
const provider = asObject(value);
|
||||
if (!provider) return null;
|
||||
return {
|
||||
name,
|
||||
displayName: asString(provider.name),
|
||||
baseUrl: asString(provider.base_url),
|
||||
envKey: asString(provider.env_key),
|
||||
wireApi: asString(provider.wire_api),
|
||||
requiresOpenaiAuth: provider.requires_openai_auth === true,
|
||||
supportsWebsockets: provider.supports_websockets === true,
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is CodexModelProviderEntry => entry !== null)
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
export function readCodexMcpServers(config: Record<string, unknown> | null): CodexMcpServerEntry[] {
|
||||
const servers = asObject(config?.mcp_servers);
|
||||
if (!servers) return [];
|
||||
|
||||
return Object.entries(servers)
|
||||
.map(([name, value]) => {
|
||||
const server = asObject(value);
|
||||
if (!server) return null;
|
||||
const transport = asString(server.command) ? 'stdio' : 'streamable-http';
|
||||
return {
|
||||
name,
|
||||
transport,
|
||||
command: asString(server.command),
|
||||
args: asStringArray(server.args),
|
||||
url: asString(server.url),
|
||||
enabled: server.enabled !== false,
|
||||
required: server.required === true,
|
||||
startupTimeoutSec: asNumber(server.startup_timeout_sec),
|
||||
toolTimeoutSec: asNumber(server.tool_timeout_sec),
|
||||
enabledTools: asStringArray(server.enabled_tools),
|
||||
disabledTools: asStringArray(server.disabled_tools),
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is CodexMcpServerEntry => entry !== null)
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
export function readCodexFeatureState(
|
||||
config: Record<string, unknown> | null
|
||||
): Record<string, boolean | null> {
|
||||
const features = asObject(config?.features);
|
||||
const state: Record<string, boolean | null> = {};
|
||||
|
||||
for (const feature of KNOWN_CODEX_FEATURES) {
|
||||
const value = features?.[feature.name];
|
||||
state[feature.name] = typeof value === 'boolean' ? value : null;
|
||||
}
|
||||
|
||||
if (features) {
|
||||
for (const [name, value] of Object.entries(features)) {
|
||||
if (!(name in state)) {
|
||||
state[name] = typeof value === 'boolean' ? value : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
+220
-54
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { parse as parseToml } from 'smol-toml';
|
||||
import { toast } from 'sonner';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
FileWarning,
|
||||
Folder,
|
||||
GripVertical,
|
||||
Info,
|
||||
@@ -19,7 +18,13 @@ import {
|
||||
import { useCodex } from '@/hooks/use-codex';
|
||||
import { isApiConflictError } from '@/lib/api-client';
|
||||
import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel';
|
||||
import { UsageCommand } from '@/components/cliproxy/provider-editor/usage-command';
|
||||
import { CodexFeaturesCard } from '@/components/compatible-cli/codex-features-card';
|
||||
import { CodexMcpServersCard } from '@/components/compatible-cli/codex-mcp-servers-card';
|
||||
import { CodexModelProvidersCard } from '@/components/compatible-cli/codex-model-providers-card';
|
||||
import { CodexProfilesCard } from '@/components/compatible-cli/codex-profiles-card';
|
||||
import { CodexProjectTrustCard } from '@/components/compatible-cli/codex-project-trust-card';
|
||||
import { CodexTopLevelControlsCard } from '@/components/compatible-cli/codex-top-level-controls-card';
|
||||
import { QuickCommands } from '@/components/shared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
@@ -33,6 +38,15 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
KNOWN_CODEX_FEATURES,
|
||||
readCodexFeatureState,
|
||||
readCodexMcpServers,
|
||||
readCodexModelProviders,
|
||||
readCodexProfiles,
|
||||
readCodexProjectTrust,
|
||||
readCodexTopLevelSettings,
|
||||
} from '@/lib/codex-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DEFAULT_CODEX_DOC_LINKS = [
|
||||
@@ -157,9 +171,12 @@ export function CodexPage() {
|
||||
refetchDiagnostics,
|
||||
rawConfig,
|
||||
rawConfigLoading,
|
||||
rawConfigError,
|
||||
refetchRawConfig,
|
||||
saveRawConfigAsync,
|
||||
isSavingRawConfig,
|
||||
patchConfigAsync,
|
||||
isPatchingConfig,
|
||||
} = useCodex();
|
||||
|
||||
const [rawDraftText, setRawDraftText] = useState<string | null>(null);
|
||||
@@ -170,6 +187,34 @@ export function CodexPage() {
|
||||
const rawEditorValidation = rawEditorParsed.valid
|
||||
? { valid: true as const }
|
||||
: { valid: false as const, error: rawEditorParsed.error };
|
||||
const controlsConfig = rawConfig?.config ?? null;
|
||||
const structuredControlsDisabled =
|
||||
rawConfigLoading || !rawConfig || rawConfigDirty || rawConfig?.parseError !== null;
|
||||
const controlsDisabledReason = rawConfigError
|
||||
? 'Structured controls unavailable: failed to load the current config.toml.'
|
||||
: rawConfigDirty
|
||||
? rawEditorValidation.valid
|
||||
? 'Save or discard raw TOML edits before using structured controls.'
|
||||
: 'Fix or discard raw TOML edits before using structured controls.'
|
||||
: rawConfig?.parseError
|
||||
? `Structured controls disabled: ${rawConfig.parseError}`
|
||||
: null;
|
||||
|
||||
const topLevelSettings = useMemo(
|
||||
() => readCodexTopLevelSettings(controlsConfig),
|
||||
[controlsConfig]
|
||||
);
|
||||
const projectTrustEntries = useMemo(
|
||||
() => readCodexProjectTrust(controlsConfig),
|
||||
[controlsConfig]
|
||||
);
|
||||
const profileEntries = useMemo(() => readCodexProfiles(controlsConfig), [controlsConfig]);
|
||||
const modelProviderEntries = useMemo(
|
||||
() => readCodexModelProviders(controlsConfig),
|
||||
[controlsConfig]
|
||||
);
|
||||
const mcpServerEntries = useMemo(() => readCodexMcpServers(controlsConfig), [controlsConfig]);
|
||||
const featureState = useMemo(() => readCodexFeatureState(controlsConfig), [controlsConfig]);
|
||||
|
||||
const setRawEditorDraftText = (nextText: string) => {
|
||||
if (nextText === rawBaseText) {
|
||||
@@ -206,6 +251,26 @@ export function CodexPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const runConfigPatch = async (
|
||||
patch: Parameters<typeof patchConfigAsync>[0],
|
||||
successMessage: string
|
||||
) => {
|
||||
try {
|
||||
await patchConfigAsync({
|
||||
...patch,
|
||||
expectedMtime: rawConfig?.exists ? rawConfig.mtime : undefined,
|
||||
});
|
||||
setRawDraftText(null);
|
||||
toast.success(successMessage);
|
||||
} catch (error) {
|
||||
if (isApiConflictError(error)) {
|
||||
toast.error('config.toml changed externally. Refresh and retry.');
|
||||
} else {
|
||||
toast.error((error as Error).message || 'Failed to update Codex config.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderOverview = () => {
|
||||
if (diagnosticsLoading) {
|
||||
return (
|
||||
@@ -242,7 +307,7 @@ export function CodexPage() {
|
||||
<div className="shrink-0 px-4 pt-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="routing">Runtime & Routing</TabsTrigger>
|
||||
<TabsTrigger value="controls">Control Center</TabsTrigger>
|
||||
<TabsTrigger value="docs">Docs</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
@@ -424,30 +489,31 @@ export function CodexPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{diagnostics.warnings.length > 0 && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||
Warnings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1.5">
|
||||
{diagnostics.warnings.map((warning) => (
|
||||
<p key={warning} className="text-sm text-amber-800 dark:text-amber-300">
|
||||
- {warning}
|
||||
</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
<QuickCommands
|
||||
snippets={[
|
||||
{
|
||||
label: 'Native Codex',
|
||||
command: 'ccs-codex',
|
||||
description: 'Launch the native Codex runtime alias.',
|
||||
},
|
||||
{
|
||||
label: 'Short alias',
|
||||
command: 'ccsx',
|
||||
description: 'Launch the short Codex runtime alias.',
|
||||
},
|
||||
{
|
||||
label: 'Target override',
|
||||
command: 'ccs codex --target codex "your prompt"',
|
||||
description: 'Run a CCS profile on the native Codex target.',
|
||||
},
|
||||
{
|
||||
label: 'Workspace trust',
|
||||
command: `codex --profile ${diagnostics.config.activeProfile || 'default'}`,
|
||||
description: 'Inspect the active profile directly in native Codex.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<TabsContent value="routing" className={tabContentClassName}>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 pr-1">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@@ -507,46 +573,146 @@ export function CodexPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Quick usage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<UsageCommand label="Codex alias (explicit)" command="ccs-codex" />
|
||||
<UsageCommand label="Codex alias (short)" command="ccsx" />
|
||||
<UsageCommand
|
||||
label="Run a profile on native Codex"
|
||||
command='ccs codex --target codex "your prompt"'
|
||||
/>
|
||||
<UsageCommand
|
||||
label="Routed profile example"
|
||||
command='ccs mycodex --target codex "your prompt"'
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{diagnostics.warnings.length > 0 && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||
Warnings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1.5">
|
||||
{diagnostics.warnings.map((warning) => (
|
||||
<p key={warning} className="text-sm text-amber-800 dark:text-amber-300">
|
||||
- {warning}
|
||||
</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="controls" className={tabContentClassName}>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 pr-1">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileWarning className="h-4 w-4" />
|
||||
What this editor affects
|
||||
<Route className="h-4 w-4" />
|
||||
Structured controls boundary
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Edits here affect native Codex sessions first because this is the user-layer{' '}
|
||||
<code>config.toml</code>.
|
||||
Guided controls write only the user-layer <code>config.toml</code>. They do
|
||||
not model the full effective Codex runtime once trusted repo layers and CCS
|
||||
transient <code>-c</code> overrides are involved.
|
||||
</p>
|
||||
<p>
|
||||
CCS-routed Codex launches may override provider-related keys transiently and
|
||||
inject <code>CCS_CODEX_API_KEY</code>.
|
||||
</p>
|
||||
<p>
|
||||
That means the file is not a complete source of truth for every routed Codex
|
||||
launch you start from CCS.
|
||||
Structured saves normalize TOML formatting and strip comments. Use the raw
|
||||
editor on the right when exact layout matters.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CodexTopLevelControlsCard
|
||||
values={topLevelSettings}
|
||||
providerNames={modelProviderEntries.map((entry) => entry.name)}
|
||||
disabled={structuredControlsDisabled}
|
||||
disabledReason={controlsDisabledReason}
|
||||
saving={isPatchingConfig}
|
||||
onSave={(values) =>
|
||||
runConfigPatch({ kind: 'top-level', values }, 'Saved top-level Codex settings.')
|
||||
}
|
||||
/>
|
||||
|
||||
<CodexProjectTrustCard
|
||||
workspacePath={diagnostics.workspacePath}
|
||||
entries={projectTrustEntries}
|
||||
disabled={structuredControlsDisabled}
|
||||
disabledReason={controlsDisabledReason}
|
||||
saving={isPatchingConfig}
|
||||
onSave={(projectPath, trustLevel) =>
|
||||
runConfigPatch(
|
||||
{ kind: 'project-trust', path: projectPath, trustLevel },
|
||||
trustLevel ? 'Saved project trust entry.' : 'Removed project trust entry.'
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<CodexProfilesCard
|
||||
activeProfile={diagnostics.config.activeProfile}
|
||||
entries={profileEntries}
|
||||
providerNames={modelProviderEntries.map((entry) => entry.name)}
|
||||
disabled={structuredControlsDisabled}
|
||||
disabledReason={controlsDisabledReason}
|
||||
saving={isPatchingConfig}
|
||||
onSave={(name, values, setAsActive) =>
|
||||
runConfigPatch(
|
||||
{ kind: 'profile', action: 'upsert', name, values, setAsActive },
|
||||
'Saved profile.'
|
||||
)
|
||||
}
|
||||
onDelete={(name) =>
|
||||
runConfigPatch({ kind: 'profile', action: 'delete', name }, 'Deleted profile.')
|
||||
}
|
||||
onSetActive={(name) =>
|
||||
runConfigPatch(
|
||||
{ kind: 'profile', action: 'set-active', name },
|
||||
'Set active profile.'
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<CodexModelProvidersCard
|
||||
entries={modelProviderEntries}
|
||||
disabled={structuredControlsDisabled}
|
||||
disabledReason={controlsDisabledReason}
|
||||
saving={isPatchingConfig}
|
||||
onSave={(name, values) =>
|
||||
runConfigPatch(
|
||||
{ kind: 'model-provider', action: 'upsert', name, values },
|
||||
'Saved model provider.'
|
||||
)
|
||||
}
|
||||
onDelete={(name) =>
|
||||
runConfigPatch(
|
||||
{ kind: 'model-provider', action: 'delete', name },
|
||||
'Deleted model provider.'
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<CodexMcpServersCard
|
||||
entries={mcpServerEntries}
|
||||
disabled={structuredControlsDisabled}
|
||||
disabledReason={controlsDisabledReason}
|
||||
saving={isPatchingConfig}
|
||||
onSave={(name, values) =>
|
||||
runConfigPatch(
|
||||
{ kind: 'mcp-server', action: 'upsert', name, values },
|
||||
'Saved MCP server.'
|
||||
)
|
||||
}
|
||||
onDelete={(name) =>
|
||||
runConfigPatch(
|
||||
{ kind: 'mcp-server', action: 'delete', name },
|
||||
'Deleted MCP server.'
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<CodexFeaturesCard
|
||||
catalog={KNOWN_CODEX_FEATURES}
|
||||
state={featureState}
|
||||
disabled={structuredControlsDisabled}
|
||||
disabledReason={controlsDisabledReason}
|
||||
onToggle={(feature, enabled) =>
|
||||
runConfigPatch({ kind: 'feature', feature, enabled }, 'Saved feature toggle.')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
Reference in new issue
Block a user