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
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A privacy-first AI agent with a portable Ethereum identity.
4
4
 
5
- ethagent binds an AI agent to a wallet-owned ERC-8004 token. Soul and memory stay encrypted under your wallet signature and pinned to IPFS. Public skills publish as plain JSON so other agents can discover what the agent does. Swap models, switch machines, or restore the same agent from a single onchain pointer.
5
+ Switch providers or machines and the AI agent you customized stays behind. `ethagent` ties the agent to a wallet you own, so its soul, memory, and skills follow you across providers, machines, and models.
6
6
 
7
7
  - **Portable.** The ERC-8004 token is the agent's durable identity. Use the ENS name as a readable handle, or the token ID plus chain as the permanent reference, to restore the same agent anywhere.
8
8
  - **Private.** Soul and memory are encrypted before they are pinned to IPFS. The wallet signature used to unlock them stays local and never submits a transaction, spends funds, or grants token approval.
@@ -61,13 +61,16 @@ Every agent has a continuity directory at `~/.ethagent/continuity`.
61
61
 
62
62
  ## Continuity
63
63
 
64
- Each agent's continuity directory holds three files. Two are private and encrypted before they ever reach IPFS; one is public so other agents can discover what the agent does.
64
+ Each agent's continuity directory holds a small set of files. Private files are encrypted before they ever reach IPFS; public files are plain JSON so other agents can discover what the agent does.
65
65
 
66
66
  | File | Visibility | Purpose |
67
67
  | --- | --- | --- |
68
68
  | `SOUL.md` | Private | Soul, boundaries, standing instructions, and identity framing. |
69
69
  | `MEMORY.md` | Private | Durable preferences, project context, decisions, and operating notes. |
70
- | `skills.json` | Public | Machine-readable capabilities. |
70
+ | `skills/` | Mixed | Skill folders. Each skill is private, discoverable, or public; new skills default to discoverable. |
71
+ | `skills.json` | Public | Machine-readable capabilities derived from public skills. |
72
+
73
+ `SOUL.md`, `MEMORY.md`, and each `SKILL.md` are plain Markdown you edit through the Identity Hub under Continuity. Skills carry extra metadata: the frontmatter at the top of each `SKILL.md` (name, description, when_to_use, visibility, tags) tells the agent when to load it. Visibility is `private` (local-only, never shared), `discoverable` (indexed in `skills.json` so other agents can find it), or `public` (indexed and surfaced on the Agent Card).
71
74
 
72
75
  - **Save Snapshot Now** encrypts the private files, pins them to IPFS, and rotates the onchain pointer to the new CID.
73
76
  - **Refetch Latest** reads the pointer back, signs the decrypt challenge with your wallet, and overwrites local files from the snapshot.
@@ -97,7 +100,7 @@ Save the token ID + network somewhere safe. ENS records can be cleared and rebui
97
100
 
98
101
  **Prepare Token Transfer** runs before any ERC-8004 token transfer, and only when the token sits directly in your wallet. An agent in Advanced custody has to switch to Simple first from Custody Mode, which unwraps the token from its Vault back to the owner wallet.
99
102
 
100
- - sender signs snapshot access, receiver signs restore access.
103
+ - Sender signs snapshot access, receiver signs restore access.
101
104
  - Sender publishes the snapshot pointer to the agent URI.
102
105
  - The actual transfer happens externally afterwards, in whichever wallet UI you prefer.
103
106
  - Once the token has moved, the receiver opens **Load Agent** with the receiving wallet and restores the same agent from the published snapshot.
