ethagent 2.3.0 → 3.0.0

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 (110) hide show
  1. package/README.md +18 -4
  2. package/package.json +2 -1
  3. package/src/app/FirstRun.tsx +157 -15
  4. package/src/app/FirstRunTimeline.tsx +4 -0
  5. package/src/app/input/AppInputProvider.tsx +19 -0
  6. package/src/app/input/appInputParser.ts +19 -4
  7. package/src/chat/ChatBottomPane.tsx +12 -1
  8. package/src/chat/ChatScreen.tsx +17 -5
  9. package/src/chat/ConversationStack.tsx +25 -19
  10. package/src/chat/MessageList.tsx +194 -53
  11. package/src/chat/chatSessionState.ts +4 -1
  12. package/src/chat/chatTurnOrchestrator.ts +65 -2
  13. package/src/chat/input/ChatInput.tsx +28 -2
  14. package/src/chat/input/imageRefs.ts +30 -0
  15. package/src/chat/input/textCursor.ts +13 -3
  16. package/src/chat/transcript/TranscriptView.tsx +7 -5
  17. package/src/chat/transcript/transcriptViewport.ts +88 -17
  18. package/src/chat/views/PermissionPrompt.tsx +26 -26
  19. package/src/chat/views/PermissionsView.tsx +18 -12
  20. package/src/chat/views/ResumeView.tsx +16 -7
  21. package/src/chat/views/RewindView.tsx +3 -1
  22. package/src/cli/ResetConfirmView.tsx +24 -9
  23. package/src/identity/continuity/editor.ts +27 -2
  24. package/src/identity/continuity/envelope.ts +125 -0
  25. package/src/identity/continuity/publicSkills.ts +37 -1
  26. package/src/identity/continuity/skills/frontmatter.ts +183 -0
  27. package/src/identity/continuity/skills/loadSkills.ts +609 -0
  28. package/src/identity/continuity/skills/publicSkillsSync.ts +32 -0
  29. package/src/identity/continuity/skills/scaffold.ts +52 -0
  30. package/src/identity/continuity/skills/types.ts +30 -0
  31. package/src/identity/continuity/storage/defaults.ts +28 -47
  32. package/src/identity/continuity/storage/files.ts +1 -0
  33. package/src/identity/continuity/storage/paths.ts +1 -0
  34. package/src/identity/continuity/storage/scaffold.ts +25 -23
  35. package/src/identity/continuity/storage/status.ts +34 -5
  36. package/src/identity/continuity/storage/types.ts +3 -2
  37. package/src/identity/continuity/storage.ts +3 -0
  38. package/src/identity/hub/OperationalRoutes.tsx +105 -3
  39. package/src/identity/hub/Routes.tsx +5 -3
  40. package/src/identity/hub/continuity/ContinuityDashboardScreen.tsx +5 -51
  41. package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +1 -1
  42. package/src/identity/hub/continuity/SavePromptScreen.tsx +1 -0
  43. package/src/identity/hub/continuity/effects.ts +36 -5
  44. package/src/identity/hub/continuity/skills/DeleteSkillConfirmScreen.tsx +112 -0
  45. package/src/identity/hub/continuity/skills/DeleteSkillScreen.tsx +123 -0
  46. package/src/identity/hub/continuity/skills/NewSkillScreen.tsx +57 -0
  47. package/src/identity/hub/continuity/skills/NewSkillVisibilityScreen.tsx +52 -0
  48. package/src/identity/hub/continuity/skills/SkillVisibilityScreen.tsx +171 -0
  49. package/src/identity/hub/continuity/skills/SkillsTreeScreen.tsx +213 -0
  50. package/src/identity/hub/continuity/snapshot.ts +3 -0
  51. package/src/identity/hub/continuity/state.ts +3 -2
  52. package/src/identity/hub/continuity/vault.ts +42 -10
  53. package/src/identity/hub/custody/CustodyEditFlow.tsx +3 -3
  54. package/src/identity/hub/identityHubReducer.ts +21 -0
  55. package/src/identity/hub/profile/effects.ts +16 -3
  56. package/src/identity/hub/restore/RestoreFlow.tsx +43 -6
  57. package/src/identity/hub/restore/apply.ts +12 -1
  58. package/src/identity/hub/restore/recovery.ts +11 -1
  59. package/src/identity/hub/restore/resolve.ts +1 -1
  60. package/src/identity/hub/restore/useRestoreEffects.ts +4 -6
  61. package/src/identity/hub/shared/components/DetailsScreen.tsx +4 -1
  62. package/src/identity/hub/shared/components/IdentitySummary.tsx +97 -53
  63. package/src/identity/hub/shared/components/MenuScreen.tsx +18 -15
  64. package/src/identity/hub/shared/components/UnlinkedIdentityScreen.tsx +1 -1
  65. package/src/identity/hub/shared/components/menuFlagsFromReconciliation.ts +8 -12
  66. package/src/identity/hub/shared/effects/sync.ts +16 -3
  67. package/src/identity/hub/shared/model/copy.ts +2 -4
  68. package/src/identity/hub/transfer/effects.ts +15 -2
  69. package/src/identity/hub/useIdentityHubContinuity.ts +145 -23
  70. package/src/identity/hub/useIdentityHubController.ts +5 -1
  71. package/src/identity/hub/useIdentityHubSideEffects.ts +2 -4
  72. package/src/mcp/manager.ts +1 -1
  73. package/src/models/ModelPicker.tsx +211 -74
  74. package/src/models/huggingface.ts +180 -2
  75. package/src/models/llamacpp.ts +261 -17
  76. package/src/models/llamacppPreflight.ts +16 -12
  77. package/src/models/modelPickerOptions.ts +57 -38
  78. package/src/providers/anthropic.ts +36 -5
  79. package/src/providers/contracts.ts +10 -1
  80. package/src/providers/gemini.ts +29 -3
  81. package/src/providers/openai-chat.ts +131 -11
  82. package/src/providers/openai-responses-format.ts +29 -8
  83. package/src/providers/openai-responses.ts +41 -11
  84. package/src/providers/registry.ts +1 -0
  85. package/src/runtime/toolExecution.ts +4 -3
  86. package/src/runtime/turn.ts +61 -30
  87. package/src/storage/config.ts +1 -0
  88. package/src/storage/sessions.ts +14 -2
  89. package/src/tools/changeDirectoryTool.ts +1 -1
  90. package/src/tools/contracts.ts +10 -0
  91. package/src/tools/deleteFileTool.ts +1 -1
  92. package/src/tools/editTool.ts +1 -1
  93. package/src/tools/listDirectoryTool.ts +1 -1
  94. package/src/tools/listSkillFilesTool.ts +77 -0
  95. package/src/tools/listSkillsTool.ts +68 -0
  96. package/src/tools/mcpResourceTools.ts +2 -2
  97. package/src/tools/privateContinuityReadTool.ts +1 -1
  98. package/src/tools/readSkillTool.ts +107 -0
  99. package/src/tools/readTool.ts +1 -1
  100. package/src/tools/registry.ts +6 -0
  101. package/src/tools/writeFileTool.ts +22 -2
  102. package/src/ui/Spinner.tsx +15 -3
  103. package/src/ui/theme.ts +2 -0
  104. package/src/utils/images.ts +140 -0
  105. package/src/utils/messages.ts +2 -0
  106. package/src/identity/continuity/localBackup.ts +0 -249
  107. package/src/identity/continuity/zipWriter.ts +0 -95
  108. package/src/identity/hub/continuity/index.ts +0 -7
  109. package/src/identity/hub/ens/index.ts +0 -11
  110. package/src/identity/hub/restore/index.ts +0 -22
