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.
- package/README.md +7 -4
- package/package.json +2 -1
- package/src/app/FirstRun.tsx +155 -15
- package/src/app/FirstRunTimeline.tsx +4 -0
- package/src/app/input/AppInputProvider.tsx +19 -0
- package/src/app/input/appInputParser.ts +19 -4
- package/src/chat/ChatBottomPane.tsx +3 -1
- package/src/chat/ChatScreen.tsx +7 -1
- package/src/chat/ConversationStack.tsx +25 -19
- package/src/chat/MessageList.tsx +194 -53
- package/src/chat/chatSessionState.ts +1 -1
- package/src/chat/chatTurnOrchestrator.ts +59 -0
- package/src/chat/input/ChatInput.tsx +3 -0
- package/src/chat/input/textCursor.ts +13 -3
- package/src/chat/transcript/TranscriptView.tsx +7 -5
- package/src/chat/transcript/transcriptViewport.ts +88 -17
- package/src/chat/views/PermissionPrompt.tsx +26 -26
- package/src/chat/views/PermissionsView.tsx +18 -12
- package/src/chat/views/RewindView.tsx +3 -1
- package/src/cli/ResetConfirmView.tsx +24 -9
- package/src/identity/continuity/editor.ts +27 -2
- package/src/identity/continuity/envelope.ts +134 -9
- package/src/identity/continuity/publicSkills.ts +54 -1
- package/src/identity/continuity/skills/frontmatter.ts +183 -0
- package/src/identity/continuity/skills/loadSkills.ts +609 -0
- package/src/identity/continuity/skills/publicSkillsSync.ts +32 -0
- package/src/identity/continuity/skills/scaffold.ts +52 -0
- package/src/identity/continuity/skills/types.ts +30 -0
- package/src/identity/continuity/storage/defaults.ts +28 -47
- package/src/identity/continuity/storage/files.ts +1 -0
- package/src/identity/continuity/storage/paths.ts +1 -0
- package/src/identity/continuity/storage/scaffold.ts +25 -23
- package/src/identity/continuity/storage/status.ts +34 -5
- package/src/identity/continuity/storage/types.ts +3 -2
- package/src/identity/continuity/storage.ts +3 -0
- package/src/identity/hub/OperationalRoutes.tsx +79 -5
- package/src/identity/hub/Routes.tsx +5 -3
- package/src/identity/hub/continuity/ContinuityDashboardScreen.tsx +7 -73
- package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +6 -6
- package/src/identity/hub/continuity/SavePromptScreen.tsx +1 -2
- package/src/identity/hub/continuity/effects.ts +36 -5
- package/src/identity/hub/continuity/skills/DeleteSkillConfirmScreen.tsx +112 -0
- package/src/identity/hub/continuity/skills/NewSkillScreen.tsx +57 -0
- package/src/identity/hub/continuity/skills/NewSkillVisibilityScreen.tsx +52 -0
- package/src/identity/hub/continuity/skills/SkillActionsScreen.tsx +151 -0
- package/src/identity/hub/continuity/skills/SkillsTreeScreen.tsx +181 -0
- package/src/identity/hub/continuity/snapshot.ts +3 -0
- package/src/identity/hub/continuity/state.ts +9 -8
- package/src/identity/hub/continuity/vault.ts +42 -10
- package/src/identity/hub/create/CreateFlow.tsx +1 -1
- package/src/identity/hub/custody/CustodyEditFlow.tsx +3 -3
- package/src/identity/hub/custody/routes.tsx +1 -1
- package/src/identity/hub/ens/EnsEditAdvancedScreens.tsx +0 -1
- package/src/identity/hub/ens/EnsEditMaintenanceScreens.tsx +0 -1
- package/src/identity/hub/identityHubReducer.ts +15 -0
- package/src/identity/hub/profile/EditProfileFlow.tsx +5 -5
- package/src/identity/hub/profile/effects.ts +16 -3
- package/src/identity/hub/restore/RestoreFlow.tsx +43 -6
- package/src/identity/hub/restore/apply.ts +12 -1
- package/src/identity/hub/restore/recovery.ts +14 -4
- package/src/identity/hub/restore/resolve.ts +1 -1
- package/src/identity/hub/restore/useRestoreEffects.ts +4 -6
- package/src/identity/hub/shared/components/DetailsScreen.tsx +4 -1
- package/src/identity/hub/shared/components/IdentitySummary.tsx +118 -54
- package/src/identity/hub/shared/components/MenuScreen.tsx +21 -18
- package/src/identity/hub/shared/components/UnlinkedIdentityScreen.tsx +4 -4
- package/src/identity/hub/shared/components/menuFlagsFromReconciliation.ts +8 -12
- package/src/identity/hub/shared/effects/sync.ts +16 -3
- package/src/identity/hub/shared/model/copy.ts +2 -4
- package/src/identity/hub/transfer/effects.ts +15 -2
- package/src/identity/hub/useIdentityHubContinuity.ts +145 -23
- package/src/identity/hub/useIdentityHubController.ts +5 -1
- package/src/identity/hub/useIdentityHubSideEffects.ts +2 -4
- package/src/identity/wallet/page/copy.ts +43 -43
- package/src/mcp/manager.ts +1 -1
- package/src/models/ModelPicker.tsx +89 -84
- package/src/models/llamacpp.ts +160 -11
- package/src/models/llamacppPreflight.ts +1 -16
- package/src/models/modelPickerOptions.ts +45 -37
- package/src/providers/contracts.ts +1 -0
- package/src/providers/openai-chat.ts +50 -9
- package/src/providers/openai-responses.ts +19 -4
- package/src/runtime/toolExecution.ts +4 -3
- package/src/runtime/turn.ts +61 -30
- package/src/tools/changeDirectoryTool.ts +1 -1
- package/src/tools/contracts.ts +10 -0
- package/src/tools/deleteFileTool.ts +1 -1
- package/src/tools/editTool.ts +1 -1
- package/src/tools/listDirectoryTool.ts +1 -1
- package/src/tools/listSkillFilesTool.ts +77 -0
- package/src/tools/listSkillsTool.ts +68 -0
- package/src/tools/mcpResourceTools.ts +2 -2
- package/src/tools/privateContinuityReadTool.ts +1 -1
- package/src/tools/readSkillTool.ts +107 -0
- package/src/tools/readTool.ts +1 -1
- package/src/tools/registry.ts +6 -0
- package/src/tools/writeFileTool.ts +22 -2
- package/src/ui/Spinner.tsx +1 -1
- package/src/identity/continuity/localBackup.ts +0 -249
- package/src/identity/continuity/zipWriter.ts +0 -95
- package/src/identity/hub/continuity/index.ts +0 -7
- package/src/identity/hub/ens/index.ts +0 -11
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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": "
|
|
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": {
|
package/src/app/FirstRun.tsx
CHANGED
|
@@ -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':
|
|
48
|
-
'detect-error':
|
|
49
|
-
'choose-path':
|
|
50
|
-
'hf-setup':
|
|
51
|
-
'cloud-provider':
|
|
52
|
-
'cloud-
|
|
53
|
-
'cloud-
|
|
54
|
-
'cloud-
|
|
55
|
-
'saving':
|
|
56
|
-
'
|
|
57
|
-
'
|
|
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<'
|
|
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 =>
|
|
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(
|
|
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
|
-
|
|
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
|
|
216
|
-
return createInputEvent(
|
|
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
|
package/src/chat/ChatScreen.tsx
CHANGED
|
@@ -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
|
-
|
|
31
|
-
{header
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
}
|