@@ -112,6 +115,17 @@ ethagent works with OpenAI, Anthropic, Gemini, and local GGUF models served thro
112
115
  - The featured local model is [Qwen3.5-9B-Uncensored](https://huggingface.co/HauhauCS/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive); other Hugging Face GGUF models work by repo ID or direct URL.
113
116
  - Cloud API keys live in the OS keyring when one is available, with an encrypted local file under `~/.ethagent` as fallback.
114
117
 
118
+ ### Image Input
119
+
120
+ Press `Alt+V` to paste an image from the clipboard. A marker like `[Image #1]` appears in the prompt; delete it to drop the attachment.
121
+
122
+ Vision support is available on:
123
+
124
+ - **OpenAI** (Chat Completions and Responses API): `gpt-4o`, `gpt-4.1`, `gpt-4-turbo`, `gpt-4-vision`, `gpt-5`, `o1`, `o3`, `o4`, `chatgpt-4`.
125
+ - **Anthropic**: `claude-3`, `claude-sonnet-4`, `claude-opus-4`, `claude-haiku-4`.
126
+ - **Gemini**: `gemini-1.5`, `gemini-2.0`, `gemini-2.5`.
127
+ - **Local llama.cpp**: vision works when both the main GGUF and a `mmproj-*.gguf` projector are loaded. The picker recommends the bundle during install; if you skipped, open `Alt+P` and any installed model with a vision encoder available shows an `Add Vision Encoder` row directly beneath it.
128
+
115
129
  ## Tools and Sessions
116
130
 
117
131
  - File ops, shell, clipboard, and MCP tools all run through the same permission layer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ethagent",
3
- "version": "2.3.0",
3
+ "version": "3.0.0",
4
4
  "description": "A privacy-first AI agent with a portable Ethereum identity",
5
5
  "type": "module",
6
6
  "main": "bin/ethagent.js",
@@ -56,6 +56,7 @@
56
56
  "react": "^19.2.4",
57
57
  "tsx": "^4.21.0",
58
58
  "viem": "^2.48.4",
59
+ "wrap-ansi": "^9.0.2",
59
60
  "zod": "^3.25.76"
60
61
  },
