The Codex desktop app's model picker asks the app server for one page of
at most 100 models and never asks for the next page (a fixed limit in the
app). AITURK sent 262, so everything after the first 100 in alphabetical
provider order was out of reach from the menu (Gemini, most NanoGPT
uncensored models, Grok, ModelArk...).
- Bridge: bridge.json codex_liste {medya_gizle, efor_gizle, one_al, gizli},
re-read when the file changes. Image/audio/video models and "-low/-high"
copies whose base model is listed are left out by default; pinned models
follow Codex's own models in the given order; hidden ones are removed.
The candidate list with each model's place is written to
log/son-liste/codex-adaylar.json for the settings screen.
- Settings > Codex Bağlama: "Models shown in Codex" with a live count of the
100 slots, search, filter, pin/move up/hide per model and the two switches.
- The IDE keeps codex_liste when it rewrites bridge.json.
- Bridge version 21, IDE 1.0.0-beta.41.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
262 lines
8.4 KiB
TypeScript
262 lines
8.4 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { type CodexLinkCopy, useCodexLinkCopy } from '@/i18n/codex-link'
|
|
import { triggerHaptic } from '@/lib/haptics'
|
|
import { AlertTriangle, Check, CircleIcon, Link, RefreshCw } from '@/lib/icons'
|
|
import { cn } from '@/lib/utils'
|
|
import { notifyError } from '@/store/notifications'
|
|
|
|
import type { CodexLinkPatch, CodexLinkStatus } from '../../../electron/codex-link-types'
|
|
|
|
import { EmptyState, ListRow, ListRowSkeleton, SettingsContent, SettingsSection } from './primitives'
|
|
import { CompressionSettingsView } from './compression-settings'
|
|
import { CodexListeSettingsView } from './codex-liste-settings'
|
|
import { IsHattiSettingsView } from './is-hatti-settings'
|
|
|
|
type Tone = 'muted' | 'ok' | 'warn'
|
|
|
|
interface StatusLine {
|
|
id: string
|
|
text: string
|
|
tone: Tone
|
|
}
|
|
|
|
/**
|
|
* What the user needs to know, top-down, stopping at the first fact that makes
|
|
* the rest moot (no Codex → nothing else to say; another bridge → ours is not
|
|
* involved; setup pending → the bridge cannot be running yet).
|
|
*/
|
|
export function codexLinkStatusLines(status: CodexLinkStatus, copy: CodexLinkCopy): StatusLine[] {
|
|
const lines: StatusLine[] = []
|
|
const error = status.lastError ? [{ id: 'error', text: copy.errors[status.lastError], tone: 'warn' as const }] : []
|
|
|
|
if (!status.codexFound) {
|
|
return [{ id: 'codex', text: copy.codexMissing, tone: 'muted' }, ...error]
|
|
}
|
|
|
|
lines.push({ id: 'codex', text: copy.codexFound, tone: 'ok' })
|
|
|
|
if (status.foreign) {
|
|
return [...lines, { id: 'foreign', text: copy.foreign, tone: 'warn' }, ...error]
|
|
}
|
|
|
|
if (!status.enabled) {
|
|
if (status.lastError) return [...lines, ...error]
|
|
return [...lines, { id: 'off', text: copy.off, tone: 'muted' }, ...error]
|
|
}
|
|
|
|
if (!status.venvReady) {
|
|
return [...lines, { id: 'venv', text: copy.venvPending, tone: 'muted' }, ...error]
|
|
}
|
|
|
|
if (!status.bridgeRunning) {
|
|
return [...lines, { id: 'bridge', text: copy.bridgeStopped, tone: 'warn' }, ...error]
|
|
}
|
|
|
|
lines.push({ id: 'bridge', text: copy.bridgeRunning, tone: 'ok' })
|
|
|
|
if (status.ideDurum === 'anahtar-yok') {
|
|
lines.push({ id: 'ide', text: copy.signIn, tone: 'warn' })
|
|
} else if (status.mod === 'aiturk' && status.ideDurum === 'tamam' && status.ideModel !== null) {
|
|
lines.push({ id: 'ide', text: copy.models(status.ideModel), tone: 'ok' })
|
|
}
|
|
|
|
return [...lines, ...error]
|
|
}
|
|
|
|
const TONE_ICON = { muted: CircleIcon, ok: Check, warn: AlertTriangle } as const
|
|
|
|
const TONE_CLASS: Record<Tone, string> = {
|
|
muted: 'text-(--ui-text-tertiary)',
|
|
ok: 'text-(--ui-text-secondary)',
|
|
warn: 'text-amber-500/90'
|
|
}
|
|
|
|
// Phone-sized hit area around the compact switch: the visible control keeps the
|
|
// design-system size, the label that wraps it is the 44px target.
|
|
const TOUCH_TARGET = 'flex min-h-11 min-w-11 cursor-pointer items-center @2xl:justify-end'
|
|
|
|
/**
|
|
* Settings → Codex Link. Main owns everything real (the preference file, the
|
|
* bridge, Codex's config, the login item); this page reads the state it reports
|
|
* and asks for changes, adopting whatever comes back as the truth.
|
|
*/
|
|
export function CodexLinkSettings() {
|
|
const copy = useCodexLinkCopy()
|
|
const bridge = typeof window === 'undefined' ? undefined : window.hermesDesktop?.codexLink
|
|
const [status, setStatus] = useState<CodexLinkStatus | null>(null)
|
|
const [loadFailed, setLoadFailed] = useState(false)
|
|
const [busy, setBusy] = useState<'sync' | 'toggle' | null>(null)
|
|
// A slow initial read must not overwrite the answer to a newer change.
|
|
const generation = useRef(0)
|
|
|
|
useEffect(() => {
|
|
if (!bridge) {
|
|
return
|
|
}
|
|
|
|
const ticket = generation.current
|
|
|
|
bridge.get().then(
|
|
next => {
|
|
if (generation.current === ticket) {
|
|
setStatus(next)
|
|
}
|
|
},
|
|
() => {
|
|
if (generation.current === ticket) {
|
|
setLoadFailed(true)
|
|
}
|
|
}
|
|
)
|
|
}, [bridge])
|
|
|
|
const change = useCallback(
|
|
async (patch: CodexLinkPatch, kind: 'sync' | 'toggle') => {
|
|
if (!bridge || busy) {
|
|
return
|
|
}
|
|
|
|
const before = status
|
|
generation.current += 1
|
|
setBusy(kind)
|
|
setLoadFailed(false)
|
|
|
|
// The switch moves at once; main's reply (or the rollback) has the last word.
|
|
if (typeof patch.enabled === 'boolean' && before) {
|
|
setStatus({ ...before, enabled: patch.enabled })
|
|
}
|
|
|
|
try {
|
|
setStatus(await bridge.set(patch))
|
|
} catch (error) {
|
|
setStatus(before)
|
|
notifyError(error, copy.saveFailed)
|
|
} finally {
|
|
setBusy(null)
|
|
}
|
|
},
|
|
[bridge, busy, copy.saveFailed, status]
|
|
)
|
|
|
|
if (!bridge) {
|
|
return (
|
|
<SettingsContent>
|
|
<EmptyState description={copy.unavailable} title={copy.title} />
|
|
</SettingsContent>
|
|
)
|
|
}
|
|
|
|
const lines = status ? codexLinkStatusLines(status, copy) : []
|
|
|
|
return (
|
|
<SettingsContent>
|
|
<SettingsSection icon={Link} title={copy.title}>
|
|
<ListRow
|
|
action={
|
|
<label className={TOUCH_TARGET}>
|
|
<Switch
|
|
aria-label={copy.title}
|
|
checked={status?.enabled ?? true}
|
|
disabled={!status || busy !== null}
|
|
onCheckedChange={enabled => {
|
|
triggerHaptic('selection')
|
|
void change({ enabled }, 'toggle')
|
|
}}
|
|
/>
|
|
</label>
|
|
}
|
|
description={copy.description}
|
|
title={copy.title}
|
|
/>
|
|
|
|
<ListRow
|
|
title={copy.modeTitle}
|
|
below={
|
|
<fieldset disabled={!status || busy !== null} className="grid gap-2 py-2">
|
|
{(['dogrudan', 'aiturk'] as const).map(mod => (
|
|
<label key={mod} className="flex min-h-11 cursor-pointer items-center gap-2">
|
|
<input
|
|
type="radio"
|
|
name="codex-link-mode"
|
|
value={mod}
|
|
checked={(status?.mod ?? 'dogrudan') === mod}
|
|
onChange={() => void change({ mod }, 'toggle')}
|
|
/>
|
|
<span>{mod === 'dogrudan' ? copy.directMode : copy.aiturkMode}</span>
|
|
</label>
|
|
))}
|
|
<p className="text-(--ui-text-tertiary)">{copy.modeRestartNote}</p>
|
|
</fieldset>
|
|
}
|
|
/>
|
|
|
|
{!status && !loadFailed && <ListRowSkeleton wide />}
|
|
|
|
{loadFailed && !status && (
|
|
<p className="py-3 text-[length:var(--conversation-caption-font-size)] text-amber-500/90" role="alert">
|
|
{copy.loadFailed}
|
|
</p>
|
|
)}
|
|
|
|
{status && (
|
|
<ListRow
|
|
below={
|
|
<ul aria-live="polite" className="mt-1.5 grid gap-1.5">
|
|
{lines.map(line => {
|
|
const Icon = TONE_ICON[line.tone]
|
|
|
|
return (
|
|
<li
|
|
className={cn(
|
|
'flex min-w-0 items-start gap-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height)',
|
|
TONE_CLASS[line.tone]
|
|
)}
|
|
data-tone={line.tone}
|
|
key={line.id}
|
|
>
|
|
<Icon aria-hidden className="mt-0.5 size-3.5 shrink-0" />
|
|
<span className="min-w-0 break-words">{line.text}</span>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
}
|
|
title={copy.statusTitle}
|
|
wide
|
|
/>
|
|
)}
|
|
|
|
<ListRow
|
|
action={
|
|
<Button
|
|
className="min-h-11 w-full @2xl:w-auto"
|
|
disabled={!status || busy !== null}
|
|
onClick={() => {
|
|
triggerHaptic('open')
|
|
void change({ sync: true }, 'sync')
|
|
}}
|
|
type="button"
|
|
variant="secondary"
|
|
>
|
|
<RefreshCw />
|
|
{busy === 'sync' ? copy.syncing : copy.sync}
|
|
</Button>
|
|
}
|
|
description={copy.syncDesc}
|
|
title={copy.syncTitle}
|
|
/>
|
|
|
|
<p className="pt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
|
{copy.restartNote}
|
|
</p>
|
|
</SettingsSection>
|
|
<IsHattiSettingsView />
|
|
<CodexListeSettingsView />
|
|
<CompressionSettingsView />
|
|
</SettingsContent>
|
|
)
|
|
}
|