@@ -7,12 +7,13 @@ import { Surface } from '../ui/Surface.js'
7
7
  import { ProgressBar } from '../ui/ProgressBar.js'
8
8
  import { theme } from '../ui/theme.js'
9
9
  import {
10
- buildLlamaCppRunner,
11
10
  DEFAULT_LLAMA_HOST,
12
11
  detectLlamaCpp,
13
12
  installLlamaCppRunner,
13
+ killRogueLlamaProcesses,
14
14
  setLlamaCppServerPath,
15
15
  startLlamaCppServer,
16
+ stopLlamaCppServer,
16
17
  type LlamaCppInstallProgress,
17
18
  type LlamaCppInstallResult,
18
19
  type LlamaCppStartResult,
@@ -32,6 +33,8 @@ import { defaultModelFor, type EthagentConfig, type ProviderId } from '../storag
32
33
  import { clearModelCatalogCache, discoverProviderModels, isOpenAIOAuthAllowedModel, OPENAI_OAUTH_DEFAULT_MODEL, type ModelCatalogResult } from './catalog.js'
33
34
  import { contextWindowInfo } from '../runtime/compaction.js'
34
35
  import {
36
+ addMmprojToInstalledModel,
37
+ backfillMmprojForModels,
35
38
  createHfDownloadPlan,
36
39
  downloadHfModel,
37
40
  fetchHuggingFaceRepoInfo,
@@ -68,7 +71,7 @@ import { formatLocalHfModelDisplayName, formatModelDisplayName } from './modelDi
68
71
  import { fetchUncensoredGgufCatalog, type UncensoredCatalogEntry } from './uncensoredCatalog.js'
69
72
 
70
73
  export type ModelPickerSelection =
71
- | { kind: 'llamacpp'; model: string }
74
+ | { kind: 'llamacpp'; model: string; mmprojPath?: string }
72
75
  | { kind: 'cloud'; provider: CloudProviderId; model: string; keyJustSet: boolean }
73
76
 
74
77
  type ModelPickerProps = {
@@ -77,6 +80,7 @@ type ModelPickerProps = {
77
80
  currentModel: string
78
81
  contextFit?: ModelPickerContextFit | null
79
82
  featuredHfRepo?: string
83
+ localOnly?: boolean
80
84
  onPick: (selection: ModelPickerSelection) => void
81
85
  onCancel: () => void
82
86
  }
@@ -113,6 +117,9 @@ type State =
113
117
  | { kind: 'localRunnerPathEntry'; data: LoadedData; model: LocalHfModel; submitting: boolean; error?: string }
114
118
  | { kind: 'localRunnerStarting'; data: LoadedData; model: LocalHfModel; startedAt: number }
115
119
  | { kind: 'localRunnerStartFail'; data: LoadedData; model: LocalHfModel; result: Extract<LlamaCppStartResult, { ok: false }> }
120
+ | { kind: 'mmprojOffer'; data: LoadedData; model: LocalHfModel }
121
+ | { kind: 'mmprojDownloading'; data: LoadedData; model: LocalHfModel; progress: HfDownloadProgress }
122
+ | { kind: 'mmprojError'; data: LoadedData; model: LocalHfModel; message: string }
116
123
 
117
124
  export const ModelPicker: React.FC<ModelPickerProps> = ({
118
125
  currentConfig,
@@ -120,12 +127,20 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
120
127
  currentModel,
121
128
  contextFit,
122
129
  featuredHfRepo,
130
+ localOnly = false,
123
131
  onPick,
124
132
  onCancel,
125
133
  }) => {
126
134
  const [state, setState] = useState<State>({ kind: 'loading' })
127
135
  const hfAbortRef = useRef<AbortController | null>(null)
128
136
  const oauthServiceRef = useRef<OpenAIOAuthService | null>(null)
137
+ const dismissToList = (data: LoadedData) => () => {
138
+ if (localOnly) {
139
+ onCancel()
140
+ } else {
141
+ setState({ kind: 'list', data })
142
+ }
143
+ }
129
144
 
130
145
  useEffect(() => {
131
146
  let cancelled = false
@@ -187,8 +202,20 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
187
202
 
188
203
  if (state.kind === 'loading') {
189
204
  return (
190
- <Surface title={contextFit ? 'Switch to Larger-Context Model' : 'Switch Provider · Model'} subtitle="Loading providers and models.">
191
- <Spinner label="loading providers..." />
205
+ <Surface
206
+ title={localOnly ? 'Local Model' : (contextFit ? 'Switch to Larger-Context Model' : 'Switch Provider · Model')}
207
+ subtitle="Loading providers and models."
208
+ footer="esc back"
209
+ >
210
+ <Spinner label={localOnly ? 'loading local models...' : 'loading providers...'} />
211
+ <Box marginTop={1}>
212
+ <Select<'cancel'>
213
+ options={[{ value: 'cancel', label: 'Back', hint: 'Return to the previous screen', role: 'utility' }]}
214
+ hintLayout="inline"
215
+ onSubmit={() => onCancel()}
216
+ onCancel={() => onCancel()}
217
+ />
218
+ </Box>
192
219
  </Surface>
193
220
  )
194
221
  }
@@ -204,7 +231,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
204
231
  label="Model Link"
205
232
  placeholder={LOCAL_MODEL_LINK_HINT}
206
233
  onSubmit={value => void inspectHfInput(state, value, setState)}
207
- onCancel={() => setState({ kind: 'list', data: state.data })}
234
+ onCancel={dismissToList(state.data)}
208
235
  />
209
236
  {state.error ? <Text color={theme.accentError}>{state.error}</Text> : null}
210
237
  </Surface>
@@ -244,6 +271,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
244
271
  const canDownload = plan.review.risk !== 'high' && plan.review.runtime === 'llama.cpp runnable'
245
272
  const fit = state.data.machineSpec ? estimateGgufMachineFit(plan.sizeBytes, state.data.machineSpec) : null
246
273
  const recommended = state.data.machineSpec ? recommendGgufFile(plan.repo, ggufFiles(plan.repo), state.data.machineSpec) : null
274
+ const mmproj = plan.mmprojCandidate
247
275
  return (
248
276
  <Surface
249
277
  title="Review Model Link"
@@ -260,19 +288,28 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
260
288
  <Text color={riskColor(plan.review.risk)}>safety: {safetyLabel(plan.review.risk)} · source: {credibilityLabel(plan.review.credibility)}</Text>
261
289
  <Text color={theme.dim}>signals: {formatSignals(plan.repo.downloads, plan.repo.likes)}</Text>
262
290
  <Text color={theme.dim}>notes: {friendlyReasons(plan.review.reasons).join('; ')}</Text>
291
+ {mmproj ? (
292
+ <Text color={theme.dim}>vision encoder available: {friendlyFileName(mmproj.filename)} (+{formatBytes(mmproj.sizeBytes)})</Text>
293
+ ) : null}
263
294
  </Box>
264
- <Select<'download' | 'pick' | 'cancel'>
295
+ <Select<'download' | 'downloadWithMmproj' | 'pick' | 'cancel'>
265
296
  options={[
266
- { value: 'download', label: 'Download This Model', disabled: !canDownload },
297
+ { value: 'download', role: 'section', label: 'Download' },
298
+ ...(mmproj ? [{ value: 'downloadWithMmproj' as const, label: `Download Model + Vision Encoder (+${formatBytes(mmproj.sizeBytes)}) · recommended`, disabled: !canDownload }] : []),
299
+ { value: 'download', label: mmproj ? 'Download Without Vision Encoder' : 'Download This Model', disabled: !canDownload },
300
+ { value: 'pick', role: 'section', label: 'Navigation' },
267
301
  { value: 'pick', label: 'Pick Another File' },
268
- { value: 'cancel', label: 'Cancel' },
302
+ { value: 'cancel', label: 'Cancel', role: 'utility' },
269
303
  ]}
270
304
  onSubmit={choice => {
271
305
  if (choice === 'download') void startHfDownload(state, setState, hfAbortRef, onPick)
306
+ else if (choice === 'downloadWithMmproj') {
307
+ void startHfDownload({ ...state, plan: { ...plan, includeMmproj: true } }, setState, hfAbortRef, onPick)
308
+ }
272
309
  else if (choice === 'pick') void inspectHfInput({ kind: 'hfInput', data: state.data }, plan.repoId, setState)
273
- else setState({ kind: 'list', data: state.data })
310
+ else dismissToList(state.data)()
274
311
  }}
275
- onCancel={() => setState({ kind: 'list', data: state.data })}
312
+ onCancel={dismissToList(state.data)}
276
313
  />
277
314
  </Surface>
278
315
  )
@@ -291,6 +328,68 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
291
328
  )
292
329
  }
293
330
 
331
+ if (state.kind === 'mmprojOffer') {
332
+ const sizeLabel = state.model.mmprojSizeBytes ? `+${formatBytes(state.model.mmprojSizeBytes)}` : 'additional download'
333
+ return (
334
+ <Surface
335
+ title="Add Image Support?"
336
+ subtitle={`${state.model.displayName} has a vision encoder available in its Hugging Face repo.`}
337
+ footer="enter select · esc back"
338
+ >
339
+ <Box flexDirection="column" marginBottom={1}>
340
+ <Text color={theme.dim}>Loading the vision encoder lets this model accept pasted images.</Text>
341
+ <Text color={theme.dim}>Without it, image paste is declined at submit time.</Text>
342
+ </Box>
343
+ <Select<'add' | 'skip' | 'cancel'>
344
+ options={[
345
+ { value: 'add', label: `Add Vision Encoder (${sizeLabel}) And Use` },
346
+ { value: 'skip', label: 'Use Without Image Support' },
347
+ { value: 'cancel', label: 'Cancel' },
348
+ ]}
349
+ onSubmit={choice => {
350
+ if (choice === 'add') void downloadMmprojAndContinue(state, setState, onPick)
351
+ else if (choice === 'skip') void startAndPickHfModel({ ...state.model, mmprojAvailable: false }, state, setState, onPick)
352
+ else dismissToList(state.data)()
353
+ }}
354
+ onCancel={dismissToList(state.data)}
355
+ />
356
+ </Surface>
357
+ )
358
+ }
359
+
360
+ if (state.kind === 'mmprojDownloading') {
361
+ const total = state.progress.total ?? state.model.mmprojSizeBytes ?? 0
362
+ const completed = state.progress.completed ?? 0
363
+ const progress = total > 0 ? completed / total : 0
364
+ const suffix = total > 0 ? `${formatBytes(completed)} / ${formatBytes(total)}` : formatBytes(completed)
365
+ return (
366
+ <Surface title="Downloading Vision Encoder" subtitle={state.model.displayName}>
367
+ <Text color={theme.dim}>{state.progress.status}</Text>
368
+ <ProgressBar progress={progress} suffix={suffix} />
369
+ </Surface>
370
+ )
371
+ }
372
+
373
+ if (state.kind === 'mmprojError') {
374
+ return (
375
+ <Surface title="Vision Encoder Download Failed" subtitle={state.message} tone="error" footer="enter select · esc back">
376
+ <Select<'retry' | 'skip' | 'back'>
377
+ options={[
378
+ { value: 'retry', label: 'Retry Download' },
379
+ { value: 'skip', label: 'Use Without Image Support' },
380
+ { value: 'back', label: 'Back To Picker' },
381
+ ]}
382
+ onSubmit={choice => {
383
+ if (choice === 'retry') setState({ kind: 'mmprojOffer', data: state.data, model: state.model })
384
+ else if (choice === 'skip') void startAndPickHfModel({ ...state.model, mmprojAvailable: false }, state, setState, onPick)
385
+ else dismissToList(state.data)()
386
+ }}
387
+ onCancel={dismissToList(state.data)}
388
+ />
389
+ </Surface>
390
+ )
391
+ }
392
+
294
393
  if (state.kind === 'hfDone') {
295
394
  return (
296
395
  <Surface
@@ -305,9 +404,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
305
404
  ]}
306
405
  onSubmit={choice => {
307
406
  if (choice === 'use') void startAndPickHfModel(state.model, state, setState, onPick)
308
- else setState({ kind: 'list', data: state.data })
407
+ else dismissToList(state.data)()
309
408
  }}
310
- onCancel={() => setState({ kind: 'list', data: state.data })}
409
+ onCancel={dismissToList(state.data)}
311
410
  />
312
411
  </Surface>
313
412
  )
@@ -323,9 +422,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
323
422
  ]}
324
423
  onSubmit={choice => {
325
424
  if (choice === 'retry') setState({ kind: 'hfInput', data: state.data, error: state.input ? undefined : state.message })
326
- else setState({ kind: 'list', data: state.data })
425
+ else dismissToList(state.data)()
327
426
  }}
328
- onCancel={() => setState({ kind: 'list', data: state.data })}
427
+ onCancel={dismissToList(state.data)}
329
428
  />
330
429
  </Surface>
331
430
  )