61
62
  "devDependencies": {
@@ -1,7 +1,8 @@
1
- import React, { useEffect, useState } from 'react'
1
+ import React, { useEffect, useRef, useState } from 'react'
2
2
  import { Box, Text } from 'ink'
3
3
  import { BrandSplash as Splash } from '../ui/BrandSplash.js'
4
4
  import { Select } from '../ui/Select.js'
5
+ import { Spinner } from '../ui/Spinner.js'
5
6
  import { Surface } from '../ui/Surface.js'
6
7
  import { TextInput } from '../ui/TextInput.js'
7
8
  import { theme } from '../ui/theme.js'
@@ -10,6 +11,9 @@ import { formatModelDisplayName } from '../models/modelDisplay.js'
10
11
  import { providerDisplayName } from '../models/providerDisplay.js'
11
12
  import { detectSpec, type SpecSnapshot } from '../models/runtimeDetection.js'
12
13
  import { FEATURED_HF_REPO_URL } from '../models/modelRecommendation.js'
14
+ import { OPENAI_OAUTH_DEFAULT_MODEL } from '../models/catalog.js'
15
+ import { OpenAIOAuthService } from '../auth/openaiOAuth/index.js'
16
+ import { openExternalUrl } from '../utils/openExternal.js'
13
17
  import {
14
18
  loadConfig,
15
19
  saveConfigWithMerge,
@@ -31,6 +35,8 @@ type Step =
31
35
  | { kind: 'choose-path'; spec: SpecSnapshot }
32
36
  | { kind: 'hf-setup'; spec: SpecSnapshot }
33
37
  | { kind: 'cloud-provider' }
38
+ | { kind: 'cloud-openai-auth' }
39
+ | { kind: 'cloud-openai-oauth'; phase: 'waiting' | 'exchanging' | 'error'; url?: string; message?: string }
34
40
  | { kind: 'cloud-key'; provider: ProviderId; error?: string }
35
41
  | { kind: 'cloud-key-saving'; provider: ProviderId }
36
42
  | { kind: 'cloud-model'; provider: ProviderId }
@@ -44,17 +50,19 @@ type FirstRunProps = {
44
50
  }
45
51
 
46
52
  const TITLE: Record<string, string> = {
47
- 'detecting': 'Inspecting Machine',
48
- 'detect-error': 'Detection Failed',
49
- 'choose-path': 'Choose How To Run',
50
- 'hf-setup': 'Local Model',
51
- 'cloud-provider': 'Pick A Cloud Provider',
52
- 'cloud-key': 'Paste API Key',
53
- 'cloud-key-saving': 'Storing Key',
54
- 'cloud-model': 'Pick A Model',
55
- 'saving': 'Saving Config',
56
- 'save-error': 'Save Failed',
57
- 'done': 'Ready',
53
+ 'detecting': 'Inspecting Machine',
54
+ 'detect-error': 'Detection Failed',
55
+ 'choose-path': 'Choose How To Run',
56
+ 'hf-setup': 'Local Model',
57
+ 'cloud-provider': 'Pick A Cloud Provider',
58
+ 'cloud-openai-auth': 'Sign in with ChatGPT or API Key',
59
+ 'cloud-openai-oauth': 'Sign in with ChatGPT',
60
+ 'cloud-key': 'Paste API Key',
61
+ 'cloud-key-saving': 'Storing Key',
62
+ 'cloud-model': 'Pick A Model',
63
+ 'saving': 'Saving Config',
64
+ 'save-error': 'Save Failed',
65
+ 'done': 'Ready',
58
66
  }
59
67
 
60
68
  const NAV_BACK = '↑↓ navigate · enter select · esc back'
@@ -65,6 +73,7 @@ export const FirstRun: React.FC<FirstRunProps> = ({ onComplete, onCancel }) => {
65
73
  const [history, setHistory] = useState<Step[]>([])
66
74
  const [firstRunIdentity, setFirstRunIdentity] = useState<EthagentConfig['identity']>(undefined)
67
75
  const [firstRunConfig, setFirstRunConfig] = useState<EthagentConfig | undefined>(undefined)
76
+ const oauthServiceRef = useRef<OpenAIOAuthService | null>(null)
68
77
 
69
78
  useEffect(() => {
70
79
  let cancelled = false
@@ -171,6 +180,51 @@ export const FirstRun: React.FC<FirstRunProps> = ({ onComplete, onCancel }) => {
171
180
 
172
181
  const navHint = (canBack: boolean): string => canBack ? NAV_BACK : NAV_CANCEL
173
182
 
183
+ const startFirstRunOpenAIOAuth = async (): Promise<void> => {
184
+ oauthServiceRef.current?.cleanup()
185
+ const service = new OpenAIOAuthService()
186
+ oauthServiceRef.current = service
187
+ goTo({ kind: 'cloud-openai-oauth', phase: 'waiting' })
188
+ try {
189
+ const result = await service.start(authUrl => {
190
+ openExternalUrl(authUrl)
191
+ setStep({ kind: 'cloud-openai-oauth', phase: 'waiting', url: authUrl })
192
+ })
193
+ if (oauthServiceRef.current !== service) return
194
+ setStep({ kind: 'cloud-openai-oauth', phase: 'exchanging' })
195
+ if (result.kind === 'apikey') {
196
+ if (typeof result.apiKey !== 'string' || result.apiKey.length === 0) {
197
+ throw new Error('OAuth result was apikey kind but no key was returned')
198
+ }
199
+ await setKey('openai', result.apiKey)
200
+ }
201
+ if (oauthServiceRef.current !== service) return
202
+ oauthServiceRef.current = null
203
+ if (result.kind === 'oauth-only') {
204
+ setStep({
205
+ kind: 'saving',
206
+ config: withFirstRunIdentity({
207
+ version: 1,
208
+ provider: 'openai',
209
+ model: OPENAI_OAUTH_DEFAULT_MODEL,
210
+ firstRunAt: new Date().toISOString(),
211
+ }),
212
+ })
213
+ return
214
+ }
215
+ setStep({ kind: 'cloud-model', provider: 'openai' })
216
+ } catch (err: unknown) {
217
+ if (oauthServiceRef.current !== service) return
218
+ oauthServiceRef.current = null
219
+ const message = err instanceof Error ? err.message : String(err)
220
+ if (message === 'OpenAI sign-in was cancelled.') {
221
+ setStep({ kind: 'cloud-openai-auth' })
222
+ return
223
+ }
224
+ setStep({ kind: 'cloud-openai-oauth', phase: 'error', message })
225
+ }
226
+ }
227
+
174
228
  const withFirstRunIdentity = (config: EthagentConfig): EthagentConfig => {
175
229
  const merged: EthagentConfig = firstRunConfig
176
230
  ? {
@@ -271,11 +325,12 @@ export const FirstRun: React.FC<FirstRunProps> = ({ onComplete, onCancel }) => {
271
325
  {spec.isAppleSilicon ? ', Apple Silicon' : ''}
272
326
  </Text>
273
327
  </Box>
274
- <Select<'cloud' | 'hf'>
328
+ <Select<'hf' | 'cloud'>
275
329
  options={[
276
- { value: 'cloud', label: 'Cloud API', hint: 'OpenAI, Anthropic, or Gemini' },
277
330
  { value: 'hf', label: 'Local Model', hint: 'Download and run locally' },
331
+ { value: 'cloud', label: 'Cloud API', hint: 'OpenAI, Anthropic, or Gemini' },
278
332
  ]}
333
+ hintLayout="inline"
279
334
  onSubmit={choice => {
280
335
  if (choice === 'cloud') goTo({ kind: 'cloud-provider' })
281
336
  else goTo({ kind: 'hf-setup', spec })
@@ -300,6 +355,7 @@ export const FirstRun: React.FC<FirstRunProps> = ({ onComplete, onCancel }) => {
300
355
  currentProvider="llamacpp"
301
356
  currentModel={modelPickerConfig.model}
302
357
  featuredHfRepo={FEATURED_HF_REPO_URL}
358
+ localOnly
303
359
  onPick={(selection: ModelPickerSelection) => {
304
360
  goTo({ kind: 'saving', config: configFromModelPickerSelection(selection, modelPickerConfig) })
305
361
  }}
@@ -316,12 +372,96 @@ export const FirstRun: React.FC<FirstRunProps> = ({ onComplete, onCancel }) => {
316
372
  { value: 'anthropic', label: 'Anthropic' },
317
373
  { value: 'gemini', label: 'Gemini' },
318
374
  ]}
319
- onSubmit={provider => goTo({ kind: 'cloud-key', provider })}
375
+ onSubmit={provider => {
376
+ if (provider === 'openai') goTo({ kind: 'cloud-openai-auth' })
377
+ else goTo({ kind: 'cloud-key', provider })
378
+ }}
320
379
  onCancel={goBack}
321
380
  />
322
381
  ), navHint(true))
323
382
  }
324
383
 
384
+ if (step.kind === 'cloud-openai-auth') {
385
+ return renderShell(step.kind, TITLE['cloud-openai-auth']!, (
386
+ <Select<'oauth' | 'apikey'>
387
+ options={[
388
+ { value: 'oauth', role: 'section', label: 'Recommended' },
389
+ { value: 'oauth', label: 'Sign in with ChatGPT', hint: 'Use your ChatGPT subscription', bold: true },
390
+ { value: 'apikey', role: 'section', label: 'Alternative' },
391
+ { value: 'apikey', label: 'Paste API Key', hint: 'Use an OpenAI platform key', role: 'utility' },
392
+ ]}
393
+ hintLayout="inline"
394
+ onSubmit={choice => {
395
+ if (choice === 'oauth') void startFirstRunOpenAIOAuth()
396
+ else goTo({ kind: 'cloud-key', provider: 'openai' })
397
+ }}
398
+ onCancel={goBack}
399
+ />
400
+ ), navHint(true))
401
+ }
402
+
403
+ if (step.kind === 'cloud-openai-oauth') {
404
+ if (step.phase === 'error') {
405
+ return renderShell(step.kind, TITLE['cloud-openai-oauth']!, (
406
+ <>
407
+ <Text color={theme.accentError}>{step.message ?? 'Sign-in did not complete.'}</Text>
408
+ <Box marginTop={1}>
409
+ <Select<'retry' | 'apikey' | 'back'>
410
+ options={[
411
+ { value: 'retry', role: 'section', label: 'Recovery' },
412
+ { value: 'retry', label: 'Try Again', hint: 'Reopen the browser sign-in flow' },
413
+ { value: 'apikey', label: 'Use API Key Instead', hint: 'Paste an OpenAI platform key' },
414
+ { value: 'back', role: 'section', label: 'Navigation' },
415
+ { value: 'back', label: 'Back', hint: 'Return to sign-in choice', role: 'utility' },
416
+ ]}
417
+ hintLayout="inline"
418
+ onSubmit={choice => {
419
+ if (choice === 'retry') void startFirstRunOpenAIOAuth()
420
+ else if (choice === 'apikey') goTo({ kind: 'cloud-key', provider: 'openai' })
421
+ else goTo({ kind: 'cloud-openai-auth' })
422
+ }}
423
+ onCancel={() => goTo({ kind: 'cloud-openai-auth' })}
424
+ />
425
+ </Box>
426
+ </>
427
+ ), navHint(true))
428
+ }
429
+ if (step.phase === 'exchanging') {
430
+ return renderShell(step.kind, TITLE['cloud-openai-oauth']!, (
431
+ <Box marginTop={1}>
432
+ <Spinner label="completing sign-in..." />
433
+ </Box>
434
+ ))
435
+ }
436
+ return renderShell(step.kind, TITLE['cloud-openai-oauth']!, (
437
+ <>
438
+ <Spinner label="waiting for browser sign-in..." />
439
+ {step.url ? (
440
+ <Box flexDirection="column" marginTop={1}>
441
+ <Text color={theme.dim}>If the browser did not open, visit:</Text>
442
+ <Text color={theme.dim}>{step.url}</Text>
443
+ </Box>
444
+ ) : null}
445
+ <Box marginTop={1}>
446
+ <Select<'cancel'>
447
+ options={[{ value: 'cancel', label: 'Cancel Sign-in', role: 'utility' }]}
448
+ hintLayout="inline"
449
+ onSubmit={() => {
450
+ oauthServiceRef.current?.cleanup()
451
+ oauthServiceRef.current = null
452
+ goTo({ kind: 'cloud-openai-auth' })
453
+ }}
454
+ onCancel={() => {
455
+ oauthServiceRef.current?.cleanup()
456
+ oauthServiceRef.current = null
457
+ goTo({ kind: 'cloud-openai-auth' })
458
+ }}
459
+ />
460
+ </Box>
461
+ </>
462
+ ))
463
+ }
464
+
325
465
  if (step.kind === 'cloud-key' || step.kind === 'cloud-key-saving') {
326
466
  const provider = step.provider
327
467
  const saving = step.kind === 'cloud-key-saving'
@@ -435,6 +575,7 @@ function configFromModelPickerSelection(selection: ModelPickerSelection, base: E
435
575
  provider: 'llamacpp',
436
576
  model: selection.model,
437
577
  baseUrl: defaultBaseUrlFor('llamacpp'),
578
+ localMmprojPath: selection.mmprojPath,
438
579
  }
439
580
  }
440
581
  return {
@@ -442,6 +583,7 @@ function configFromModelPickerSelection(selection: ModelPickerSelection, base: E
442
583
  provider: selection.provider,
443
584
  model: selection.model,
444
585
  baseUrl: undefined,
586
+ localMmprojPath: undefined,
445
587
  }
446
588
  }
447
589
 
@@ -11,6 +11,8 @@ export type FirstRunStepKind =
11
11
  | 'choose-path'
12
12
  | 'hf-setup'
13
13
  | 'cloud-provider'
14
+ | 'cloud-openai-auth'
15
+ | 'cloud-openai-oauth'
14
16
  | 'cloud-key'
15
17
  | 'cloud-key-saving'
16
18
  | 'cloud-model'
@@ -29,6 +31,8 @@ export function firstRunStageNumber(stepKind: FirstRunStepKind): number {
29
31
  case 'choose-path':
30
32
  case 'hf-setup':
31
33
  case 'cloud-provider':
34
+ case 'cloud-openai-auth':
35
+ case 'cloud-openai-oauth':
32
36
  case 'cloud-key':
33
37
  case 'cloud-key-saving':
34
38
  case 'cloud-model':
@@ -1,3 +1,6 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
1
4
  import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react'
2
5
  import { useStdin, useStdout } from 'ink'
3
6
  import type { AppInputEvent } from './appInputParser.js'
@@ -13,6 +16,21 @@ import {
13
16
  parseAppInput,
14
17
  } from './appInputParser.js'
15
18
 
19
+ const DEBUG_INPUT = Boolean(process.env.ETHAGENT_DEBUG_INPUT)
20
+ const DEBUG_LOG_PATH = path.join(os.homedir(), '.ethagent', 'input-debug.log')
21
+
22
+ function logRawChunk(chunk: Buffer | string): void {
23
+ if (!DEBUG_INPUT) return
24
+ try {
25
+ const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8')
26
+ const codepoints = [...text].map(c => c.codePointAt(0)?.toString(16).padStart(4, '0') ?? '????').join(' ')
27
+ const hex = Buffer.isBuffer(chunk) ? chunk.toString('hex') : Buffer.from(chunk, 'utf8').toString('hex')
28
+ const line = `[${new Date().toISOString()}] codepoints=${codepoints} hex=${hex} text=${JSON.stringify(text)}\n`
29
+ fs.appendFileSync(DEBUG_LOG_PATH, line)
30
+ } catch {
31
+ }
32
+ }
33
+
16
34
  type InputHandler = (input: string, key: AppInputEvent['key'], event: AppInputEvent) => void
17
35
 
18
36
  type HandlerEntry = {
@@ -57,6 +75,7 @@ export const AppInputProvider: React.FC<{ children: React.ReactNode }> = ({ chil
57
75
  if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return
58
76
 
59
77
  const handleData = (chunk: Buffer | string) => {
78
+ logRawChunk(chunk)
60
79
  if (flushTimerRef.current) {
61
80
  clearTimeout(flushTimerRef.current)
62
81
  flushTimerRef.current = null
@@ -175,9 +175,11 @@ function parseNormalInput(source: string, flushing: boolean): NormalParseResult
175
175
 
176
176
  if (source.length >= 2) {
177
177
  const next = source.slice(1, 2)
178
+ const code = next.charCodeAt(0)
179
+ const altInput = code >= 0x20 && code <= 0x7e ? next : ''
178
180
  return {
179
181
  kind: 'event',
180
- event: createInputEvent(next, { meta: true }, source.slice(0, 2)),
182
+ event: createInputEvent(altInput, { meta: true }, source.slice(0, 2)),
181
183
  length: 2,
182
184
  }
183
185
  }
@@ -189,6 +191,18 @@ function parseNormalInput(source: string, flushing: boolean): NormalParseResult
189
191
  }
190
192
  }
191
193
 
194
+ const PRINTABLE_CHAR_RE = /[\p{L}\p{N}\p{M}\p{P}\p{S}\p{Z}]/u
195
+
196
+ function stripNoise(text: string): string {
197
+ let out = ''
198
+ for (const ch of text) {
199
+ if (ch === '\uFFFD') continue
200
+ if (!PRINTABLE_CHAR_RE.test(ch)) continue
201
+ out += ch
202
+ }
203
+ return out
204
+ }
205
+
192
206
  function createTextEvent(text: string): AppInputEvent {
193
207
  if (text === '\r' || text === '\n') return createInputEvent('', { return: true }, text)
194
208
  if (text === '\t') return createInputEvent('', { tab: true }, text)
@@ -204,7 +218,8 @@ function createTextEvent(text: string): AppInputEvent {
204
218
  }
205
219
  if (/[A-Z]/.test(text)) return createInputEvent(text, { shift: true }, text)
206
220
  }
207
- return createInputEvent(text, {}, text)
221
+ const cleaned = stripNoise(text)
222
+ return createInputEvent(cleaned, {}, text)
208
223
  }
209
224
 
210
225
  function keycodeEvent(raw: string, codepoint: number, modifier: number): AppInputEvent {
@@ -212,8 +227,8 @@ function keycodeEvent(raw: string, codepoint: number, modifier: number): AppInpu
212
227
  if (codepoint === 9) return createInputEvent('', { ...key, tab: true }, raw)
213
228
  if (codepoint === 13) return createInputEvent('', { ...key, return: true }, raw)
214
229
  if (codepoint === 27) return createInputEvent('', { ...key, escape: true, meta: true }, raw)
215
- const char = String.fromCodePoint(codepoint)
216
- return createInputEvent(char, key, raw)
230
+ const printable = codepoint >= 0x20 && codepoint <= 0x7e
231
+ return createInputEvent(printable ? String.fromCodePoint(codepoint) : '', key, raw)
217
232
  }
218
233
 
219
234
  function decodeModifier(modifier: number): Partial<Key> {
@@ -13,6 +13,7 @@ import { ChatInput } from './input/ChatInput.js'
13
13
  import { IdentityHub, type IdentityHubInitialAction, type IdentityHubResult } from '../identity/hub/IdentityHub.js'
14
14
  import type { CopyResult } from '../utils/clipboard.js'
15
15
  import { getSlashSuggestions } from './commands.js'
16
+ import { modelSupportsImages } from '../utils/images.js'
16
17
  import { Box, Text } from 'ink'
17
18
  import { theme } from '../ui/theme.js'
18
19
  import { Spinner } from '../ui/Spinner.js'
@@ -54,6 +55,7 @@ type ChatBottomPaneProps = {
54
55
  history: string[]
55
56
  busy: boolean
56
57
  streaming: boolean
58
+ streamingStartedAt: number | null
57
59
  activity: BottomPaneActivity
58
60
  placeholderHints: string[]
59
61
  queuedInputs: string[]
@@ -100,6 +102,7 @@ export function ChatBottomPane({
100
102
  history,
101
103
  busy,
102
104
  streaming,
105
+ streamingStartedAt,
103
106
  activity,
104
107
  placeholderHints,
105
108
  queuedInputs,
@@ -256,7 +259,7 @@ export function ChatBottomPane({
256
259
  </Box>
257
260
  ) : streaming ? (
258
261
  <Box marginLeft={2} marginBottom={1}>
259
- <Spinner active hint="esc to cancel" />
262
+ <Spinner active hint="esc to cancel" startedAt={streamingStartedAt ?? undefined} />
260
263
  </Box>
261
264
  ) : null}
262
265
  <ChatInput
@@ -270,6 +273,14 @@ export function ChatBottomPane({
270
273
  cwd={cwd}
271
274
  seedText={pendingInputDraft}
272
275
  onSeedConsumed={onInputDraftConsumed}
276
+ onImagePaste={() => {
277
+ if (!modelSupportsImages(config.provider, config.model, { mmprojPath: config.localMmprojPath })) {
278
+ const hint = config.provider === 'llamacpp'
279
+ ? ' · run "Add Vision Encoder" in alt+p to enable image input on this model'
280
+ : ' · switch via alt+p'
281
+ pushNote(`current model "${config.model}" does not accept image input${hint}`, 'error')
282
+ }
283
+ }}
273
284
  />
274
285
  <Box marginLeft={2} marginTop={0} flexDirection="column">
275
286
  <Text>
@@ -134,6 +134,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
134
134
  const [rows, setRows] = useState<MessageRow[]>([])
135
135
  const [history, setHistory] = useState<string[]>([])
136
136
  const [streaming, setStreaming] = useState(false)
137
+ const [streamingStartedAt, setStreamingStartedAt] = useState<number | null>(null)
137
138
  const [queuedInputs, setQueuedInputs] = useState<string[]>([])
138
139
  const [turns, setTurns] = useState(0)
139
140
  const [approxTokens, setApproxTokens] = useState(0)
@@ -840,6 +841,10 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
840
841
  const controller = new AbortController()
841
842
  streamAbortRef.current = controller
842
843
  let planCandidate: PendingPlan | null = null
844
+ const setStreamingWithStart = (value: boolean) => {
845
+ setStreaming(value)
846
+ setStreamingStartedAt(value ? Date.now() : null)
847
+ }
843
848
  const result = await runStreamingTurn({
844
849
  provider: turnProvider,
845
850
  mode: activeMode,
@@ -854,7 +859,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
854
859
  getDisplayCwd: () => compressHome(cwdRef.current),
855
860
  getSessionMessages: () => sessionMessagesRef.current,
856
861
  setActiveCheckpoint: checkpoint => { activeCheckpointRef.current = checkpoint },
857
- setStreaming,
862
+ setStreaming: setStreamingWithStart,
858
863
  updateRows,
859
864
  pushNote,
860
865
  persistTurnMessage: message => persistSessionMessage(attachActiveTurn(message)),
@@ -1197,7 +1202,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
1197
1202
  clearTranscript()
1198
1203
  overlayRef.current = 'none'
1199
1204
  setOverlay('none')
1200
- pushNote('Cleared saved chat logs and resume context from this machine.', 'dim')
1205
+ pushNote('Cleared saved sessions and resume context from this machine.', 'dim')
1201
1206
  },
1202
1207
  [clearTranscript, pushNote],
1203
1208
  )
@@ -1517,17 +1522,23 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
1517
1522
 
1518
1523
  const exitHint = exitState.pending ? 'ctrl+c again to quit' : null
1519
1524
  const runtimeModeLabel = sessionModeLabel(mode)
1525
+ const runtimeModeColor =
1526
+ mode === 'plan'
1527
+ ? theme.modePlan
1528
+ : mode === 'accept-edits'
1529
+ ? theme.modeAcceptEdits
1530
+ : theme.text
1520
1531
  const footerRight = (
1521
1532
  <Box flexDirection="row">
1522
1533
  {exitHint ? (
1523
1534
  <>
1524
- <Text color={theme.text}>{exitHint}</Text>
1535
+ <Text color={theme.accentPeriwinkle}>{exitHint}</Text>
1525
1536
  <Text color={theme.dim}> · </Text>
1526
1537
  </>
1527
1538
  ) : null}
1528
1539
  {runtimeModeLabel ? (
1529
1540
  <>
1530
- <Text bold>{runtimeModeLabel}</Text>
1541
+ <Text color={runtimeModeColor} bold>{runtimeModeLabel}</Text>
1531
1542
  <Text color={theme.dim}> (</Text>
1532
1543
  <Text color={theme.accentPeriwinkle}>shift+tab to cycle</Text>
1533
1544
  <Text color={theme.dim}>) · </Text>
@@ -1563,6 +1574,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
1563
1574
  history={history}
1564
1575
  busy={busy}
1565
1576
  streaming={streaming}
1577
+ streamingStartedAt={streamingStartedAt}
1566
1578
  activity={null}
1567
1579
  placeholderHints={placeholderHints}
1568
1580
  queuedInputs={queuedInputs}
@@ -1613,7 +1625,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
1613
1625
  }
1614
1626
 
1615
1627
  export function chatFooterShortcutText(canScrollTranscript: boolean): string {
1616
- return `${canScrollTranscript ? 'pgup/pgdn scroll · ' : ''}alt+p model · alt+i identity`
1628
+ return 'alt+p model · alt+i identity'
1617
1629
  }
1618
1630
 
1619
1631
  function formatContextLabel(usage: ContextUsage): string {
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { Box } from 'ink'
2
+ import { Box, Static } from 'ink'
3
3
  import { TranscriptView } from './transcript/TranscriptView.js'
4
4
  import type { MessageRow } from './MessageList.js'
5
5
 
@@ -27,24 +27,30 @@ export const ConversationStack: React.FC<ConversationStackProps> = ({
27
27
  onTranscriptScrollabilityChange,
28
28
  }) => {
29
29
  return (
30
- <Box flexDirection="column" padding={1}>
31
- {header}
32
- <TranscriptView
33
- key={`transcript-${sessionKey}`}
34
- rows={rows}
35
- active={transcriptActive}
36
- bottomVariant={bottomVariant}
37
- onVisibleReasoningIdsChange={onVisibleReasoningIdsChange}
38
- onScrollabilityChange={onTranscriptScrollabilityChange}
39
- />
40
- <Box marginTop={1} width="100%">
41
- {bottom}
42
- </Box>
43
- {status ? (
44
- <Box marginTop={1}>
45
- {status}
46
- </Box>
30
+ <>
31
+ {header ? (
32
+ <Static items={[{ id: `header-${sessionKey}`, node: header }]}>
33
+ {item => <Box key={item.id} paddingX={1} paddingTop={1}>{item.node}</Box>}
34
+ </Static>
47
35
  ) : null}
48
- </Box>
36
+ <Box flexDirection="column" padding={1}>
37
+ <TranscriptView
38
+ key={`transcript-${sessionKey}`}
39
+ rows={rows}
40
+ active={transcriptActive}
41
+ bottomVariant={bottomVariant}
42
+ onVisibleReasoningIdsChange={onVisibleReasoningIdsChange}
43
+ onScrollabilityChange={onTranscriptScrollabilityChange}
44
+ />
45
+ <Box marginTop={1} width="100%">
46
+ {bottom}
47
+ </Box>
48
+ {status ? (
49
+ <Box marginTop={1}>
50
+ {status}
51
+ </Box>
52
+ ) : null}
53
+ </Box>
54
+ </>
49
55
  )
50
56
  }