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.
- package/README.md +18 -4
- package/package.json +2 -1
- package/src/app/FirstRun.tsx +157 -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 +12 -1
- package/src/chat/ChatScreen.tsx +17 -5
- package/src/chat/ConversationStack.tsx +25 -19
- package/src/chat/MessageList.tsx +194 -53
- package/src/chat/chatSessionState.ts +4 -1
- package/src/chat/chatTurnOrchestrator.ts +65 -2
- package/src/chat/input/ChatInput.tsx +28 -2
- package/src/chat/input/imageRefs.ts +30 -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/ResumeView.tsx +16 -7
- 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 +125 -0
- package/src/identity/continuity/publicSkills.ts +37 -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 +105 -3
- package/src/identity/hub/Routes.tsx +5 -3
- package/src/identity/hub/continuity/ContinuityDashboardScreen.tsx +5 -51
- package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +1 -1
- package/src/identity/hub/continuity/SavePromptScreen.tsx +1 -0
- 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/DeleteSkillScreen.tsx +123 -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/SkillVisibilityScreen.tsx +171 -0
- package/src/identity/hub/continuity/skills/SkillsTreeScreen.tsx +213 -0
- package/src/identity/hub/continuity/snapshot.ts +3 -0
- package/src/identity/hub/continuity/state.ts +3 -2
- package/src/identity/hub/continuity/vault.ts +42 -10
- package/src/identity/hub/custody/CustodyEditFlow.tsx +3 -3
- package/src/identity/hub/identityHubReducer.ts +21 -0
- 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 +11 -1
- 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 +97 -53
- package/src/identity/hub/shared/components/MenuScreen.tsx +18 -15
- package/src/identity/hub/shared/components/UnlinkedIdentityScreen.tsx +1 -1
- 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/mcp/manager.ts +1 -1
- package/src/models/ModelPicker.tsx +211 -74
- package/src/models/huggingface.ts +180 -2
- package/src/models/llamacpp.ts +261 -17
- package/src/models/llamacppPreflight.ts +16 -12
- package/src/models/modelPickerOptions.ts +57 -38
- package/src/providers/anthropic.ts +36 -5
- package/src/providers/contracts.ts +10 -1
- package/src/providers/gemini.ts +29 -3
- package/src/providers/openai-chat.ts +131 -11
- package/src/providers/openai-responses-format.ts +29 -8
- package/src/providers/openai-responses.ts +41 -11
- package/src/providers/registry.ts +1 -0
- package/src/runtime/toolExecution.ts +4 -3
- package/src/runtime/turn.ts +61 -30
- package/src/storage/config.ts +1 -0
- package/src/storage/sessions.ts +14 -2
- 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 +15 -3
- package/src/ui/theme.ts +2 -0
- package/src/utils/images.ts +140 -0
- package/src/utils/messages.ts +2 -0
- 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.
|
|
@@ -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": "
|
|
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": {
|
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'
|
|
@@ -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(
|
|
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> {
|
|
@@ -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>
|
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)),
|
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
}
|