@@ -355,7 +454,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
355
454
  const target = targets.find(item => `${item.kind}:${item.id}` === value)
356
455
  if (target) setState({ kind: 'localUninstallConfirm', data: state.data, target })
357
456
  }}
358
- onCancel={() => setState({ kind: 'list', data: state.data })}
457
+ onCancel={dismissToList(state.data)}
359
458
  />
360
459
  )}
361
460
  </Surface>
@@ -401,8 +500,8 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
401
500
  <Surface title="Local Model Uninstalled" subtitle={state.modelName} footer="enter back to picker · esc close">
402
501
  <Select<'back'>
403
502
  options={[{ value: 'back', label: 'Back To Picker' }]}
404
- onSubmit={() => setState({ kind: 'list', data: state.data })}
405
- onCancel={() => setState({ kind: 'list', data: state.data })}
503
+ onSubmit={dismissToList(state.data)}
504
+ onCancel={dismissToList(state.data)}
406
505
  />
407
506
  </Surface>
408
507
  )
@@ -418,9 +517,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
418
517
  ]}
419
518
  onSubmit={choice => {
420
519
  if (choice === 'retry') void uninstallLocalModel({ kind: 'localUninstallConfirm', data: state.data, target: state.target }, setState)
421
- else setState({ kind: 'list', data: state.data })
520
+ else dismissToList(state.data)()
422
521
  }}
