ethagent 2.4.0 → 3.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 (103) hide show
  1. package/README.md +7 -4
  2. package/package.json +2 -1
  3. package/src/app/FirstRun.tsx +155 -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 +3 -1
  8. package/src/chat/ChatScreen.tsx +7 -1
  9. package/src/chat/ConversationStack.tsx +25 -19
  10. package/src/chat/MessageList.tsx +194 -53
  11. package/src/chat/chatSessionState.ts +1 -1
  12. package/src/chat/chatTurnOrchestrator.ts +59 -0
  13. package/src/chat/input/ChatInput.tsx +3 -0
  14. package/src/chat/input/textCursor.ts +13 -3
  15. package/src/chat/transcript/TranscriptView.tsx +7 -5
  16. package/src/chat/transcript/transcriptViewport.ts +88 -17
  17. package/src/chat/views/PermissionPrompt.tsx +26 -26
  18. package/src/chat/views/PermissionsView.tsx +18 -12
  19. package/src/chat/views/RewindView.tsx +3 -1
  20. package/src/cli/ResetConfirmView.tsx +24 -9
  21. package/src/identity/continuity/editor.ts +27 -2
  22. package/src/identity/continuity/envelope.ts +134 -9
  23. package/src/identity/continuity/publicSkills.ts +54 -1
  24. package/src/identity/continuity/skills/frontmatter.ts +183 -0
  25. package/src/identity/continuity/skills/loadSkills.ts +609 -0
  26. package/src/identity/continuity/skills/publicSkillsSync.ts +32 -0
  27. package/src/identity/continuity/skills/scaffold.ts +52 -0
  28. package/src/identity/continuity/skills/types.ts +30 -0
  29. package/src/identity/continuity/storage/defaults.ts +28 -47
  30. package/src/identity/continuity/storage/files.ts +1 -0
  31. package/src/identity/continuity/storage/paths.ts +1 -0
  32. package/src/identity/continuity/storage/scaffold.ts +25 -23
  33. package/src/identity/continuity/storage/status.ts +34 -5
  34. package/src/identity/continuity/storage/types.ts +3 -2
  35. package/src/identity/continuity/storage.ts +3 -0
  36. package/src/identity/hub/OperationalRoutes.tsx +79 -5
  37. package/src/identity/hub/Routes.tsx +5 -3
  38. package/src/identity/hub/continuity/ContinuityDashboardScreen.tsx +7 -73
  39. package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +6 -6
  40. package/src/identity/hub/continuity/SavePromptScreen.tsx +1 -2
  41. package/src/identity/hub/continuity/effects.ts +36 -5
  42. package/src/identity/hub/continuity/skills/DeleteSkillConfirmScreen.tsx +112 -0
  43. package/src/identity/hub/continuity/skills/NewSkillScreen.tsx +57 -0
  44. package/src/identity/hub/continuity/skills/NewSkillVisibilityScreen.tsx +52 -0
  45. package/src/identity/hub/continuity/skills/SkillActionsScreen.tsx +151 -0
  46. package/src/identity/hub/continuity/skills/SkillsTreeScreen.tsx +181 -0
  47. package/src/identity/hub/continuity/snapshot.ts +3 -0
  48. package/src/identity/hub/continuity/state.ts +9 -8
  49. package/src/identity/hub/continuity/vault.ts +42 -10
  50. package/src/identity/hub/create/CreateFlow.tsx +1 -1
  51. package/src/identity/hub/custody/CustodyEditFlow.tsx +3 -3
  52. package/src/identity/hub/custody/routes.tsx +1 -1
  53. package/src/identity/hub/ens/EnsEditAdvancedScreens.tsx +0 -1
  54. package/src/identity/hub/ens/EnsEditMaintenanceScreens.tsx +0 -1
  55. package/src/identity/hub/identityHubReducer.ts +15 -0
  56. package/src/identity/hub/profile/EditProfileFlow.tsx +5 -5
  57. package/src/identity/hub/profile/effects.ts +16 -3
  58. package/src/identity/hub/restore/RestoreFlow.tsx +43 -6
  59. package/src/identity/hub/restore/apply.ts +12 -1
  60. package/src/identity/hub/restore/recovery.ts +14 -4
  61. package/src/identity/hub/restore/resolve.ts +1 -1
  62. package/src/identity/hub/restore/useRestoreEffects.ts +4 -6
  63. package/src/identity/hub/shared/components/DetailsScreen.tsx +4 -1
  64. package/src/identity/hub/shared/components/IdentitySummary.tsx +118 -54
  65. package/src/identity/hub/shared/components/MenuScreen.tsx +21 -18
  66. package/src/identity/hub/shared/components/UnlinkedIdentityScreen.tsx +4 -4
  67. package/src/identity/hub/shared/components/menuFlagsFromReconciliation.ts +8 -12
  68. package/src/identity/hub/shared/effects/sync.ts +16 -3
  69. package/src/identity/hub/shared/model/copy.ts +2 -4
  70. package/src/identity/hub/transfer/effects.ts +15 -2
  71. package/src/identity/hub/useIdentityHubContinuity.ts +145 -23
  72. package/src/identity/hub/useIdentityHubController.ts +5 -1
  73. package/src/identity/hub/useIdentityHubSideEffects.ts +2 -4
  74. package/src/identity/wallet/page/copy.ts +43 -43
  75. package/src/mcp/manager.ts +1 -1
  76. package/src/models/ModelPicker.tsx +89 -84
  77. package/src/models/llamacpp.ts +160 -11
  78. package/src/models/llamacppPreflight.ts +1 -16
  79. package/src/models/modelPickerOptions.ts +45 -37
  80. package/src/providers/contracts.ts +1 -0
  81. package/src/providers/openai-chat.ts +50 -9
  82. package/src/providers/openai-responses.ts +19 -4
  83. package/src/runtime/toolExecution.ts +4 -3
  84. package/src/runtime/turn.ts +61 -30
  85. package/src/tools/changeDirectoryTool.ts +1 -1
  86. package/src/tools/contracts.ts +10 -0
  87. package/src/tools/deleteFileTool.ts +1 -1
  88. package/src/tools/editTool.ts +1 -1
  89. package/src/tools/listDirectoryTool.ts +1 -1
  90. package/src/tools/listSkillFilesTool.ts +77 -0
  91. package/src/tools/listSkillsTool.ts +68 -0
  92. package/src/tools/mcpResourceTools.ts +2 -2
  93. package/src/tools/privateContinuityReadTool.ts +1 -1
  94. package/src/tools/readSkillTool.ts +107 -0
  95. package/src/tools/readTool.ts +1 -1
  96. package/src/tools/registry.ts +6 -0
  97. package/src/tools/writeFileTool.ts +22 -2
  98. package/src/ui/Spinner.tsx +1 -1
  99. package/src/identity/continuity/localBackup.ts +0 -249
  100. package/src/identity/continuity/zipWriter.ts +0 -95
  101. package/src/identity/hub/continuity/index.ts +0 -7
  102. package/src/identity/hub/ens/index.ts +0 -11
  103. 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ethagent",
