ethagent 0.2.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -32
  3. package/bin/ethagent.js +11 -2
  4. package/package.json +25 -7
  5. package/src/app/FirstRun.tsx +412 -0
  6. package/src/app/hooks/useCancelRequest.ts +22 -0
  7. package/src/app/hooks/useDoublePress.ts +46 -0
  8. package/src/app/hooks/useExitOnCtrlC.ts +36 -0
  9. package/src/app/input/AppInputProvider.tsx +116 -0
  10. package/src/app/input/appInputParser.ts +279 -0
  11. package/src/app/keybindings/KeybindingProvider.tsx +134 -0
  12. package/src/app/keybindings/resolver.ts +42 -0
  13. package/src/app/keybindings/types.ts +26 -0
  14. package/src/chat/ChatBottomPane.tsx +280 -0
  15. package/src/chat/ChatInput.tsx +722 -0
  16. package/src/chat/ChatScreen.tsx +1575 -0
  17. package/src/chat/ContextLimitView.tsx +95 -0
  18. package/src/chat/ContinuityEditReviewView.tsx +48 -0
  19. package/src/chat/ConversationStack.tsx +47 -0
  20. package/src/chat/CopyPicker.tsx +52 -0
  21. package/src/chat/MessageList.tsx +609 -0
  22. package/src/chat/PermissionPrompt.tsx +153 -0
  23. package/src/chat/PermissionsView.tsx +159 -0
  24. package/src/chat/PlanApprovalView.tsx +91 -0
  25. package/src/chat/ResumeView.tsx +267 -0
  26. package/src/chat/RewindView.tsx +386 -0
  27. package/src/chat/SessionStatus.tsx +51 -0
  28. package/src/chat/TranscriptView.tsx +202 -0
  29. package/src/chat/chatInputState.ts +247 -0
  30. package/src/chat/chatPaste.ts +49 -0
  31. package/src/chat/chatScreenUtils.ts +187 -0
  32. package/src/chat/chatSessionState.ts +142 -0
  33. package/src/chat/chatTurnOrchestrator.ts +701 -0
  34. package/src/chat/commands.ts +673 -0
  35. package/src/chat/textCursor.ts +202 -0
  36. package/src/chat/toolResultDisplay.ts +8 -0
  37. package/src/chat/transcriptViewport.ts +247 -0
  38. package/src/cli/ResetConfirmView.tsx +61 -0
  39. package/src/cli/main.tsx +177 -0
  40. package/src/cli/preview.tsx +19 -0
  41. package/src/cli/reset.ts +106 -0
  42. package/src/identity/continuity/editor.ts +149 -0
  43. package/src/identity/continuity/envelope.ts +345 -0
  44. package/src/identity/continuity/history.ts +153 -0
  45. package/src/identity/continuity/privateEdit.ts +334 -0
  46. package/src/identity/continuity/publicSkills.ts +173 -0
  47. package/src/identity/continuity/snapshots.ts +183 -0
  48. package/src/identity/continuity/storage.ts +507 -0
  49. package/src/identity/crypto/backupEnvelope.ts +486 -0
  50. package/src/identity/crypto/eth.ts +137 -0
  51. package/src/identity/hub/IdentityHub.tsx +845 -0
  52. package/src/identity/hub/identityHubEffects.ts +1100 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +209 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -0
  57. package/src/identity/hub/screens/CreateFlow.tsx +206 -0
  58. package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
  59. package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
  60. package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
  61. package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
  62. package/src/identity/hub/screens/MenuScreen.tsx +117 -0
  63. package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
  64. package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
  65. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
  66. package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
  67. package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
  68. package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
  69. package/src/identity/profile/imagePicker.ts +180 -0
  70. package/src/identity/registry/erc8004.ts +1106 -0
  71. package/src/identity/registry/registryConfig.ts +69 -0
  72. package/src/identity/storage/ipfs.ts +212 -0
  73. package/src/identity/storage/pinataJwt.ts +53 -0
  74. package/src/identity/wallet/browserWallet.ts +393 -0
  75. package/src/identity/wallet/wallet-page/wallet.html +1082 -0
  76. package/src/mcp/approvals.ts +113 -0
  77. package/src/mcp/config.ts +235 -0
  78. package/src/mcp/manager.ts +541 -0
  79. package/src/mcp/names.ts +19 -0
  80. package/src/mcp/output.ts +96 -0
  81. package/src/models/ModelPicker.tsx +1446 -0
  82. package/src/models/catalog.ts +296 -0
  83. package/src/models/huggingface.ts +651 -0
  84. package/src/models/llamacpp.ts +810 -0
  85. package/src/models/llamacppPreflight.ts +150 -0
  86. package/src/models/modelDisplay.ts +105 -0
  87. package/src/models/modelPickerOptions.ts +421 -0
  88. package/src/models/modelRecommendation.ts +140 -0
  89. package/src/models/runtimeDetection.ts +81 -0
  90. package/src/models/uncensoredCatalog.ts +86 -0
  91. package/src/providers/anthropic.ts +259 -0
  92. package/src/providers/contracts.ts +62 -0
  93. package/src/providers/errors.ts +62 -0
  94. package/src/providers/gemini.ts +152 -0
  95. package/src/providers/openai-chat.ts +472 -0
  96. package/src/providers/registry.ts +42 -0
  97. package/src/providers/retry.ts +58 -0
  98. package/src/providers/sse.ts +93 -0
  99. package/src/runtime/compaction.ts +389 -0
  100. package/src/runtime/cwd.ts +43 -0
  101. package/src/runtime/sessionMode.ts +55 -0
  102. package/src/runtime/systemPrompt.ts +209 -0
  103. package/src/runtime/toolClaimGuards.ts +143 -0
  104. package/src/runtime/toolExecution.ts +304 -0
  105. package/src/runtime/toolIntent.ts +163 -0
  106. package/src/runtime/turn.ts +858 -0
  107. package/src/storage/atomicWrite.ts +68 -0
  108. package/src/storage/config.ts +189 -0
  109. package/src/storage/factoryReset.ts +130 -0
  110. package/src/storage/history.ts +58 -0
  111. package/src/storage/identity.ts +99 -0
  112. package/src/storage/permissions.ts +76 -0
  113. package/src/storage/rewind.ts +246 -0
  114. package/src/storage/secrets.ts +181 -0
  115. package/src/storage/sessionExport.ts +49 -0
  116. package/src/storage/sessions.ts +482 -0
  117. package/src/tools/bashSafety.ts +174 -0
  118. package/src/tools/bashTool.ts +140 -0
  119. package/src/tools/changeDirectoryTool.ts +213 -0
  120. package/src/tools/contracts.ts +179 -0
  121. package/src/tools/deleteFileTool.ts +111 -0
  122. package/src/tools/editTool.ts +160 -0
  123. package/src/tools/editUtils.ts +170 -0
  124. package/src/tools/listDirectoryTool.ts +55 -0
  125. package/src/tools/mcpResourceTools.ts +95 -0
  126. package/src/tools/permissionRules.ts +85 -0
  127. package/src/tools/privateContinuityEditTool.ts +178 -0
  128. package/src/tools/privateContinuityReadTool.ts +107 -0
  129. package/src/tools/readTool.ts +85 -0
  130. package/src/tools/registry.ts +67 -0
  131. package/src/tools/writeFileTool.ts +142 -0
  132. package/src/ui/BrandSplash.tsx +193 -0
  133. package/src/ui/ProgressBar.tsx +34 -0
  134. package/src/ui/Select.tsx +143 -0
  135. package/src/ui/Spinner.tsx +269 -0
  136. package/src/ui/Surface.tsx +47 -0
  137. package/src/ui/TextInput.tsx +97 -0
  138. package/src/ui/theme.ts +59 -0
  139. package/src/utils/clipboard.ts +216 -0
  140. package/src/utils/markdownSegments.ts +51 -0
  141. package/src/utils/messages.ts +35 -0
  142. package/src/utils/withRetry.ts +280 -0
  143. package/src/cli.tsx +0 -147