423
- onCancel={() => setState({ kind: 'list', data: state.data })}
522
+ onCancel={dismissToList(state.data)}
424
523
  />
425
524
  </Surface>
426
525
  )
@@ -449,9 +548,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
449
548
  if (choice === 'download') setState({ kind: 'hfInput', data: state.data })
450
549
  else if (choice === 'install') void installRunnerAndStart(state, setState, onPick)
451
550
  else if (choice === 'path') setState({ kind: 'localRunnerPathEntry', data: state.data, model: state.model, submitting: false })
452
- else setState({ kind: 'list', data: state.data })
551
+ else dismissToList(state.data)()
453
552
  }}
454
- onCancel={() => setState({ kind: 'list', data: state.data })}
553
+ onCancel={dismissToList(state.data)}
455
554
  />
456
555
  </Surface>
457
556
  )
@@ -470,15 +569,20 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
470
569
  const options = buildRunnerRecoveryOptions(state.result)
471
570
  return (
472
571
  <Surface title="Runner Setup Needs Attention" subtitle={state.result.message} tone="error" footer="enter select · esc back">
473
- <Select<'retry' | 'build' | 'path' | 'back'>
572
+ <Select<'stop-and-retry' | 'path' | 'back'>
474
573
  options={options}
574
+ hintLayout="inline"
475
575
  onSubmit={choice => {
476
- if (choice === 'retry') void installRunnerAndStart({ kind: 'localRunnerSetup', data: state.data, model: state.model }, setState, onPick)
477
- else if (choice === 'build') void buildRunnerAndStart({ kind: 'localRunnerSetup', data: state.data, model: state.model }, setState, onPick)
576
+ if (choice === 'stop-and-retry') {
577
+ void (async () => {
578
+ await killRogueLlamaProcesses()
579
+ await installRunnerAndStart({ kind: 'localRunnerSetup', data: state.data, model: state.model }, setState, onPick)
580
+ })()
581
+ }
478
582
  else if (choice === 'path') setState({ kind: 'localRunnerPathEntry', data: state.data, model: state.model, submitting: false })
479
- else setState({ kind: 'list', data: state.data })
583
+ else dismissToList(state.data)()
480
584
  }}
481
- onCancel={() => setState({ kind: 'list', data: state.data })}
585
+ onCancel={dismissToList(state.data)}
482
586
  />
483
587
  </Surface>
484
588
  )
