mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-11 16:24:48 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user