@@ -0,0 +1,1446 @@
1
+ import React, { useEffect, useRef, useState } from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { Select, type SelectOption } from '../ui/Select.js'
4
+ import { Spinner } from '../ui/Spinner.js'
5
+ import { TextInput } from '../ui/TextInput.js'
6
+ import { Surface } from '../ui/Surface.js'
7
+ import { ProgressBar } from '../ui/ProgressBar.js'
8
+ import { theme } from '../ui/theme.js'
9
+ import {
10
+ buildLlamaCppRunner,
11
+ DEFAULT_LLAMA_HOST,
12
+ detectLlamaCpp,
13
+ installLlamaCppRunner,
14
+ setLlamaCppServerPath,
15
+ startLlamaCppServer,
16
+ type LlamaCppInstallProgress,
17
+ type LlamaCppInstallResult,
18
+ type LlamaCppStartResult,
19
+ } from './llamacpp.js'
20
+ import { detectSpec, type SpecSnapshot } from './runtimeDetection.js'
21
+ import {
22
+ estimateGgufMachineFit,
23
+ orderGgufFilesForSpec,
24
+ recommendGgufFile,
25
+ type GgufMachineFit,
26
+ } from './modelRecommendation.js'
27
+ import { hasKey, rmKey, setKey } from '../storage/secrets.js'
28
+ import { defaultModelFor, type EthagentConfig, type ProviderId } from '../storage/config.js'
29
+ import { clearModelCatalogCache, discoverProviderModels, type ModelCatalogResult } from './catalog.js'
30
+ import { contextWindowInfo } from '../runtime/compaction.js'
31
+ import {
32
+ createHfDownloadPlan,
33
+ downloadHfModel,
34
+ fetchHuggingFaceRepoInfo,
35
+ findLocalHfModel,
36
+ ggufFiles,
37
+ loadLocalHfModels,
38
+ localModelId,
39
+ modelFromPlan,
40
+ parseHuggingFaceRef,
41
+ uninstallLocalHfModel,
42
+ type HfCredibility,
43
+ type HfDownloadPlan,
44
+ type HfDownloadProgress,
45
+ type HfRisk,
46
+ type HuggingFaceRepoInfo,
47
+ type HuggingFaceSibling,
48
+ type LocalHfModel,
49
+ } from './huggingface.js'
50
+ import {
51
+ buildLocalModelCatalogOptions,
52
+ buildModelPickerOptions,
53
+ catalogOptionValue,
54
+ LOCAL_MODEL_LINK_EXAMPLE,
55
+ LOCAL_MODEL_LINK_HINT,
56
+ MODEL_PICKER_CLOUD_PROVIDERS,
57
+ orderModelsForContextFit,
58
+ type CloudProviderId,
59
+ type ModelPickerContextFit,
60
+ type ModelPickerOptionsData,
61
+ } from './modelPickerOptions.js'
62
+ import { formatLocalHfModelDisplayName, formatModelDisplayName } from './modelDisplay.js'
63
+ import { fetchUncensoredGgufCatalog, type UncensoredCatalogEntry } from './uncensoredCatalog.js'
64
+
65
+ export type ModelPickerSelection =
66
+ | { kind: 'llamacpp'; model: string }
67
+ | { kind: 'cloud'; provider: CloudProviderId; model: string; keyJustSet: boolean }
68
+
69
+ type ModelPickerProps = {
70
+ currentConfig: EthagentConfig
71
+ currentProvider: ProviderId
72
+ currentModel: string
73
+ contextFit?: ModelPickerContextFit | null
74
+ featuredHfRepo?: string
75
+ onPick: (selection: ModelPickerSelection) => void
76
+ onCancel: () => void
77
+ }
78
+
79
+ type LoadedData = ModelPickerOptionsData
80
+ type LocalUninstallTarget = { kind: 'hf'; id: string; displayName: string; sizeBytes: number }
81
+
82
+ type State =
83
+ | { kind: 'loading' }
84
+ | { kind: 'list'; data: LoadedData }
85
+ | { kind: 'localCatalogLoading'; data: LoadedData }
86
+ | { kind: 'localCatalog'; data: LoadedData; catalog: UncensoredCatalogEntry[] }
87
+ | { kind: 'localCatalogError'; data: LoadedData; message: string }
88
+ | { kind: 'catalog'; provider: CloudProviderId; data: LoadedData }
89
+ | { kind: 'keyEntry'; provider: CloudProviderId; action: 'set' | 'edit'; data: LoadedData; submitting: boolean; error?: string }
90
+ | { kind: 'keyManage'; provider: CloudProviderId; data: LoadedData; submitting: boolean; error?: string }
91
+ | { kind: 'hfInput'; data: LoadedData; error?: string }
92
+ | { kind: 'hfLoading'; data: LoadedData; input: string }
93
+ | { kind: 'hfFilePick'; data: LoadedData; input: string; repo: HuggingFaceRepoInfo; files: HuggingFaceSibling[] }
94
+ | { kind: 'hfReview'; data: LoadedData; plan: HfDownloadPlan }
95
+ | { kind: 'hfDownloading'; data: LoadedData; plan: HfDownloadPlan; progress: HfDownloadProgress }
96
+ | { kind: 'hfDone'; data: LoadedData; model: LocalHfModel; alreadyInstalled?: boolean }
97
+ | { kind: 'hfError'; data: LoadedData; message: string; input?: string }
98
+ | { kind: 'localUninstallPick'; data: LoadedData }
99
+ | { kind: 'localUninstallConfirm'; data: LoadedData; target: LocalUninstallTarget }
100
+ | { kind: 'localUninstalling'; data: LoadedData; target: LocalUninstallTarget }
101
+ | { kind: 'localUninstallDone'; data: LoadedData; modelName: string }
102
+ | { kind: 'localUninstallError'; data: LoadedData; target: LocalUninstallTarget; message: string }
103
+ | { kind: 'localRunnerSetup'; data: LoadedData; model: LocalHfModel }
104
+ | { kind: 'localRunnerInstalling'; data: LoadedData; model: LocalHfModel; startedAt: number; progress: LlamaCppInstallProgress }
105
+ | { kind: 'localRunnerInstallFail'; data: LoadedData; model: LocalHfModel; result: Extract<LlamaCppInstallResult, { ok: false }> }
106
+ | { kind: 'localRunnerPathEntry'; data: LoadedData; model: LocalHfModel; submitting: boolean; error?: string }
107
+ | { kind: 'localRunnerStarting'; data: LoadedData; model: LocalHfModel; startedAt: number }
108
+ | { kind: 'localRunnerStartFail'; data: LoadedData; model: LocalHfModel; result: Extract<LlamaCppStartResult, { ok: false }> }
109
+
110
+ export const ModelPicker: React.FC<ModelPickerProps> = ({
111
+ currentConfig,
112
+ currentProvider,
113
+ currentModel,
114
+ contextFit,
115
+ featuredHfRepo,
116
+ onPick,
117
+ onCancel,
118
+ }) => {
119
+ const [state, setState] = useState<State>({ kind: 'loading' })
120
+ const hfAbortRef = useRef<AbortController | null>(null)
121
+
122
+ useEffect(() => {
123
+ let cancelled = false
124
+ void (async () => {
125
+ const [llamaCpp, hfModels, machineSpec, keyEntries] = await Promise.all([
126
+ probeLlamaCpp(),
127
+ loadHfPickerModels(),
128
+ detectSpec(),
129
+ Promise.all(MODEL_PICKER_CLOUD_PROVIDERS.map(async p => [p, await hasKey(p)] as const)),
130
+ ])
131
+ if (cancelled) return
132
+ const cloudKeys = Object.fromEntries(keyEntries) as Partial<Record<ProviderId, boolean>>
133
+ const catalogEntries = await Promise.all(
134
+ MODEL_PICKER_CLOUD_PROVIDERS
135
+ .filter(provider => cloudKeys[provider])
136
+ .map(async provider => [provider, await discoverProviderModels(configForProvider(currentConfig, provider))] as const),
137
+ )
138
+ if (cancelled) return
139
+ const cloudCatalogs = Object.fromEntries(catalogEntries) as Partial<Record<ProviderId, ModelCatalogResult>>
140
+ const data: LoadedData = {
141
+ llamaCpp,
142
+ hfModels,
143
+ machineSpec,
144
+ cloudKeys,
145
+ cloudCatalogs,
146
+ }
147
+ if (featuredHfRepo) {
148
+ const installedFeatured = await findInstalledHfModelForInput(featuredHfRepo)
149
+ if (cancelled) return
150
+ if (installedFeatured) {
151
+ setState({ kind: 'hfDone', data, model: installedFeatured, alreadyInstalled: true })
152
+ return
153
+ }
154
+ }
155
+ setState({ kind: 'list', data })
156
+ // If a featured repo was provided (first-run local flow), auto-inspect it
157
+ if (featuredHfRepo) {
158
+ await inspectHfInput({ kind: 'hfInput', data }, featuredHfRepo, setState)
159
+ }
160
+ })()
161
+ return () => { cancelled = true }
162
+ }, [currentConfig, featuredHfRepo])
163
+
164
+ useEffect(() => () => {
165
+ hfAbortRef.current?.abort()
166
+ }, [])
167
+
168
+ if (state.kind === 'loading') {
169
+ return (
170
+ <Surface title={contextFit ? 'Switch to Larger-Context Model' : 'Switch Provider / Model'} subtitle="Loading providers and models.">
171
+ <Spinner label="loading providers..." />
172
+ </Surface>
173
+ )
174
+ }
175
+
176
+ if (state.kind === 'hfInput') {
177
+ return (
178
+ <Surface
179
+ title="Add Local Model"
180
+ subtitle={LOCAL_MODEL_LINK_EXAMPLE}
181
+ footer="enter checks link · esc returns to picker"
182
+ >
183
+ <TextInput
184
+ label="model link"
185
+ placeholder={LOCAL_MODEL_LINK_HINT}
186
+ onSubmit={value => void inspectHfInput(state, value, setState)}
187
+ onCancel={() => setState({ kind: 'list', data: state.data })}
188
+ />
189
+ {state.error ? <Text color="#e87070">{state.error}</Text> : null}
190
+ </Surface>
191
+ )
192
+ }
193
+
194
+ if (state.kind === 'hfLoading') {
195
+ return (
196
+ <Surface title="Checking Model Link" subtitle={state.input}>
197
+ <Spinner label="reading model page..." />
198
+ </Surface>
199
+ )
200
+ }
201
+
202
+ if (state.kind === 'hfFilePick') {
203
+ const options = buildHfFileOptions(state.repo, state.files, state.data.machineSpec, state.data.hfModels.map(model => model.id))
204
+ const recommendedIndex = Math.max(0, options.findIndex(option => option.subtext?.includes('recommended')))
205
+ return (
206
+ <Surface
207
+ title="Choose a Compatible File"
208
+ subtitle={`${state.repo.repoId} has ${state.files.length} compatible local model file${state.files.length === 1 ? '' : 's'}.`}
209
+ footer="enter selects · esc returns to link input"
210
+ >
211
+ <Select
212
+ options={options}
213
+ initialIndex={recommendedIndex}
214
+ maxVisible={10}
215
+ onSubmit={filename => void reviewHfFile(state, filename, setState)}
216
+ onCancel={() => setState({ kind: 'hfInput', data: state.data })}
217
+ />
218
+ </Surface>
219
+ )
220
+ }
221
+
222
+ if (state.kind === 'hfReview') {
223
+ const { plan } = state
224
+ const canDownload = plan.review.risk !== 'high' && plan.review.runtime === 'llama.cpp runnable'
225
+ const fit = state.data.machineSpec ? estimateGgufMachineFit(plan.sizeBytes, state.data.machineSpec) : null
226
+ const recommended = state.data.machineSpec ? recommendGgufFile(plan.repo, ggufFiles(plan.repo), state.data.machineSpec) : null
227
+ return (
228
+ <Surface
229
+ title="Review Model Link"
230
+ subtitle="Only download models from creators you trust. Check the license and source before continuing."
231
+ footer="enter selects · esc returns to picker"
232
+ tone={plan.review.risk === 'high' ? 'error' : plan.review.risk === 'medium' ? 'muted' : 'primary'}
233
+ >
234
+ <Box flexDirection="column" marginBottom={1}>
235
+ <Text color={theme.text}>{plan.displayName}</Text>
236
+ <Text color={theme.dim}>source: huggingface.co/{plan.repoId}</Text>
237
+ <Text color={theme.dim}>file: {friendlyFileName(plan.filename)}</Text>
238
+ <Text color={theme.dim}>license: {plan.repo.license ?? 'unknown'} · size: {formatBytes(plan.sizeBytes)}</Text>
239
+ {fit ? <Text color={fitColor(fit.fit)}>fit: {fitLabel(fit.fit, recommended?.file.filename === plan.filename)}</Text> : null}
240
+ <Text color={riskColor(plan.review.risk)}>safety: {safetyLabel(plan.review.risk)} · source: {credibilityLabel(plan.review.credibility)}</Text>
241
+ <Text color={theme.dim}>signals: {formatSignals(plan.repo.downloads, plan.repo.likes)}</Text>
242
+ <Text color={theme.dim}>notes: {friendlyReasons(plan.review.reasons).join('; ')}</Text>
243
+ </Box>
244
+ <Select<'download' | 'pick' | 'cancel'>
245
+ options={[
246
+ { value: 'download', label: 'download this model', disabled: !canDownload },
247
+ { value: 'pick', label: 'pick another file' },
248
+ { value: 'cancel', label: 'cancel' },
249
+ ]}
250
+ onSubmit={choice => {
251
+ if (choice === 'download') void startHfDownload(state, setState, hfAbortRef, onPick)
252
+ else if (choice === 'pick') void inspectHfInput({ kind: 'hfInput', data: state.data }, plan.repoId, setState)
253
+ else setState({ kind: 'list', data: state.data })
254
+ }}
255
+ onCancel={() => setState({ kind: 'list', data: state.data })}
256
+ />
257
+ </Surface>
258
+ )
259
+ }
260
+
261
+ if (state.kind === 'hfDownloading') {
262
+ const total = state.progress.total ?? state.plan.sizeBytes
263
+ const completed = state.progress.completed ?? 0
264
+ const progress = total > 0 ? completed / total : 0
265
+ const suffix = total > 0 ? `${formatBytes(completed)} / ${formatBytes(total)}` : formatBytes(completed)
266
+ return (
267
+ <Surface title="Downloading Model" subtitle={state.plan.displayName}>
268
+ <Text color={theme.dim}>{state.progress.status}</Text>
269
+ <ProgressBar progress={progress} suffix={suffix} variant="rainbow" />
270
+ </Surface>
271
+ )
272
+ }
273
+
274
+ if (state.kind === 'hfDone') {
275
+ return (
276
+ <Surface
277
+ title={state.alreadyInstalled ? 'Model Already Downloaded' : 'Model Ready'}
278
+ subtitle={state.model.displayName}
279
+ footer="enter selects · esc returns to picker"
280
+ >
281
+ <Select<'use' | 'back'>
282
+ options={[
283
+ { value: 'use', label: 'use this model now' },
284
+ { value: 'back', label: 'back to picker' },
285
+ ]}
286
+ onSubmit={choice => {
287
+ if (choice === 'use') void startAndPickHfModel(state.model, state, setState, onPick)
288
+ else setState({ kind: 'list', data: state.data })
289
+ }}
290
+ onCancel={() => setState({ kind: 'list', data: state.data })}
291
+ />
292
+ </Surface>
293
+ )
294
+ }
295
+
296
+ if (state.kind === 'hfError') {
297
+ return (
298
+ <Surface title="Model Link Failed" subtitle={state.message} tone="error" footer="enter selects · esc returns to picker">
299
+ <Select<'retry' | 'back'>
300
+ options={[
301
+ { value: 'retry', label: state.input ? 'retry link' : 'download another model' },
302
+ { value: 'back', label: 'back to picker' },
303
+ ]}
304
+ onSubmit={choice => {
305
+ if (choice === 'retry') setState({ kind: 'hfInput', data: state.data, error: state.input ? undefined : state.message })
306
+ else setState({ kind: 'list', data: state.data })
307
+ }}
308
+ onCancel={() => setState({ kind: 'list', data: state.data })}
309
+ />
310
+ </Surface>
311
+ )
312
+ }
313
+
314
+ if (state.kind === 'localUninstallPick') {
315
+ const targets = localUninstallTargets(state.data)
316
+ const options = targets.map(target => ({
317
+ value: `${target.kind}:${target.id}`,
318
+ label: target.displayName,
319
+ subtext: [
320
+ 'downloaded GGUF file',
321
+ formatBytes(target.sizeBytes),
322
+ isCurrentLocalUninstallTarget(target, currentProvider, currentModel) ? 'currently selected' : '',
323
+ ].filter(Boolean).join(' · '),
324
+ role: 'option' as const,
325
+ }))
326
+ return (
327
+ <Surface title="Uninstall Downloaded GGUF" subtitle="Choose a downloaded model file to remove." footer="enter selects · esc returns to picker">
328
+ {options.length === 0 ? (
329
+ <Text color={theme.dim}>No local models found.</Text>
330
+ ) : (
331
+ <Select
332
+ options={options}
333
+ maxVisible={10}
334
+ onSubmit={value => {
335
+ const target = targets.find(item => `${item.kind}:${item.id}` === value)
336
+ if (target) setState({ kind: 'localUninstallConfirm', data: state.data, target })
337
+ }}
338
+ onCancel={() => setState({ kind: 'list', data: state.data })}
339
+ />
340
+ )}
341
+ </Surface>
342
+ )
343
+ }
344
+
345
+ if (state.kind === 'localUninstallConfirm') {
346
+ const modelName = state.target.displayName
347
+ return (
348
+ <Surface title="Confirm Uninstall" subtitle={modelName} footer="enter selects · esc returns to model list">
349
+ <Box flexDirection="column" marginBottom={1}>
350
+ <Text color={theme.dim}>{localUninstallBoundaryCopy(state.target)}</Text>
351
+ <Text color={theme.dim}>Runner binaries are left unchanged.</Text>
352
+ </Box>
353
+ <Select<'confirm' | 'back'>
354
+ options={[
355
+ { value: 'confirm', label: 'uninstall local model' },
356
+ { value: 'back', label: 'back' },
357
+ ]}
358
+ onSubmit={choice => {
359
+ if (choice === 'confirm') void uninstallLocalModel(state, setState)
360
+ else setState({ kind: 'localUninstallPick', data: state.data })
361
+ }}
362
+ onCancel={() => setState({ kind: 'localUninstallPick', data: state.data })}
363
+ />
364
+ </Surface>
365
+ )
366
+ }
367
+
368
+ if (state.kind === 'localUninstalling') {
369
+ return (
370
+ <Surface
371
+ title="Uninstalling Local Model"
372
+ subtitle={state.target.displayName}
373
+ >
374
+ <Spinner label="removing local model..." />
375
+ </Surface>
376
+ )
377
+ }
378
+
379
+ if (state.kind === 'localUninstallDone') {
380
+ return (
381
+ <Surface title="Local Model Uninstalled" subtitle={state.modelName} footer="enter returns to picker · esc closes">
382
+ <Select<'back'>
383
+ options={[{ value: 'back', label: 'back to picker' }]}
384
+ onSubmit={() => setState({ kind: 'list', data: state.data })}
385
+ onCancel={() => setState({ kind: 'list', data: state.data })}
386
+ />
387
+ </Surface>
388
+ )
389
+ }
390
+
391
+ if (state.kind === 'localUninstallError') {
392
+ return (
393
+ <Surface title="Could Not Uninstall Local Model" subtitle={state.message} tone="error" footer="enter selects · esc returns to picker">
394
+ <Select<'retry' | 'back'>
395
+ options={[
396
+ { value: 'retry', label: 'try again' },
397
+ { value: 'back', label: 'back to picker' },
398
+ ]}
399
+ onSubmit={choice => {
400
+ if (choice === 'retry') void uninstallLocalModel({ kind: 'localUninstallConfirm', data: state.data, target: state.target }, setState)
401
+ else setState({ kind: 'list', data: state.data })
402
+ }}
403
+ onCancel={() => setState({ kind: 'list', data: state.data })}
404
+ />
405
+ </Surface>
406
+ )
407
+ }
408
+
409
+ if (state.kind === 'localRunnerSetup') {
410
+ return (
411
+ <Surface
412
+ title="Install Local Runner"
413
+ subtitle="This model is downloaded. Install the local runner once to start it here."
414
+ footer="enter selects · esc returns to picker"
415
+ >
416
+ <Box flexDirection="column" marginBottom={1}>
417
+ <Text color={theme.dim}>Ethagent tried to start {friendlyFileName(state.model.filename)} automatically.</Text>
418
+ <Text color={theme.dim}>After this one-time install, downloaded local models start automatically.</Text>
419
+ <Text color={theme.dim}>Advanced: paste an existing llama-server path or run a compatible server at {DEFAULT_LLAMA_HOST}.</Text>
420
+ </Box>
421
+ <Select<'install' | 'path' | 'back' | 'download'>
422
+ options={[
423
+ { value: 'install', label: 'install local runner' },
424
+ { value: 'path', label: 'use existing runner path' },
425
+ { value: 'back', label: 'back to picker' },
426
+ { value: 'download', label: 'add another local model' },
427
+ ]}
428
+ onSubmit={choice => {
429
+ if (choice === 'download') setState({ kind: 'hfInput', data: state.data })
430
+ else if (choice === 'install') void installRunnerAndStart(state, setState, onPick)
431
+ else if (choice === 'path') setState({ kind: 'localRunnerPathEntry', data: state.data, model: state.model, submitting: false })
432
+ else setState({ kind: 'list', data: state.data })
433
+ }}
434
+ onCancel={() => setState({ kind: 'list', data: state.data })}
435
+ />
436
+ </Surface>
437
+ )
438
+ }
439
+
440
+ if (state.kind === 'localRunnerInstalling') {
441
+ return (
442
+ <Surface title="Installing Local Runner" subtitle="This may take a few minutes.">
443
+ <ElapsedSpinner startedAt={state.startedAt} label={state.progress.label} />
444
+ <ProgressBar progress={state.progress.progress} variant="rainbow" />
445
+ </Surface>
446
+ )
447
+ }
448
+
449
+ if (state.kind === 'localRunnerInstallFail') {
450
+ const options = buildRunnerRecoveryOptions(state.result)
451
+ return (
452
+ <Surface title="Runner Setup Needs Attention" subtitle={state.result.message} tone="error" footer="enter selects · esc returns to picker">
453
+ <Select<'retry' | 'build' | 'path' | 'back'>
454
+ options={options}
455
+ onSubmit={choice => {
456
+ if (choice === 'retry') void installRunnerAndStart({ kind: 'localRunnerSetup', data: state.data, model: state.model }, setState, onPick)
457
+ else if (choice === 'build') void buildRunnerAndStart({ kind: 'localRunnerSetup', data: state.data, model: state.model }, setState, onPick)
458
+ else if (choice === 'path') setState({ kind: 'localRunnerPathEntry', data: state.data, model: state.model, submitting: false })
459
+ else setState({ kind: 'list', data: state.data })
460
+ }}
461
+ onCancel={() => setState({ kind: 'list', data: state.data })}
462
+ />
463
+ </Surface>
464
+ )
465
+ }
466
+
467
+ if (state.kind === 'localRunnerPathEntry') {
468
+ return (
469
+ <Surface
470
+ title="Runner Path"
471
+ subtitle="Paste the full path to llama-server."
472
+ footer="enter saves · esc returns to install"
473
+ >
474
+ {state.submitting ? (
475
+ <Spinner label="checking runner path..." />
476
+ ) : (
477
+ <TextInput
478
+ label="llama-server"
479
+ placeholder={runnerPathPlaceholder()}
480
+ onSubmit={value => void saveRunnerPathAndStart(state, value, setState, onPick)}
481
+ onCancel={() => setState({ kind: 'localRunnerSetup', data: state.data, model: state.model })}
482
+ />
483
+ )}
484
+ {state.error ? <Text color="#e87070">{state.error}</Text> : null}
485
+ </Surface>
486
+ )
487
+ }
488
+
489
+ if (state.kind === 'localRunnerStarting') {
490
+ return (
491
+ <Surface title="Starting Local Model" subtitle={state.model.displayName}>
492
+ <ElapsedSpinner startedAt={state.startedAt} label="starting local runner" />
493
+ </Surface>
494
+ )
495
+ }
496
+
497
+ if (state.kind === 'localRunnerStartFail') {
498
+ return (
499
+ <Surface title="Local Model Failed to Start" subtitle={localRunnerStartFailureSubtitle(state.result)} tone="error" footer="enter selects · esc returns to picker">
500
+ <Select<'retry' | 'path' | 'install' | 'back'>
501
+ options={[
502
+ { value: 'retry', label: 'try again' },
503
+ { value: 'path', label: 'use existing runner path' },
504
+ { value: 'install', label: 'install local runner' },
505
+ { value: 'back', label: 'back to picker' },
506
+ ]}
507
+ onSubmit={choice => {
508
+ if (choice === 'retry') void startAndPickHfModel(state.model, { kind: 'hfDone', data: state.data, model: state.model }, setState, onPick)
509
+ else if (choice === 'path') setState({ kind: 'localRunnerPathEntry', data: state.data, model: state.model, submitting: false })
510
+ else if (choice === 'install') void installRunnerAndStart({ kind: 'localRunnerSetup', data: state.data, model: state.model }, setState, onPick)
511
+ else setState({ kind: 'list', data: state.data })
512
+ }}
513
+ onCancel={() => setState({ kind: 'list', data: state.data })}
514
+ />
515
+ </Surface>
516
+ )
517
+ }
518
+
519
+ if (state.kind === 'keyEntry') {
520
+ const { provider, action, submitting, error } = state
521
+ return (
522
+ <Surface
523
+ title={`${capitalize(action)} ${provider} API Key`}
524
+ subtitle="Stored in your OS keyring when available; never written to config in plaintext."
525
+ footer="enter saves · esc returns to picker"
526
+ >
527
+ {submitting ? (
528
+ <Spinner label={`saving ${provider} key...`} />
529
+ ) : (
530
+ <TextInput
531
+ label={`${provider} key`}
532
+ placeholder={providerKeyPlaceholder(provider)}
533
+ isSecret
534
+ onSubmit={(value) => void submitKey(state, value, currentConfig, setState)}
535
+ onCancel={() => setState({ kind: 'list', data: state.data })}
536
+ />
537
+ )}
538
+ {error ? <Text color="#e87070">{error}</Text> : null}
539
+ </Surface>
540
+ )
541
+ }
542
+
543
+ if (state.kind === 'keyManage') {
544
+ const { provider, submitting, error } = state
545
+ return (
546
+ <Surface
547
+ title={`${capitalize(provider)} API Key`}
548
+ subtitle="Manage the stored key for this provider."
549
+ footer="enter selects · esc returns to picker"
550
+ >
551
+ {submitting ? (
552
+ <Spinner label={`removing ${provider} key...`} />
553
+ ) : (
554
+ <Select
555
+ options={[
556
+ { value: 'edit', label: 'replace stored api key' },
557
+ { value: 'delete', label: 'remove stored api key' },
558
+ { value: 'cancel', label: 'back' },
559
+ ]}
560
+ onSubmit={(value) => {
561
+ if (value === 'edit') {
562
+ setState({ kind: 'keyEntry', provider, action: 'edit', data: state.data, submitting: false })
563
+ return
564
+ }
565
+ if (value === 'cancel') {
566
+ setState({ kind: 'list', data: state.data })
567
+ return
568
+ }
569
+ void deleteKey(state, currentConfig, setState)
570
+ }}
571
+ onCancel={() => setState({ kind: 'list', data: state.data })}
572
+ />
573
+ )}
574
+ {error ? <Text color="#e87070">{error}</Text> : null}
575
+ </Surface>
576
+ )
577
+ }
578
+
579
+
580
+
581
+ if (state.kind === 'catalog') {
582
+ const catalog = state.data.cloudCatalogs[state.provider]
583
+ const options = buildCatalogOptions(state.provider, catalog, currentProvider, currentModel, contextFit)
584
+ const initialIndex = options.findIndex(opt => {
585
+ if (opt.disabled) return false
586
+ const parsed = parseFullCatalogValue(opt.value)
587
+ return parsed?.provider === currentProvider && parsed.model === currentModel
588
+ })
589
+ return (
590
+ <Surface
591
+ title={`${capitalize(state.provider)} Full Catalog`}
592
+ subtitle={contextFit ? contextFitSubtitle(contextFit) : 'All discovered models for this provider'}
593
+ footer="enter selects · esc returns to picker"
594
+ >
595
+ <Select
596
+ options={options}
597
+ initialIndex={initialIndex === -1 ? 0 : initialIndex}
598
+ maxVisible={12}
599
+ onSubmit={(value) => {
600
+ const parsed = parseFullCatalogValue(value)
601
+ if (parsed) onPick({ kind: 'cloud', provider: parsed.provider, model: parsed.model, keyJustSet: false })
602
+ }}
603
+ onCancel={() => setState({ kind: 'list', data: state.data })}
604
+ />
605
+ </Surface>
606
+ )
607
+ }
608
+
609
+ if (state.kind === 'localCatalogLoading') {
610
+ return (
611
+ <Surface title="view full catalog" subtitle="loading files from configured hugging face repo.">
612
+ <Spinner label="reading hugging face files..." />
613
+ </Surface>
614
+ )
615
+ }
616
+
617
+ if (state.kind === 'localCatalogError') {
618
+ return (
619
+ <Surface title="view full catalog failed" subtitle={state.message} tone="error" footer="enter selects · esc returns to picker">
620
+ <Select<'retry' | 'paste' | 'back'>
621
+ options={[
622
+ { value: 'retry', label: 'retry catalog' },
623
+ { value: 'paste', label: 'paste a GGUF link' },
624
+ { value: 'back', label: 'back to picker' },
625
+ ]}
626
+ onSubmit={choice => {
627
+ if (choice === 'retry') void openLocalCatalog(state.data, setState)
628
+ else if (choice === 'paste') setState({ kind: 'hfInput', data: state.data })
629
+ else setState({ kind: 'list', data: state.data })
630
+ }}
631
+ onCancel={() => setState({ kind: 'list', data: state.data })}
632
+ />
633
+ </Surface>
634
+ )
635
+ }
636
+
637
+ if (state.kind === 'localCatalog') {
638
+ const options = buildLocalModelCatalogOptions(state.data, { currentProvider, currentModel, contextFit }, state.catalog)
639
+ const initialIndex = localModelOptionIndex(options, currentProvider, currentModel)
640
+ return (
641
+ <Surface
642
+ title="view full catalog"
643
+ subtitle="one recommendation for this machine + install status"
644
+ footer="enter selects · esc returns to picker"
645
+ >
646
+ <Select
647
+ options={options}
648
+ initialIndex={initialIndex === -1 ? 0 : initialIndex}
649
+ maxVisible={12}
650
+ onSubmit={(value) => handleSubmit(value, state, setState, onPick)}
651
+ onCancel={() => setState({ kind: 'list', data: state.data })}
652
+ />
653
+ </Surface>
654
+ )
655
+ }
656
+
657
+ const { data } = state
658
+ const options = buildModelPickerOptions(data, { currentProvider, currentModel, contextFit })
659
+ const initialIndex = localOrCloudOptionIndex(options, currentProvider, currentModel)
660
+
661
+ return (
662
+ <Surface
663
+ title={contextFit ? 'Switch to Larger-Context Model' : 'Switch Provider / Model'}
664
+ subtitle={contextFit ? contextFitSubtitle(contextFit) : 'Downloaded GGUF files + cloud providers'}
665
+ footer="enter selects · esc closes · /models lists installed models"
666
+ >
667
+ <Select
668
+ options={options}
669
+ initialIndex={initialIndex === -1 ? 0 : initialIndex}
670
+ maxVisible={10}
671
+ onSubmit={(value) => handleSubmit(value, state, setState, onPick)}
672
+ onCancel={onCancel}
673
+ />
674
+ </Surface>
675
+ )
676
+ }
677
+
678
+ function handleSubmit(
679
+ value: string,
680
+ state: Extract<State, { kind: 'list' | 'localCatalog' }>,
681
+ setState: (s: State) => void,
682
+ onPick: (sel: ModelPickerSelection) => void,
683
+ ): void {
684
+ if (value.startsWith('hdr:')) return
685
+ if (value.startsWith('hf:')) {
686
+ const id = value.slice(3)
687
+ if (id === 'download') {
688
+ setState({ kind: 'hfInput', data: state.data })
689
+ return
690
+ }
691
+ const model = state.data.hfModels.find(item => item.id === id)
692
+ if (!model) return
693
+ void (async () => {
694
+ const local = await findLocalHfModel(id)
695
+ if (!local) {
696
+ setState({ kind: 'hfError', data: state.data, message: 'local model metadata was not found' })
697
+ return
698
+ }
699
+ await startAndPickHfModel(local, state, setState, onPick)
700
+ })()
701
+ return
702
+ }
703
+ if (value.startsWith('uc:') && state.kind === 'localCatalog') {
704
+ const entry = state.catalog.find(item => catalogOptionValue(item.repo.repoId, item.file.filename) === value)
705
+ if (entry) void reviewCatalogModel(state, entry, setState)
706
+ return
707
+ }
708
+ if (value === 'local:uninstall') {
709
+ setState({ kind: 'localUninstallPick', data: state.data })
710
+ return
711
+ }
712
+ if (value === 'local:catalog') {
713
+ void openLocalCatalog(state.data, setState)
714
+ return
715
+ }
716
+ if (value.startsWith('key:')) {
717
+ const parsed = parseKeyValue(value)
718
+ if (!parsed) return
719
+ if (parsed.action === 'manage') {
720
+ setState({ kind: 'keyManage', provider: parsed.provider, data: state.data, submitting: false })
721
+ return
722
+ }
723
+ setState({ kind: 'keyEntry', provider: parsed.provider, action: parsed.action, data: state.data, submitting: false })
724
+ return
725
+ }
726
+ if (value.startsWith('catalog:')) {
727
+ const provider = value.slice('catalog:'.length)
728
+ if (isCloudProvider(provider)) setState({ kind: 'catalog', provider, data: state.data })
729
+ return
730
+ }
731
+ if (value.startsWith('c:')) {
732
+ const parsed = parseCloudValue(value)
733
+ if (parsed) {
734
+ onPick({ kind: 'cloud', provider: parsed.provider, model: parsed.model, keyJustSet: false })
735
+ return
736
+ }
737
+ }
738
+ }
739
+
740
+ async function openLocalCatalog(
741
+ data: LoadedData,
742
+ setState: (s: State) => void,
743
+ ): Promise<void> {
744
+ setState({ kind: 'localCatalogLoading', data })
745
+ try {
746
+ const installedModels = await loadLocalHfModels()
747
+ const catalog = await fetchUncensoredGgufCatalog({
748
+ machineSpec: data.machineSpec,
749
+ installedModels,
750
+ })
751
+ setState({
752
+ kind: 'localCatalog',
753
+ data: { ...data, hfModels: await loadHfPickerModels() },
754
+ catalog,
755
+ })
756
+ } catch (err: unknown) {
757
+ setState({ kind: 'localCatalogError', data, message: (err as Error).message })
758
+ }
759
+ }
760
+
761
+ async function reviewCatalogModel(
762
+ state: Extract<State, { kind: 'localCatalog' }>,
763
+ entry: UncensoredCatalogEntry,
764
+ setState: (s: State) => void,
765
+ ): Promise<void> {
766
+ const files = ggufFiles(entry.repo)
767
+ const installed = chooseInstalledHfModelForRepo(
768
+ await loadLocalHfModels(),
769
+ entry.repo,
770
+ files,
771
+ entry.file.filename,
772
+ state.data.machineSpec,
773
+ )
774
+ if (installed) {
775
+ setState({
776
+ kind: 'hfDone',
777
+ data: { ...state.data, hfModels: await loadHfPickerModels() },
778
+ model: installed,
779
+ alreadyInstalled: true,
780
+ })
781
+ return
782
+ }
783
+ try {
784
+ const plan = await createHfDownloadPlan(entry.repo.repoId, entry.file.filename)
785
+ setState({ kind: 'hfReview', data: state.data, plan })
786
+ } catch (err: unknown) {
787
+ setState({ kind: 'hfError', data: state.data, message: (err as Error).message, input: entry.repo.repoId })
788
+ }
789
+ }
790
+
791
+ function buildCatalogOptions(
792
+ provider: CloudProviderId,
793
+ catalog: ModelCatalogResult | undefined,
794
+ currentProvider: ProviderId,
795
+ currentModel: string,
796
+ contextFit?: ModelPickerContextFit | null,
797
+ ): SelectOption<string>[] {
798
+ if (!catalog || catalog.entries.length === 0) {
799
+ return [{
800
+ value: `hdr:catalog-empty:${provider}`,
801
+ label: 'no models found',
802
+ disabled: true,
803
+ role: 'notice',
804
+ prefix: 'note',
805
+ }]
806
+ }
807
+ const sourceById = new Map(catalog.entries.map(entry => [entry.id, entry.source]))
808
+ return orderModelsForContextFit(provider, catalog.entries.map(entry => entry.id), contextFit).map(id => {
809
+ const active = currentProvider === provider && currentModel === id
810
+ const suffix = sourceById.get(id) === 'fallback' ? ' fallback' : ''
811
+ const displayName = formatModelDisplayName(provider, id, { maxLength: 64 })
812
+ return {
813
+ value: `full:${provider}:${id}`,
814
+ label: contextFitLabel(provider, id, `${displayName}${active ? ' *' : ''}${suffix}`, contextFit),
815
+ role: 'option',
816
+ }
817
+ })
818
+ }
819
+
820
+ function parseCloudValue(value: string): { provider: CloudProviderId; model: string } | null {
821
+ if (!value.startsWith('c:')) return null
822
+ const rest = value.slice(2)
823
+ const sep = rest.indexOf(':')
824
+ if (sep === -1) return null
825
+ const provider = rest.slice(0, sep)
826
+ const model = rest.slice(sep + 1)
827
+ if (!isCloudProvider(provider) || !model) return null
828
+ return { provider, model }
829
+ }
830
+
831
+ function localModelOptionIndex(
832
+ options: SelectOption<string>[],
833
+ currentProvider: ProviderId,
834
+ currentModel: string,
835
+ ): number {
836
+ return options.findIndex(opt => {
837
+ if (opt.disabled) return false
838
+ if (opt.value.startsWith('hf:')) return opt.value.slice(3) === currentModel && currentProvider === 'llamacpp'
839
+ if (opt.value.startsWith('uc:')) return opt.value.slice(3) === currentModel && currentProvider === 'llamacpp'
840
+ return false
841
+ })
842
+ }
843
+
844
+ function localOrCloudOptionIndex(
845
+ options: SelectOption<string>[],
846
+ currentProvider: ProviderId,
847
+ currentModel: string,
848
+ ): number {
849
+ return options.findIndex(opt => {
850
+ if (opt.disabled) return false
851
+ if (opt.value.startsWith('hf:')) return opt.value.slice(3) === currentModel && currentProvider === 'llamacpp'
852
+ if (opt.value.startsWith('uc:')) return opt.value.slice(3) === currentModel && currentProvider === 'llamacpp'
853
+ const cloud = parseCloudValue(opt.value)
854
+ return cloud?.provider === currentProvider && cloud.model === currentModel
855
+ })
856
+ }
857
+
858
+ function parseFullCatalogValue(value: string): { provider: CloudProviderId; model: string } | null {
859
+ if (!value.startsWith('full:')) return null
860
+ const rest = value.slice(5)
861
+ const sep = rest.indexOf(':')
862
+ if (sep === -1) return null
863
+ const provider = rest.slice(0, sep)
864
+ const model = rest.slice(sep + 1)
865
+ if (!isCloudProvider(provider) || !model) return null
866
+ return { provider, model }
867
+ }
868
+
869
+ function parseKeyValue(value: string): { action: 'set' | 'edit' | 'manage'; provider: CloudProviderId } | null {
870
+ if (!value.startsWith('key:')) return null
871
+ const parts = value.split(':')
872
+ if (parts.length !== 3) return null
873
+ const action = parts[1]
874
+ const provider = parts[2]
875
+ if (action !== 'set' && action !== 'edit' && action !== 'manage') return null
876
+ if (!isCloudProvider(provider)) return null
877
+ return { action, provider }
878
+ }
879
+
880
+ async function submitKey(
881
+ state: Extract<State, { kind: 'keyEntry' }>,
882
+ value: string,
883
+ currentConfig: EthagentConfig,
884
+ setState: (s: State) => void,
885
+ ): Promise<void> {
886
+ const trimmed = value.trim()
887
+ if (!trimmed) {
888
+ setState({ ...state, error: 'key cannot be empty' })
889
+ return
890
+ }
891
+ setState({ ...state, submitting: true, error: undefined })
892
+ try {
893
+ await setKey(state.provider, trimmed)
894
+ const data = await refreshProviderKeyState(state.data, currentConfig, state.provider)
895
+ setState({ kind: 'list', data })
896
+ } catch (err: unknown) {
897
+ setState({ ...state, submitting: false, error: (err as Error).message })
898
+ }
899
+ }
900
+
901
+ async function deleteKey(
902
+ state: Extract<State, { kind: 'keyManage' }>,
903
+ currentConfig: EthagentConfig,
904
+ setState: (s: State) => void,
905
+ ): Promise<void> {
906
+ setState({ ...state, submitting: true, error: undefined })
907
+ try {
908
+ await rmKey(state.provider)
909
+ const data = await refreshProviderKeyState(state.data, currentConfig, state.provider)
910
+ setState({ kind: 'list', data })
911
+ } catch (err: unknown) {
912
+ setState({ ...state, submitting: false, error: (err as Error).message })
913
+ }
914
+ }
915
+
916
+ async function refreshProviderKeyState(
917
+ data: LoadedData,
918
+ currentConfig: EthagentConfig,
919
+ provider: CloudProviderId,
920
+ ): Promise<LoadedData> {
921
+ clearModelCatalogCache()
922
+ const keySet = await hasKey(provider)
923
+ const cloudKeys = { ...data.cloudKeys, [provider]: keySet }
924
+ const cloudCatalogs = { ...data.cloudCatalogs }
925
+ if (keySet) {
926
+ cloudCatalogs[provider] = await discoverProviderModels(configForProvider(currentConfig, provider))
927
+ } else {
928
+ delete cloudCatalogs[provider]
929
+ }
930
+ return { ...data, cloudKeys, cloudCatalogs }
931
+ }
932
+
933
+ function configForProvider(config: EthagentConfig, provider: CloudProviderId): EthagentConfig {
934
+ return {
935
+ ...config,
936
+ provider,
937
+ model: config.provider === provider ? config.model : defaultModelFor(provider),
938
+ baseUrl: provider === 'openai' && config.provider === 'openai' ? config.baseUrl : undefined,
939
+ }
940
+ }
941
+
942
+ export function buildHfFileOptions(
943
+ repo: HuggingFaceRepoInfo,
944
+ files: HuggingFaceSibling[],
945
+ spec: SpecSnapshot | undefined,
946
+ installedModelIds: string[] = [],
947
+ ): SelectOption<string>[] {
948
+ const ordered = spec
949
+ ? orderGgufFilesForSpec(repo, files, spec)
950
+ : files.map(file => ({ file, fit: 'unknown' as GgufMachineFit, score: 0, budgetBytes: 0 }))
951
+ const recommended = spec ? ordered[0]?.file.filename : undefined
952
+ const installed = new Set(installedModelIds)
953
+ return ordered.map(item => {
954
+ const size = item.file.sizeBytes ? formatBytes(item.file.sizeBytes) : ''
955
+ const indicators = [
956
+ item.file.filename === recommended ? 'recommended' : '',
957
+ installed.has(localModelId(repo.repoId, item.file.filename)) ? 'installed' : '',
958
+ ]
959
+ return {
960
+ value: item.file.filename,
961
+ label: item.file.filename,
962
+ subtext: modelMetadataSubtext(size, indicators),
963
+ role: 'option' as const,
964
+ }
965
+ })
966
+ }
967
+
968
+ function buildRunnerRecoveryOptions(
969
+ result: Extract<LlamaCppInstallResult, { ok: false }>,
970
+ ): SelectOption<'retry' | 'build' | 'path' | 'back'>[] {
971
+ const options: SelectOption<'retry' | 'build' | 'path' | 'back'>[] = []
972
+ if (result.recovery.includes('source-build')) {
973
+ options.push({
974
+ value: 'build',
975
+ label: 'build local runner',
976
+ hint: 'uses git and cmake if installed',
977
+ })
978
+ }
979
+ if (result.recovery.includes('runner-path')) {
980
+ options.push({ value: 'path', label: 'use existing runner path' })
981
+ }
982
+ if (result.recovery.includes('retry-install')) {
983
+ options.push({ value: 'retry', label: 'retry automatic install' })
984
+ }
985
+ options.push({ value: 'back', label: 'back to picker' })
986
+ return options
987
+ }
988
+
989
+ function localRunnerStartFailureSubtitle(result: Extract<LlamaCppStartResult, { ok: false }>): string {
990
+ switch (result.code) {
991
+ case 'readiness-timeout':
992
+ return 'the local runner is still loading or did not answer in time'
993
+ case 'runner-exited':
994
+ return 'the local runner closed before becoming ready'
995
+ case 'spawn-failed':
996
+ return 'the local runner could not be started'
997
+ case 'different-model-running':
998
+ return result.message
999
+ case 'model-file-missing':
1000
+ return result.message
1001
+ case 'runner-not-installed':
1002
+ return 'this machine still needs a local runner'
1003
+ }
1004
+ }
1005
+
1006
+ async function findInstalledHfModelForInput(input: string): Promise<LocalHfModel | null> {
1007
+ const ref = parseHuggingFaceRef(input)
1008
+ const installed = await loadLocalHfModels()
1009
+ return installed.find(model =>
1010
+ model.status === 'ready'
1011
+ && model.repoId === ref.repoId
1012
+ && (!ref.filename || model.filename === ref.filename)
1013
+ ) ?? null
1014
+ }
1015
+
1016
+ export function chooseInstalledHfModelForRepo(
1017
+ installed: LocalHfModel[],
1018
+ repo: HuggingFaceRepoInfo,
1019
+ files: HuggingFaceSibling[],
1020
+ requestedFilename: string | undefined,
1021
+ spec: SpecSnapshot | undefined,
1022
+ ): LocalHfModel | null {
1023
+ const compatibleFiles = new Set(files.map(file => file.filename))
1024
+ const candidates = installed.filter(model =>
1025
+ model.status === 'ready'
1026
+ && model.repoId === repo.repoId
1027
+ && compatibleFiles.has(model.filename)
1028
+ && (!requestedFilename || model.filename === requestedFilename)
1029
+ )
1030
+ if (requestedFilename || candidates.length <= 1) return candidates[0] ?? null
1031
+
1032
+ const orderedFiles = spec
1033
+ ? orderGgufFilesForSpec(repo, files, spec).map(item => item.file.filename)
1034
+ : files.map(file => file.filename)
1035
+ for (const filename of orderedFiles) {
1036
+ const match = candidates.find(model => model.filename === filename)
1037
+ if (match) return match
1038
+ }
1039
+ return candidates[0] ?? null
1040
+ }
1041
+
1042
+ async function inspectHfInput(
1043
+ state: Extract<State, { kind: 'hfInput' }>,
1044
+ value: string,
1045
+ setState: (s: State) => void,
1046
+ ): Promise<void> {
1047
+ const input = value.trim()
1048
+ if (!input) {
1049
+ setState({ ...state, error: 'paste a model link or repo id' })
1050
+ return
1051
+ }
1052
+ setState({ kind: 'hfLoading', data: state.data, input })
1053
+ try {
1054
+ const ref = parseHuggingFaceRef(input)
1055
+ const repo = await fetchHuggingFaceRepoInfo(ref)
1056
+ const files = ggufFiles(repo)
1057
+ if (files.length === 0) {
1058
+ setState({
1059
+ kind: 'hfInput',
1060
+ data: state.data,
1061
+ error: 'no compatible local model files found; paste a different model link',
1062
+ })
1063
+ return
1064
+ }
1065
+ const installed = chooseInstalledHfModelForRepo(
1066
+ await loadLocalHfModels(),
1067
+ repo,
1068
+ files,
1069
+ ref.filename,
1070
+ state.data.machineSpec,
1071
+ )
1072
+ if (installed) {
1073
+ setState({
1074
+ kind: 'hfDone',
1075
+ data: { ...state.data, hfModels: await loadHfPickerModels() },
1076
+ model: installed,
1077
+ alreadyInstalled: true,
1078
+ })
1079
+ return
1080
+ }
1081
+ const recommendedFilename = state.data.machineSpec
1082
+ ? recommendGgufFile(repo, files, state.data.machineSpec)?.file.filename
1083
+ : files[0]?.filename
1084
+ if (ref.filename || files.length === 1) {
1085
+ const plan = await createHfDownloadPlan(input, ref.filename ?? recommendedFilename)
1086
+ setState({ kind: 'hfReview', data: state.data, plan })
1087
+ return
1088
+ }
1089
+ setState({ kind: 'hfFilePick', data: state.data, input, repo, files })
1090
+ } catch (err: unknown) {
1091
+ setState({ kind: 'hfInput', data: state.data, error: (err as Error).message })
1092
+ }
1093
+ }
1094
+
1095
+ async function reviewHfFile(
1096
+ state: Extract<State, { kind: 'hfFilePick' }>,
1097
+ filename: string,
1098
+ setState: (s: State) => void,
1099
+ ): Promise<void> {
1100
+ setState({ kind: 'hfLoading', data: state.data, input: state.input })
1101
+ try {
1102
+ const installed = chooseInstalledHfModelForRepo(
1103
+ await loadLocalHfModels(),
1104
+ state.repo,
1105
+ state.files,
1106
+ filename,
1107
+ state.data.machineSpec,
1108
+ )
1109
+ if (installed) {
1110
+ setState({
1111
+ kind: 'hfDone',
1112
+ data: { ...state.data, hfModels: await loadHfPickerModels() },
1113
+ model: installed,
1114
+ alreadyInstalled: true,
1115
+ })
1116
+ return
1117
+ }
1118
+ const plan = await createHfDownloadPlan(state.input, filename)
1119
+ setState({ kind: 'hfReview', data: state.data, plan })
1120
+ } catch (err: unknown) {
1121
+ setState({ kind: 'hfError', data: state.data, message: (err as Error).message, input: state.input })
1122
+ }
1123
+ }
1124
+
1125
+ async function startHfDownload(
1126
+ state: Extract<State, { kind: 'hfReview' }>,
1127
+ setState: (s: State) => void,
1128
+ abortRef: React.MutableRefObject<AbortController | null>,
1129
+ onPick: (sel: ModelPickerSelection) => void,
1130
+ ): Promise<void> {
1131
+ const controller = new AbortController()
1132
+ abortRef.current = controller
1133
+ setState({ kind: 'hfDownloading', data: state.data, plan: state.plan, progress: { status: 'starting', completed: 0, total: state.plan.sizeBytes } })
1134
+ try {
1135
+ for await (const progress of downloadHfModel(state.plan, controller.signal)) {
1136
+ if (controller.signal.aborted) return
1137
+ setState({ kind: 'hfDownloading', data: state.data, plan: state.plan, progress })
1138
+ }
1139
+ const model = await findLocalHfModel(`${state.plan.repoId}#${state.plan.filename}`)
1140
+ ?? modelFromPlan(state.plan, undefined, 'ready')
1141
+ const data = {
1142
+ ...state.data,
1143
+ hfModels: await loadHfPickerModels(),
1144
+ }
1145
+ await startAndPickHfModel(model, { kind: 'hfDone', data, model }, setState, onPick)
1146
+ } catch (err: unknown) {
1147
+ if (controller.signal.aborted) return
1148
+ setState({ kind: 'hfError', data: state.data, message: (err as Error).message, input: state.plan.repoId })
1149
+ } finally {
1150
+ abortRef.current = null
1151
+ }
1152
+ }
1153
+
1154
+ function localUninstallTargets(data: LoadedData): LocalUninstallTarget[] {
1155
+ return data.hfModels.map(model => ({
1156
+ kind: 'hf' as const,
1157
+ id: model.id,
1158
+ displayName: formatLocalHfModelDisplayName(model.id, {
1159
+ displayName: model.displayName,
1160
+ maxLength: 64,
1161
+ }),
1162
+ sizeBytes: model.sizeBytes,
1163
+ }))
1164
+ }
1165
+
1166
+ function isCurrentLocalUninstallTarget(
1167
+ target: LocalUninstallTarget,
1168
+ currentProvider: ProviderId,
1169
+ currentModel: string,
1170
+ ): boolean {
1171
+ return target.kind === 'hf' && currentProvider === 'llamacpp' && target.id === currentModel
1172
+ }
1173
+
1174
+ function localUninstallBoundaryCopy(_target: LocalUninstallTarget): string {
1175
+ return 'This removes only the downloaded GGUF file and metadata from this machine.'
1176
+ }
1177
+
1178
+ async function uninstallLocalModel(
1179
+ state: Extract<State, { kind: 'localUninstallConfirm' }>,
1180
+ setState: (s: State) => void,
1181
+ ): Promise<void> {
1182
+ setState({ kind: 'localUninstalling', data: state.data, target: state.target })
1183
+ const modelName = state.target.displayName
1184
+ try {
1185
+ await uninstallLocalHfModel(state.target.id)
1186
+ const data = await refreshLocalModelData(state.data)
1187
+ setState({ kind: 'localUninstallDone', data, modelName })
1188
+ } catch (err: unknown) {
1189
+ setState({
1190
+ kind: 'localUninstallError',
1191
+ data: state.data,
1192
+ target: state.target,
1193
+ message: (err as Error).message,
1194
+ })
1195
+ }
1196
+ }
1197
+
1198
+ async function refreshLocalModelData(data: LoadedData): Promise<LoadedData> {
1199
+ const hfModels = await loadHfPickerModels()
1200
+ return {
1201
+ ...data,
1202
+ hfModels,
1203
+ }
1204
+ }
1205
+
1206
+ async function startAndPickHfModel(
1207
+ model: LocalHfModel,
1208
+ state: Extract<State, { kind: 'list' | 'localCatalog' | 'hfDone' }>,
1209
+ setState: (s: State) => void,
1210
+ onPick: (sel: ModelPickerSelection) => void,
1211
+ ): Promise<void> {
1212
+ if (model.risk === 'high') {
1213
+ setState({ kind: 'hfError', data: state.data, message: 'blocked high-risk model; choose a model from a more credible source' })
1214
+ return
1215
+ }
1216
+ setState({ kind: 'localRunnerStarting', data: state.data, model, startedAt: Date.now() })
1217
+ const result = await startLlamaCppServer({
1218
+ modelPath: model.localPath,
1219
+ modelAlias: model.id,
1220
+ })
1221
+ const llamaCpp = await probeLlamaCpp()
1222
+ const data = { ...state.data, llamaCpp }
1223
+ if (!result.ok) {
1224
+ if (result.code === 'runner-not-installed') {
1225
+ setState({ kind: 'localRunnerSetup', data, model })
1226
+ return
1227
+ }
1228
+ setState({ kind: 'localRunnerStartFail', data, model, result })
1229
+ return
1230
+ }
1231
+ onPick({ kind: 'llamacpp', model: model.id })
1232
+ }
1233
+
1234
+ async function installRunnerAndStart(
1235
+ state: Extract<State, { kind: 'localRunnerSetup' }>,
1236
+ setState: (s: State) => void,
1237
+ onPick: (sel: ModelPickerSelection) => void,
1238
+ ): Promise<void> {
1239
+ await runRunnerSetup(state, setState, onPick, installLlamaCppRunner)
1240
+ }
1241
+
1242
+ async function buildRunnerAndStart(
1243
+ state: Extract<State, { kind: 'localRunnerSetup' }>,
1244
+ setState: (s: State) => void,
1245
+ onPick: (sel: ModelPickerSelection) => void,
1246
+ ): Promise<void> {
1247
+ await runRunnerSetup(state, setState, onPick, buildLlamaCppRunner)
1248
+ }
1249
+
1250
+ async function runRunnerSetup(
1251
+ state: Extract<State, { kind: 'localRunnerSetup' }>,
1252
+ setState: (s: State) => void,
1253
+ onPick: (sel: ModelPickerSelection) => void,
1254
+ setup: (onProgress?: (progress: LlamaCppInstallProgress) => void) => Promise<LlamaCppInstallResult>,
1255
+ ): Promise<void> {
1256
+ const startedAt = Date.now()
1257
+ const initialProgress: LlamaCppInstallProgress = {
1258
+ phase: 'checking',
1259
+ label: 'preparing local runner',
1260
+ progress: 0.04,
1261
+ }
1262
+ const updateProgress = (progress: LlamaCppInstallProgress): void => {
1263
+ setState({ kind: 'localRunnerInstalling', data: state.data, model: state.model, startedAt, progress })
1264
+ }
1265
+
1266
+ setState({ kind: 'localRunnerInstalling', data: state.data, model: state.model, startedAt, progress: initialProgress })
1267
+ const result = await setup(updateProgress)
1268
+ if (!result.ok) {
1269
+ setState({ kind: 'localRunnerInstallFail', data: state.data, model: state.model, result })
1270
+ return
1271
+ }
1272
+ await startAndPickHfModel(state.model, { kind: 'hfDone', data: state.data, model: state.model }, setState, onPick)
1273
+ }
1274
+
1275
+ async function saveRunnerPathAndStart(
1276
+ state: Extract<State, { kind: 'localRunnerPathEntry' }>,
1277
+ value: string,
1278
+ setState: (s: State) => void,
1279
+ onPick: (sel: ModelPickerSelection) => void,
1280
+ ): Promise<void> {
1281
+ const runnerPath = value.trim().replace(/^"|"$/g, '')
1282
+ if (!runnerPath) {
1283
+ setState({ ...state, error: 'paste the full path to llama-server' })
1284
+ return
1285
+ }
1286
+ setState({ ...state, submitting: true, error: undefined })
1287
+ try {
1288
+ await setLlamaCppServerPath(runnerPath)
1289
+ await startAndPickHfModel(state.model, { kind: 'hfDone', data: state.data, model: state.model }, setState, onPick)
1290
+ } catch (err: unknown) {
1291
+ setState({ ...state, submitting: false, error: (err as Error).message })
1292
+ }
1293
+ }
1294
+
1295
+ function contextFitSubtitle(contextFit: ModelPickerContextFit): string {
1296
+ const threshold = contextFit.thresholdPercent ?? 90
1297
+ return `pending prompt needs ~${formatTokens(contextFit.usedTokens)} tokens; choose a model under ${threshold}% or use /compact.`
1298
+ }
1299
+
1300
+ function contextFitLabel(
1301
+ provider: ProviderId,
1302
+ model: string,
1303
+ baseLabel: string,
1304
+ contextFit?: ModelPickerContextFit | null,
1305
+ ): string {
1306
+ if (!contextFit) return baseLabel
1307
+ const info = contextWindowInfo(provider, model)
1308
+ const percent = info.tokens > 0 ? Math.round((contextFit.usedTokens / info.tokens) * 100) : 0
1309
+ return `${baseLabel} ${formatContextWindow(info.tokens)} ctx ${percent}%`
1310
+ }
1311
+
1312
+ function formatTokens(count: number): string {
1313
+ if (count < 1000) return String(count)
1314
+ if (count < 10_000) return `${(count / 1000).toFixed(1)}k`
1315
+ return `${Math.round(count / 1000)}k`
1316
+ }
1317
+
1318
+ function formatContextWindow(tokens: number): string {
1319
+ if (tokens >= 1_000_000) {
1320
+ const millions = tokens / 1_000_000
1321
+ return Number.isInteger(millions) ? `${millions}m` : `${millions.toFixed(1)}m`
1322
+ }
1323
+ if (tokens >= 1000) return `${Math.round(tokens / 1000)}k`
1324
+ return String(tokens)
1325
+ }
1326
+
1327
+ async function loadHfPickerModels(): Promise<ModelPickerOptionsData['hfModels']> {
1328
+ const installed = await loadLocalHfModels()
1329
+ return installed.map(model => ({
1330
+ id: model.id,
1331
+ displayName: model.displayName,
1332
+ sizeBytes: model.sizeBytes,
1333
+ quantization: model.quantization,
1334
+ risk: model.risk,
1335
+ task: model.task,
1336
+ status: model.status,
1337
+ }))
1338
+ }
1339
+
1340
+ async function probeLlamaCpp(): Promise<ModelPickerOptionsData['llamaCpp']> {
1341
+ try {
1342
+ const status = await detectLlamaCpp()
1343
+ return {
1344
+ binaryPresent: status.binaryPresent,
1345
+ serverUp: status.serverUp,
1346
+ }
1347
+ } catch (err: unknown) {
1348
+ return { binaryPresent: false, serverUp: false, error: (err as Error).message }
1349
+ }
1350
+ }
1351
+
1352
+ function formatBytes(bytes: number): string {
1353
+ if (bytes <= 0) return 'size unknown'
1354
+ const gb = bytes / 1e9
1355
+ if (gb >= 1) return `${gb.toFixed(1)} GB`
1356
+ return `${Math.round(bytes / 1e6)} MB`
1357
+ }
1358
+
1359
+ function modelMetadataSubtext(size: string, indicators: string[]): string | undefined {
1360
+ return [size, ...indicators].filter(Boolean).join(' · ') || undefined
1361
+ }
1362
+
1363
+ function riskColor(risk: string): string {
1364
+ if (risk === 'high') return '#e87070'
1365
+ if (risk === 'medium') return theme.dim
1366
+ return theme.accentSecondary
1367
+ }
1368
+
1369
+ function fitColor(fit: GgufMachineFit): string {
1370
+ if (fit === 'too-large') return '#e87070'
1371
+ if (fit === 'tight') return theme.accentWarm
1372
+ return theme.dim
1373
+ }
1374
+
1375
+ function fitLabel(fit: GgufMachineFit, recommended: boolean): string {
1376
+ if (recommended && fit !== 'too-large') return 'recommended for this machine'
1377
+ if (recommended) return 'best match found; may be too large'
1378
+ return fileFitHint(fit)
1379
+ }
1380
+
1381
+ function fileFitHint(fit: GgufMachineFit): string {
1382
+ switch (fit) {
1383
+ case 'fits': return 'fits this machine'
1384
+ case 'tight': return 'may be slow or tight on memory'
1385
+ case 'too-large': return 'likely too large for this machine'
1386
+ case 'unknown': return 'machine fit unknown'
1387
+ }
1388
+ }
1389
+
1390
+ function formatSignals(downloads: number | undefined, likes: number | undefined): string {
1391
+ const d = downloads == null ? 'downloads unknown' : `${downloads} downloads`
1392
+ const l = likes == null ? 'likes unknown' : `${likes} likes`
1393
+ return `${d}, ${l}`
1394
+ }
1395
+
1396
+ function friendlyFileName(filename: string): string {
1397
+ return filename.split('/').pop() ?? filename
1398
+ }
1399
+
1400
+ function safetyLabel(risk: HfRisk): string {
1401
+ if (risk === 'low') return 'reviewed'
1402
+ if (risk === 'medium') return 'needs review'
1403
+ return 'blocked'
1404
+ }
1405
+
1406
+ function credibilityLabel(credibility: HfCredibility): string {
1407
+ if (credibility === 'established') return 'established'
1408
+ if (credibility === 'normal') return 'some signals'
1409
+ return 'limited signals'
1410
+ }
1411
+
1412
+ function friendlyReasons(reasons: string[]): string[] {
1413
+ return reasons.map(reason => {
1414
+ if (reason.includes('compatible local model file')) return 'compatible local model file'
1415
+ if (reason.includes('selected file is not compatible')) return 'file is not compatible with local chat'
1416
+ if (reason.includes('revision is mutable')) return 'model link may point to changing files'
1417
+ if (reason.includes('license is missing')) return 'license is missing'
1418
+ if (reason.includes('limited public usage signals')) return 'source has limited public usage'
1419
+ if (reason.includes('pickle/bin')) return 'repo also contains risky model file formats'
1420
+ return reason
1421
+ })
1422
+ }
1423
+
1424
+ function providerKeyPlaceholder(provider: ProviderId): string {
1425
+ if (provider === 'openai') return 'sk-...'
1426
+ if (provider === 'anthropic') return 'sk-ant-...'
1427
+ if (provider === 'gemini') return 'AIza...'
1428
+ return ''
1429
+ }
1430
+
1431
+ function runnerPathPlaceholder(): string {
1432
+ if (process.platform === 'win32') return 'C:\\path\\to\\llama-server.exe'
1433
+ return '/path/to/llama-server'
1434
+ }
1435
+
1436
+ function capitalize(value: string): string {
1437
+ return value.charAt(0).toUpperCase() + value.slice(1)
1438
+ }
1439
+
1440
+ function isCloudProvider(value: string | undefined): value is CloudProviderId {
1441
+ return value === 'openai' || value === 'anthropic' || value === 'gemini'
1442
+ }
1443
+
1444
+ const ElapsedSpinner: React.FC<{ startedAt: number; label: string }> = ({ startedAt, label }) => {
1445
+ return <Spinner label={label} startedAt={startedAt} />
1446
+ }