@@ -528,9 +632,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
528
632
  if (choice === 'retry') void startAndPickHfModel(state.model, { kind: 'hfDone', data: state.data, model: state.model }, setState, onPick)
529
633
  else if (choice === 'path') setState({ kind: 'localRunnerPathEntry', data: state.data, model: state.model, submitting: false })
530
634
  else if (choice === 'install') void installRunnerAndStart({ kind: 'localRunnerSetup', data: state.data, model: state.model }, setState, onPick)
531
- else setState({ kind: 'list', data: state.data })
635
+ else dismissToList(state.data)()
532
636
  }}
533
- onCancel={() => setState({ kind: 'list', data: state.data })}
637
+ onCancel={dismissToList(state.data)}
534
638
  />
535
639
  </Surface>
536
640
  )
@@ -554,7 +658,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
554
658
  placeholder={providerKeyPlaceholder(provider)}
555
659
  isSecret
556
660
  onSubmit={(value) => void submitKey(state, value, currentConfig, setState)}
557
- onCancel={() => setState({ kind: 'list', data: state.data })}
661
+ onCancel={dismissToList(state.data)}
558
662
  />
559
663
  )}
560
664
  {error ? <Text color={theme.accentError}>{error}</Text> : null}
@@ -576,9 +680,11 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
576
680
  ) : (
577
681
  <Select
578
682
  options={[
683
+ { value: 'edit', role: 'section', label: 'Credential' },
579
684
  { value: 'edit', label: 'Replace Stored API Key' },
580
685
  { value: 'delete', label: 'Remove Stored API Key' },
581
- { value: 'cancel', label: 'Back' },
686
+ { value: 'cancel', role: 'section', label: 'Navigation' },
687
+ { value: 'cancel', label: 'Back', role: 'utility' },
582
688
  ]}
583
689
  onSubmit={(value) => {
584
690
  if (value === 'edit') {
@@ -591,7 +697,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
591
697
  }
592
698
  void deleteKey(state, currentConfig, setState, onPick, currentProvider)
593
699
  }}
594
- onCancel={() => setState({ kind: 'list', data: state.data })}
700
+ onCancel={dismissToList(state.data)}
595
701
  />
596
702
  )}
