chenbhao commited on
Commit
4e7af9b
·
1 Parent(s): eb16a76

feat: desktop setting provider

Browse files
desktop/index.html CHANGED
@@ -12,7 +12,7 @@
12
  </style>
13
  <script>
14
  (function () {
15
- var bootDeadlineMs = 8000
16
 
17
  function summarize(value) {
18
  if (!value) return 'Unknown startup error'
@@ -27,6 +27,8 @@
27
 
28
  function renderStartupError(reason) {
29
  if (window.__CC_HAHA_BOOTSTRAPPED__) return
 
 
30
  var root = document.getElementById('root')
31
  if (!root) return
32
 
@@ -94,8 +96,13 @@
94
  })
95
 
96
  window.setTimeout(function () {
 
97
  renderStartupError('Desktop app did not finish bootstrapping within ' + bootDeadlineMs + 'ms. This usually means the WebView could not execute the app bundle or startup code stopped before React mounted.')
98
  }, bootDeadlineMs)
 
 
 
 
99
  })()
100
  </script>
101
  </head>
 
12
  </style>
13
  <script>
14
  (function () {
15
+ var bootDeadlineMs = 30000
16
 
17
  function summarize(value) {
18
  if (!value) return 'Unknown startup error'
 
27
 
28
  function renderStartupError(reason) {
29
  if (window.__CC_HAHA_BOOTSTRAPPED__) return
30
+ if (window.__CC_HAHA_STARTUP_ERROR_SHOWN__) return
31
+ window.__CC_HAHA_STARTUP_ERROR_SHOWN__ = true
32
  var root = document.getElementById('root')
33
  if (!root) return
34
 
 
96
  })
97
 
98
  window.setTimeout(function () {
99
+ if (window.__CC_HAHA_STARTUP_ERROR_SHOWN__) return
100
  renderStartupError('Desktop app did not finish bootstrapping within ' + bootDeadlineMs + 'ms. This usually means the WebView could not execute the app bundle or startup code stopped before React mounted.')
101
  }, bootDeadlineMs)
102
+
103
+ window.addEventListener('load', function () {
104
+ window.__CC_HAHA_PAGE_LOADED__ = true
105
+ })
106
  })()
107
  </script>
108
  </head>
desktop/package.json CHANGED
@@ -5,12 +5,13 @@
5
  "type": "module",