3
- "version": "2.4.0",
3
+ "version": "3.0.1",
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'
@@ -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> {
@@ -55,6 +55,7 @@ type ChatBottomPaneProps = {
55
55
  history: string[]
56
56
  busy: boolean
57
57
  streaming: boolean
58
+ streamingStartedAt: number | null
58
59
  activity: BottomPaneActivity
59
60
  placeholderHints: string[]
60
61
  queuedInputs: string[]
@@ -101,6 +102,7 @@ export function ChatBottomPane({
101
102
  history,
102
103
  busy,
103
104
  streaming,
105
+ streamingStartedAt,
104
106
  activity,
105
107
  placeholderHints,
106
108
  queuedInputs,
@@ -257,7 +259,7 @@ export function ChatBottomPane({
257
259
  </Box>
258
260
  ) : streaming ? (
259
261
  <Box marginLeft={2} marginBottom={1}>
260
- <Spinner active hint="esc to cancel" />
262
+ <Spinner active hint="esc to cancel" startedAt={streamingStartedAt ?? undefined} />
261
263
  </Box>
262
264
  ) : null}
263
265
  <ChatInput
@@ -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)),
@@ -1569,6 +1574,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
1569
1574
  history={history}
1570
1575
  busy={busy}
1571
1576
  streaming={streaming}
1577
+ streamingStartedAt={streamingStartedAt}
1572
1578
  activity={null}
1573
1579
  placeholderHints={placeholderHints}
1574
1580
  queuedInputs={queuedInputs}
@@ -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
  }