597
703
  {error ? <Text color={theme.accentError}>{error}</Text> : null}
@@ -629,7 +735,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
629
735
  }
630
736
  void signOutOAuth(state, currentConfig, setState, onPick, currentProvider)
631
737
  }}
632
- onCancel={() => setState({ kind: 'list', data: state.data })}
738
+ onCancel={dismissToList(state.data)}
633
739
  />
634
740
  )}
635
741
  {error ? <Text color={theme.accentError}>{error}</Text> : null}
@@ -655,9 +761,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
655
761
  onSubmit={choice => {
656
762
  if (choice === 'retry') void startOpenAIOAuthFlow(state.data, currentConfig, setState, oauthServiceRef, onPick)
657
763
  else if (choice === 'apikey') setState({ kind: 'keyEntry', provider: 'openai', action: 'set', data: state.data, submitting: false })
658
- else setState({ kind: 'list', data: state.data })
764
+ else dismissToList(state.data)()
659
765
  }}
660
- onCancel={() => setState({ kind: 'list', data: state.data })}
766
+ onCancel={dismissToList(state.data)}
661
767
  />
662
768
  </Surface>
663
769
  )
@@ -719,11 +825,12 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
719
825
  options={options}
720
826
  initialIndex={initialIndex === -1 ? 0 : initialIndex}
721
827
  maxVisible={12}
828
+ hintLayout="inline"
722
829
  onSubmit={(value) => {
723
830
  const parsed = parseFullCatalogValue(value)
724
831
  if (parsed) onPick({ kind: 'cloud', provider: parsed.provider, model: parsed.model, keyJustSet: false })
725
832
  }}
726
- onCancel={() => setState({ kind: 'list', data: state.data })}
833
+ onCancel={dismissToList(state.data)}
727
834
  />
728
835
  </Surface>
729
836
  )
@@ -749,9 +856,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
749
856
  onSubmit={choice => {
750
857
  if (choice === 'retry') void openLocalCatalog(state.data, setState)
751
858
  else if (choice === 'paste') setState({ kind: 'hfInput', data: state.data })
752
- else setState({ kind: 'list', data: state.data })
859
+ else dismissToList(state.data)()
753
860
  }}
754
- onCancel={() => setState({ kind: 'list', data: state.data })}
861
+ onCancel={dismissToList(state.data)}
755
862
  />
756
863
  </Surface>
757
864
  )
@@ -770,28 +877,30 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
770
877
  options={options}
771
878
  initialIndex={initialIndex === -1 ? 0 : initialIndex}
772
879
  maxVisible={12}
773
- onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel, currentConfig, oauthServiceRef)}
774
- onCancel={() => setState({ kind: 'list', data: state.data })}
880
+ hintLayout="inline"
881
+ onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel, currentConfig, oauthServiceRef, localOnly)}
882
+ onCancel={dismissToList(state.data)}
775
883
  />
776
884
  </Surface>
777
885
  )
778
886
  }
779
887
 
780
888
  const { data } = state
781
- const options = buildModelPickerOptions(data, { currentProvider, currentModel, contextFit })
889
+ const options = buildModelPickerOptions(data, { currentProvider, currentModel, contextFit }, { localOnly })
782
890
  const initialIndex = localOrCloudOptionIndex(options, currentProvider, currentModel)
783
891
 