6
  "scripts": {
7
  "dev": "vite",
 
8
  "build": "tsc -b && vite build",
9
  "build:sidecars": "bun run ./scripts/build-sidecars.ts",
10
  "build:macos-arm64": "bash ./scripts/build-macos-arm64.sh",
11
  "build:windows-x64": "powershell -ExecutionPolicy Bypass -File ./scripts/build-windows-x64.ps1",
12
  "preview": "vite preview",
13
- "tauri": "GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri",
14
  "test": "vitest",
15
  "test:ui": "vitest --ui",
16
  "lint": "tsc --noEmit"
 
5
  "type": "module",
6
  "scripts": {
7
  "dev": "vite",
8
+ "dev:kill": "fuser -k 1420/tcp 2>/dev/null; bun run dev",
9
  "build": "tsc -b && vite build",
10
  "build:sidecars": "bun run ./scripts/build-sidecars.ts",
11
  "build:macos-arm64": "bash ./scripts/build-macos-arm64.sh",
12
  "build:windows-x64": "powershell -ExecutionPolicy Bypass -File ./scripts/build-windows-x64.ps1",
13
  "preview": "vite preview",
14
+ "tauri": "lsof -ti:1420 | xargs kill -9 2>/dev/null; GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri",
15
  "test": "vitest",
16
  "test:ui": "vitest --ui",
17
  "lint": "tsc --noEmit"
desktop/src-tauri/tauri.conf.json CHANGED
@@ -6,7 +6,7 @@
6
  "build": {
7
  "frontendDist": "../dist",
8
  "devUrl": "http://localhost:1420",
9
- "beforeDevCommand": "bun run build:sidecars && bun run dev",
10
  "beforeBuildCommand": "bun run build && bun run build:sidecars"
11
  },
12
  "app": {
 
6
  "build": {
7
  "frontendDist": "../dist",
8
  "devUrl": "http://localhost:1420",
9
+ "beforeDevCommand": "lsof -ti:1420 | xargs kill -9 2>/dev/null; bun run build:sidecars && bun run dev",
10
  "beforeBuildCommand": "bun run build && bun run build:sidecars"
11
  },
12
  "app": {
desktop/src/api/cliAuth.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { api } from './client'
2
+
3
+ type AuthProvider = 'anthropic' | 'openai' | 'openrouter' | 'local' | 'opencode' | 'nvidia'
4
+
5
+ type CliAuthConfig = {
6
+ authProvider: AuthProvider | null
7
+ nvidiaApiKey?: string
8
+ openRouterApiKey?: string
9
+ openAiApiKey?: string
10
+ openAiAccessToken?: string
11
+ openCodeApiKey?: string
12
+ openCodeModelName?: string
13
+ localBaseUrl?: string
14
+ localModelName?: string
15
+ }
16
+
17
+ export type { AuthProvider, CliAuthConfig }
18
+
19
+ export const cliAuthApi = {
20
+ get(): Promise<CliAuthConfig> {
21
+ return api.get<CliAuthConfig>('/api/cli-auth')
22
+ },
23
+
24
+ update(config: Partial<CliAuthConfig>): Promise<{ ok: true }> {
25
+ return api.put<{ ok: true }>('/api/cli-auth', config)
26
+ },
27
+
28
+ invalidateCache(): Promise<{ ok: true }> {
29
+ return api.post<{ ok: true }>('/api/cli-auth/invalidate', {})
30
+ },
31
+ }
desktop/src/components/settings/AnthropicLoginPanel.tsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect } from 'react'
2
+ import { open as shellOpen } from '@tauri-apps/plugin-shell'
3
+ import { useHahaOAuthStore } from '../../stores/hahaOAuthStore'
4
+ import { useTranslation } from '../../i18n'
5
+
6
+ export function AnthropicLoginPanel() {
7
+ const t = useTranslation()
8
+ const {
9
+ status,
10
+ isLoading,
11
+ error,
12
+ fetchStatus,
13
+ login,
14
+ logout,
15
+ startPolling,
16
+ stopPolling,
17
+ } = useHahaOAuthStore()
18
+
19
+ useEffect(() => {
20
+ fetchStatus()
21
+ return () => stopPolling()
22
+ }, [fetchStatus, stopPolling])
23
+
24
+ const handleLogin = async () => {
25
+ try {
26
+ const { authorizeUrl } = await login()
27
+ try {
28
+ await shellOpen(authorizeUrl)
29
+ startPolling()
30
+ } catch (err) {
31
+ console.error('[AnthropicLoginPanel] shellOpen failed:', err)
32
+ useHahaOAuthStore.setState({
33
+ error: t('settings.claudeOfficialLogin.openBrowserFailed'),
34
+ })
35
+ }
36
+ } catch {
37
+ // store.login() errors are already captured into store.error
38
+ }
39
+ }
40
+
41
+ if (status === null) {
42
+ if (error) {
43
+ return (
44
+ <div className="text-xs text-[var(--color-error)] py-2">
45
+ {t('settings.claudeOfficialLogin.errorPrefix')}{error}
46
+ </div>
47
+ )
48
+ }
49
+ return (
50
+ <div className="text-xs text-[var(--color-text-tertiary)] py-2">
51
+ {t('common.loading')}
52
+ </div>
53
+ )
54
+ }
55
+
56
+ if (status.loggedIn) {
57
+ const subTypeLabel = status.subscriptionType
58
+ ? status.subscriptionType.toUpperCase()
59
+ : t('settings.claudeOfficialLogin.subTypeUnknown')
60
+ return (
61
+ <div className="flex items-center gap-3 py-2">
62
+ <span className="text-sm text-[var(--color-success)]">
63
+ ✓ {t('settings.claudeOfficialLogin.loggedInPrefix')} {subTypeLabel})
64
+ </span>
65
+ <button
66
+ type="button"
67
+ onClick={logout}
68
+ disabled={isLoading}
69
+ className="px-3 py-1 text-xs rounded-md border border-[var(--color-border-separator)] bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50 transition-colors"
70
+ >
71
+ {isLoading
72
+ ? t('settings.claudeOfficialLogin.logoutProcessing')
73
+ : t('settings.claudeOfficialLogin.logoutButton')}
74
+ </button>
75
+ </div>
76
+ )
77
+ }
78
+
79
+ return (
80
+ <div className="flex flex-col gap-2 py-2">
81
+ <div className="text-sm text-[var(--color-text-secondary)]">
82
+ {t('settings.claudeOfficialLogin.intro')}
83
+ </div>
84
+ <button
85
+ type="button"
86
+ onClick={handleLogin}
87
+ disabled={isLoading}
88
+ className="self-start rounded-md bg-[image:var(--gradient-btn-primary)] px-4 py-2 text-sm text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] hover:brightness-105 disabled:opacity-50 transition-opacity"
89
+ >
90
+ {isLoading
91
+ ? t('settings.claudeOfficialLogin.loginStarting')
92
+ : t('settings.claudeOfficialLogin.loginButton')}
93
+ </button>
94
+ {error && (
95
+ <div className="text-xs text-[var(--color-error)]">
96
+ {t('settings.claudeOfficialLogin.errorPrefix')}{error}
97
+ </div>
98
+ )}
99
+ </div>
100
+ )
101
+ }
desktop/src/components/settings/CliLoginProviderSettings.tsx ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react'
2
+ import { useCliAuthStore } from '../../stores/cliAuthStore'
3
+ import { AnthropicLoginPanel } from './AnthropicLoginPanel'
4
+ import { OpenAILoginPanel } from './OpenAILoginPanel'
5
+ import { NvidiaLoginPanel } from './NvidiaLoginPanel'
6
+ import { OpenRouterLoginPanel } from './OpenRouterLoginPanel'
7
+ import { OpenCodeLoginPanel } from './OpenCodeLoginPanel'
8
+ import { LocalLoginPanel } from './LocalLoginPanel'
9
+ import type { AuthProvider } from '../../api/cliAuth'
10
+
11
+ const PROVIDER_OPTIONS: { id: AuthProvider; name: string; description: string; icon: string }[] = [
12
+ { id: 'anthropic', name: 'Anthropic', description: 'Subscription login, Console API billing, or Bedrock/Foundry/Vertex', icon: '🤖' },
13
+ { id: 'openai', name: 'OpenAI / Codex', description: 'Codex login, Codex auth import, or OpenAI API key', icon: '🤖' },
14
+ { id: 'openrouter', name: 'OpenRouter', description: 'OpenRouter API key via Responses API', icon: '🔀' },
15
+ { id: 'opencode', name: 'OpenCode Zen', description: 'Free models (Big Pickle, GPT 5 Nano) or Zen API key', icon: '✨' },
16
+ { id: 'local', name: 'Local', description: 'Local model server (Ollama, LM Studio, vLLM, etc.)', icon: '💻' },
17
+ { id: 'nvidia', name: 'NVIDIA', description: 'NVIDIA NIM API key from build.nvidia.com', icon: '🔷' },
18
+ ]
19
+
20
+ const LOGIN_PANEL_MAP: Record<AuthProvider, React.FC> = {
21
+ anthropic: AnthropicLoginPanel,
22
+ openai: OpenAILoginPanel,
23
+ openrouter: OpenRouterLoginPanel,
24
+ opencode: OpenCodeLoginPanel,
25
+ local: LocalLoginPanel,
26
+ nvidia: NvidiaLoginPanel,
27
+ }
28
+
29
+ export function CliLoginProviderSettings() {
30
+ const { authProvider, isLoading, fetchAuth, clearAuth } = useCliAuthStore()
31
+ const [expandedProvider, setExpandedProvider] = useState<AuthProvider | null>(null)
32
+
33
+ useEffect(() => {
34
+ fetchAuth()
35
+ }, [fetchAuth])
36
+
37
+ const handleProviderClick = (providerId: AuthProvider) => {
38
+ if (expandedProvider === providerId) {
39
+ setExpandedProvider(null)
40
+ } else {
41
+ setExpandedProvider(providerId)
42
+ }
43
+ }
44
+
45
+ return (
46
+ <div className="max-w-2xl">
47
+ <div className="mb-4">
48
+ <h2 className="text-base font-semibold text-[var(--color-text-primary)]">Provider</h2>
49
+ <p className="text-sm text-[var(--color-text-tertiary)] mt-0.5">
50
+ Choose which provider to use. Pick a provider first, then configure the login method.
51
+ </p>
52
+ </div>
53
+
54
+ {authProvider && (
55
+ <div className="mb-3 px-4 py-2 rounded-lg bg-[var(--color-surface-container)] border border-[var(--color-border)]">
56
+ <div className="flex items-center justify-between">
57
+ <span className="text-sm text-[var(--color-text-secondary)]">
58
+ Current provider: <strong className="text-[var(--color-text-primary)]">{PROVIDER_OPTIONS.find(p => p.id === authProvider)?.name || authProvider}</strong>
59
+ </span>
60
+ <button
61
+ onClick={clearAuth}
62
+ disabled={isLoading}
63
+ className="text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-error)] disabled:opacity-50"
64
+ >
65
+ Clear &amp; change
66
+ </button>
67
+ </div>
68
+ </div>
69
+ )}
70
+
71
+ <div className="flex flex-col gap-2">
72
+ {PROVIDER_OPTIONS.map((provider) => {
73
+ const isActive = provider.id === authProvider
74
+ const isExpanded = expandedProvider === provider.id
75
+ const LoginPanel = LOGIN_PANEL_MAP[provider.id]
76
+
77
+ return (
78
+ <div
79
+ key={provider.id}
80
+ className={`rounded-xl border transition-all ${
81
+ isExpanded && isActive
82
+ ? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
83
+ : isActive
84
+ ? 'border-[var(--color-success)]/30 bg-[var(--color-surface-container)]'
85
+ : isExpanded
86
+ ? 'border-[var(--color-border-focus)]'
87
+ : 'border-[var(--color-border)] hover:border-[var(--color-border-focus)]'
88
+ }`}
89
+ >
90
+ <button
91
+ className={`w-full flex items-center gap-4 px-4 py-3.5 text-left ${!isExpanded && !isActive ? 'cursor-pointer' : ''}`}
92
+ onClick={() => handleProviderClick(provider.id)}
93
+ >
94
+ <span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${
95
+ isActive ? 'bg-[var(--color-success)]' : isExpanded ? 'bg-[var(--color-brand)]' : 'bg-[var(--color-text-tertiary)]'
96
+ }`} />
97
+ <div className="flex-1 min-w-0">
98
+ <div className="flex items-center gap-2">
99
+ <span className="text-sm font-semibold text-[var(--color-text-primary)]">{provider.name}</span>
100
+ {isActive && authProvider && (
101
+ <span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-success)]/18 bg-[var(--color-success)]/14 text-[var(--color-success)] leading-none">Active</span>
102
+ )}
103
+ </div>
104
+ <div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{provider.description}</div>
105
+ </div>
106
+ <svg
107
+ className={`w-4 h-4 text-[var(--color-text-tertiary)] transition-transform ${isExpanded ? 'rotate-180' : ''}`}
108
+ fill="none" stroke="currentColor" viewBox="0 0 24 24"
109
+ >
110
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
111
+ </svg>
112
+ </button>
113
+
114
+ {isExpanded && (
115
+ <div className="px-4 pb-4 pt-2 border-t border-[var(--color-border-separator)]">
116
+ <LoginPanel />
117
+ </div>
118
+ )}
119
+ </div>
120
+ )
121
+ })}
122
+ </div>
123
+ </div>
124
+ )
125
+ }
desktop/src/components/settings/LocalLoginPanel.tsx ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react'
2
+ import { useCliAuthStore } from '../../stores/cliAuthStore'
3
+
4
+ export function LocalLoginPanel() {
5
+ const { localBaseUrl, localModelName, saveLocalModelConfig, isLoading } = useCliAuthStore()
6
+ const [url, setUrl] = useState('')
7
+ const [modelName, setModelName] = useState('')
8
+ const [status, setStatus] = useState<string | null>(null)
9
+ const defaultUrl = 'http://127.0.0.1:8001'
10
+
11
+ useEffect(() => {
12
+ if (localBaseUrl) setUrl(localBaseUrl)
13
+ if (localModelName) setModelName(localModelName)
14
+ }, [localBaseUrl, localModelName])
15
+
16
+ const handleSave = async () => {
17
+ const finalUrl = url.trim() || defaultUrl
18
+ const finalModel = modelName.trim() || 'default'
19
+ if (!finalUrl) { setStatus('Please enter a URL'); return }
20
+ try {
21
+ new URL(finalUrl) // validate URL
22
+ } catch {
23
+ setStatus('Invalid URL format')
24
+ return
25
+ }
26
+ setStatus(null)
27
+ try {
28
+ await saveLocalModelConfig(finalUrl, finalModel)
29
+ setStatus('Saved!')
30
+ } catch (err) {
31
+ setStatus(err instanceof Error ? err.message : String(err))
32
+ }
33
+ }
34
+
35
+ return (
36
+ <div className="flex flex-col gap-3 py-2">
37
+ <div className="text-sm text-[var(--color-text-secondary)]">
38
+ Configure a local model server (Ollama, LM Studio, vLLM, etc.).
39
+ </div>
40
+ <div className="flex flex-col gap-2">
41
+ <div>
42
+ <label className="text-xs text-[var(--color-text-tertiary)] mb-1 block">Server URL</label>
43
+ <input
44
+ type="text"
45
+ value={url}
46
+ onChange={e => setUrl(e.target.value)}
47
+ placeholder={localBaseUrl || defaultUrl}
48
+ className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:border-[var(--color-brand)] focus:outline-none"
49
+ />
50
+ </div>
51
+ <div>
52
+ <label className="text-xs text-[var(--color-text-tertiary)] mb-1 block">Model Name</label>
53
+ <input
54
+ type="text"
55
+ value={modelName}
56
+ onChange={e => setModelName(e.target.value)}
57
+ placeholder={localModelName || 'default'}
58
+ className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:border-[var(--color-brand)] focus:outline-none"
59
+ />
60
+ </div>
61
+ </div>
62
+ <div className="flex items-center gap-2">
63
+ <button
64
+ onClick={handleSave}
65
+ disabled={isLoading}
66
+ className="rounded-md bg-[var(--color-brand)] px-4 py-2 text-sm text-white hover:brightness-105 disabled:opacity-50"
67
+ >
68
+ {isLoading ? 'Saving...' : 'Save'}
69
+ </button>
70
+ </div>
71
+ {status && (
72
+ <div className={`text-xs ${status === 'Saved!' ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
73
+ {status}
74
+ </div>
75
+ )}
76
+ </div>
77
+ )
78
+ }
desktop/src/components/settings/NvidiaLoginPanel.tsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react'
2
+ import { useCliAuthStore } from '../../stores/cliAuthStore'
3
+
4
+ export function NvidiaLoginPanel() {
5
+ const { nvidiaApiKey, saveNvidiaApiKey, isLoading } = useCliAuthStore()
6
+ const [apiKey, setApiKey] = useState('')
7
+ const [status, setStatus] = useState<string | null>(null)
8
+
9
+ useEffect(() => {
10
+ if (nvidiaApiKey) setApiKey(nvidiaApiKey)
11
+ }, [nvidiaApiKey])
12
+
13
+ const handleSave = async () => {
14
+ if (!apiKey.trim()) { setStatus('Please enter an API key'); return }
15
+ setStatus(null)
16
+ try {
17
+ await saveNvidiaApiKey(apiKey.trim())
18
+ setStatus('Saved!')
19
+ } catch (err) {
20
+ setStatus(err instanceof Error ? err.message : String(err))
21
+ }
22
+ }
23
+
24
+ const handleClear = async () => {
25
+ await useCliAuthStore.getState().clearAuth()
26
+ setApiKey('')
27
+ setStatus('Cleared')
28
+ }
29
+
30
+ return (
31
+ <div className="flex flex-col gap-3 py-2">
32
+ <div className="text-sm text-[var(--color-text-secondary)]">
33
+ Paste your NVIDIA API key to use models from the NVIDIA API catalog via the OpenAI-compatible endpoint.
34
+ </div>
35
+ <div className="flex items-center gap-2">
36
+ <input
37
+ type="password"
38
+ value={apiKey}
39
+ onChange={e => setApiKey(e.target.value)}
40
+ placeholder={nvidiaApiKey ? '••••••••' : 'sk-...'}
41
+ className="flex-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:border-[var(--color-brand)] focus:outline-none"
42
+ />
43
+ <button
44
+ onClick={handleSave}
45
+ disabled={isLoading}
46
+ className="rounded-md bg-[var(--color-brand)] px-4 py-2 text-sm text-white hover:brightness-105 disabled:opacity-50"
47
+ >
48
+ {isLoading ? 'Saving...' : 'Save'}
49
+ </button>
50
+ {nvidiaApiKey && (
51
+ <button
52
+ onClick={handleClear}
53
+ className="rounded-md border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
54
+ >
55
+ Clear
56
+ </button>
57
+ )}
58
+ </div>
59
+ {status && (
60
+ <div className={`text-xs ${status === 'Saved!' ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
61
+ {status}
62
+ </div>
63
+ )}
64
+ </div>
65
+ )
66
+ }
desktop/src/components/settings/OpenAILoginPanel.tsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react'
2
+ import { useCliAuthStore } from '../../stores/cliAuthStore'
3
+
4
+ export function OpenAILoginPanel() {
5
+ const { openAiApiKey, saveOpenAIApiKey, isLoading } = useCliAuthStore()
6
+ const [apiKey, setApiKey] = useState('')
7
+ const [status, setStatus] = useState<string | null>(null)
8
+
9
+ useEffect(() => {
10
+ if (openAiApiKey) setApiKey(openAiApiKey)
11
+ }, [openAiApiKey])
12
+
13
+ const handleSave = async () => {
14
+ if (!apiKey.trim()) { setStatus('Please enter an API key'); return }
15
+ setStatus(null)
16
+ try {
17
+ await saveOpenAIApiKey(apiKey.trim())
18
+ setStatus('Saved!')
19
+ } catch (err) {
20
+ setStatus(err instanceof Error ? err.message : String(err))
21
+ }
22
+ }
23
+
24
+ const handleClear = async () => {
25
+ await useCliAuthStore.getState().clearAuth()
26
+ setApiKey('')
27
+ setStatus('Cleared')
28
+ }
29
+
30
+ return (
31
+ <div className="flex flex-col gap-3 py-2">
32
+ <div className="text-sm text-[var(--color-text-secondary)]">
33
+ Paste your OpenAI API key to use OpenAI models with standard platform billing.
34
+ </div>
35
+ <div className="flex items-center gap-2">
36
+ <input
37
+ type="password"
38
+ value={apiKey}
39
+ onChange={e => setApiKey(e.target.value)}
40
+ placeholder={openAiApiKey ? '••••••••' : 'sk-...'}
41
+ className="flex-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:border-[var(--color-brand)] focus:outline-none"
42
+ />
43
+ <button
44
+ onClick={handleSave}
45
+ disabled={isLoading}
46
+ className="rounded-md bg-[var(--color-brand)] px-4 py-2 text-sm text-white hover:brightness-105 disabled:opacity-50"
47
+ >
48
+ {isLoading ? 'Saving...' : 'Save'}
49
+ </button>
50
+ {openAiApiKey && (
51
+ <button
52
+ onClick={handleClear}
53
+ className="rounded-md border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
54
+ >
55
+ Clear
56
+ </button>
57
+ )}
58
+ </div>
59
+ {status && (
60
+ <div className={`text-xs ${status === 'Saved!' ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
61
+ {status}
62
+ </div>
63
+ )}
64
+ </div>
65
+ )
66
+ }
desktop/src/components/settings/OpenCodeLoginPanel.tsx ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import { useCliAuthStore } from '../../stores/cliAuthStore'
3
+
4
+ export function OpenCodeLoginPanel() {
5
+ const { openCodeApiKey, saveOpenCodeApiKey, isLoading } = useCliAuthStore()
6
+ const [mode, setMode] = useState<'menu' | 'api_key'>('menu')
7
+ const [apiKey, setApiKey] = useState('')
8
+ const [status, setStatus] = useState<string | null>(null)
9
+
10
+ const handleFreeModels = async () => {
11
+ setStatus(null)
12
+ try {
13
+ await saveOpenCodeApiKey('', '')
14
+ setStatus('Free models configured!')
15
+ } catch (err) {
16
+ setStatus(err instanceof Error ? err.message : String(err))
17
+ }
18
+ }
19
+
20
+ const handleSave = async () => {
21
+ if (!apiKey.trim()) { setStatus('Please enter an API key'); return }
22
+ setStatus(null)
23
+ try {
24
+ await saveOpenCodeApiKey(apiKey.trim(), '')
25
+ setStatus('Saved!')
26
+ } catch (err) {
27
+ setStatus(err instanceof Error ? err.message : String(err))
28
+ }
29
+ }
30
+
31
+ const handleClear = async () => {
32
+ await useCliAuthStore.getState().clearAuth()
33
+ setApiKey('')
34
+ setStatus('Cleared')
35
+ }
36
+
37
+ if (mode === 'api_key') {
38
+ return (
39
+ <div className="flex flex-col gap-3 py-2">
40
+ <div className="text-sm text-[var(--color-text-secondary)]">
41
+ OpenCode Zen API keys use pay-as-you-go billing. Sign up at <a href="https://opencode.ai/zen" className="text-[var(--color-brand)]" target="_blank">opencode.ai/zen</a> to get your key.
42
+ </div>
43
+ <div className="flex items-center gap-2">
44
+ <input
45
+ type="password"
46
+ value={apiKey}
47
+ onChange={e => setApiKey(e.target.value)}
48
+ placeholder={openCodeApiKey ? '••••••••' : 'oc-...'}
49
+ className="flex-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:border-[var(--color-brand)] focus:outline-none"
50
+ />
51
+ <button
52
+ onClick={handleSave}
53
+ disabled={isLoading}
54
+ className="rounded-md bg-[var(--color-brand)] px-4 py-2 text-sm text-white hover:brightness-105 disabled:opacity-50"
55
+ >
56
+ {isLoading ? 'Saving...' : 'Save'}
57
+ </button>
58
+ </div>
59
+ <button
60
+ onClick={() => { setMode('menu'); setApiKey(''); setStatus(null) }}
61
+ className="self-start text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]"
62
+ >
63
+ &larr; Back to options
64
+ </button>
65
+ {status && (
66
+ <div className={`text-xs ${status === 'Saved!' ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
67
+ {status}
68
+ </div>
69
+ )}
70
+ </div>
71
+ )
72
+ }
73
+
74
+ return (
75
+ <div className="flex flex-col gap-3 py-2">
76
+ <div className="text-sm text-[var(--color-text-secondary)]">
77
+ OpenCode Zen offers free models (no API key needed) or paid models via API key.
78
+ </div>
79
+ {status && (
80
+ <div className={`text-xs ${status === 'Saved!' || status === 'Free models configured!' ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
81
+ {status}
82
+ </div>
83
+ )}
84
+ <div className="flex flex-col gap-2">
85
+ <button
86
+ onClick={handleFreeModels}
87
+ disabled={isLoading}
88
+ className="flex items-center gap-3 rounded-lg border border-[var(--color-border)] px-4 py-3 text-left hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
89
+ >
90
+ <div>
91
+ <div className="text-sm font-medium text-[var(--color-text-primary)]">Use free models</div>
92
+ <div className="text-xs text-[var(--color-text-tertiary)]">No API key required</div>
93
+ </div>
94
+ </button>
95
+ <button
96
+ onClick={() => { setMode('api_key'); setApiKey('') }}
97
+ className="flex items-center gap-3 rounded-lg border border-[var(--color-border)] px-4 py-3 text-left hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]"
98
+ >
99
+ <div>
100
+ <div className="text-sm font-medium text-[var(--color-text-primary)]">Paste OpenCode Zen API key</div>
101
+ <div className="text-xs text-[var(--color-text-tertiary)]">Access paid models, pay-as-you-go</div>
102
+ </div>
103
+ </button>
104
+ {openCodeApiKey && (
105
+ <button
106
+ onClick={handleClear}
107
+ className="self-start text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-error)]"
108
+ >
109
+ Clear credentials
110
+ </button>
111
+ )}
112
+ </div>
113
+ </div>
114
+ )
115
+ }
desktop/src/components/settings/OpenRouterLoginPanel.tsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react'
2
+ import { useCliAuthStore } from '../../stores/cliAuthStore'
3
+
4
+ export function OpenRouterLoginPanel() {
5
+ const { openRouterApiKey, saveOpenRouterApiKey, isLoading } = useCliAuthStore()
6
+ const [apiKey, setApiKey] = useState('')
7
+ const [status, setStatus] = useState<string | null>(null)
8
+
9
+ useEffect(() => {
10
+ if (openRouterApiKey) setApiKey(openRouterApiKey)
11
+ }, [openRouterApiKey])
12
+
13
+ const handleSave = async () => {
14
+ if (!apiKey.trim()) { setStatus('Please enter an API key'); return }
15
+ setStatus(null)
16
+ try {
17
+ await saveOpenRouterApiKey(apiKey.trim())
18
+ setStatus('Saved!')
19
+ } catch (err) {
20
+ setStatus(err instanceof Error ? err.message : String(err))
21
+ }
22
+ }
23
+
24
+ const handleClear = async () => {
25
+ await useCliAuthStore.getState().clearAuth()
26
+ setApiKey('')
27
+ setStatus('Cleared')
28
+ }
29
+
30
+ return (
31
+ <div className="flex flex-col gap-3 py-2">
32
+ <div className="text-sm text-[var(--color-text-secondary)]">
33
+ Paste your OpenRouter key to use the Anthropic-compatible OpenRouter base URL at <code>https://openrouter.ai/api</code>.
34
+ </div>
35
+ <div className="flex items-center gap-2">
36
+ <input
37
+ type="password"
38
+ value={apiKey}
39
+ onChange={e => setApiKey(e.target.value)}
40
+ placeholder={openRouterApiKey ? '••••••••' : 'sk-or-...'}
41
+ className="flex-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:border-[var(--color-brand)] focus:outline-none"
42
+ />
43
+ <button
44
+ onClick={handleSave}
45
+ disabled={isLoading}
46
+ className="rounded-md bg-[var(--color-brand)] px-4 py-2 text-sm text-white hover:brightness-105 disabled:opacity-50"
47
+ >
48
+ {isLoading ? 'Saving...' : 'Save'}
49
+ </button>
50
+ {openRouterApiKey && (
51
+ <button
52
+ onClick={handleClear}
53
+ className="rounded-md border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
54
+ >
55
+ Clear
56
+ </button>
57
+ )}
58
+ </div>
59
+ {status && (
60
+ <div className={`text-xs ${status === 'Saved!' ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
61
+ {status}
62
+ </div>
63
+ )}
64
+ </div>
65
+ )
66
+ }
desktop/src/main.tsx CHANGED
@@ -7,6 +7,8 @@ import { runDesktopPersistenceMigrations } from './lib/persistenceMigrations'
7
  declare global {
8
  interface Window {
9
  __CC_HAHA_BOOTSTRAPPED__?: boolean
 
 
10
  __CC_HAHA_SHOW_STARTUP_ERROR__?: (reason: unknown) => void
11
  }
12
  }
@@ -32,7 +34,10 @@ export async function bootstrapDesktopApp(
32
  loadModules: () => Promise<DesktopBootstrapModules> = loadDesktopBootstrapModules,
33
  ) {
34
  try {
 
35
  const [{ App }, { ErrorBoundary }, { installClientDiagnosticsCapture }, { initializeTheme }] = await loadModules()
 
 
36
  initializeTheme()
37
  installClientDiagnosticsCapture()
38
 
@@ -40,6 +45,7 @@ export async function bootstrapDesktopApp(
40
  throw new Error('Desktop root element not found')
41
  }
42
 
 
43
  ReactDOM.createRoot(root).render(
44
  <React.StrictMode>
45
  <ErrorBoundary>
@@ -48,6 +54,7 @@ export async function bootstrapDesktopApp(
48
  </React.StrictMode>,
49
  )
50
  window.__CC_HAHA_BOOTSTRAPPED__ = true
 
51
  } catch (error) {
52
  console.error('[desktop] Failed to bootstrap app', error)
53
  if (root) {
@@ -60,7 +67,14 @@ export async function bootstrapDesktopApp(
60
  }
61
  }
62
 
63
- runDesktopPersistenceMigrations()
64
- void initializeAppZoom()
 
 
 
 
 
 
65
 
 
66
  void bootstrapDesktopApp()
 
7
  declare global {
8
  interface Window {
9
  __CC_HAHA_BOOTSTRAPPED__?: boolean
10
+ __CC_HAHA_PAGE_LOADED__?: boolean
11
+ __CC_HAHA_STARTUP_ERROR_SHOWN__?: boolean
12
  __CC_HAHA_SHOW_STARTUP_ERROR__?: (reason: unknown) => void
13
  }
14
  }
 
34
  loadModules: () => Promise<DesktopBootstrapModules> = loadDesktopBootstrapModules,
35
  ) {
36
  try {
37
+ console.log('[desktop] Loading desktop bootstrap modules...')
38
  const [{ App }, { ErrorBoundary }, { installClientDiagnosticsCapture }, { initializeTheme }] = await loadModules()
39
+ console.log('[desktop] Bootstrap modules loaded')
40
+
41
  initializeTheme()
42
  installClientDiagnosticsCapture()
43
 
 
45
  throw new Error('Desktop root element not found')
46
  }
47
 
48
+ console.log('[desktop] Rendering React...')
49
  ReactDOM.createRoot(root).render(
50
  <React.StrictMode>
51
  <ErrorBoundary>
 
54
  </React.StrictMode>,
55
  )
56
  window.__CC_HAHA_BOOTSTRAPPED__ = true
57
+ console.log('[desktop] React rendered, app bootstrapped')
58
  } catch (error) {
59
  console.error('[desktop] Failed to bootstrap app', error)
60
  if (root) {
 
67
  }
68
  }
69
 
70
+ console.log('[desktop] Starting migrations...')
71
+ const migrationReport = runDesktopPersistenceMigrations()
72
+ console.log('[desktop] Migrations complete', migrationReport)
73
+
74
+ console.log('[desktop] Initializing app zoom...')
75
+ const zoomPromise = initializeAppZoom()
76
+ zoomPromise.then(level => console.log('[desktop] App zoom initialized:', level))
77
+ .catch(err => console.error('[desktop] App zoom failed:', err))
78
 
79
+ console.log('[desktop] Bootstrapping desktop app...')
80
  void bootstrapDesktopApp()
desktop/src/pages/Settings.tsx CHANGED
@@ -2,17 +2,13 @@ import { useState, useEffect, useMemo, useRef, type CSSProperties, type ReactNod
2
  import QRCode from 'qrcode'
3
  import { Copy, Eye, EyeOff, PowerOff, QrCode, RotateCw } from 'lucide-react'
4
  import { useSettingsStore, UI_ZOOM_DEFAULT, UI_ZOOM_MIN, UI_ZOOM_MAX, UI_ZOOM_STEP } from '../stores/settingsStore'
5
- import { useProviderStore } from '../stores/providerStore'
6
  import { useTranslation } from '../i18n'
7
- import { Modal } from '../components/shared/Modal'
8
  import { ConfirmDialog } from '../components/shared/ConfirmDialog'
9
  import { Input } from '../components/shared/Input'
10
  import { Button } from '../components/shared/Button'
11
  import { Dropdown } from '../components/shared/Dropdown'
12
  import type { PermissionMode, EffortLevel, ThemeMode, UpdateProxyMode, NetworkProxyMode, WebSearchMode, AppMode } from '../types/settings'
13
  import type { Locale } from '../i18n'
14
- import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider'
15
- import type { ProviderPreset } from '../types/providerPreset'
16
  import { AdapterSettings } from './AdapterSettings'
17
  import { useAgentStore } from '../stores/agentStore'
18
  import { useSessionStore } from '../stores/sessionStore'
@@ -31,9 +27,7 @@ import { DiagnosticsSettings } from './DiagnosticsSettings'
31
  import { ActivitySettings } from './ActivitySettings'
32
  import { MemorySettings } from './MemorySettings'
33
  import { useUIStore, type SettingsTab } from '../stores/uiStore'
34
- import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
35
- import { ChatGPTOfficialLogin } from '../components/settings/ChatGPTOfficialLogin'
36
- import { OPENAI_OFFICIAL_PROVIDER_ID } from '../constants/openaiOfficialProvider'
37
  import { useUpdateStore } from '../stores/updateStore'
38
  import { formatBytes } from '../lib/formatBytes'
39
  import { isTauriRuntime } from '../lib/desktopRuntime'
@@ -44,12 +38,6 @@ import {
44
  requestDesktopNotificationPermission,
45
  type DesktopNotificationPermission,
46
  } from '../lib/desktopNotifications'
47
- import {
48
- API_KEY_JSON_PLACEHOLDER,
49
- maskSettingsJsonSecrets,
50
- restoreSettingsJsonSecrets,
51
- stripProviderSettingsJsonEnv,
52
- } from '../lib/providerSettingsJson'
53
  import { copyTextToClipboard } from '../components/chat/clipboard'
54
 
55
  const NETWORK_TIMEOUT_MIN_SECONDS = 5
@@ -204,1221 +192,9 @@ function TabButton({ icon, label, active, onClick }: { icon: string; label: stri
204
  // ─── Provider Settings ──────────────────────────────────────
205
 
206
  function ProviderSettings() {
207
- const {
208
- providers,
209
- activeId,
210
- hasLoadedProviders,
211
- presets,
212
- isLoading,
213
- isPresetsLoading,
214
- fetchProviders,
215
- fetchPresets,
216
- deleteProvider,
217
- activateProvider,
218
- activateOfficial,
219
- testProvider,
220
- } = useProviderStore()
221
- const fetchSettings = useSettingsStore((s) => s.fetchAll)
222
- const t = useTranslation()
223
- const [editingProvider, setEditingProvider] = useState<SavedProvider | null>(null)
224
- const [showCreateModal, setShowCreateModal] = useState(false)
225
- const [pendingDeleteProvider, setPendingDeleteProvider] = useState<SavedProvider | null>(null)
226
- const [isDeletingProvider, setIsDeletingProvider] = useState(false)
227
- const [testResults, setTestResults] = useState<Record<string, { loading: boolean; result?: ProviderTestResult }>>({})
228
-
229
- useEffect(() => {
230
- void fetchProviders()
231
- void fetchPresets()
232
- }, [fetchPresets, fetchProviders])
233
-
234
- const presetMap = useMemo(
235
- () => new Map(presets.map((preset) => [preset.id, preset])),
236
- [presets],
237
- )
238
-
239
- const handleDelete = async (provider: SavedProvider) => {
240
- if (activeId === provider.id) return
241
- setPendingDeleteProvider(provider)
242
- }
243
-
244
- const confirmDelete = async () => {
245
- if (!pendingDeleteProvider) return
246
- setIsDeletingProvider(true)
247
- try {
248
- await deleteProvider(pendingDeleteProvider.id)
249
- setPendingDeleteProvider(null)
250
- } catch (error) {
251
- console.error(error)
252
- } finally {
253
- setIsDeletingProvider(false)
254
- }
255
- }
256
-
257
- const handleTest = async (provider: SavedProvider) => {
258
- setTestResults((r) => ({ ...r, [provider.id]: { loading: true } }))
259
- try {
260
- const result = await testProvider(provider.id)
261
- setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result } }))
262
- } catch {
263
- setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result: { connectivity: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } } } }))
264
- }
265
- }
266
-
267
- const handleActivate = async (id: string) => {
268
- await activateProvider(id)
269
- await fetchSettings()
270
- }
271
-
272
- const handleActivateOfficial = async () => {
273
- await activateOfficial()
274
- await fetchSettings()
275
- }
276
-
277
- const isClaudeOfficialActive = hasLoadedProviders && activeId === null
278
- const isOpenAIOfficialActive = hasLoadedProviders && activeId === OPENAI_OFFICIAL_PROVIDER_ID
279
-
280
- return (
281
- <div className="max-w-2xl">
282
- <div className="flex items-center justify-between mb-4">
283
- <div>
284
- <h2 className="text-base font-semibold text-[var(--color-text-primary)]">{t('settings.providers.title')}</h2>
285
- <p className="text-sm text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.description')}</p>
286
- </div>
287
- <Button size="sm" onClick={() => setShowCreateModal(true)} disabled={isPresetsLoading || presets.length === 0}>
288
- <span className="material-symbols-outlined text-[16px]">add</span>
289
- {t('settings.providers.addProvider')}
290
- </Button>
291
- </div>
292
-
293
- {/* Official provider — always visible at top */}
294
- <div
295
- data-testid="claude-official-provider"
296
- className={`relative flex flex-col rounded-xl border transition-all mb-2 ${
297
- isClaudeOfficialActive
298
- ? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
299
- : 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] cursor-pointer'
300
- }`}
301
- >
302
- <div
303
- className="flex items-center gap-4 px-4 py-3.5"
304
- onClick={() => !isClaudeOfficialActive && handleActivateOfficial()}
305
- >
306
- <span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isClaudeOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
307
- <div className="flex-1 min-w-0">
308
- <div className="flex items-center gap-2">
309
- <span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.officialName')}</span>
310
- {isClaudeOfficialActive && (
311
- <span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('settings.providers.default')}</span>
312
- )}
313
- </div>
314
- <div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.officialDesc')}</div>
315
- </div>
316
- </div>
317
-
318
- {isClaudeOfficialActive && (
319
- <div className="px-4 pb-4 pt-3 border-t border-[var(--color-border-separator)]">
320
- <ClaudeOfficialLogin />
321
- </div>
322
- )}
323
- </div>
324
-
325
- <div
326
- data-testid="openai-official-provider"
327
- className={`relative flex flex-col rounded-xl border transition-all mb-2 ${
328
- isOpenAIOfficialActive
329
- ? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
330
- : 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] cursor-pointer'
331
- }`}
332
- >
333
- <div
334
- className="flex items-center gap-4 px-4 py-3.5"
335
- onClick={() => !isOpenAIOfficialActive && handleActivate(OPENAI_OFFICIAL_PROVIDER_ID)}
336
- >
337
- <span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOpenAIOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
338
- <div className="flex-1 min-w-0">
339
- <div className="flex items-center gap-2">
340
- <span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.openaiOfficialName')}</span>
341
- {isOpenAIOfficialActive && (
342
- <span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('settings.providers.default')}</span>
343
- )}
344
- </div>
345
- <div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.openaiOfficialDesc')}</div>
346
- </div>
347
- </div>
348
-
349
- {isOpenAIOfficialActive && (
350
- <div className="px-4 pb-4 pt-3 border-t border-[var(--color-border-separator)]">
351
- <ChatGPTOfficialLogin />
352
- </div>
353
- )}
354
- </div>
355
-
356
- {/* Saved providers */}
357
- {isLoading && providers.length === 0 ? (
358
- <div className="flex justify-center py-8">
359
- <div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
360
- </div>
361
- ) : (
362
- <div className="flex flex-col gap-2">
363
- {providers.map((provider) => {
364
- const isActive = activeId === provider.id
365
- const test = testResults[provider.id]
366
- const preset = presetMap.get(provider.presetId)
367
- return (
368
- <div
369
- key={provider.id}
370
- className={`relative flex items-center gap-4 px-4 py-3.5 rounded-xl border transition-all group ${
371
- isActive
372
- ? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
373
- : 'border-[var(--color-border)] hover:border-[var(--color-border-focus)]'
374
- }`}
375
- >
376
- <span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
377
- <div className="flex-1 min-w-0">
378
- <div className="flex items-center gap-2">
379
- <span className="text-sm font-semibold text-[var(--color-text-primary)] truncate">{provider.name}</span>
380
- {preset && preset.id !== 'custom' && (
381
- <span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)] leading-none">{preset.name}</span>
382
- )}
383
- {provider.apiFormat && provider.apiFormat !== 'anthropic' && (
384
- <span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-warning)] leading-none">
385
- {provider.apiFormat === 'openai_chat' ? 'OpenAI Chat' : 'OpenAI Responses'}
386
- </span>
387
- )}
388
- {isActive && (
389
- <span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('settings.providers.default')}</span>
390
- )}
391
- </div>
392
- <div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
393
- {provider.baseUrl} &middot; {provider.models.main}
394
- </div>
395
- {test && !test.loading && test.result && (
396
- <div className="text-xs mt-1 flex flex-col gap-0.5">
397
- <span className={test.result.connectivity.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}>
398
- {test.result.connectivity.success
399
- ? t('settings.providers.connectivityOk', { latency: String(test.result.connectivity.latencyMs) })
400
- : t('settings.providers.connectivityFailed', { error: test.result.connectivity.error || '' })}
401
- </span>
402
- {test.result.proxy && (
403
- <span className={test.result.proxy.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}>
404
- {test.result.proxy.success
405
- ? t('settings.providers.proxyOk', { latency: String(test.result.proxy.latencyMs) })
406
- : t('settings.providers.proxyFailed', { error: test.result.proxy.error || '' })}
407
- </span>
408
- )}
409
- </div>
410
- )}
411
- </div>
412
- <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
413
- {!isActive && (
414
- <Button variant="ghost" size="sm" onClick={() => handleActivate(provider.id)}>{t('settings.providers.setDefault')}</Button>
415
- )}
416
- <Button variant="ghost" size="sm" onClick={() => handleTest(provider)} loading={test?.loading}>{t('settings.providers.test')}</Button>
417
- <Button variant="ghost" size="sm" onClick={() => setEditingProvider(provider)}>{t('settings.providers.edit')}</Button>
418
- {!isActive && (
419
- <Button variant="ghost" size="sm" onClick={() => handleDelete(provider)} className="text-[var(--color-error)] hover:text-[var(--color-error)]">{t('common.delete')}</Button>
420
- )}
421
- </div>
422
- </div>
423
- )
424
- })}
425
- </div>
426
- )}
427
-
428
- {/* Create Modal — conditionally rendered so state resets on close */}
429
- {showCreateModal && (
430
- <ProviderFormModal open={true} onClose={() => setShowCreateModal(false)} mode="create" presets={presets} />
431
- )}
432
-
433
- {/* Edit Modal */}
434
- {editingProvider && (
435
- <ProviderFormModal key={editingProvider.id} open={true} onClose={() => setEditingProvider(null)} mode="edit" provider={editingProvider} presets={presets} />
436
- )}
437
-
438
- <ConfirmDialog
439
- open={pendingDeleteProvider !== null}
440
- onClose={() => {
441
- if (isDeletingProvider) return
442
- setPendingDeleteProvider(null)
443
- }}
444
- onConfirm={confirmDelete}
445
- title={t('common.delete')}
446
- body={pendingDeleteProvider ? t('settings.providers.confirmDelete', { name: pendingDeleteProvider.name }) : ''}
447
- confirmLabel={t('common.delete')}
448
- cancelLabel={t('common.cancel')}
449
- confirmVariant="danger"
450
- loading={isDeletingProvider}
451
- />
452
- </div>
453
- )
454
- }
455
-
456
- // ─── Provider Form Modal ──────────────────────────────────────
457
-
458
- type ProviderFormProps = {
459
- open: boolean
460
- onClose: () => void
461
- mode: 'create' | 'edit'
462
- provider?: SavedProvider
463
- presets: ProviderPreset[]
464
- }
465
-
466
- function requirePreset(preset: ProviderPreset | undefined): ProviderPreset {
467
- if (!preset) {
468
- throw new Error('Provider presets are not configured')
469
- }
470
- return preset
471
- }
472
-
473
- const AUTO_COMPACT_WINDOW_ENV_KEY = 'CLAUDE_CODE_AUTO_COMPACT_WINDOW'
474
- const MODEL_CONTEXT_WINDOWS_ENV_KEY = 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS'
475
- const MODEL_CONTEXT_WINDOW_MIN = 16000
476
- const MODEL_CONTEXT_WINDOW_MAX = 10000000
477
- const MODEL_SLOTS = ['main', 'haiku', 'sonnet', 'opus'] as const
478
- const DEFAULT_PROVIDER_AUTH_STRATEGY: ProviderAuthStrategy = 'auth_token'
479
- const AUTH_ENV_KEYS = new Set(['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'])
480
- type ModelSlot = typeof MODEL_SLOTS[number]
481
- type ModelContextInputs = Record<ModelSlot, string>
482
-
483
- function formatContextWindow(value: number): string {
484
- return value.toLocaleString('en-US')
485
- }
486
-
487
- function getPresetAutoCompactWindow(preset: ProviderPreset): string {
488
- return preset.defaultEnv?.[AUTO_COMPACT_WINDOW_ENV_KEY] ?? ''
489
- }
490
-
491
- function getPresetAuthStrategy(preset: ProviderPreset): ProviderAuthStrategy {
492
- return preset.authStrategy ?? DEFAULT_PROVIDER_AUTH_STRATEGY
493
- }
494
-
495
- function omitAuthEnv(env: Record<string, string> | undefined): Record<string, string> {
496
- if (!env) return {}
497
- return Object.fromEntries(
498
- Object.entries(env).filter(([key]) => !AUTH_ENV_KEYS.has(key.toUpperCase())),
499
- )
500
- }
501
-
502
- function getProviderAuthValue(apiKey: string, preset: ProviderPreset): string {
503
- return apiKey || preset.defaultEnv?.ANTHROPIC_AUTH_TOKEN || preset.defaultEnv?.ANTHROPIC_API_KEY || (preset.needsApiKey ? '(your API key)' : '')
504
- }
505
-
506
- function buildSettingsJsonAuthEnv(
507
- apiFormat: ApiFormat,
508
- authStrategy: ProviderAuthStrategy,
509
- apiKey: string,
510
- preset: ProviderPreset,
511
- ): Record<string, string> {
512
- if (apiFormat !== 'anthropic') {
513
- return { ANTHROPIC_API_KEY: 'proxy-managed' }
514
- }
515
-
516
- const value = getProviderAuthValue(apiKey, preset)
517
- switch (authStrategy) {
518
- case 'api_key':
519
- return value ? { ANTHROPIC_API_KEY: value } : {}
520
- case 'auth_token':
521
- return value ? { ANTHROPIC_AUTH_TOKEN: value } : {}
522
- case 'auth_token_empty_api_key':
523
- return {
524
- ANTHROPIC_API_KEY: '',
525
- ...(value ? { ANTHROPIC_AUTH_TOKEN: value } : {}),
526
- }
527
- case 'dual_same_token':
528
- return value ? { ANTHROPIC_API_KEY: value, ANTHROPIC_AUTH_TOKEN: value } : {}
529
- case 'dual_dummy':
530
- return { ANTHROPIC_API_KEY: 'dummy', ANTHROPIC_AUTH_TOKEN: 'dummy' }
531
- }
532
- }
533
-
534
- function inferAuthStrategyFromEnv(env: Record<string, string>): ProviderAuthStrategy | null {
535
- if (env.ANTHROPIC_API_KEY === 'dummy' && env.ANTHROPIC_AUTH_TOKEN === 'dummy') return 'dual_dummy'
536
- if (env.ANTHROPIC_API_KEY === '' && env.ANTHROPIC_AUTH_TOKEN) return 'auth_token_empty_api_key'
537
- if (env.ANTHROPIC_API_KEY && env.ANTHROPIC_AUTH_TOKEN && env.ANTHROPIC_API_KEY === env.ANTHROPIC_AUTH_TOKEN) return 'dual_same_token'
538
- if (env.ANTHROPIC_AUTH_TOKEN) return 'auth_token'
539
- if (env.ANTHROPIC_API_KEY) return 'api_key'
540
- return null
541
- }
542
-
543
- function parseAutoCompactWindowInput(value: string): number | undefined {
544
- const trimmed = value.trim()
545
- if (!trimmed) return undefined
546
- const parsed = Number(trimmed)
547
- if (!Number.isInteger(parsed)) return undefined
548
- if (parsed < MODEL_CONTEXT_WINDOW_MIN || parsed > MODEL_CONTEXT_WINDOW_MAX) return undefined
549
- return parsed
550
- }
551
-
552
- function getAutoCompactWindowErrorKey(value: string): 'number' | 'range' | null {
553
- const trimmed = value.trim()
554
- if (!trimmed) return null
555
- const parsed = Number(trimmed)
556
- if (!Number.isInteger(parsed)) return 'number'
557
- if (parsed < MODEL_CONTEXT_WINDOW_MIN || parsed > MODEL_CONTEXT_WINDOW_MAX) return 'range'
558
- return null
559
- }
560
-
561
- function parseModelContextWindowsInput(value: string): number | undefined {
562
- return parseAutoCompactWindowInput(value)
563
- }
564
-
565
- function getModelContextWindowErrorKey(value: string): 'number' | 'range' | null {
566
- return getAutoCompactWindowErrorKey(value)
567
- }
568
-
569
- function getModelContextInputValue(
570
- model: string | undefined,
571
- preset: ProviderPreset,
572
- provider?: SavedProvider,
573
- ): string {
574
- const trimmedModel = model?.trim()
575
- if (!trimmedModel) return ''
576
- const value = provider?.modelContextWindows?.[trimmedModel] ?? preset.modelContextWindows?.[trimmedModel]
577
- return value !== undefined ? String(value) : ''
578
- }
579
-
580
- function getModelContextInputs(
581
- models: ModelMapping,
582
- preset: ProviderPreset,
583
- provider?: SavedProvider,
584
- ): ModelContextInputs {
585
- const inputs = {} as ModelContextInputs
586
- for (const slot of MODEL_SLOTS) {
587
- inputs[slot] = getModelContextInputValue(models[slot], preset, provider)
588
- }
589
- return inputs
590
- }
591
-
592
- function buildModelContextWindows(
593
- models: ModelMapping,
594
- inputs: ModelContextInputs,
595
- ): Record<string, number> {
596
- const windows: Record<string, number> = {}
597
- for (const slot of MODEL_SLOTS) {
598
- const model = models[slot]?.trim()
599
- const parsed = parseModelContextWindowsInput(inputs[slot])
600
- if (model && parsed !== undefined) {
601
- windows[model] = parsed
602
- }
603
- }
604
- return windows
605
- }
606
-
607
- function normalizeModelMapping(models: ModelMapping): ModelMapping {
608
- const main = models.main.trim()
609
- return {
610
- main,
611
- haiku: models.haiku.trim() || main,
612
- sonnet: models.sonnet.trim() || main,
613
- opus: models.opus.trim() || main,
614
- }
615
- }
616
-
617
- function updateSettingsJsonAutoCompactWindow(raw: string, value: string): string {
618
- try {
619
- const parsed = JSON.parse(raw || '{}') as { env?: Record<string, unknown> }
620
- const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
621
- ? parsed.env
622
- : {}
623
- const env = { ...existingEnv }
624
- const trimmed = value.trim()
625
- if (trimmed) {
626
- env[AUTO_COMPACT_WINDOW_ENV_KEY] = trimmed
627
- } else {
628
- delete env[AUTO_COMPACT_WINDOW_ENV_KEY]
629
- }
630
- parsed.env = env
631
- return JSON.stringify(parsed, null, 2)
632
- } catch {
633
- return raw
634
- }
635
- }
636
-
637
- function updateSettingsJsonModelContextWindows(
638
- raw: string,
639
- modelContextWindows: Record<string, number>,
640
- ): string {
641
- try {
642
- const parsed = JSON.parse(raw || '{}') as { env?: Record<string, unknown> }
643
- const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
644
- ? parsed.env
645
- : {}
646
- const env = { ...existingEnv }
647
- if (Object.keys(modelContextWindows).length > 0) {
648
- env[MODEL_CONTEXT_WINDOWS_ENV_KEY] = JSON.stringify(modelContextWindows)
649
- } else {
650
- delete env[MODEL_CONTEXT_WINDOWS_ENV_KEY]
651
- }
652
- parsed.env = env
653
- return JSON.stringify(parsed, null, 2)
654
- } catch {
655
- return raw
656
- }
657
- }
658
-
659
- function updateSettingsJsonModels(raw: string, models: ModelMapping): string {
660
- try {
661
- const parsed = JSON.parse(raw || '{}') as { env?: Record<string, unknown> }
662
- const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
663
- ? parsed.env
664
- : {}
665
- parsed.env = {
666
- ...existingEnv,
667
- ANTHROPIC_MODEL: models.main,
668
- ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
669
- ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
670
- ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
671
- }
672
- return JSON.stringify(parsed, null, 2)
673
- } catch {
674
- return raw
675
- }
676
- }
677
-
678
- function updateSettingsJsonProviderConnection(
679
- raw: string,
680
- apiFormat: ApiFormat,
681
- authStrategy: ProviderAuthStrategy,
682
- apiKey: string,
683
- preset: ProviderPreset,
684
- baseUrl: string,
685
- ): string {
686
- try {
687
- const parsed = JSON.parse(raw || '{}') as { env?: Record<string, unknown> }
688
- const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
689
- ? parsed.env
690
- : {}
691
- const env = { ...existingEnv }
692
- delete env.ANTHROPIC_API_KEY
693
- delete env.ANTHROPIC_AUTH_TOKEN
694
- env.ANTHROPIC_BASE_URL = apiFormat !== 'anthropic' ? 'http://127.0.0.1:3456/proxy' : baseUrl
695
- Object.assign(env, buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, preset))
696
- parsed.env = env
697
- return JSON.stringify(parsed, null, 2)
698
- } catch {
699
- return raw
700
- }
701
- }
702
-
703
- function buildFallbackPreset(provider?: SavedProvider): ProviderPreset {
704
- return {
705
- id: provider?.presetId ?? 'custom',
706
- name: provider?.name ?? 'Custom',
707
- baseUrl: provider?.baseUrl ?? '',
708
- apiFormat: provider?.apiFormat ?? 'anthropic',
709
- authStrategy: provider?.authStrategy,
710
- defaultModels: provider?.models ?? { main: '', haiku: '', sonnet: '', opus: '' },
711
- modelContextWindows: provider?.modelContextWindows,
712
- defaultEnv: provider?.autoCompactWindow !== undefined
713
- ? { [AUTO_COMPACT_WINDOW_ENV_KEY]: String(provider.autoCompactWindow) }
714
- : undefined,
715
- needsApiKey: true,
716
- websiteUrl: '',
717
- }
718
- }
719
-
720
- function openExternalUrl(url: string) {
721
- if (!isTauriRuntime()) {
722
- window.open(url, '_blank', 'noopener,noreferrer')
723
- return
724
- }
725
-
726
- void import('@tauri-apps/plugin-shell')
727
- .then((mod) => mod.open(url))
728
- .catch(() => window.open(url, '_blank', 'noopener,noreferrer'))
729
- }
730
-
731
- function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderFormProps) {
732
- const { createProvider, updateProvider, testConfig } = useProviderStore()
733
- const fetchSettings = useSettingsStore((s) => s.fetchAll)
734
- const t = useTranslation()
735
-
736
- const availablePresets = presets.filter((p) => p.id !== 'official')
737
- const regularPresets = availablePresets.filter((p) => !p.featured)
738
- const featuredPresets = availablePresets.filter((p) => p.featured)
739
- const presetDefaultEnvKeys = useMemo(
740
- () => presets.flatMap((preset) => Object.keys(preset.defaultEnv ?? {})),
741
- [presets],
742
- )
743
- const fallbackPreset = provider
744
- ? buildFallbackPreset(provider)
745
- : requirePreset(availablePresets[availablePresets.length - 1])
746
- const initialPreset = requirePreset(
747
- provider
748
- ? availablePresets.find((p) => p.id === provider.presetId) ?? fallbackPreset
749
- : availablePresets[0] ?? fallbackPreset,
750
- )
751
-
752
- const [selectedPreset, setSelectedPreset] = useState<ProviderPreset>(initialPreset)
753
- const [name, setName] = useState(provider?.name ?? initialPreset.name)
754
- const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl)
755
- const [apiFormat, setApiFormat] = useState<ApiFormat>(provider?.apiFormat ?? initialPreset.apiFormat ?? 'anthropic')
756
- const [authStrategy, setAuthStrategy] = useState<ProviderAuthStrategy>(provider?.authStrategy ?? getPresetAuthStrategy(initialPreset))
757
- const [apiKey, setApiKey] = useState(provider?.apiKey ?? '')
758
- const [showApiKey, setShowApiKey] = useState(false)
759
- const [notes, setNotes] = useState(provider?.notes ?? '')
760
- const [models, setModels] = useState<ModelMapping>(provider?.models ?? { ...initialPreset.defaultModels })
761
- const [modelContextInputs, setModelContextInputs] = useState<ModelContextInputs>(
762
- getModelContextInputs(provider?.models ?? initialPreset.defaultModels, initialPreset, provider),
763
- )
764
- const [autoCompactWindow, setAutoCompactWindow] = useState(
765
- provider?.autoCompactWindow !== undefined
766
- ? String(provider.autoCompactWindow)
767
- : getPresetAutoCompactWindow(initialPreset),
768
- )
769
- const [showContextSettings, setShowContextSettings] = useState(false)
770
- const [isSubmitting, setIsSubmitting] = useState(false)
771
- const [testResult, setTestResult] = useState<ProviderTestResult | null>(null)
772
- const [isTesting, setIsTesting] = useState(false)
773
- const [settingsJson, setSettingsJson] = useState('')
774
- const [settingsJsonError, setSettingsJsonError] = useState<string | null>(null)
775
- const jsonPastedRef = useRef(false)
776
-
777
- // Load current settings.json and merge provider env vars
778
- useEffect(() => {
779
- // Skip if JSON was just populated by user paste
780
- if (jsonPastedRef.current) {
781
- jsonPastedRef.current = false
782
- return
783
- }
784
- import('../api/providers').then(({ providersApi }) => {
785
- providersApi.getSettings().then((settings) => {
786
- const needsProxy = apiFormat !== 'anthropic'
787
- const autoCompactWindowEnv = autoCompactWindow.trim()
788
- const modelContextWindows = buildModelContextWindows(models, modelContextInputs)
789
- const normalizedModels = normalizeModelMapping(models)
790
- const existingEnv = (settings.env as Record<string, string>) || {}
791
- const cleanedEnv = stripProviderSettingsJsonEnv(existingEnv, presetDefaultEnvKeys)
792
- const merged = {
793
- ...settings,
794
- skipWebFetchPreflight: settings.skipWebFetchPreflight ?? true,
795
- env: {
796
- ...cleanedEnv,
797
- ...omitAuthEnv(selectedPreset.defaultEnv),
798
- ...(autoCompactWindowEnv ? { [AUTO_COMPACT_WINDOW_ENV_KEY]: autoCompactWindowEnv } : {}),
799
- ...(Object.keys(modelContextWindows).length > 0
800
- ? { [MODEL_CONTEXT_WINDOWS_ENV_KEY]: JSON.stringify(modelContextWindows) }
801
- : {}),
802
- ANTHROPIC_BASE_URL: needsProxy ? 'http://127.0.0.1:3456/proxy' : baseUrl,
803
- ...buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, selectedPreset),
804
- ANTHROPIC_MODEL: normalizedModels.main,
805
- ANTHROPIC_DEFAULT_HAIKU_MODEL: normalizedModels.haiku,
806
- ANTHROPIC_DEFAULT_SONNET_MODEL: normalizedModels.sonnet,
807
- ANTHROPIC_DEFAULT_OPUS_MODEL: normalizedModels.opus,
808
- },
809
- }
810
- setSettingsJson(JSON.stringify(merged, null, 2))
811
- }).catch(() => {
812
- setSettingsJson(JSON.stringify({}, null, 2))
813
- })
814
- })
815
- // eslint-disable-next-line react-hooks/exhaustive-deps
816
- }, [selectedPreset.id])
817
-
818
- const handlePresetChange = (preset: ProviderPreset) => {
819
- setSelectedPreset(preset)
820
- setName(preset.name)
821
- setBaseUrl(preset.baseUrl)
822
- setApiFormat(preset.apiFormat ?? 'anthropic')
823
- setAuthStrategy(getPresetAuthStrategy(preset))
824
- setModels({ ...preset.defaultModels })
825
- setModelContextInputs(getModelContextInputs(preset.defaultModels, preset))
826
- setAutoCompactWindow(getPresetAutoCompactWindow(preset))
827
- setShowContextSettings(false)
828
- setTestResult(null)
829
- }
830
-
831
- const isCustom = selectedPreset.id === 'custom'
832
- const requiresApiKey = selectedPreset.needsApiKey !== false
833
- const autoCompactWindowErrorKey = getAutoCompactWindowErrorKey(autoCompactWindow)
834
- const modelContextWindowErrorSlots = MODEL_SLOTS.filter((slot) => getModelContextWindowErrorKey(modelContextInputs[slot]))
835
- const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || !requiresApiKey || apiKey.trim()) && models.main.trim() && !settingsJsonError && !autoCompactWindowErrorKey && modelContextWindowErrorSlots.length === 0
836
- const apiKeyUrl = selectedPreset.apiKeyUrl?.trim()
837
- const promoText = selectedPreset.promoText?.trim()
838
- const displayedSettingsJson = showApiKey
839
- ? settingsJson
840
- : maskSettingsJsonSecrets(settingsJson)
841
- const apiFormatItems = [
842
- {
843
- value: 'anthropic' as const,
844
- label: t('settings.providers.apiFormatAnthropic'),
845
- icon: <span className="material-symbols-outlined text-[17px]">hub</span>,
846
- },
847
- {
848
- value: 'openai_chat' as const,
849
- label: t('settings.providers.apiFormatOpenaiChat'),
850
- icon: <span className="material-symbols-outlined text-[17px]">forum</span>,
851
- },
852
- {
853
- value: 'openai_responses' as const,
854
- label: t('settings.providers.apiFormatOpenaiResponses'),
855
- icon: <span className="material-symbols-outlined text-[17px]">route</span>,
856
- },
857
- ]
858
- const selectedApiFormatLabel = apiFormatItems.find((item) => item.value === apiFormat)?.label ?? t('settings.providers.apiFormatAnthropic')
859
- const authStrategyItems = [
860
- {
861
- value: 'auth_token' as const,
862
- label: t('settings.providers.authStrategyAuthToken'),
863
- description: t('settings.providers.authStrategyAuthTokenDesc'),
864
- icon: <span className="material-symbols-outlined text-[17px]">key</span>,
865
- },
866
- {
867
- value: 'auth_token_empty_api_key' as const,
868
- label: t('settings.providers.authStrategyAuthTokenEmptyApiKey'),
869
- description: t('settings.providers.authStrategyAuthTokenEmptyApiKeyDesc'),
870
- icon: <span className="material-symbols-outlined text-[17px]">key_off</span>,
871
- },
872
- {
873
- value: 'api_key' as const,
874
- label: t('settings.providers.authStrategyApiKey'),
875
- description: t('settings.providers.authStrategyApiKeyDesc'),
876
- icon: <span className="material-symbols-outlined text-[17px]">vpn_key</span>,
877
- },
878
- {
879
- value: 'dual_same_token' as const,
880
- label: t('settings.providers.authStrategyDualSameToken'),
881
- description: t('settings.providers.authStrategyDualSameTokenDesc'),
882
- icon: <span className="material-symbols-outlined text-[17px]">sync_alt</span>,
883
- },
884
- {
885
- value: 'dual_dummy' as const,
886
- label: t('settings.providers.authStrategyDualDummy'),
887
- description: t('settings.providers.authStrategyDualDummyDesc'),
888
- icon: <span className="material-symbols-outlined text-[17px]">construction</span>,
889
- },
890
- ] satisfies Array<{ value: ProviderAuthStrategy; label: string; description: string; icon: ReactNode }>
891
- const selectedAuthStrategyLabel = authStrategyItems.find((item) => item.value === authStrategy)?.label ?? t('settings.providers.authStrategyAuthToken')
892
- const configuredContextWindows = buildModelContextWindows(models, modelContextInputs)
893
- const configuredContextSummary = Object.entries(configuredContextWindows)
894
- .filter(([model], index, entries) => entries.findIndex(([candidate]) => candidate === model) === index)
895
- .map(([model, value]) => `${model}: ${formatContextWindow(value)}`)
896
- const parsedFallbackContextWindow = parseAutoCompactWindowInput(autoCompactWindow)
897
- const fallbackContextSummary = parsedFallbackContextWindow !== undefined
898
- ? t('settings.providers.contextFallbackSummary', {
899
- tokens: formatContextWindow(parsedFallbackContextWindow),
900
- })
901
- : t('settings.providers.contextFallbackAuto')
902
- const contextSummary = configuredContextSummary.length > 0
903
- ? [...configuredContextSummary, fallbackContextSummary].join(' · ')
904
- : t('settings.providers.contextSummaryAuto')
905
- const shouldShowContextFields = showContextSettings || modelContextWindowErrorSlots.length > 0 || !!autoCompactWindowErrorKey
906
- const handleAutoCompactWindowChange = (value: string) => {
907
- setAutoCompactWindow(value)
908
- setSettingsJson((current) => updateSettingsJsonAutoCompactWindow(current, value))
909
- }
910
- const handleBaseUrlChange = (value: string) => {
911
- setBaseUrl(value)
912
- setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, apiKey, selectedPreset, value))
913
- }
914
- const handleApiKeyChange = (value: string) => {
915
- setApiKey(value)
916
- setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, value, selectedPreset, baseUrl))
917
- }
918
- const handleApiFormatChange = (value: ApiFormat) => {
919
- setApiFormat(value)
920
- setSettingsJson((current) => updateSettingsJsonProviderConnection(current, value, authStrategy, apiKey, selectedPreset, baseUrl))
921
- }
922
- const handleAuthStrategyChange = (value: ProviderAuthStrategy) => {
923
- setAuthStrategy(value)
924
- setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, value, apiKey, selectedPreset, baseUrl))
925
- }
926
- const handleModelChange = (slot: ModelSlot, value: string) => {
927
- const nextModels = { ...models, [slot]: value }
928
- const nextInputs = {
929
- ...modelContextInputs,
930
- [slot]: getModelContextInputValue(value, selectedPreset, provider),
931
- }
932
- setModels(nextModels)
933
- setModelContextInputs(nextInputs)
934
- setSettingsJson((current) => updateSettingsJsonModelContextWindows(
935
- updateSettingsJsonModels(current, normalizeModelMapping(nextModels)),
936
- buildModelContextWindows(nextModels, nextInputs),
937
- ))
938
- }
939
- const handleModelContextWindowChange = (slot: ModelSlot, value: string) => {
940
- const nextInputs = { ...modelContextInputs, [slot]: value }
941
- setModelContextInputs(nextInputs)
942
- setSettingsJson((current) => updateSettingsJsonModelContextWindows(
943
- current,
944
- buildModelContextWindows(models, nextInputs),
945
- ))
946
- }
947
- const renderPresetButton = (preset: ProviderPreset) => (
948
- <button
949
- key={preset.id}
950
- onClick={() => handlePresetChange(preset)}
951
- className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-all ${
952
- selectedPreset.id === preset.id
953
- ? 'border-[var(--color-brand)] bg-[var(--color-surface-container-high)] text-[var(--color-brand)] shadow-[var(--shadow-focus-ring)]'
954
- : 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
955
- }`}
956
- >
957
- {preset.name}
958
- </button>
959
- )
960
-
961
- const handleSubmit = async () => {
962
- if (!canSubmit) return
963
- const normalizedModels = normalizeModelMapping(models)
964
- const parsedAutoCompactWindow = parseAutoCompactWindowInput(autoCompactWindow)
965
- const parsedModelContextWindows = buildModelContextWindows(models, modelContextInputs)
966
- setIsSubmitting(true)
967
- try {
968
- // Write the edited cc-haha settings.json first so provider-specific model
969
- // settings never conflict with the user's global ~/.claude/settings.json.
970
- if (settingsJson.trim()) {
971
- try {
972
- const parsed = restoreSettingsJsonSecrets(JSON.parse(settingsJson), settingsJson, apiKey)
973
- const { providersApi } = await import('../api/providers')
974
- await providersApi.updateSettings(parsed)
975
- } catch {
976
- // JSON validation already prevents this
977
- }
978
- }
979
-
980
- if (mode === 'create') {
981
- await createProvider({
982
- presetId: selectedPreset.id,
983
- name: name.trim(),
984
- apiKey: apiKey.trim(),
985
- authStrategy,
986
- baseUrl: baseUrl.trim(),
987
- apiFormat,
988
- models: normalizedModels,
989
- ...(parsedAutoCompactWindow !== undefined && { autoCompactWindow: parsedAutoCompactWindow }),
990
- ...(Object.keys(parsedModelContextWindows).length > 0 && { modelContextWindows: parsedModelContextWindows }),
991
- notes: notes.trim() || undefined,
992
- })
993
- } else if (provider) {
994
- const input: UpdateProviderInput = {
995
- name: name.trim(),
996
- baseUrl: baseUrl.trim(),
997
- authStrategy,
998
- apiFormat,
999
- models: normalizedModels,
1000
- autoCompactWindow: parsedAutoCompactWindow ?? null,
1001
- modelContextWindows: Object.keys(parsedModelContextWindows).length > 0
1002
- ? parsedModelContextWindows
1003
- : null,
1004
- notes: notes.trim() || undefined,
1005
- }
1006
- if (apiKey.trim()) input.apiKey = apiKey.trim()
1007
- await updateProvider(provider.id, input)
1008
- }
1009
- await fetchSettings()
1010
- onClose()
1011
- } catch (err) {
1012
- console.error('Failed to save provider:', err)
1013
- } finally {
1014
- setIsSubmitting(false)
1015
- }
1016
- }
1017
-
1018
- const handleTest = async () => {
1019
- if (!baseUrl.trim() || !models.main.trim()) return
1020
- setIsTesting(true)
1021
- setTestResult(null)
1022
- try {
1023
- let result: ProviderTestResult
1024
- if (mode === 'edit' && provider && !apiKey.trim()) {
1025
- result = await useProviderStore.getState().testProvider(provider.id, {
1026
- baseUrl: baseUrl.trim(),
1027
- modelId: models.main.trim(),
1028
- apiFormat,
1029
- authStrategy,
1030
- })
1031
- } else {
1032
- if (requiresApiKey && !apiKey.trim()) return
1033
- result = await testConfig({
1034
- baseUrl: baseUrl.trim(),
1035
- apiKey: apiKey.trim() || selectedPreset.defaultEnv?.ANTHROPIC_AUTH_TOKEN || 'local',
1036
- modelId: models.main.trim(),
1037
- authStrategy,
1038
- apiFormat,
1039
- })
1040
- }
1041
- setTestResult(result)
1042
- } catch {
1043
- setTestResult({ connectivity: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } })
1044
- } finally {
1045
- setIsTesting(false)
1046
- }
1047
- }
1048
-
1049
- return (
1050
- <Modal
1051
- open={open}
1052
- onClose={onClose}
1053
- title={mode === 'create' ? t('settings.providers.addTitle') : t('settings.providers.editTitle')}
1054
- width={720}
1055
- footer={
1056
- <>
1057
- <Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
1058
- <Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>
1059
- {mode === 'create' ? t('common.add') : t('common.save')}
1060
- </Button>
1061
- </>
1062
- }
1063
- >
1064
- <div className="flex flex-col gap-4">
1065
- {/* Preset chips */}
1066
- {mode === 'create' && (
1067
- <div>
1068
- <label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.preset')}</label>
1069
- <div className="flex flex-col gap-2">
1070
- <div className="flex flex-wrap gap-2">
1071
- {regularPresets.map(renderPresetButton)}
1072
- </div>
1073
- {featuredPresets.length > 0 && (
1074
- <div className="flex flex-wrap gap-2 border-t border-[var(--color-border)]/60 pt-2">
1075
- {featuredPresets.map(renderPresetButton)}
1076
- </div>
1077
- )}
1078
- </div>
1079
- </div>
1080
- )}
1081
-
1082
- <Input label={t('settings.providers.name')} required value={name} onChange={(e) => setName(e.target.value)} placeholder={t('settings.providers.namePlaceholder')} />
1083
-
1084
- <Input label={t('settings.providers.notes')} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t('settings.providers.notesPlaceholder')} />
1085
-
1086
- <Input label={t('settings.providers.baseUrl')} required value={baseUrl} onChange={(e) => handleBaseUrlChange(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} />
1087
-
1088
- {/* API Format */}
1089
- {(isCustom || mode === 'edit') ? (
1090
- <div>
1091
- <label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</label>
1092
- <Dropdown<ApiFormat>
1093
- items={apiFormatItems}
1094
- value={apiFormat}
1095
- onChange={handleApiFormatChange}
1096
- width="100%"
1097
- className="block w-full"
1098
- trigger={
1099
- <button
1100
- type="button"
1101
- className="flex h-10 w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)]"
1102
- >
1103
- <span className="min-w-0 flex-1 truncate">{selectedApiFormatLabel}</span>
1104
- <span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">expand_more</span>
1105
- </button>
1106
- }
1107
- />
1108
- {apiFormat !== 'anthropic' && (
1109
- <p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.proxyHint')}</p>
1110
- )}
1111
- </div>
1112
- ) : apiFormat !== 'anthropic' ? (
1113
- <div>
1114
- <label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</label>
1115
- <div className="text-xs text-[var(--color-text-tertiary)] px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border border-[var(--color-border)]">
1116
- {apiFormat === 'openai_chat' ? t('settings.providers.apiFormatOpenaiChat') : t('settings.providers.apiFormatOpenaiResponses')}
1117
- </div>
1118
- </div>
1119
- ) : null}
1120
-
1121
- {apiFormat === 'anthropic' && (
1122
- <div>
1123
- <label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.authStrategy')}</label>
1124
- <Dropdown<ProviderAuthStrategy>
1125
- items={authStrategyItems}
1126
- value={authStrategy}
1127
- onChange={handleAuthStrategyChange}
1128
- width="100%"
1129
- className="block w-full"
1130
- trigger={
1131
- <button
1132
- type="button"
1133
- className="flex min-h-10 w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)]"
1134
- >
1135
- <span className="min-w-0 flex-1 truncate">{selectedAuthStrategyLabel}</span>
1136
- <span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">expand_more</span>
1137
- </button>
1138
- }
1139
- />
1140
- </div>
1141
- )}
1142
-
1143
- <div className="flex flex-col gap-1">
1144
- <label htmlFor="provider-api-key" className="text-sm font-medium text-[var(--color-text-primary)]">
1145
- {t('settings.providers.apiKey')}
1146
- {mode === 'create' && requiresApiKey && <span className="text-[var(--color-error)] ml-0.5">*</span>}
1147
- </label>
1148
- <div className="relative">
1149
- <input
1150
- id="provider-api-key"
1151
- type={showApiKey ? 'text' : 'password'}
1152
- value={apiKey}
1153
- onChange={(e) => handleApiKeyChange(e.target.value)}
1154
- placeholder="sk-..."
1155
- className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 pr-10 text-sm text-[var(--color-text-primary)] outline-none transition-colors duration-150 placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]"
1156
- />
1157
- <button
1158
- type="button"
1159
- onClick={() => setShowApiKey((visible) => !visible)}
1160
- aria-label={showApiKey ? 'Hide API Key' : 'Show API Key'}
1161
- className="absolute right-1.5 top-1/2 flex h-7 w-7 -translate-y-1/2 cursor-pointer items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus:outline-none focus:shadow-[var(--shadow-focus-ring)]"
1162
- >
1163
- <span className="material-symbols-outlined text-[16px]">
1164
- {showApiKey ? 'visibility_off' : 'visibility'}
1165
- </span>
1166
- </button>
1167
- </div>
1168
- </div>
1169
-
1170
- {(apiKeyUrl || promoText) && (
1171
- <div className="-mt-2 flex flex-col gap-1.5">
1172
- {apiKeyUrl && (
1173
- <button
1174
- type="button"
1175
- onClick={() => openExternalUrl(apiKeyUrl)}
1176
- className="group inline-flex h-6 w-fit cursor-pointer items-center gap-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 text-[11px] font-medium leading-none text-[var(--color-brand)] transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus:outline-none focus:shadow-[var(--shadow-focus-ring)]"
1177
- >
1178
- <span className="material-symbols-outlined text-[13px]">key</span>
1179
- {t('settings.providers.getApiKey')}
1180
- <span className="material-symbols-outlined text-[9px] opacity-60 transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5">arrow_outward</span>
1181
- </button>
1182
- )}
1183
- {promoText && (
1184
- <button
1185
- type="button"
1186
- onClick={() => apiKeyUrl && openExternalUrl(apiKeyUrl)}
1187
- disabled={!apiKeyUrl}
1188
- className="group flex w-full cursor-pointer items-start gap-1.5 rounded-[var(--radius-sm)] border border-[var(--color-brand)]/25 bg-[var(--color-brand)]/8 px-2.5 py-1.5 text-left text-[11px] leading-5 text-[var(--color-text-primary)] transition-colors hover:border-[var(--color-brand)]/45 hover:bg-[var(--color-brand)]/12 focus:outline-none focus:shadow-[var(--shadow-focus-ring)] disabled:cursor-default disabled:hover:border-[var(--color-brand)]/25 disabled:hover:bg-[var(--color-brand)]/8"
1189
- >
1190
- <span className="material-symbols-outlined mt-0.5 text-[13px] text-[var(--color-brand)]">tips_and_updates</span>
1191
- <span>{promoText}</span>
1192
- {apiKeyUrl && (
1193
- <span className="material-symbols-outlined ml-auto mt-1 text-[10px] text-[var(--color-brand)] opacity-45 transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5">arrow_outward</span>
1194
- )}
1195
- </button>
1196
- )}
1197
- </div>
1198
- )}
1199
-
1200
- {/* Model Mapping */}
1201
- <div>
1202
- <label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.modelMapping')}</label>
1203
- <div className="grid grid-cols-2 gap-2">
1204
- <Input label={t('settings.providers.mainModel')} required value={models.main} onChange={(e) => handleModelChange('main', e.target.value)} placeholder="Model ID" />
1205
- <Input label={t('settings.providers.haikuModel')} value={models.haiku} onChange={(e) => handleModelChange('haiku', e.target.value)} placeholder={t('settings.providers.sameAsMain')} />
1206
- <Input label={t('settings.providers.sonnetModel')} value={models.sonnet} onChange={(e) => handleModelChange('sonnet', e.target.value)} placeholder={t('settings.providers.sameAsMain')} />
1207
- <Input label={t('settings.providers.opusModel')} value={models.opus} onChange={(e) => handleModelChange('opus', e.target.value)} placeholder={t('settings.providers.sameAsMain')} />
1208
- </div>
1209
- </div>
1210
-
1211
- <div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
1212
- <button
1213
- type="button"
1214
- onClick={() => setShowContextSettings((visible) => !visible)}
1215
- className="flex w-full items-start gap-3 px-3 py-3 text-left outline-none transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:shadow-[var(--shadow-focus-ring)]"
1216
- aria-expanded={shouldShowContextFields}
1217
- >
1218
- <span className="material-symbols-outlined mt-0.5 text-[18px] text-[var(--color-brand)]">compress</span>
1219
- <span className="min-w-0 flex-1">
1220
- <span className="block text-sm font-medium text-[var(--color-text-primary)]">
1221
- {t('settings.providers.contextSettingsTitle')}
1222
- </span>
1223
- <span className="mt-1 block truncate text-xs text-[var(--color-text-secondary)]">
1224
- {contextSummary}
1225
- </span>
1226
- <span className="mt-1 block text-[11px] leading-5 text-[var(--color-text-tertiary)]">
1227
- {t('settings.providers.contextSettingsDesc')}
1228
- </span>
1229
- </span>
1230
- <span className="mt-0.5 inline-flex items-center gap-1 text-xs font-medium text-[var(--color-brand)]">
1231
- {shouldShowContextFields
1232
- ? t('settings.providers.contextSettingsHide')
1233
- : t('settings.providers.contextSettingsEdit')}
1234
- <span className="material-symbols-outlined text-[16px]">
1235
- {shouldShowContextFields ? 'expand_less' : 'expand_more'}
1236
- </span>
1237
- </span>
1238
- </button>
1239
-
1240
- {shouldShowContextFields && (
1241
- <div className="border-t border-[var(--color-border)] px-3 pb-3 pt-3">
1242
- <div>
1243
- <label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.modelContextWindows')}</label>
1244
- <div className="grid grid-cols-2 gap-2">
1245
- {MODEL_SLOTS.map((slot) => {
1246
- const errorKey = getModelContextWindowErrorKey(modelContextInputs[slot])
1247
- const labelKey = slot === 'main'
1248
- ? 'settings.providers.mainContextWindow'
1249
- : slot === 'haiku'
1250
- ? 'settings.providers.haikuContextWindow'
1251
- : slot === 'sonnet'
1252
- ? 'settings.providers.sonnetContextWindow'
1253
- : 'settings.providers.opusContextWindow'
1254
- return (
1255
- <div key={slot}>
1256
- <Input
1257
- label={t(labelKey)}
1258
- value={modelContextInputs[slot]}
1259
- onChange={(e) => handleModelContextWindowChange(slot, e.target.value)}
1260
- placeholder={t('settings.providers.contextWindowPlaceholder')}
1261
- />
1262
- {errorKey && (
1263
- <p className="text-[11px] text-[var(--color-error)] mt-1">
1264
- {errorKey === 'number'
1265
- ? t('settings.providers.modelContextWindowNumberError')
1266
- : t('settings.providers.modelContextWindowRangeError')}
1267
- </p>
1268
- )}
1269
- </div>
1270
- )
1271
- })}
1272
- </div>
1273
- <p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">
1274
- {t('settings.providers.modelContextWindowsDesc')}
1275
- </p>
1276
- </div>
1277
-
1278
- <div className="mt-3">
1279
- <Input
1280
- label={t('settings.providers.autoCompactWindow')}
1281
- value={autoCompactWindow}
1282
- onChange={(e) => handleAutoCompactWindowChange(e.target.value)}
1283
- placeholder={t('settings.providers.autoCompactWindowPlaceholder')}
1284
- />
1285
- {autoCompactWindowErrorKey ? (
1286
- <p className="text-[11px] text-[var(--color-error)] mt-1">
1287
- {autoCompactWindowErrorKey === 'number'
1288
- ? t('settings.providers.autoCompactWindowNumberError')
1289
- : t('settings.providers.autoCompactWindowRangeError')}
1290
- </p>
1291
- ) : (
1292
- <p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">
1293
- {t('settings.providers.autoCompactWindowDesc')}
1294
- </p>
1295
- )}
1296
- </div>
1297
- </div>
1298
- )}
1299
- </div>
1300
-
1301
- {/* Test connection */}
1302
- <div className="flex items-center gap-3">
1303
- <Button variant="secondary" size="sm" onClick={handleTest} loading={isTesting} disabled={!baseUrl.trim() || !models.main.trim()}>
1304
- {t('settings.providers.testConnection')}
1305
- </Button>
1306
- {testResult && (
1307
- <div className="flex flex-col gap-0.5">
1308
- <span className={`text-xs ${testResult.connectivity.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
1309
- {testResult.connectivity.success
1310
- ? t('settings.providers.connectivityOk', { latency: String(testResult.connectivity.latencyMs) })
1311
- : t('settings.providers.connectivityFailed', { error: testResult.connectivity.error || '' })}
1312
- </span>
1313
- {testResult.proxy && (
1314
- <span className={`text-xs ${testResult.proxy.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
1315
- {testResult.proxy.success
1316
- ? t('settings.providers.proxyOk', { latency: String(testResult.proxy.latencyMs) })
1317
- : t('settings.providers.proxyFailed', { error: testResult.proxy.error || '' })}
1318
- </span>
1319
- )}
1320
- </div>
1321
- )}
1322
- </div>
1323
-
1324
- {/* Settings JSON — editable, shown for all presets including official */}
1325
- <div>
1326
- <label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.settingsJson')}</label>
1327
- <textarea
1328
- value={displayedSettingsJson}
1329
- onChange={(e) => {
1330
- const raw = e.target.value
1331
- try {
1332
- const parsed = restoreSettingsJsonSecrets(JSON.parse(raw), settingsJson, apiKey)
1333
- setSettingsJson(JSON.stringify(parsed, null, 2))
1334
- setSettingsJsonError(null)
1335
- // Auto-fill form fields from parsed JSON env
1336
- const env = parsed.env as Record<string, string> | undefined
1337
- if (env) {
1338
- if (env.ANTHROPIC_BASE_URL) {
1339
- setBaseUrl(env.ANTHROPIC_BASE_URL)
1340
- // Auto-switch to matching preset or Custom
1341
- if (mode === 'create') {
1342
- const matchedPreset = availablePresets.find((p) => p.id !== 'custom' && p.baseUrl === env.ANTHROPIC_BASE_URL)
1343
- const targetPreset = requirePreset(
1344
- matchedPreset ?? availablePresets.find((p) => p.id === 'custom'),
1345
- )
1346
- if (targetPreset.id !== selectedPreset.id) {
1347
- jsonPastedRef.current = true
1348
- setSelectedPreset(targetPreset)
1349
- }
1350
- }
1351
- }
1352
- const nextApiKey = env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY
1353
- if (nextApiKey && nextApiKey !== '(your API key)' && nextApiKey !== API_KEY_JSON_PLACEHOLDER) {
1354
- setApiKey(nextApiKey)
1355
- }
1356
- const nextAuthStrategy = inferAuthStrategyFromEnv(env)
1357
- if (nextAuthStrategy) {
1358
- setAuthStrategy(nextAuthStrategy)
1359
- }
1360
- if (env[AUTO_COMPACT_WINDOW_ENV_KEY] !== undefined) {
1361
- setAutoCompactWindow(String(env[AUTO_COMPACT_WINDOW_ENV_KEY]))
1362
- } else {
1363
- setAutoCompactWindow('')
1364
- }
1365
- let parsedContextWindows: Record<string, number> = {}
1366
- if (typeof env[MODEL_CONTEXT_WINDOWS_ENV_KEY] === 'string') {
1367
- try {
1368
- const parsedContext = JSON.parse(env[MODEL_CONTEXT_WINDOWS_ENV_KEY]) as Record<string, unknown>
1369
- parsedContextWindows = Object.fromEntries(
1370
- Object.entries(parsedContext)
1371
- .filter(([, value]) => typeof value === 'number' && Number.isInteger(value)),
1372
- ) as Record<string, number>
1373
- } catch {
1374
- parsedContextWindows = {}
1375
- }
1376
- }
1377
- const newModels: Partial<ModelMapping> = {}
1378
- if (env.ANTHROPIC_MODEL) newModels.main = env.ANTHROPIC_MODEL
1379
- if (env.ANTHROPIC_DEFAULT_HAIKU_MODEL) newModels.haiku = env.ANTHROPIC_DEFAULT_HAIKU_MODEL
1380
- if (env.ANTHROPIC_DEFAULT_SONNET_MODEL) newModels.sonnet = env.ANTHROPIC_DEFAULT_SONNET_MODEL
1381
- if (env.ANTHROPIC_DEFAULT_OPUS_MODEL) newModels.opus = env.ANTHROPIC_DEFAULT_OPUS_MODEL
1382
- if (Object.keys(newModels).length > 0) {
1383
- setModels((prev) => {
1384
- const nextModels = { ...prev, ...newModels }
1385
- setModelContextInputs(getModelContextInputs(nextModels, {
1386
- ...selectedPreset,
1387
- modelContextWindows: parsedContextWindows,
1388
- }))
1389
- return nextModels
1390
- })
1391
- } else if (Object.keys(parsedContextWindows).length > 0) {
1392
- setModelContextInputs(getModelContextInputs(models, {
1393
- ...selectedPreset,
1394
- modelContextWindows: parsedContextWindows,
1395
- }))
1396
- }
1397
- }
1398
- } catch (err) {
1399
- setSettingsJson(raw)
1400
- setSettingsJsonError(err instanceof Error ? err.message : 'Invalid JSON')
1401
- }
1402
- }}
1403
- rows={16}
1404
- spellCheck={false}
1405
- className={`w-full text-xs px-3 py-3 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border font-mono leading-relaxed resize-y text-[var(--color-text-secondary)] outline-none ${
1406
- settingsJsonError
1407
- ? 'border-[var(--color-error)] focus:border-[var(--color-error)]'
1408
- : 'border-[var(--color-border)] focus:border-[var(--color-border-focus)]'
1409
- }`}
1410
- />
1411
- {settingsJsonError && (
1412
- <p className="text-[11px] text-[var(--color-error)] mt-1">{t('settings.providers.jsonError', { error: settingsJsonError })}</p>
1413
- )}
1414
- <p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.settingsJsonDesc')}</p>
1415
- </div>
1416
- </div>
1417
- </Modal>
1418
- )
1419
  }
1420
 
1421
-
1422
  // ─── Permission Settings ──────────────────────────────────────
1423
 
1424
  function PermissionSettings() {
 
2
  import QRCode from 'qrcode'
3
  import { Copy, Eye, EyeOff, PowerOff, QrCode, RotateCw } from 'lucide-react'
4
  import { useSettingsStore, UI_ZOOM_DEFAULT, UI_ZOOM_MIN, UI_ZOOM_MAX, UI_ZOOM_STEP } from '../stores/settingsStore'
 
5
  import { useTranslation } from '../i18n'
 
6
  import { ConfirmDialog } from '../components/shared/ConfirmDialog'
7
  import { Input } from '../components/shared/Input'
8
  import { Button } from '../components/shared/Button'
9
  import { Dropdown } from '../components/shared/Dropdown'
10
  import type { PermissionMode, EffortLevel, ThemeMode, UpdateProxyMode, NetworkProxyMode, WebSearchMode, AppMode } from '../types/settings'
11
  import type { Locale } from '../i18n'
 
 
12
  import { AdapterSettings } from './AdapterSettings'
13
  import { useAgentStore } from '../stores/agentStore'
14
  import { useSessionStore } from '../stores/sessionStore'
 
27
  import { ActivitySettings } from './ActivitySettings'
28
  import { MemorySettings } from './MemorySettings'
29
  import { useUIStore, type SettingsTab } from '../stores/uiStore'
30
+ import { CliLoginProviderSettings } from '../components/settings/CliLoginProviderSettings'
 
 
31
  import { useUpdateStore } from '../stores/updateStore'
32
  import { formatBytes } from '../lib/formatBytes'
33
  import { isTauriRuntime } from '../lib/desktopRuntime'
 
38
  requestDesktopNotificationPermission,
39
  type DesktopNotificationPermission,
40
  } from '../lib/desktopNotifications'
 
 
 
 
 
 
41
  import { copyTextToClipboard } from '../components/chat/clipboard'
42
 
43
  const NETWORK_TIMEOUT_MIN_SECONDS = 5
 
192
  // ─── Provider Settings ──────────────────────────────────────
193
 
194
  function ProviderSettings() {
195
+ return <CliLoginProviderSettings />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  }
197
 
 
198
  // ─── Permission Settings ──────────────────────────────────────
199
 
200
  function PermissionSettings() {
desktop/src/stores/cliAuthStore.ts ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from 'zustand'
2
+ import { cliAuthApi, type AuthProvider, type CliAuthConfig } from '../api/cliAuth'
3
+
4
+ type CliAuthStore = {
5
+ authProvider: AuthProvider | null
6
+ nvidiaApiKey: string | null
7
+ openRouterApiKey: string | null
8
+ openAiApiKey: string | null
9
+ openCodeApiKey: string | null
10
+ openCodeModelName: string | null
11
+ localBaseUrl: string | null
12
+ localModelName: string | null
13
+ isLoading: boolean
14
+ error: string | null
15
+
16
+ fetchAuth: () => Promise<void>
17
+ setAuthProvider: (provider: AuthProvider) => Promise<void>
18
+ saveNvidiaApiKey: (apiKey: string) => Promise<void>
19
+ saveOpenRouterApiKey: (apiKey: string) => Promise<void>
20
+ saveOpenAIApiKey: (apiKey: string) => Promise<void>
21
+ saveOpenCodeApiKey: (apiKey: string, modelName?: string) => Promise<void>
22
+ saveLocalModelConfig: (baseUrl: string, modelName: string) => Promise<void>
23
+ clearAuth: () => Promise<void>
24
+ }
25
+
26
+ export const useCliAuthStore = create<CliAuthStore>((set) => ({
27
+ authProvider: null,
28
+ nvidiaApiKey: null,
29
+ openRouterApiKey: null,
30
+ openAiApiKey: null,
31
+ openCodeApiKey: null,
32
+ openCodeModelName: null,
33
+ localBaseUrl: null,
34
+ localModelName: null,
35
+ isLoading: false,
36
+ error: null,
37
+
38
+ fetchAuth: async () => {
39
+ set({ isLoading: true, error: null })
40
+ try {
41
+ const config = await cliAuthApi.get()
42
+ set({
43
+ authProvider: config.authProvider,
44
+ nvidiaApiKey: config.nvidiaApiKey || null,
45
+ openRouterApiKey: config.openRouterApiKey || null,
46
+ openAiApiKey: config.openAiApiKey || config.openAiAccessToken || null,
47
+ openCodeApiKey: config.openCodeApiKey || null,
48
+ openCodeModelName: config.openCodeModelName || null,
49
+ localBaseUrl: config.localBaseUrl || null,
50
+ localModelName: config.localModelName || null,
51
+ isLoading: false,
52
+ })
53
+ } catch (err) {
54
+ set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
55
+ }
56
+ },
57
+
58
+ setAuthProvider: async (provider: AuthProvider) => {
59
+ set({ isLoading: true, error: null })
60
+ try {
61
+ await cliAuthApi.update({ authProvider: provider })
62
+ await cliAuthApi.invalidateCache()
63
+ set({ authProvider: provider, isLoading: false })
64
+ } catch (err) {
65
+ set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
66
+ }
67
+ },
68
+
69
+ saveNvidiaApiKey: async (apiKey: string) => {
70
+ set({ isLoading: true, error: null })
71
+ try {
72
+ await cliAuthApi.update({ authProvider: 'nvidia', nvidiaApiKey: apiKey })
73
+ await cliAuthApi.invalidateCache()
74
+ set({ authProvider: 'nvidia', nvidiaApiKey: apiKey, isLoading: false })
75
+ } catch (err) {
76
+ set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
77
+ }
78
+ },
79
+
80
+ saveOpenRouterApiKey: async (apiKey: string) => {
81
+ set({ isLoading: true, error: null })
82
+ try {
83
+ await cliAuthApi.update({ authProvider: 'openrouter', openRouterApiKey: apiKey })
84
+ await cliAuthApi.invalidateCache()
85
+ set({ authProvider: 'openrouter', openRouterApiKey: apiKey, isLoading: false })
86
+ } catch (err) {
87
+ set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
88
+ }
89
+ },
90
+
91
+ saveOpenAIApiKey: async (apiKey: string) => {
92
+ set({ isLoading: true, error: null })
93
+ try {
94
+ await cliAuthApi.update({ authProvider: 'openai', openAiApiKey: apiKey })
95
+ await cliAuthApi.invalidateCache()
96
+ set({ authProvider: 'openai', openAiApiKey: apiKey, isLoading: false })
97
+ } catch (err) {
98
+ set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
99
+ }
100
+ },
101
+
102
+ saveOpenCodeApiKey: async (apiKey: string, modelName?: string) => {
103
+ set({ isLoading: true, error: null })
104
+ try {
105
+ const updates: Record<string, unknown> = { authProvider: 'opencode', openCodeApiKey: apiKey }
106
+ if (modelName) updates.openCodeModelName = modelName
107
+ await cliAuthApi.update(updates as Partial<CliAuthConfig>)
108
+ await cliAuthApi.invalidateCache()
109
+ set({
110
+ authProvider: 'opencode',
111
+ openCodeApiKey: apiKey,
112
+ ...(modelName ? { openCodeModelName: modelName } : {}),
113
+ isLoading: false,
114
+ })
115
+ } catch (err) {
116
+ set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
117
+ }
118
+ },
119
+
120
+ saveLocalModelConfig: async (baseUrl: string, modelName: string) => {
121
+ set({ isLoading: true, error: null })
122
+ try {
123
+ await cliAuthApi.update({ authProvider: 'local', localBaseUrl: baseUrl, localModelName: modelName })
124
+ await cliAuthApi.invalidateCache()
125
+ set({ authProvider: 'local', localBaseUrl: baseUrl, localModelName: modelName, isLoading: false })
126
+ } catch (err) {
127
+ set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
128
+ }
129
+ },
130
+
131
+ clearAuth: async () => {
132
+ set({ isLoading: true, error: null })
133
+ try {
134
+ await cliAuthApi.update({ authProvider: null })
135
+ await cliAuthApi.invalidateCache()
136
+ set({ authProvider: null, nvidiaApiKey: null, openRouterApiKey: null, openAiApiKey: null, openCodeApiKey: null, localBaseUrl: null, localModelName: null, isLoading: false })
137
+ } catch (err) {
138
+ set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
139
+ }
140
+ },
141
+ }))
desktop/tsconfig.tsbuildinfo CHANGED
@@ -1 +1 @@
1
- {"root":["./src/App.tsx","./src/main.test.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/__tests__/agentsSettings.test.tsx","./src/__tests__/diagnosticsSettings.test.tsx","./src/__tests__/generalSettings.test.tsx","./src/__tests__/mcpSettings.test.tsx","./src/__tests__/memorySettings.test.tsx","./src/__tests__/pages.test.tsx","./src/__tests__/pluginsSettings.test.tsx","./src/__tests__/skillsSettings.test.tsx","./src/__tests__/tauriCapabilities.test.ts","./src/api/activityStats.ts","./src/api/adapters.ts","./src/api/agents.ts","./src/api/cliTasks.ts","./src/api/client.test.ts","./src/api/client.ts","./src/api/computerUse.ts","./src/api/desktopUiPreferences.ts","./src/api/diagnostics.ts","./src/api/doctor.ts","./src/api/filesystem.ts","./src/api/h5Access.ts","./src/api/hahaOAuth.ts","./src/api/hahaOpenAIOAuth.ts","./src/api/mcp.ts","./src/api/memory.ts","./src/api/models.ts","./src/api/openTargets.test.ts","./src/api/openTargets.ts","./src/api/plugins.ts","./src/api/providers.ts","./src/api/search.ts","./src/api/sessions.test.ts","./src/api/sessions.ts","./src/api/settings.ts","./src/api/skills.ts","./src/api/tasks.ts","./src/api/teams.ts","./src/api/terminal.ts","./src/api/websocket.test.ts","./src/api/websocket.ts","./src/components/ErrorBoundary.test.tsx","./src/components/ErrorBoundary.tsx","./src/components/chat/AskUserQuestion.test.tsx","./src/components/chat/AskUserQuestion.tsx","./src/components/chat/AssistantMessage.tsx","./src/components/chat/AttachmentGallery.test.tsx","./src/components/chat/AttachmentGallery.tsx","./src/components/chat/ChatInput.test.tsx","./src/components/chat/ChatInput.tsx","./src/components/chat/CodeViewer.test.tsx","./src/components/chat/CodeViewer.tsx","./src/components/chat/ComposerDropOverlay.tsx","./src/components/chat/ComputerUsePermissionModal.test.tsx","./src/components/chat/ComputerUsePermissionModal.tsx","./src/components/chat/ContextUsageIndicator.test.tsx","./src/components/chat/ContextUsageIndicator.tsx","./src/components/chat/CurrentTurnChangeCard.tsx","./src/components/chat/DiffViewer.tsx","./src/components/chat/FileSearchMenu.test.tsx","./src/components/chat/FileSearchMenu.tsx","./src/components/chat/ImageGalleryModal.tsx","./src/components/chat/InlineImageGallery.tsx","./src/components/chat/InlineTaskSummary.tsx","./src/components/chat/LocalSlashCommandPanel.test.tsx","./src/components/chat/LocalSlashCommandPanel.tsx","./src/components/chat/MermaidRenderer.test.tsx","./src/components/chat/MermaidRenderer.tsx","./src/components/chat/MessageActionBar.tsx","./src/components/chat/MessageList.test.tsx","./src/components/chat/MessageList.tsx","./src/components/chat/PermissionDialog.tsx","./src/components/chat/SessionTaskBar.test.tsx","./src/components/chat/SessionTaskBar.tsx","./src/components/chat/StreamingIndicator.tsx","./src/components/chat/TerminalChrome.tsx","./src/components/chat/ThinkingBlock.tsx","./src/components/chat/ToolCallBlock.tsx","./src/components/chat/ToolCallGroup.tsx","./src/components/chat/ToolResultBlock.tsx","./src/components/chat/UserMessage.test.tsx","./src/components/chat/UserMessage.tsx","./src/components/chat/chatBlocks.test.tsx","./src/components/chat/clipboard.ts","./src/components/chat/composerUtils.test.ts","./src/components/chat/composerUtils.ts","./src/components/chat/useComposerFileDrop.ts","./src/components/companion/CompanionControls.tsx","./src/components/companion/CompanionTranscript.tsx","./src/components/companion/CompanionVideoPanel.tsx","./src/components/controls/ModelSelector.test.tsx","./src/components/controls/ModelSelector.tsx","./src/components/controls/PermissionModeSelector.test.tsx","./src/components/controls/PermissionModeSelector.tsx","./src/components/doctor/DoctorPanel.tsx","./src/components/layout/AppShell.test.tsx","./src/components/layout/AppShell.tsx","./src/components/layout/ContentRouter.test.tsx","./src/components/layout/ContentRouter.tsx","./src/components/layout/H5ConnectionView.tsx","./src/components/layout/OpenProjectMenu.test.tsx","./src/components/layout/OpenProjectMenu.tsx","./src/components/layout/Sidebar.test.tsx","./src/components/layout/Sidebar.tsx","./src/components/layout/StartupErrorView.test.tsx","./src/components/layout/StartupErrorView.tsx","./src/components/layout/StatusBar.tsx","./src/components/layout/TabBar.test.tsx","./src/components/layout/TabBar.tsx","./src/components/layout/TitleBar.tsx","./src/components/layout/WindowControls.test.tsx","./src/components/layout/WindowControls.tsx","./src/components/markdown/MarkdownRenderer.test.tsx","./src/components/markdown/MarkdownRenderer.tsx","./src/components/plugins/PluginDetail.tsx","./src/components/plugins/PluginList.tsx","./src/components/settings/ChatGPTOfficialLogin.test.tsx","./src/components/settings/ChatGPTOfficialLogin.tsx","./src/components/settings/ClaudeOfficialLogin.tsx","./src/components/shared/Button.tsx","./src/components/shared/ConfirmDialog.tsx","./src/components/shared/CopyButton.tsx","./src/components/shared/DirectoryPicker.test.tsx","./src/components/shared/DirectoryPicker.tsx","./src/components/shared/Dropdown.tsx","./src/components/shared/Input.tsx","./src/components/shared/MobileBottomSheet.tsx","./src/components/shared/Modal.test.tsx","./src/components/shared/Modal.tsx","./src/components/shared/ProjectContextChip.test.tsx","./src/components/shared/ProjectContextChip.tsx","./src/components/shared/RepositoryLaunchControls.test.tsx","./src/components/shared/RepositoryLaunchControls.tsx","./src/components/shared/Spinner.tsx","./src/components/shared/Textarea.tsx","./src/components/shared/Toast.tsx","./src/components/shared/UpdateChecker.test.tsx","./src/components/shared/UpdateChecker.tsx","./src/components/skills/SkillDetail.test.tsx","./src/components/skills/SkillDetail.tsx","./src/components/skills/SkillList.tsx","./src/components/tasks/DayOfWeekPicker.tsx","./src/components/tasks/NewTaskModal.test.tsx","./src/components/tasks/NewTaskModal.tsx","./src/components/tasks/PromptEditor.tsx","./src/components/tasks/TaskEmptyState.tsx","./src/components/tasks/TaskList.tsx","./src/components/tasks/TaskRow.tsx","./src/components/tasks/TaskRunsPanel.test.tsx","./src/components/tasks/TaskRunsPanel.tsx","./src/components/teams/TeamStatusBar.tsx","./src/components/workspace/WorkspaceCodeSurface.tsx","./src/components/workspace/WorkspacePanel.test.tsx","./src/components/workspace/WorkspacePanel.tsx","./src/config/spinnerVerbs.ts","./src/constants/modelCatalog.ts","./src/constants/openaiOfficialProvider.ts","./src/hooks/useCompanionAudio.ts","./src/hooks/useCompanionWebSocket.ts","./src/hooks/useKeyboardShortcuts.test.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useMicrophone.ts","./src/hooks/useMobileViewport.test.tsx","./src/hooks/useMobileViewport.ts","./src/hooks/useScheduledTaskDesktopNotifications.test.tsx","./src/hooks/useScheduledTaskDesktopNotifications.ts","./src/hooks/useSelectionPopoverDismiss.ts","./src/hooks/useWebcam.ts","./src/i18n/index.test.tsx","./src/i18n/index.ts","./src/i18n/locales/en.ts","./src/i18n/locales/zh.ts","./src/lib/appZoom.test.ts","./src/lib/appZoom.ts","./src/lib/audioEngine.ts","./src/lib/composerAttachments.test.ts","./src/lib/composerAttachments.ts","./src/lib/cronDescribe.ts","./src/lib/desktopNotificationNavigation.test.ts","./src/lib/desktopNotificationNavigation.ts","./src/lib/desktopNotifications.test.ts","./src/lib/desktopNotifications.ts","./src/lib/desktopRuntime.test.ts","./src/lib/desktopRuntime.ts","./src/lib/diagnosticsCapture.ts","./src/lib/doctorRepair.test.ts","./src/lib/doctorRepair.ts","./src/lib/formatBytes.ts","./src/lib/parseRunOutput.ts","./src/lib/persistenceMigrations.test.ts","./src/lib/persistenceMigrations.ts","./src/lib/providerSettingsJson.ts","./src/lib/sessionTitle.test.ts","./src/lib/sessionTitle.ts","./src/lib/__tests__/cronDescribe.test.ts","./src/lib/__tests__/providerSettingsJson.test.ts","./src/mocks/data.ts","./src/pages/ActiveSession.test.tsx","./src/pages/ActiveSession.tsx","./src/pages/ActivitySettings.test.tsx","./src/pages/ActivitySettings.tsx","./src/pages/AdapterSettings.test.tsx","./src/pages/AdapterSettings.tsx","./src/pages/AgentTeams.tsx","./src/pages/Companion.tsx","./src/pages/ComputerUseSettings.test.tsx","./src/pages/ComputerUseSettings.tsx","./src/pages/DiagnosticsSettings.tsx","./src/pages/EmptySession.test.tsx","./src/pages/EmptySession.tsx","./src/pages/McpSettings.tsx","./src/pages/MemorySettings.tsx","./src/pages/NewTaskModal.tsx","./src/pages/ScheduledTasks.tsx","./src/pages/ScheduledTasksEmpty.tsx","./src/pages/ScheduledTasksList.tsx","./src/pages/SessionControls.tsx","./src/pages/Settings.tsx","./src/pages/TerminalSettings.test.tsx","./src/pages/TerminalSettings.tsx","./src/pages/ToolInspection.tsx","./src/stores/adapterStore.test.ts","./src/stores/adapterStore.ts","./src/stores/agentStore.ts","./src/stores/chatStore.test.ts","./src/stores/chatStore.ts","./src/stores/cliTaskStore.test.ts","./src/stores/cliTaskStore.ts","./src/stores/companionStore.ts","./src/stores/hahaOAuthStore.test.ts","./src/stores/hahaOAuthStore.ts","./src/stores/hahaOpenAIOAuthStore.test.ts","./src/stores/hahaOpenAIOAuthStore.ts","./src/stores/mcpStore.ts","./src/stores/memoryStore.ts","./src/stores/openTargetStore.test.ts","./src/stores/openTargetStore.ts","./src/stores/pluginStore.test.ts","./src/stores/pluginStore.ts","./src/stores/providerStore.test.ts","./src/stores/providerStore.ts","./src/stores/sessionRuntimeStore.ts","./src/stores/sessionStore.test.ts","./src/stores/sessionStore.ts","./src/stores/settingsStore.test.ts","./src/stores/settingsStore.ts","./src/stores/skillStore.ts","./src/stores/tabStore.test.ts","./src/stores/tabStore.ts","./src/stores/taskStore.ts","./src/stores/teamStore.ts","./src/stores/terminalPanelStore.ts","./src/stores/uiStore.test.ts","./src/stores/uiStore.ts","./src/stores/updateStore.test.ts","./src/stores/updateStore.ts","./src/stores/workspaceChatContextStore.test.ts","./src/stores/workspaceChatContextStore.ts","./src/stores/workspacePanelStore.test.ts","./src/stores/workspacePanelStore.ts","./src/theme/globals.test.ts","./src/types/adapter.ts","./src/types/chat.ts","./src/types/cliTask.ts","./src/types/companion.ts","./src/types/mcp.ts","./src/types/memory.ts","./src/types/plugin.ts","./src/types/provider.ts","./src/types/providerPreset.ts","./src/types/qrcode.d.ts","./src/types/runtime.ts","./src/types/session.ts","./src/types/settings.ts","./src/types/skill.ts","./src/types/task.ts","./src/types/team.ts"],"errors":true,"version":"5.9.3"}
 
1
+ {"root":["./src/App.tsx","./src/main.test.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/__tests__/agentsSettings.test.tsx","./src/__tests__/diagnosticsSettings.test.tsx","./src/__tests__/generalSettings.test.tsx","./src/__tests__/mcpSettings.test.tsx","./src/__tests__/memorySettings.test.tsx","./src/__tests__/pages.test.tsx","./src/__tests__/pluginsSettings.test.tsx","./src/__tests__/skillsSettings.test.tsx","./src/__tests__/tauriCapabilities.test.ts","./src/api/activityStats.ts","./src/api/adapters.ts","./src/api/agents.ts","./src/api/cliTasks.ts","./src/api/client.test.ts","./src/api/client.ts","./src/api/computerUse.ts","./src/api/desktopUiPreferences.ts","./src/api/diagnostics.ts","./src/api/doctor.ts","./src/api/filesystem.ts","./src/api/h5Access.ts","./src/api/hahaOAuth.ts","./src/api/hahaOpenAIOAuth.ts","./src/api/mcp.ts","./src/api/memory.ts","./src/api/models.ts","./src/api/openTargets.test.ts","./src/api/openTargets.ts","./src/api/plugins.ts","./src/api/providers.ts","./src/api/search.ts","./src/api/sessions.test.ts","./src/api/sessions.ts","./src/api/settings.ts","./src/api/skills.ts","./src/api/tasks.ts","./src/api/teams.ts","./src/api/terminal.ts","./src/api/websocket.test.ts","./src/api/websocket.ts","./src/components/ErrorBoundary.test.tsx","./src/components/ErrorBoundary.tsx","./src/components/chat/AskUserQuestion.test.tsx","./src/components/chat/AskUserQuestion.tsx","./src/components/chat/AssistantMessage.tsx","./src/components/chat/AttachmentGallery.test.tsx","./src/components/chat/AttachmentGallery.tsx","./src/components/chat/ChatInput.test.tsx","./src/components/chat/ChatInput.tsx","./src/components/chat/CodeViewer.test.tsx","./src/components/chat/CodeViewer.tsx","./src/components/chat/ComposerDropOverlay.tsx","./src/components/chat/ComputerUsePermissionModal.test.tsx","./src/components/chat/ComputerUsePermissionModal.tsx","./src/components/chat/ContextUsageIndicator.test.tsx","./src/components/chat/ContextUsageIndicator.tsx","./src/components/chat/CurrentTurnChangeCard.tsx","./src/components/chat/DiffViewer.tsx","./src/components/chat/FileSearchMenu.test.tsx","./src/components/chat/FileSearchMenu.tsx","./src/components/chat/ImageGalleryModal.tsx","./src/components/chat/InlineImageGallery.tsx","./src/components/chat/InlineTaskSummary.tsx","./src/components/chat/LocalSlashCommandPanel.test.tsx","./src/components/chat/LocalSlashCommandPanel.tsx","./src/components/chat/MermaidRenderer.test.tsx","./src/components/chat/MermaidRenderer.tsx","./src/components/chat/MessageActionBar.tsx","./src/components/chat/MessageList.test.tsx","./src/components/chat/MessageList.tsx","./src/components/chat/PermissionDialog.tsx","./src/components/chat/SessionTaskBar.test.tsx","./src/components/chat/SessionTaskBar.tsx","./src/components/chat/StreamingIndicator.tsx","./src/components/chat/TerminalChrome.tsx","./src/components/chat/ThinkingBlock.tsx","./src/components/chat/ToolCallBlock.tsx","./src/components/chat/ToolCallGroup.tsx","./src/components/chat/ToolResultBlock.tsx","./src/components/chat/UserMessage.test.tsx","./src/components/chat/UserMessage.tsx","./src/components/chat/chatBlocks.test.tsx","./src/components/chat/clipboard.ts","./src/components/chat/composerUtils.test.ts","./src/components/chat/composerUtils.ts","./src/components/chat/useComposerFileDrop.ts","./src/components/companion/CompanionControls.tsx","./src/components/companion/CompanionTopBar.tsx","./src/components/companion/CompanionTranscript.tsx","./src/components/companion/CompanionVideoPanel.tsx","./src/components/companion/ScenarioSelector.tsx","./src/components/companion/ScreenShareDialog.tsx","./src/components/controls/ModelSelector.test.tsx","./src/components/controls/ModelSelector.tsx","./src/components/controls/PermissionModeSelector.test.tsx","./src/components/controls/PermissionModeSelector.tsx","./src/components/doctor/DoctorPanel.tsx","./src/components/layout/AppShell.test.tsx","./src/components/layout/AppShell.tsx","./src/components/layout/ContentRouter.test.tsx","./src/components/layout/ContentRouter.tsx","./src/components/layout/H5ConnectionView.tsx","./src/components/layout/OpenProjectMenu.test.tsx","./src/components/layout/OpenProjectMenu.tsx","./src/components/layout/Sidebar.test.tsx","./src/components/layout/Sidebar.tsx","./src/components/layout/StartupErrorView.test.tsx","./src/components/layout/StartupErrorView.tsx","./src/components/layout/StatusBar.tsx","./src/components/layout/TabBar.test.tsx","./src/components/layout/TabBar.tsx","./src/components/layout/TitleBar.tsx","./src/components/layout/WindowControls.test.tsx","./src/components/layout/WindowControls.tsx","./src/components/markdown/MarkdownRenderer.test.tsx","./src/components/markdown/MarkdownRenderer.tsx","./src/components/plugins/PluginDetail.tsx","./src/components/plugins/PluginList.tsx","./src/components/settings/ChatGPTOfficialLogin.test.tsx","./src/components/settings/ChatGPTOfficialLogin.tsx","./src/components/settings/ClaudeOfficialLogin.tsx","./src/components/shared/Button.tsx","./src/components/shared/ConfirmDialog.tsx","./src/components/shared/CopyButton.tsx","./src/components/shared/DirectoryPicker.test.tsx","./src/components/shared/DirectoryPicker.tsx","./src/components/shared/Dropdown.tsx","./src/components/shared/Input.tsx","./src/components/shared/MobileBottomSheet.tsx","./src/components/shared/Modal.test.tsx","./src/components/shared/Modal.tsx","./src/components/shared/ProjectContextChip.test.tsx","./src/components/shared/ProjectContextChip.tsx","./src/components/shared/RepositoryLaunchControls.test.tsx","./src/components/shared/RepositoryLaunchControls.tsx","./src/components/shared/Spinner.tsx","./src/components/shared/Textarea.tsx","./src/components/shared/Toast.tsx","./src/components/shared/UpdateChecker.test.tsx","./src/components/shared/UpdateChecker.tsx","./src/components/skills/SkillDetail.test.tsx","./src/components/skills/SkillDetail.tsx","./src/components/skills/SkillList.tsx","./src/components/tasks/DayOfWeekPicker.tsx","./src/components/tasks/NewTaskModal.test.tsx","./src/components/tasks/NewTaskModal.tsx","./src/components/tasks/PromptEditor.tsx","./src/components/tasks/TaskEmptyState.tsx","./src/components/tasks/TaskList.tsx","./src/components/tasks/TaskRow.tsx","./src/components/tasks/TaskRunsPanel.test.tsx","./src/components/tasks/TaskRunsPanel.tsx","./src/components/teams/TeamStatusBar.tsx","./src/components/workspace/WorkspaceCodeSurface.tsx","./src/components/workspace/WorkspacePanel.test.tsx","./src/components/workspace/WorkspacePanel.tsx","./src/config/spinnerVerbs.ts","./src/constants/modelCatalog.ts","./src/constants/openaiOfficialProvider.ts","./src/hooks/useCameraDevices.ts","./src/hooks/useCompanionAudio.ts","./src/hooks/useCompanionWebSocket.ts","./src/hooks/useKeyboardShortcuts.test.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useMicrophone.ts","./src/hooks/useMiniCPMoBackend.ts","./src/hooks/useMobileViewport.test.tsx","./src/hooks/useMobileViewport.ts","./src/hooks/useScheduledTaskDesktopNotifications.test.tsx","./src/hooks/useScheduledTaskDesktopNotifications.ts","./src/hooks/useScreenShare.ts","./src/hooks/useSelectionPopoverDismiss.ts","./src/hooks/useWebcam.ts","./src/i18n/index.test.tsx","./src/i18n/index.ts","./src/i18n/locales/en.ts","./src/i18n/locales/zh.ts","./src/lib/appZoom.test.ts","./src/lib/appZoom.ts","./src/lib/audioEngine.ts","./src/lib/composerAttachments.test.ts","./src/lib/composerAttachments.ts","./src/lib/cronDescribe.ts","./src/lib/desktopNotificationNavigation.test.ts","./src/lib/desktopNotificationNavigation.ts","./src/lib/desktopNotifications.test.ts","./src/lib/desktopNotifications.ts","./src/lib/desktopRuntime.test.ts","./src/lib/desktopRuntime.ts","./src/lib/diagnosticsCapture.ts","./src/lib/doctorRepair.test.ts","./src/lib/doctorRepair.ts","./src/lib/formatBytes.ts","./src/lib/parseRunOutput.ts","./src/lib/persistenceMigrations.test.ts","./src/lib/persistenceMigrations.ts","./src/lib/providerSettingsJson.ts","./src/lib/sessionTitle.test.ts","./src/lib/sessionTitle.ts","./src/lib/__tests__/cronDescribe.test.ts","./src/lib/__tests__/providerSettingsJson.test.ts","./src/mocks/data.ts","./src/pages/ActiveSession.test.tsx","./src/pages/ActiveSession.tsx","./src/pages/ActivitySettings.test.tsx","./src/pages/ActivitySettings.tsx","./src/pages/AdapterSettings.test.tsx","./src/pages/AdapterSettings.tsx","./src/pages/AgentTeams.tsx","./src/pages/Companion.tsx","./src/pages/ComputerUseSettings.test.tsx","./src/pages/ComputerUseSettings.tsx","./src/pages/DiagnosticsSettings.tsx","./src/pages/EmptySession.test.tsx","./src/pages/EmptySession.tsx","./src/pages/McpSettings.tsx","./src/pages/MemorySettings.tsx","./src/pages/NewTaskModal.tsx","./src/pages/ScheduledTasks.tsx","./src/pages/ScheduledTasksEmpty.tsx","./src/pages/ScheduledTasksList.tsx","./src/pages/SessionControls.tsx","./src/pages/Settings.tsx","./src/pages/TerminalSettings.test.tsx","./src/pages/TerminalSettings.tsx","./src/pages/ToolInspection.tsx","./src/stores/adapterStore.test.ts","./src/stores/adapterStore.ts","./src/stores/agentStore.ts","./src/stores/chatStore.test.ts","./src/stores/chatStore.ts","./src/stores/cliTaskStore.test.ts","./src/stores/cliTaskStore.ts","./src/stores/companionStore.ts","./src/stores/hahaOAuthStore.test.ts","./src/stores/hahaOAuthStore.ts","./src/stores/hahaOpenAIOAuthStore.test.ts","./src/stores/hahaOpenAIOAuthStore.ts","./src/stores/mcpStore.ts","./src/stores/memoryStore.ts","./src/stores/openTargetStore.test.ts","./src/stores/openTargetStore.ts","./src/stores/pluginStore.test.ts","./src/stores/pluginStore.ts","./src/stores/providerStore.test.ts","./src/stores/providerStore.ts","./src/stores/sessionRuntimeStore.ts","./src/stores/sessionStore.test.ts","./src/stores/sessionStore.ts","./src/stores/settingsStore.test.ts","./src/stores/settingsStore.ts","./src/stores/skillStore.ts","./src/stores/tabStore.test.ts","./src/stores/tabStore.ts","./src/stores/taskStore.ts","./src/stores/teamStore.ts","./src/stores/terminalPanelStore.ts","./src/stores/uiStore.test.ts","./src/stores/uiStore.ts","./src/stores/updateStore.test.ts","./src/stores/updateStore.ts","./src/stores/workspaceChatContextStore.test.ts","./src/stores/workspaceChatContextStore.ts","./src/stores/workspacePanelStore.test.ts","./src/stores/workspacePanelStore.ts","./src/theme/globals.test.ts","./src/types/adapter.ts","./src/types/chat.ts","./src/types/cliTask.ts","./src/types/companion.ts","./src/types/mcp.ts","./src/types/memory.ts","./src/types/plugin.ts","./src/types/provider.ts","./src/types/providerPreset.ts","./src/types/qrcode.d.ts","./src/types/runtime.ts","./src/types/session.ts","./src/types/settings.ts","./src/types/skill.ts","./src/types/task.ts","./src/types/team.ts"],"errors":true,"version":"5.9.3"}
src/server/api/cli-auth.ts ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * CLI Auth Provider REST API
3
+ *
4
+ * GET /api/cli-auth — read current CLI auth provider config from ~/.claude.json
5
+ * PUT /api/cli-auth — update CLI auth provider config in ~/.claude.json
6
+ * POST /api/cli-auth/invalidate — invalidate the cliProviderModelCache
7
+ */
8
+
9
+ import { homedir } from 'node:os'
10
+ import { readFileSync, writeFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { ApiError, errorResponse } from '../middleware/errorHandler.js'
13
+
14
+ type AuthProvider = 'anthropic' | 'openai' | 'openrouter' | 'local' | 'opencode' | 'nvidia'
15
+
16
+ type CliAuthConfig = {
17
+ authProvider: AuthProvider | null
18
+ nvidiaApiKey?: string
19
+ openRouterApiKey?: string
20
+ openAiApiKey?: string
21
+ openAiAccessToken?: string
22
+ openCodeApiKey?: string
23
+ openCodeModelName?: string
24
+ localBaseUrl?: string
25
+ localModelName?: string
26
+ }
27
+
28
+ function getClaudeJsonPath(): string {
29
+ return join(homedir(), '.claude.json')
30
+ }
31
+
32
+ function readCliAuthConfig(): CliAuthConfig {
33
+ try {
34
+ const raw = readFileSync(getClaudeJsonPath(), 'utf8')
35
+ const config = JSON.parse(raw) as Record<string, unknown>
36
+ return {
37
+ authProvider: (config.authProvider as AuthProvider) || null,
38
+ nvidiaApiKey: config.nvidiaApiKey as string | undefined,
39
+ openRouterApiKey: config.openRouterApiKey as string | undefined,
40
+ openAiApiKey: config.openAiApiKey as string | undefined,
41
+ openAiAccessToken: config.openAiAccessToken as string | undefined,
42
+ openCodeApiKey: config.openCodeApiKey as string | undefined,
43
+ openCodeModelName: config.openCodeModelName as string | undefined,
44
+ localBaseUrl: config.localBaseUrl as string | undefined,
45
+ localModelName: config.localModelName as string | undefined,
46
+ }
47
+ } catch {
48
+ return { authProvider: null }
49
+ }
50
+ }
51
+
52
+ function writeCliAuthConfig(updates: Partial<CliAuthConfig>): void {
53
+ const filePath = getClaudeJsonPath()
54
+ let config: Record<string, unknown> = {}
55
+ try {
56
+ const raw = readFileSync(filePath, 'utf8')
57
+ config = JSON.parse(raw) as Record<string, unknown>
58
+ } catch {
59
+ // file doesn't exist yet, start fresh
60
+ }
61
+
62
+ if (updates.authProvider !== undefined) {
63
+ config.authProvider = updates.authProvider || undefined
64
+ }
65
+
66
+ if (updates.nvidiaApiKey !== undefined) {
67
+ config.nvidiaApiKey = updates.nvidiaApiKey || undefined
68
+ }
69
+
70
+ if (updates.openRouterApiKey !== undefined) {
71
+ config.openRouterApiKey = updates.openRouterApiKey || undefined
72
+ }
73
+
74
+ if (updates.openAiApiKey !== undefined) {
75
+ config.openAiApiKey = updates.openAiApiKey || undefined
76
+ config.openAiAccessToken = undefined
77
+ config.openAiRefreshToken = undefined
78
+ config.openAiTokenExpiresAt = undefined
79
+ config.openAiWorkspaceId = undefined
80
+ }
81
+
82
+ if (updates.openAiAccessToken !== undefined) {
83
+ config.openAiAccessToken = updates.openAiAccessToken || undefined
84
+ config.openAiApiKey = undefined
85
+ config.openAiRefreshToken = undefined
86
+ config.openAiTokenExpiresAt = undefined
87
+ config.openAiWorkspaceId = undefined
88
+ }
89
+
90
+ if (updates.openCodeApiKey !== undefined) {
91
+ config.openCodeApiKey = updates.openCodeApiKey || undefined
92
+ }
93
+
94
+ if (updates.openCodeModelName !== undefined) {
95
+ config.openCodeModelName = updates.openCodeModelName || undefined
96
+ }
97
+
98
+ if (updates.localBaseUrl !== undefined) {
99
+ config.localBaseUrl = updates.localBaseUrl || undefined
100
+ }
101
+
102
+ if (updates.localModelName !== undefined) {
103
+ config.localModelName = updates.localModelName || undefined
104
+ }
105
+
106
+ writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n')
107
+ }
108
+
109
+ export async function handleCliAuthApi(
110
+ req: Request,
111
+ _url: URL,
112
+ segments: string[],
113
+ ): Promise<Response> {
114
+ try {
115
+ const sub = segments[2]
116
+
117
+ if (sub === 'invalidate') {
118
+ if (req.method !== 'POST') throw methodNotAllowed(req.method)
119
+ const { invalidateCliProviderModelCache } = await import('./models.js')
120
+ invalidateCliProviderModelCache()
121
+ return Response.json({ ok: true })
122
+ }
123
+
124
+ if (!sub) {
125
+ if (req.method === 'GET') {
126
+ const config = readCliAuthConfig()
127
+ return Response.json(config)
128
+ }
129
+
130
+ if (req.method === 'PUT') {
131
+ const body = await parseJsonBody(req)
132
+ writeCliAuthConfig(body as Partial<CliAuthConfig>)
133
+ const { invalidateCliProviderModelCache } = await import('./models.js')
134
+ invalidateCliProviderModelCache()
135
+ return Response.json({ ok: true })
136
+ }
137
+
138
+ throw methodNotAllowed(req.method)
139
+ }
140
+
141
+ throw ApiError.notFound(`Unknown cli-auth endpoint: ${sub}`)
142
+ } catch (error) {
143
+ return errorResponse(error)
144
+ }
145
+ }
146
+
147
+ async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
148
+ try {
149
+ return (await req.json()) as Record<string, unknown>
150
+ } catch {
151
+ throw ApiError.badRequest('Invalid JSON body')
152
+ }
153
+ }
154
+
155
+ function methodNotAllowed(method: string): ApiError {
156
+ return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED')
157
+ }
src/server/api/models.ts CHANGED
@@ -167,6 +167,11 @@ let cliProviderModelCache: ApiModelInfo[] | null = null
167
  let cliProviderModelCacheTime = 0
168
  const CLI_PROVIDER_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
169
 
 
 
 
 
 
170
  async function fetchCliProviderModels(): Promise<ApiModelInfo[]> {
171
  if (cliProviderModelCache && Date.now() - cliProviderModelCacheTime < CLI_PROVIDER_CACHE_TTL) {
172
  return cliProviderModelCache
@@ -285,7 +290,57 @@ export async function handleModelsApi(
285
 
286
  // ─── Handlers ─────────────────────────────────────────────────────────────────
287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  async function handleModelsList(): Promise<Response> {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  const { providers, activeId } = await providerService.listProviders()
290
  if (isOpenAIOfficialProviderId(activeId)) {
291
  return Response.json({
@@ -306,15 +361,6 @@ async function handleModelsList(): Promise<Response> {
306
  })
307
  }
308
 
309
- // No cc-haha provider active — check if CLI has an auth provider configured
310
- const cliModels = await fetchCliProviderModels()
311
- if (cliModels.length > 0) {
312
- return Response.json({
313
- models: cliModels,
314
- provider: { id: 'cli', name: 'CLI Provider' },
315
- })
316
- }
317
-
318
  return Response.json({ models: getStandaloneModelList(), provider: null })
319
  }
320
 
 
167
  let cliProviderModelCacheTime = 0
168
  const CLI_PROVIDER_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
169
 
170
+ export function invalidateCliProviderModelCache(): void {
171
+ cliProviderModelCache = null
172
+ cliProviderModelCacheTime = 0
173
+ }
174
+
175
  async function fetchCliProviderModels(): Promise<ApiModelInfo[]> {
176
  if (cliProviderModelCache && Date.now() - cliProviderModelCacheTime < CLI_PROVIDER_CACHE_TTL) {
177
  return cliProviderModelCache
 
290
 
291
  // ─── Handlers ─────────────────────────────────────────────────────────────────
292
 
293
+ async function readCliAuthProvider(): Promise<{
294
+ authProvider?: 'anthropic' | 'openai' | 'openrouter' | 'local' | 'opencode' | 'nvidia'
295
+ } | null> {
296
+ try {
297
+ const { homedir } = await import('node:os')
298
+ const { readFileSync } = await import('node:fs')
299
+ const { join } = await import('node:path')
300
+ const raw = readFileSync(join(homedir(), '.claude.json'), 'utf8')
301
+ return JSON.parse(raw)
302
+ } catch {
303
+ return null
304
+ }
305
+ }
306
+
307
+ const CLI_PROVIDER_NAMES: Record<string, string> = {
308
+ opencode: 'OpenCode Zen',
309
+ nvidia: 'NVIDIA',
310
+ openrouter: 'OpenRouter',
311
+ local: 'Local',
312
+ anthropic: 'Anthropic',
313
+ openai: 'OpenAI',
314
+ }
315
+
316
  async function handleModelsList(): Promise<Response> {
317
+ // First check if CLI has an authProvider configured — this takes precedence
318
+ // over cc-haha providers since CLI's /login command manages it
319
+ const cliConfig = await readCliAuthProvider()
320
+ const cliAuthProvider = cliConfig?.authProvider
321
+
322
+ if (cliAuthProvider && cliAuthProvider !== 'anthropic' && cliAuthProvider !== 'openai') {
323
+ const cliModels = await fetchCliProviderModels()
324
+ if (cliModels.length > 0) {
325
+ return Response.json({
326
+ models: cliModels,
327
+ provider: {
328
+ id: `cli-${cliAuthProvider}`,
329
+ name: CLI_PROVIDER_NAMES[cliAuthProvider] || cliAuthProvider,
330
+ },
331
+ })
332
+ }
333
+ // If no models fetched yet but authProvider is set, still return provider info
334
+ // The model list will be populated once available
335
+ return Response.json({
336
+ models: [],
337
+ provider: {
338
+ id: `cli-${cliAuthProvider}`,
339
+ name: CLI_PROVIDER_NAMES[cliAuthProvider] || cliAuthProvider,
340
+ },
341
+ })
342
+ }
343
+
344
  const { providers, activeId } = await providerService.listProviders()
345
  if (isOpenAIOfficialProviderId(activeId)) {
346
  return Response.json({
 
361
  })
362
  }
363
 
 
 
 
 
 
 
 
 
 
364
  return Response.json({ models: getStandaloneModelList(), provider: null })
365
  }
366
 
src/server/router.ts CHANGED
@@ -27,6 +27,7 @@ import { handleActivityStatsApi } from './api/activityStats.js'
27
  import { handleOpenTargetsApi } from './api/open-targets.js'
28
  import { handleMemoryApi } from './api/memory.js'
29
  import { handleDesktopUiApi } from './api/desktop-ui.js'
 
30
 
31
  export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
32
  const path = url.pathname
@@ -119,6 +120,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
119
  case 'desktop-ui':
120
  return handleDesktopUiApi(req, url, segments)
121
 
 
 
 
122
  case 'filesystem':
123
  return handleFilesystemRoute(url.pathname, url)
124
 
 
27
  import { handleOpenTargetsApi } from './api/open-targets.js'
28
  import { handleMemoryApi } from './api/memory.js'
29
  import { handleDesktopUiApi } from './api/desktop-ui.js'
30
+ import { handleCliAuthApi } from './api/cli-auth.js'
31
 
32
  export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
33
  const path = url.pathname
 
120
  case 'desktop-ui':
121
  return handleDesktopUiApi(req, url, segments)
122
 
123
+ case 'cli-auth':
124
+ return handleCliAuthApi(req, url, segments)
125
+
126
  case 'filesystem':
127
  return handleFilesystemRoute(url.pathname, url)
128
 
src/server/services/conversationService.ts CHANGED
@@ -866,6 +866,61 @@ export class ConversationService {
866
  return args
867
  }
868
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
869
  private async buildChildEnv(
870
  workDir: string,
871
  sdkUrl?: string,
@@ -923,8 +978,25 @@ export class ConversationService {
923
  if (explicitProviderEnv && options?.model?.trim()) {
924
  explicitProviderEnv.ANTHROPIC_MODEL = options.model.trim()
925
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
926
  const attributionHeaderEnv = attributionHeaderEnvForModel(
927
  options?.model?.trim() ||
 
928
  explicitProviderEnv?.ANTHROPIC_MODEL ||
929
  cleanEnv.ANTHROPIC_MODEL,
930
  )
@@ -969,10 +1041,16 @@ export class ConversationService {
969
  // 否则 CLI 会忽略 provider 的 AUTH_TOKEN、错误地走 OAuth 打到第三方
970
  // endpoint。详见 src/utils/auth.ts isManagedOAuthContext()。
971
  ...(explicitProviderEnv ?? {}),
 
 
 
972
  ...networkEnv,
973
- ...(this.shouldMarkManagedOAuth(options?.providerId)
974
- ? await this.buildOfficialOAuthEnv()
975
- : {}),
 
 
 
976
  ...attributionHeaderEnv,
977
  }
978
  }
@@ -1024,6 +1102,13 @@ export class ConversationService {
1024
  return true
1025
  }
1026
 
 
 
 
 
 
 
 
1027
  const configDir =
1028
  process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
1029
  const ccHahaDir = path.join(configDir, 'cc-haha')
@@ -1078,6 +1163,13 @@ export class ConversationService {
1078
  return false
1079
  }
1080
 
 
 
 
 
 
 
 
1081
  const configDir =
1082
  process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
1083
  const settingsPath = path.join(configDir, 'cc-haha', 'settings.json')
 
866
  return args
867
  }
868
 
869
+ private readCliGlobalConfig(): {
870
+ authProvider?: 'anthropic' | 'openai' | 'openrouter' | 'local' | 'opencode' | 'nvidia'
871
+ openCodeApiKey?: string
872
+ openCodeModelName?: string
873
+ nvidiaApiKey?: string
874
+ openRouterApiKey?: string
875
+ localBaseUrl?: string
876
+ localModelName?: string
877
+ } | null {
878
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
879
+ const configPath = path.join(configDir, '.claude.json')
880
+ try {
881
+ const raw = fs.readFileSync(configPath, 'utf-8')
882
+ return JSON.parse(raw) as ReturnType<typeof this.readCliGlobalConfig>
883
+ } catch {
884
+ return null
885
+ }
886
+ }
887
+
888
+ private buildCliProviderEnv(
889
+ authProvider: 'opencode' | 'nvidia' | 'openrouter' | 'local',
890
+ config: NonNullable<ReturnType<typeof this.readCliGlobalConfig>>,
891
+ ): Record<string, string> {
892
+ const env: Record<string, string> = {}
893
+
894
+ if (authProvider === 'opencode') {
895
+ if (config.openCodeApiKey) {
896
+ env.ANTHROPIC_API_KEY = config.openCodeApiKey
897
+ }
898
+ if (config.openCodeModelName) {
899
+ env.ANTHROPIC_MODEL = config.openCodeModelName
900
+ }
901
+ // opencode uses custom fetch override, no base URL override needed
902
+ } else if (authProvider === 'nvidia') {
903
+ if (config.nvidiaApiKey) {
904
+ env.ANTHROPIC_API_KEY = config.nvidiaApiKey
905
+ }
906
+ env.ANTHROPIC_BASE_URL = 'https://integrate.api.nvidia.com/v1'
907
+ } else if (authProvider === 'openrouter') {
908
+ if (config.openRouterApiKey) {
909
+ env.ANTHROPIC_API_KEY = config.openRouterApiKey
910
+ }
911
+ env.ANTHROPIC_BASE_URL = 'https://openrouter.ai/api/v1'
912
+ } else if (authProvider === 'local') {
913
+ if (config.localBaseUrl) {
914
+ env.ANTHROPIC_BASE_URL = config.localBaseUrl
915
+ }
916
+ if (config.localModelName) {
917
+ env.ANTHROPIC_MODEL = config.localModelName
918
+ }
919
+ }
920
+
921
+ return env
922
+ }
923
+
924
  private async buildChildEnv(
925
  workDir: string,
926
  sdkUrl?: string,
 
978
  if (explicitProviderEnv && options?.model?.trim()) {
979
  explicitProviderEnv.ANTHROPIC_MODEL = options.model.trim()
980
  }
981
+
982
+ // Check for CLI-managed authProvider (opencode, nvidia, openrouter, local)
983
+ // These are configured via CLI's /login command and stored in ~/.claude.json
984
+ const cliConfig = this.readCliGlobalConfig()
985
+ const cliAuthProvider = cliConfig?.authProvider
986
+ const isCliManagedProvider =
987
+ cliAuthProvider !== undefined &&
988
+ cliAuthProvider !== 'anthropic' &&
989
+ cliAuthProvider !== 'openai'
990
+ const cliProviderEnv = isCliManagedProvider
991
+ ? this.buildCliProviderEnv(
992
+ cliAuthProvider as 'opencode' | 'nvidia' | 'openrouter' | 'local',
993
+ cliConfig!,
994
+ )
995
+ : null
996
+
997
  const attributionHeaderEnv = attributionHeaderEnvForModel(
998
  options?.model?.trim() ||
999
+ cliProviderEnv?.ANTHROPIC_MODEL ||
1000
  explicitProviderEnv?.ANTHROPIC_MODEL ||
1001
  cleanEnv.ANTHROPIC_MODEL,
1002
  )
 
1041
  // 否则 CLI 会忽略 provider 的 AUTH_TOKEN、错误地走 OAuth 打到第三方
1042
  // endpoint。详见 src/utils/auth.ts isManagedOAuthContext()。
1043
  ...(explicitProviderEnv ?? {}),
1044
+ // CLI-managed provider env (opencode/nvidia/openrouter/local from ~/.claude.json)
1045
+ // takes precedence over cc-haha provider env
1046
+ ...(cliProviderEnv ?? {}),
1047
  ...networkEnv,
1048
+ // Skip Desktop OAuth when using CLI-managed providers (they handle their own auth)
1049
+ ...(isCliManagedProvider
1050
+ ? {}
1051
+ : this.shouldMarkManagedOAuth(options?.providerId)
1052
+ ? await this.buildOfficialOAuthEnv()
1053
+ : {}),
1054
  ...attributionHeaderEnv,
1055
  }
1056
  }
 
1102
  return true
1103
  }
1104
 
1105
+ // Check ~/.claude.json first - if CLI has a non-anthropic/non-openai authProvider,
1106
+ // strip inherited env so CLI reads from its own config
1107
+ const cliConfig = this.readCliGlobalConfig()
1108
+ if (cliConfig?.authProvider && cliConfig.authProvider !== 'anthropic' && cliConfig.authProvider !== 'openai') {
1109
+ return true
1110
+ }
1111
+
1112
  const configDir =
1113
  process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
1114
  const ccHahaDir = path.join(configDir, 'cc-haha')
 
1163
  return false
1164
  }
1165
 
1166
+ // Check ~/.claude.json first - if CLI has a non-anthropic/non-openai authProvider,
1167
+ // it manages its own auth, skip Desktop OAuth injection
1168
+ const cliConfig = this.readCliGlobalConfig()
1169
+ if (cliConfig?.authProvider && cliConfig.authProvider !== 'anthropic' && cliConfig.authProvider !== 'openai') {
1170
+ return false
1171
+ }
1172
+
1173
  const configDir =
1174
  process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
1175
  const settingsPath = path.join(configDir, 'cc-haha', 'settings.json')
src/server/services/providerService.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
  normalizeModelMapping,
36
  normalizeProvidersIndex,
37
  } from './providerRuntimeEnv.js'
 
38
  import { getProxyFetchOptions } from '../../utils/proxy.js'
39
  import {
40
  getManualNetworkProxyUrl,
@@ -82,6 +83,84 @@ export class ProviderService {
82
  return path.join(this.getCcHahaDir(), 'providers.json')
83
  }
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  private async readIndex(): Promise<ProvidersIndex> {
86
  await ensurePersistentStorageUpgraded()
87
  return readRecoverableJsonFile({
@@ -125,6 +204,8 @@ export class ProviderService {
125
  // --- CRUD ---
126
 
127
  async listProviders(): Promise<{ providers: SavedProvider[]; activeId: string | null }> {
 
 
128
  const index = await this.readIndex()
129
  return { providers: index.providers, activeId: index.activeId }
130
  }
 
35
  normalizeModelMapping,
36
  normalizeProvidersIndex,
37
  } from './providerRuntimeEnv.js'
38
+ import { PROVIDER_PRESETS } from '../config/providerPresets.js'
39
  import { getProxyFetchOptions } from '../../utils/proxy.js'
40
  import {
41
  getManualNetworkProxyUrl,
 
83
  return path.join(this.getCcHahaDir(), 'providers.json')
84
  }
85
 
86
+ /**
87
+ * Path to the TUI's global config file (~/.claude.json).
88
+ * Always at the home directory level, not under CLAUDE_CONFIG_DIR.
89
+ */
90
+ private getTuiConfigPath(): string {
91
+ return path.join(os.homedir(), '.claude.json')
92
+ }
93
+
94
+ /**
95
+ * Auto-import the provider configured in the TUI's global config (~/.claude.json).
96
+ * When the user configured a provider via /login in the TUI, this ensures the
97
+ * desktop app sees and uses the same provider automatically.
98
+ *
99
+ * Only imports when no desktop provider is currently active, to avoid overriding
100
+ * the user's desktop-specific choice.
101
+ */
102
+ private async autoImportTuiProvider(): Promise<void> {
103
+ const tuiConfigPath = this.getTuiConfigPath()
104
+ let raw: string
105
+ try {
106
+ raw = await fs.readFile(tuiConfigPath, 'utf-8')
107
+ } catch {
108
+ return // No TUI config file
109
+ }
110
+
111
+ let tuiConfig: {
112
+ authProvider?: string
113
+ nvidiaApiKey?: string
114
+ }
115
+ try {
116
+ tuiConfig = JSON.parse(raw)
117
+ } catch {
118
+ return // Invalid JSON
119
+ }
120
+
121
+ const providerId = tuiConfig.authProvider
122
+ if (!providerId) return // No TUI provider configured
123
+
124
+ // Read current index to check if already imported
125
+ const index = await this.readIndex()
126
+ if (index.activeId) return // already has a desktop provider active
127
+
128
+ // Check if a TUI-imported provider already exists (e.g. from a previous import)
129
+ const tuiProviderTag = `tui-${providerId}`
130
+ const existing = index.providers.find(p => p.presetId === tuiProviderTag)
131
+ if (existing) {
132
+ index.activeId = existing.id
133
+ await this.writeIndex(index)
134
+ return
135
+ }
136
+
137
+ // Find the matching preset
138
+ const preset = PROVIDER_PRESETS.find(p => p.id === providerId)
139
+ if (!preset) return // No matching desktop preset for this TUI provider
140
+
141
+ // Extract API key based on provider type
142
+ let apiKey = ''
143
+ if (providerId === 'nvidia') {
144
+ apiKey = tuiConfig.nvidiaApiKey || ''
145
+ if (!apiKey) return
146
+ }
147
+
148
+ const provider: SavedProvider = {
149
+ id: crypto.randomUUID(),
150
+ presetId: tuiProviderTag,
151
+ name: `TUI: ${preset.name}`,
152
+ apiKey,
153
+ baseUrl: preset.baseUrl,
154
+ apiFormat: preset.apiFormat,
155
+ runtimeKind: 'anthropic_compatible',
156
+ models: preset.defaultModels,
157
+ }
158
+
159
+ index.providers.push(provider)
160
+ index.activeId = provider.id
161
+ await this.writeIndex(index)
162
+ }
163
+
164
  private async readIndex(): Promise<ProvidersIndex> {
165
  await ensurePersistentStorageUpgraded()
166
  return readRecoverableJsonFile({
 
204
  // --- CRUD ---
205
 
206
  async listProviders(): Promise<{ providers: SavedProvider[]; activeId: string | null }> {
207
+ // Auto-import TUI provider so desktop follows the TUI's provider choice
208
+ await this.autoImportTuiProvider()
209
  const index = await this.readIndex()
210
  return { providers: index.providers, activeId: index.activeId }
211
  }
src/server/ws/handler.ts CHANGED
@@ -9,6 +9,8 @@
9
  import type { ServerWebSocket } from 'bun'
10
  import type { ClientMessage, ServerMessage } from './events.js'
11
  import * as os from 'node:os'
 
 
12
  import {
13
  ConversationStartupError,
14
  conversationService,
@@ -1743,7 +1745,36 @@ async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings>
1743
  }
1744
 
1745
  async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
1746
- // Check if a custom provider is active
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1747
  const { providers, activeId } = await providerService.listProviders()
1748
  let resolvedActiveId = activeId
1749
  if (activeId && !isKnownRuntimeProviderId(activeId, providers)) {
@@ -1803,6 +1834,30 @@ function resolveDesktopThinkingMode(
1803
  return settings.alwaysThinkingEnabled === false ? 'disabled' : undefined
1804
  }
1805
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1806
  async function buildSessionStartupDiagnosticMessage(
1807
  sessionId: string,
1808
  cause: string,
 
9
  import type { ServerWebSocket } from 'bun'
10
  import type { ClientMessage, ServerMessage } from './events.js'
11
  import * as os from 'node:os'
12
+ import * as fs from 'node:fs'
13
+ import * as path from 'node:path'
14
  import {
15
  ConversationStartupError,
16
  conversationService,
 
1745
  }
1746
 
1747
  async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
1748
+ // First check if CLI has an authProvider configured in ~/.claude.json
1749
+ // (opencode, nvidia, openrouter, local, etc.) - these take precedence
1750
+ // over cc-haha providers since they are managed by the CLI's /login command
1751
+ const cliConfig = await readCliGlobalConfig()
1752
+ if (cliConfig?.authProvider && cliConfig.authProvider !== 'anthropic' && cliConfig.authProvider !== 'openai') {
1753
+ const userSettings = await settingsService.getUserSettings()
1754
+ const baseModel =
1755
+ typeof userSettings.model === 'string' && userSettings.model.trim()
1756
+ ? userSettings.model
1757
+ : undefined
1758
+ const modelContext =
1759
+ typeof userSettings.modelContext === 'string' && userSettings.modelContext.trim()
1760
+ ? userSettings.modelContext
1761
+ : undefined
1762
+ const effort =
1763
+ typeof userSettings.effort === 'string' && userSettings.effort.trim()
1764
+ ? userSettings.effort
1765
+ : undefined
1766
+ const thinking = resolveDesktopThinkingMode(userSettings)
1767
+
1768
+ return {
1769
+ permissionMode: await settingsService.getPermissionMode().catch(() => undefined),
1770
+ model: baseModel ? (modelContext ? `${baseModel}:${modelContext}` : baseModel) : undefined,
1771
+ effort,
1772
+ thinking,
1773
+ providerId: undefined, // CLI manages auth via ~/.claude.json - pass undefined so shouldMarkManagedOAuth checks cli config
1774
+ }
1775
+ }
1776
+
1777
+ // Check if a custom cc-haha provider is active
1778
  const { providers, activeId } = await providerService.listProviders()
1779
  let resolvedActiveId = activeId
1780
  if (activeId && !isKnownRuntimeProviderId(activeId, providers)) {
 
1834
  return settings.alwaysThinkingEnabled === false ? 'disabled' : undefined
1835
  }
1836
 
1837
+ /**
1838
+ * Read CLI's ~/.claude.json directly to check authProvider.
1839
+ * Bypasses getGlobalConfig cache since this runs in the server process
1840
+ * and needs to reflect changes made by CLI's /login command.
1841
+ */
1842
+ function readCliGlobalConfig(): {
1843
+ authProvider?: 'anthropic' | 'openai' | 'openrouter' | 'local' | 'opencode' | 'nvidia'
1844
+ openCodeApiKey?: string
1845
+ openCodeModelName?: string
1846
+ nvidiaApiKey?: string
1847
+ openRouterApiKey?: string
1848
+ localBaseUrl?: string
1849
+ localModelName?: string
1850
+ } | null {
1851
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
1852
+ const configPath = path.join(configDir, '.claude.json')
1853
+ try {
1854
+ const raw = fs.readFileSync(configPath, 'utf-8')
1855
+ return JSON.parse(raw)
1856
+ } catch {
1857
+ return null
1858
+ }
1859
+ }
1860
+
1861
  async function buildSessionStartupDiagnosticMessage(
1862
  sessionId: string,
1863
  cause: string,