784
892
  return (
785
893
  <Surface
786
- title={contextFit ? 'Switch to Larger-Context Model' : 'Switch Provider · Model'}
787
- subtitle={contextFit ? contextFitSubtitle(contextFit) : 'Downloaded GGUF files · cloud providers'}
788
- footer="enter select · esc close · /models lists installed models"
894
+ title={localOnly ? 'Local Model' : (contextFit ? 'Switch to Larger-Context Model' : 'Switch Provider · Model')}
895
+ subtitle={localOnly ? 'Downloaded GGUF files and curated catalog' : (contextFit ? contextFitSubtitle(contextFit) : 'Downloaded GGUF files · cloud providers')}
896
+ footer={localOnly ? 'enter select · esc back' : 'enter select · esc close · /models lists installed models'}
789
897
  >
790
898
  <Select
791
899
  options={options}
792
900
  initialIndex={initialIndex === -1 ? 0 : initialIndex}
793
901
  maxVisible={10}
794
- onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel, currentConfig, oauthServiceRef)}
902
+ hintLayout="inline"
903
+ onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel, currentConfig, oauthServiceRef, localOnly)}
795
904
  onCancel={onCancel}
796
905
  />
797
906
  </Surface>
@@ -806,6 +915,7 @@ function handleSubmit(
806
915
  onCancel: () => void,
807
916
  currentConfig: EthagentConfig,
808
917
  oauthServiceRef: React.MutableRefObject<OpenAIOAuthService | null>,
918
+ localOnly: boolean = false,
809
919
  ): void {
810
920
  if (value.startsWith('hdr:')) return
811
921
  if (value === 'cancel') {
@@ -813,7 +923,8 @@ function handleSubmit(
813
923
  return
814
924
  }
815
925
  if (value === 'back' && state.kind === 'localCatalog') {
816
- setState({ kind: 'list', data: state.data })
926
+ if (localOnly) onCancel()
927
+ else setState({ kind: 'list', data: state.data })
817
928
  return
818
929
  }
819
930
  if (value.startsWith('hf:')) {
@@ -834,6 +945,18 @@ function handleSubmit(
834
945
  })()
835
946
  return
836
947
  }
948
+ if (value.startsWith('hfmmproj:') && state.kind === 'list') {
949
+ const id = value.slice('hfmmproj:'.length)
950
+ void (async () => {
951
+ const local = await findLocalHfModel(id)
952
+ if (!local) {
953
+ setState({ kind: 'hfError', data: state.data, message: 'local model metadata was not found' })
954
+ return
955
+ }
956
+ setState({ kind: 'mmprojOffer', data: state.data, model: local })
957
+ })()
958
+ return
959
+ }
837
960
  if (value.startsWith('uc:') && state.kind === 'localCatalog') {
838
961
  const entry = state.catalog.find(item => catalogOptionValue(item.repo.repoId, item.file.filename) === value)
839
962
  if (entry) void reviewCatalogModel(state, entry, setState)
@@ -1216,24 +1339,13 @@ export function buildHfFileOptions(
1216
1339
  }
1217
1340
 
1218
1341
  function buildRunnerRecoveryOptions(
1219
- result: Extract<LlamaCppInstallResult, { ok: false }>,
1220
- ): SelectOption<'retry' | 'build' | 'path' | 'back'>[] {
1221
- const options: SelectOption<'retry' | 'build' | 'path' | 'back'>[] = []
1222
- if (result.recovery.includes('source-build')) {
1223
- options.push({
1224
- value: 'build',
1225
- label: 'Build Local Runner',
1226
- hint: 'Uses git and CMake if installed',
1227
- })
1228
- }
1229
- if (result.recovery.includes('runner-path')) {
1230
- options.push({ value: 'path', label: 'Use Existing Runner Path' })
1231
- }
1232
- if (result.recovery.includes('retry-install')) {
1233
- options.push({ value: 'retry', label: 'Retry Automatic Install' })
1234
- }
1235
- options.push({ value: 'back', label: 'Back To Picker' })
1236
- return options
1342
+ _result: Extract<LlamaCppInstallResult, { ok: false }>,
1343
+ ): SelectOption<'stop-and-retry' | 'path' | 'back'>[] {
1344
+ return [
1345
+ { value: 'stop-and-retry', label: 'Stop and Retry', hint: 'Stop background runners and try the install again' },
1346
+ { value: 'path', label: 'Use Existing Runner Path' },
1347
+ { value: 'back', label: 'Back To Picker' },
1348
+ ]
1237
1349
  }
1238
1350
 
1239
1351
  function localRunnerStartFailureSubtitle(result: Extract<LlamaCppStartResult, { ok: false }>): string {
@@ -1445,6 +1557,30 @@ async function uninstallLocalModel(
1445
1557
  }
1446
1558
  }
1447
1559
 
1560
+ async function downloadMmprojAndContinue(
1561
+ state: Extract<State, { kind: 'mmprojOffer' }>,
1562
+ setState: (s: State) => void,
1563
+ onPick: (sel: ModelPickerSelection) => void,
1564
+ ): Promise<void> {
1565
+ setState({ kind: 'mmprojDownloading', data: state.data, model: state.model, progress: { status: 'starting' } })
1566
+ try {
1567
+ for await (const progress of addMmprojToInstalledModel(state.model.id)) {
1568
+ setState({ kind: 'mmprojDownloading', data: state.data, model: state.model, progress })
1569
+ }
1570
+ } catch (err: unknown) {
1571
+ setState({ kind: 'mmprojError', data: state.data, model: state.model, message: (err as Error).message })
1572
+ return
1573
+ }
1574
+ const updated = await findLocalHfModel(state.model.id)
1575
+ if (!updated || !updated.mmprojPath) {
1576
+ setState({ kind: 'mmprojError', data: state.data, model: state.model, message: 'projector downloaded but path was not persisted' })
1577
+ return
1578
+ }
1579
+ await stopLlamaCppServer().catch(() => null)
1580
+ const data = { ...state.data, hfModels: await loadHfPickerModels() }
1581
+ await startAndPickHfModel(updated, { kind: 'mmprojOffer', data, model: updated }, setState, onPick)
1582
+ }
1583
+
1448
1584
  async function refreshLocalModelData(data: LoadedData): Promise<LoadedData> {
1449
1585
  const hfModels = await loadHfPickerModels()
1450
1586
  return {
@@ -1455,7 +1591,7 @@ async function refreshLocalModelData(data: LoadedData): Promise<LoadedData> {
1455
1591
 
1456
1592
  async function startAndPickHfModel(
1457
1593
  model: LocalHfModel,
1458
- state: Extract<State, { kind: 'list' | 'localCatalog' | 'hfDone' }>,
1594
+ state: Extract<State, { kind: 'list' | 'localCatalog' | 'hfDone' | 'mmprojOffer' | 'mmprojError' }>,
1459
1595
  setState: (s: State) => void,
1460
1596
  onPick: (sel: ModelPickerSelection) => void,
1461
1597
  ): Promise<void> {
@@ -1463,10 +1599,15 @@ async function startAndPickHfModel(
1463
1599
  setState({ kind: 'hfError', data: state.data, message: 'blocked high-risk model; choose a model from a more credible source' })
1464
1600
  return
1465
1601
  }
1602
+ if (model.mmprojAvailable && !model.mmprojPath && state.kind !== 'mmprojOffer' && state.kind !== 'mmprojError') {
1603
+ setState({ kind: 'mmprojOffer', data: state.data, model })
1604
+ return
1605
+ }
1466
1606
  setState({ kind: 'localRunnerStarting', data: state.data, model, startedAt: Date.now() })
1467
1607
  const result = await startLlamaCppServer({
1468
1608
  modelPath: model.localPath,
1469
1609
  modelAlias: model.id,
1610
+ mmprojPath: model.mmprojPath,
1470
1611
  })
1471
1612
  const llamaCpp = await probeLlamaCpp()
1472
1613
  const data = { ...state.data, llamaCpp }
@@ -1478,7 +1619,7 @@ async function startAndPickHfModel(
1478
1619
  setState({ kind: 'localRunnerStartFail', data, model, result })
1479
1620
  return
1480
1621
  }
1481
- onPick({ kind: 'llamacpp', model: model.id })
1622
+ onPick({ kind: 'llamacpp', model: model.id, mmprojPath: model.mmprojPath })
1482
1623
  }
1483
1624
 
1484
1625
  async function installRunnerAndStart(
@@ -1489,14 +1630,6 @@ async function installRunnerAndStart(
1489
1630
  await runRunnerSetup(state, setState, onPick, installLlamaCppRunner)
1490
1631
  }
1491
1632
 
1492
- async function buildRunnerAndStart(
1493
- state: Extract<State, { kind: 'localRunnerSetup' }>,
1494
- setState: (s: State) => void,
1495
- onPick: (sel: ModelPickerSelection) => void,
1496
- ): Promise<void> {
1497
- await runRunnerSetup(state, setState, onPick, buildLlamaCppRunner)
1498
- }
1499
-
1500
1633
  async function runRunnerSetup(
1501
1634
  state: Extract<State, { kind: 'localRunnerSetup' }>,
1502
1635
  setState: (s: State) => void,
@@ -1576,7 +1709,8 @@ function formatContextWindow(tokens: number): string {
1576
1709
 
1577
1710
  async function loadHfPickerModels(): Promise<ModelPickerOptionsData['hfModels']> {
1578
1711
  const installed = await loadLocalHfModels()
1579
- return installed.map(model => ({
1712
+ const backfilled = await backfillMmprojForModels(installed)
1713
+ return backfilled.map(model => ({
1580
1714
  id: model.id,
1581
1715
  displayName: model.displayName,
1582
1716
  sizeBytes: model.sizeBytes,
@@ -1584,6 +1718,9 @@ async function loadHfPickerModels(): Promise<ModelPickerOptionsData['hfModels']>
1584
1718
  risk: model.risk,
1585
1719
  task: model.task,
1586
1720
  status: model.status,
1721
+ mmprojPath: model.mmprojPath,
1722
+ mmprojAvailable: model.mmprojAvailable,
1723
+ mmprojSizeBytes: model.mmprojSizeBytes,
1587
1724
  }))
1588
1725
  }
1589
1726