ethagent 0.2.1 → 1.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/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +25 -7
- package/src/app/FirstRun.tsx +412 -0
- package/src/app/hooks/useCancelRequest.ts +22 -0
- package/src/app/hooks/useDoublePress.ts +46 -0
- package/src/app/hooks/useExitOnCtrlC.ts +36 -0
- package/src/app/input/AppInputProvider.tsx +116 -0
- package/src/app/input/appInputParser.ts +279 -0
- package/src/app/keybindings/KeybindingProvider.tsx +134 -0
- package/src/app/keybindings/resolver.ts +42 -0
- package/src/app/keybindings/types.ts +26 -0
- package/src/chat/ChatBottomPane.tsx +280 -0
- package/src/chat/ChatInput.tsx +722 -0
- package/src/chat/ChatScreen.tsx +1575 -0
- package/src/chat/ContextLimitView.tsx +95 -0
- package/src/chat/ContinuityEditReviewView.tsx +48 -0
- package/src/chat/ConversationStack.tsx +47 -0
- package/src/chat/CopyPicker.tsx +52 -0
- package/src/chat/MessageList.tsx +609 -0
- package/src/chat/PermissionPrompt.tsx +153 -0
- package/src/chat/PermissionsView.tsx +159 -0
- package/src/chat/PlanApprovalView.tsx +91 -0
- package/src/chat/ResumeView.tsx +267 -0
- package/src/chat/RewindView.tsx +386 -0
- package/src/chat/SessionStatus.tsx +51 -0
- package/src/chat/TranscriptView.tsx +202 -0
- package/src/chat/chatInputState.ts +247 -0
- package/src/chat/chatPaste.ts +49 -0
- package/src/chat/chatScreenUtils.ts +187 -0
- package/src/chat/chatSessionState.ts +142 -0
- package/src/chat/chatTurnOrchestrator.ts +701 -0
- package/src/chat/commands.ts +673 -0
- package/src/chat/textCursor.ts +202 -0
- package/src/chat/toolResultDisplay.ts +8 -0
- package/src/chat/transcriptViewport.ts +247 -0
- package/src/cli/ResetConfirmView.tsx +61 -0
- package/src/cli/main.tsx +177 -0
- package/src/cli/preview.tsx +19 -0
- package/src/cli/reset.ts +106 -0
- package/src/identity/continuity/editor.ts +149 -0
- package/src/identity/continuity/envelope.ts +345 -0
- package/src/identity/continuity/history.ts +153 -0
- package/src/identity/continuity/privateEdit.ts +334 -0
- package/src/identity/continuity/publicSkills.ts +173 -0
- package/src/identity/continuity/snapshots.ts +183 -0
- package/src/identity/continuity/storage.ts +507 -0
- package/src/identity/crypto/backupEnvelope.ts +486 -0
- package/src/identity/crypto/eth.ts +137 -0
- package/src/identity/hub/IdentityHub.tsx +845 -0
- package/src/identity/hub/identityHubEffects.ts +1100 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +209 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -0
- package/src/identity/hub/screens/CreateFlow.tsx +206 -0
- package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
- package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
- package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
- package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
- package/src/identity/hub/screens/MenuScreen.tsx +117 -0
- package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
- package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
- package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
- package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
- package/src/identity/profile/imagePicker.ts +180 -0
- package/src/identity/registry/erc8004.ts +1106 -0
- package/src/identity/registry/registryConfig.ts +69 -0
- package/src/identity/storage/ipfs.ts +212 -0
- package/src/identity/storage/pinataJwt.ts +53 -0
- package/src/identity/wallet/browserWallet.ts +393 -0
- package/src/identity/wallet/wallet-page/wallet.html +1082 -0
- package/src/mcp/approvals.ts +113 -0
- package/src/mcp/config.ts +235 -0
- package/src/mcp/manager.ts +541 -0
- package/src/mcp/names.ts +19 -0
- package/src/mcp/output.ts +96 -0
- package/src/models/ModelPicker.tsx +1446 -0
- package/src/models/catalog.ts +296 -0
- package/src/models/huggingface.ts +651 -0
- package/src/models/llamacpp.ts +810 -0
- package/src/models/llamacppPreflight.ts +150 -0
- package/src/models/modelDisplay.ts +105 -0
- package/src/models/modelPickerOptions.ts +421 -0
- package/src/models/modelRecommendation.ts +140 -0
- package/src/models/runtimeDetection.ts +81 -0
- package/src/models/uncensoredCatalog.ts +86 -0
- package/src/providers/anthropic.ts +259 -0
- package/src/providers/contracts.ts +62 -0
- package/src/providers/errors.ts +62 -0
- package/src/providers/gemini.ts +152 -0
- package/src/providers/openai-chat.ts +472 -0
- package/src/providers/registry.ts +42 -0
- package/src/providers/retry.ts +58 -0
- package/src/providers/sse.ts +93 -0
- package/src/runtime/compaction.ts +389 -0
- package/src/runtime/cwd.ts +43 -0
- package/src/runtime/sessionMode.ts +55 -0
- package/src/runtime/systemPrompt.ts +209 -0
- package/src/runtime/toolClaimGuards.ts +143 -0
- package/src/runtime/toolExecution.ts +304 -0
- package/src/runtime/toolIntent.ts +163 -0
- package/src/runtime/turn.ts +858 -0
- package/src/storage/atomicWrite.ts +68 -0
- package/src/storage/config.ts +189 -0
- package/src/storage/factoryReset.ts +130 -0
- package/src/storage/history.ts +58 -0
- package/src/storage/identity.ts +99 -0
- package/src/storage/permissions.ts +76 -0
- package/src/storage/rewind.ts +246 -0
- package/src/storage/secrets.ts +181 -0
- package/src/storage/sessionExport.ts +49 -0
- package/src/storage/sessions.ts +482 -0
- package/src/tools/bashSafety.ts +174 -0
- package/src/tools/bashTool.ts +140 -0
- package/src/tools/changeDirectoryTool.ts +213 -0
- package/src/tools/contracts.ts +179 -0
- package/src/tools/deleteFileTool.ts +111 -0
- package/src/tools/editTool.ts +160 -0
- package/src/tools/editUtils.ts +170 -0
- package/src/tools/listDirectoryTool.ts +55 -0
- package/src/tools/mcpResourceTools.ts +95 -0
- package/src/tools/permissionRules.ts +85 -0
- package/src/tools/privateContinuityEditTool.ts +178 -0
- package/src/tools/privateContinuityReadTool.ts +107 -0
- package/src/tools/readTool.ts +85 -0
- package/src/tools/registry.ts +67 -0
- package/src/tools/writeFileTool.ts +142 -0
- package/src/ui/BrandSplash.tsx +193 -0
- package/src/ui/ProgressBar.tsx +34 -0
- package/src/ui/Select.tsx +143 -0
- package/src/ui/Spinner.tsx +269 -0
- package/src/ui/Surface.tsx +47 -0
- package/src/ui/TextInput.tsx +97 -0
- package/src/ui/theme.ts +59 -0
- package/src/utils/clipboard.ts +216 -0
- package/src/utils/markdownSegments.ts +51 -0
- package/src/utils/messages.ts +35 -0
- package/src/utils/withRetry.ts +280 -0
- package/src/cli.tsx +0 -147
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import { BrandSplash as Splash } from '../ui/BrandSplash.js'
|
|
4
|
+
import { Select } from '../ui/Select.js'
|
|
5
|
+
import { TextInput } from '../ui/TextInput.js'
|
|
6
|
+
import { theme } from '../ui/theme.js'
|
|
7
|
+
import { ModelPicker, type ModelPickerSelection } from '../models/ModelPicker.js'
|
|
8
|
+
import { formatModelDisplayName } from '../models/modelDisplay.js'
|
|
9
|
+
import { detectSpec, type SpecSnapshot } from '../models/runtimeDetection.js'
|
|
10
|
+
import { FEATURED_HF_REPO_URL } from '../models/modelRecommendation.js'
|
|
11
|
+
import {
|
|
12
|
+
saveConfig,
|
|
13
|
+
normalizeConfig,
|
|
14
|
+
defaultModelFor,
|
|
15
|
+
defaultBaseUrlFor,
|
|
16
|
+
type EthagentConfig,
|
|
17
|
+
type ProviderId,
|
|
18
|
+
} from '../storage/config.js'
|
|
19
|
+
import { setKey } from '../storage/secrets.js'
|
|
20
|
+
import { IdentityHub, type IdentityHubResult } from '../identity/hub/IdentityHub.js'
|
|
21
|
+
|
|
22
|
+
type Step =
|
|
23
|
+
| { kind: 'detecting' }
|
|
24
|
+
| { kind: 'detect-error'; message: string }
|
|
25
|
+
| { kind: 'identity-start'; spec: SpecSnapshot }
|
|
26
|
+
| { kind: 'identity-start-saving'; spec: SpecSnapshot; result: IdentityHubResult }
|
|
27
|
+
| { kind: 'choose-path'; spec: SpecSnapshot }
|
|
28
|
+
| { kind: 'hf-setup'; spec: SpecSnapshot }
|
|
29
|
+
| { kind: 'cloud-provider' }
|
|
30
|
+
| { kind: 'cloud-key'; provider: ProviderId; error?: string }
|
|
31
|
+
| { kind: 'cloud-key-saving'; provider: ProviderId }
|
|
32
|
+
| { kind: 'cloud-model'; provider: ProviderId }
|
|
33
|
+
| { kind: 'saving'; config: EthagentConfig }
|
|
34
|
+
| { kind: 'save-error'; config: EthagentConfig; message: string }
|
|
35
|
+
| { kind: 'done'; config: EthagentConfig }
|
|
36
|
+
|
|
37
|
+
type FirstRunProps = {
|
|
38
|
+
onComplete: (config: EthagentConfig) => void
|
|
39
|
+
onCancel: () => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const STATUS: Record<string, string> = {
|
|
43
|
+
'detecting': 'first-run setup · inspecting machine',
|
|
44
|
+
'detect-error': 'first-run setup · detection failed',
|
|
45
|
+
'choose-path': 'first-run setup · choose how to run',
|
|
46
|
+
'hf-setup': 'first-run setup · local model',
|
|
47
|
+
'cloud-provider': 'first-run setup · pick a cloud provider',
|
|
48
|
+
'cloud-key': 'first-run setup · paste API key',
|
|
49
|
+
'cloud-key-saving': 'first-run setup · storing key',
|
|
50
|
+
'cloud-model': 'first-run setup · pick a model',
|
|
51
|
+
'saving': 'first-run setup · saving config',
|
|
52
|
+
'save-error': 'first-run setup · save failed',
|
|
53
|
+
'done': 'ready',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const NAV_BACK = '↑↓ navigate · enter select · esc back'
|
|
57
|
+
const NAV_CANCEL = '↑↓ navigate · enter select · esc cancel setup'
|
|
58
|
+
|
|
59
|
+
export const FirstRun: React.FC<FirstRunProps> = ({ onComplete, onCancel }) => {
|
|
60
|
+
const [step, setStep] = useState<Step>({ kind: 'detecting' })
|
|
61
|
+
const [history, setHistory] = useState<Step[]>([])
|
|
62
|
+
const [firstRunIdentity, setFirstRunIdentity] = useState<EthagentConfig['identity']>(undefined)
|
|
63
|
+
|
|
64
|
+
const goTo = (next: Step): void => {
|
|
65
|
+
setHistory(h => [...h, step])
|
|
66
|
+
setStep(next)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const goBack = (): void => {
|
|
70
|
+
if (history.length === 0) {
|
|
71
|
+
onCancel()
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
const prev = history[history.length - 1]!
|
|
75
|
+
setStep(prev)
|
|
76
|
+
setHistory(h => h.slice(0, -1))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
let cancelled = false
|
|
81
|
+
detectSpec()
|
|
82
|
+
.then(spec => {
|
|
83
|
+
if (cancelled) return
|
|
84
|
+
setStep({ kind: 'identity-start', spec })
|
|
85
|
+
})
|
|
86
|
+
.catch((err: unknown) => {
|
|
87
|
+
if (cancelled) return
|
|
88
|
+
setStep({ kind: 'detect-error', message: (err as Error).message })
|
|
89
|
+
})
|
|
90
|
+
return () => { cancelled = true }
|
|
91
|
+
}, [])
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (step.kind !== 'saving') return
|
|
95
|
+
let cancelled = false
|
|
96
|
+
const config = normalizeConfig(step.config)
|
|
97
|
+
saveConfig(config)
|
|
98
|
+
.then(() => {
|
|
99
|
+
if (cancelled) return
|
|
100
|
+
setStep({ kind: 'done', config })
|
|
101
|
+
onComplete(config)
|
|
102
|
+
})
|
|
103
|
+
.catch((err: unknown) => {
|
|
104
|
+
if (cancelled) return
|
|
105
|
+
setStep({ kind: 'save-error', config, message: (err as Error).message })
|
|
106
|
+
})
|
|
107
|
+
return () => { cancelled = true }
|
|
108
|
+
}, [step, onComplete])
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (step.kind !== 'identity-start-saving') return
|
|
112
|
+
let cancelled = false
|
|
113
|
+
const persist = async (): Promise<void> => {
|
|
114
|
+
if (step.result.kind === 'token') {
|
|
115
|
+
setFirstRunIdentity(step.result.identity)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
persist()
|
|
119
|
+
.then(() => {
|
|
120
|
+
if (cancelled) return
|
|
121
|
+
setStep({ kind: 'choose-path', spec: step.spec })
|
|
122
|
+
})
|
|
123
|
+
.catch((err: unknown) => {
|
|
124
|
+
if (cancelled) return
|
|
125
|
+
setStep({
|
|
126
|
+
kind: 'detect-error',
|
|
127
|
+
message: `could not store identity: ${(err as Error).message}`,
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
return () => { cancelled = true }
|
|
131
|
+
}, [step])
|
|
132
|
+
|
|
133
|
+
const hint = (canBack: boolean): React.ReactElement => (
|
|
134
|
+
<Box marginTop={1}>
|
|
135
|
+
<Text color={theme.dim}>{canBack ? NAV_BACK : NAV_CANCEL}</Text>
|
|
136
|
+
</Box>
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
const withFirstRunIdentity = (config: EthagentConfig): EthagentConfig =>
|
|
140
|
+
firstRunIdentity ? { ...config, identity: firstRunIdentity } : config
|
|
141
|
+
|
|
142
|
+
if (step.kind === 'detecting') {
|
|
143
|
+
return (
|
|
144
|
+
<Box flexDirection="column" padding={1}>
|
|
145
|
+
<Splash tipLine={STATUS['detecting']} />
|
|
146
|
+
<Text color={theme.dim}>inspecting machine…</Text>
|
|
147
|
+
</Box>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (step.kind === 'detect-error') {
|
|
152
|
+
return (
|
|
153
|
+
<Box flexDirection="column" padding={1}>
|
|
154
|
+
<Splash tipLine={STATUS['detect-error']} />
|
|
155
|
+
<Text color="#e87070">could not inspect machine: {step.message}</Text>
|
|
156
|
+
<Box marginTop={1}>
|
|
157
|
+
<Select<'quit'>
|
|
158
|
+
options={[{ value: 'quit', label: 'quit' }]}
|
|
159
|
+
onSubmit={onCancel}
|
|
160
|
+
onCancel={onCancel}
|
|
161
|
+
/>
|
|
162
|
+
</Box>
|
|
163
|
+
</Box>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (step.kind === 'identity-start') {
|
|
168
|
+
return (
|
|
169
|
+
<Box flexDirection="column" padding={1}>
|
|
170
|
+
<Splash tipLine="first-run setup · agent identity" />
|
|
171
|
+
<IdentityHub
|
|
172
|
+
mode="first-run"
|
|
173
|
+
onComplete={result => {
|
|
174
|
+
if (result.kind === 'cancel') {
|
|
175
|
+
onCancel()
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
if (result.kind === 'skip') {
|
|
179
|
+
setStep({ kind: 'choose-path', spec: step.spec })
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
setStep({ kind: 'identity-start-saving', spec: step.spec, result })
|
|
183
|
+
}}
|
|
184
|
+
/>
|
|
185
|
+
</Box>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (step.kind === 'identity-start-saving') {
|
|
190
|
+
return (
|
|
191
|
+
<Box flexDirection="column" padding={1}>
|
|
192
|
+
<Splash tipLine="first-run setup · storing identity" />
|
|
193
|
+
<Text color={theme.dim}>storing identity...</Text>
|
|
194
|
+
</Box>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (step.kind === 'choose-path') {
|
|
199
|
+
const { spec } = step
|
|
200
|
+
return (
|
|
201
|
+
<Box flexDirection="column" padding={1}>
|
|
202
|
+
<Splash tipLine={STATUS['choose-path']} />
|
|
203
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
204
|
+
<Text color={theme.dim}>
|
|
205
|
+
detected {formatGB(spec.effectiveRamBytes)} RAM
|
|
206
|
+
{spec.gpuVramBytes ? `, ${formatGB(spec.gpuVramBytes)} VRAM` : ''}
|
|
207
|
+
{spec.isAppleSilicon ? ', Apple Silicon' : ''}
|
|
208
|
+
</Text>
|
|
209
|
+
</Box>
|
|
210
|
+
<Select<'cloud' | 'hf'>
|
|
211
|
+
label="how do you want to run?"
|
|
212
|
+
options={[
|
|
213
|
+
{ value: 'cloud', label: 'cloud API', hint: 'anthropic, openai, or gemini' },
|
|
214
|
+
{ value: 'hf', label: 'local model', hint: 'download and run locally' },
|
|
215
|
+
]}
|
|
216
|
+
onSubmit={choice => {
|
|
217
|
+
if (choice === 'cloud') goTo({ kind: 'cloud-provider' })
|
|
218
|
+
else goTo({ kind: 'hf-setup', spec })
|
|
219
|
+
}}
|
|
220
|
+
onCancel={onCancel}
|
|
221
|
+
/>
|
|
222
|
+
{hint(false)}
|
|
223
|
+
</Box>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
if (step.kind === 'hf-setup') {
|
|
230
|
+
const modelPickerConfig: EthagentConfig = withFirstRunIdentity({
|
|
231
|
+
version: 1,
|
|
232
|
+
provider: 'llamacpp',
|
|
233
|
+
model: defaultModelFor('llamacpp'),
|
|
234
|
+
baseUrl: defaultBaseUrlFor('llamacpp'),
|
|
235
|
+
firstRunAt: new Date().toISOString(),
|
|
236
|
+
})
|
|
237
|
+
return (
|
|
238
|
+
<Box flexDirection="column" padding={1}>
|
|
239
|
+
<Splash tipLine={STATUS['hf-setup']} />
|
|
240
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
241
|
+
<Text color={theme.dim}>featured: {FEATURED_HF_REPO_URL}</Text>
|
|
242
|
+
</Box>
|
|
243
|
+
<ModelPicker
|
|
244
|
+
currentConfig={modelPickerConfig}
|
|
245
|
+
currentProvider="llamacpp"
|
|
246
|
+
currentModel={modelPickerConfig.model}
|
|
247
|
+
featuredHfRepo={FEATURED_HF_REPO_URL}
|
|
248
|
+
onPick={(selection: ModelPickerSelection) => {
|
|
249
|
+
goTo({ kind: 'saving', config: configFromModelPickerSelection(selection, modelPickerConfig) })
|
|
250
|
+
}}
|
|
251
|
+
onCancel={goBack}
|
|
252
|
+
/>
|
|
253
|
+
</Box>
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (step.kind === 'cloud-provider') {
|
|
258
|
+
return (
|
|
259
|
+
<Box flexDirection="column" padding={1}>
|
|
260
|
+
<Splash tipLine={STATUS['cloud-provider']} />
|
|
261
|
+
<Text color={theme.accentSecondary} bold>pick a cloud provider</Text>
|
|
262
|
+
<Box marginTop={1}>
|
|
263
|
+
<Select<ProviderId>
|
|
264
|
+
options={[
|
|
265
|
+
{ value: 'openai', label: 'openai' },
|
|
266
|
+
{ value: 'anthropic', label: 'anthropic' },
|
|
267
|
+
{ value: 'gemini', label: 'gemini' },
|
|
268
|
+
]}
|
|
269
|
+
onSubmit={provider => goTo({ kind: 'cloud-key', provider })}
|
|
270
|
+
onCancel={goBack}
|
|
271
|
+
/>
|
|
272
|
+
</Box>
|
|
273
|
+
{hint(true)}
|
|
274
|
+
</Box>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (step.kind === 'cloud-key' || step.kind === 'cloud-key-saving') {
|
|
279
|
+
const provider = step.provider
|
|
280
|
+
const saving = step.kind === 'cloud-key-saving'
|
|
281
|
+
const error = step.kind === 'cloud-key' ? step.error : undefined
|
|
282
|
+
return (
|
|
283
|
+
<Box flexDirection="column" padding={1}>
|
|
284
|
+
<Splash tipLine={saving ? STATUS['cloud-key-saving'] : STATUS['cloud-key']} />
|
|
285
|
+
<Text color={theme.accentSecondary} bold>paste your {provider} API key</Text>
|
|
286
|
+
<Text color={theme.dim}>stored in your OS keyring when available; never written to config.json</Text>
|
|
287
|
+
{error ? <Text color="#e87070">{error}</Text> : null}
|
|
288
|
+
<Box marginTop={1}>
|
|
289
|
+
<TextInput
|
|
290
|
+
isSecret
|
|
291
|
+
placeholder={provider === 'openai' ? 'sk-...' : 'paste key and press enter'}
|
|
292
|
+
validate={v => v.trim().length >= 8 ? null : 'key looks too short'}
|
|
293
|
+
onSubmit={async value => {
|
|
294
|
+
const trimmed = value.trim()
|
|
295
|
+
setHistory(h => [...h, { kind: 'cloud-key', provider }])
|
|
296
|
+
setStep({ kind: 'cloud-key-saving', provider })
|
|
297
|
+
try {
|
|
298
|
+
await setKey(provider, trimmed)
|
|
299
|
+
setStep({ kind: 'cloud-model', provider })
|
|
300
|
+
} catch (err: unknown) {
|
|
301
|
+
setHistory(h => h.slice(0, -1))
|
|
302
|
+
setStep({
|
|
303
|
+
kind: 'cloud-key',
|
|
304
|
+
provider,
|
|
305
|
+
error: `could not store key: ${(err as Error).message}`,
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
}}
|
|
309
|
+
onCancel={goBack}
|
|
310
|
+
/>
|
|
311
|
+
</Box>
|
|
312
|
+
{saving ? <Text color={theme.dim}>storing key…</Text> : hint(true)}
|
|
313
|
+
</Box>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (step.kind === 'cloud-model') {
|
|
318
|
+
const { provider } = step
|
|
319
|
+
const defaultModel = defaultModelFor(provider)
|
|
320
|
+
return (
|
|
321
|
+
<Box flexDirection="column" padding={1}>
|
|
322
|
+
<Splash tipLine={STATUS['cloud-model']} />
|
|
323
|
+
<Text color={theme.accentSecondary} bold>which model?</Text>
|
|
324
|
+
<Text color={theme.dim}>press enter to accept default: {defaultModel}</Text>
|
|
325
|
+
<Box marginTop={1}>
|
|
326
|
+
<TextInput
|
|
327
|
+
initialValue={defaultModel}
|
|
328
|
+
placeholder={defaultModel}
|
|
329
|
+
onSubmit={model => goTo({
|
|
330
|
+
kind: 'saving',
|
|
331
|
+
config: withFirstRunIdentity({
|
|
332
|
+
version: 1,
|
|
333
|
+
provider,
|
|
334
|
+
model: model.trim() || defaultModel,
|
|
335
|
+
firstRunAt: new Date().toISOString(),
|
|
336
|
+
}),
|
|
337
|
+
})}
|
|
338
|
+
onCancel={goBack}
|
|
339
|
+
/>
|
|
340
|
+
</Box>
|
|
341
|
+
{hint(true)}
|
|
342
|
+
</Box>
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (step.kind === 'saving') {
|
|
347
|
+
return (
|
|
348
|
+
<Box flexDirection="column" padding={1}>
|
|
349
|
+
<Splash tipLine={STATUS['saving']} />
|
|
350
|
+
<Text color={theme.dim}>saving config…</Text>
|
|
351
|
+
</Box>
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (step.kind === 'save-error') {
|
|
356
|
+
return (
|
|
357
|
+
<Box flexDirection="column" padding={1}>
|
|
358
|
+
<Splash tipLine={STATUS['save-error']} />
|
|
359
|
+
<Text color="#e87070">{step.message}</Text>
|
|
360
|
+
<Box marginTop={1}>
|
|
361
|
+
<Select<'retry' | 'back' | 'quit'>
|
|
362
|
+
options={[
|
|
363
|
+
{ value: 'retry', label: 'retry save' },
|
|
364
|
+
{ value: 'back', label: 'go back and edit' },
|
|
365
|
+
{ value: 'quit', label: 'quit setup' },
|
|
366
|
+
]}
|
|
367
|
+
onSubmit={choice => {
|
|
368
|
+
if (choice === 'retry') setStep({ kind: 'saving', config: step.config })
|
|
369
|
+
else if (choice === 'back') goBack()
|
|
370
|
+
else onCancel()
|
|
371
|
+
}}
|
|
372
|
+
onCancel={goBack}
|
|
373
|
+
/>
|
|
374
|
+
</Box>
|
|
375
|
+
{hint(history.length > 0)}
|
|
376
|
+
</Box>
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (step.kind === 'done') {
|
|
381
|
+
return (
|
|
382
|
+
<Box flexDirection="column" padding={1}>
|
|
383
|
+
<Splash tipLine={`ready · ${step.config.provider} · ${formatModelDisplayName(step.config.provider, step.config.model, { maxLength: 48 })}`} />
|
|
384
|
+
<Text color={theme.accentSecondary}>all set.</Text>
|
|
385
|
+
</Box>
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return null
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function configFromModelPickerSelection(selection: ModelPickerSelection, base: EthagentConfig): EthagentConfig {
|
|
393
|
+
if (selection.kind === 'llamacpp') {
|
|
394
|
+
return {
|
|
395
|
+
...base,
|
|
396
|
+
provider: 'llamacpp',
|
|
397
|
+
model: selection.model,
|
|
398
|
+
baseUrl: defaultBaseUrlFor('llamacpp'),
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
...base,
|
|
403
|
+
provider: selection.provider,
|
|
404
|
+
model: selection.model,
|
|
405
|
+
baseUrl: undefined,
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function formatGB(bytes: number): string {
|
|
410
|
+
const gb = bytes / (1024 * 1024 * 1024)
|
|
411
|
+
return gb < 10 ? `${gb.toFixed(1)}GB` : `${Math.round(gb)}GB`
|
|
412
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import { useKeybinding } from '../keybindings/KeybindingProvider.js'
|
|
3
|
+
|
|
4
|
+
type Options = {
|
|
5
|
+
abortSignal?: AbortSignal
|
|
6
|
+
onCancel: () => void
|
|
7
|
+
isActive?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useCancelRequest({ abortSignal, onCancel, isActive = true }: Options): void {
|
|
11
|
+
const canCancel = abortSignal !== undefined && !abortSignal.aborted
|
|
12
|
+
|
|
13
|
+
const handleCancel = useCallback(() => {
|
|
14
|
+
if (!canCancel) return
|
|
15
|
+
onCancel()
|
|
16
|
+
}, [canCancel, onCancel])
|
|
17
|
+
|
|
18
|
+
useKeybinding('chat:cancel', handleCancel, {
|
|
19
|
+
context: 'Chat',
|
|
20
|
+
isActive: isActive && canCancel,
|
|
21
|
+
})
|
|
22
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
|
|
2
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
3
|
+
|
|
4
|
+
export const DOUBLE_PRESS_TIMEOUT_MS = 1800
|
|
5
|
+
|
|
6
|
+
export function useDoublePress(
|
|
7
|
+
setPending: (pending: boolean) => void,
|
|
8
|
+
onDoublePress: () => void,
|
|
9
|
+
): () => void {
|
|
10
|
+
const lastPressRef = useRef<number>(0)
|
|
11
|
+
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
|
12
|
+
|
|
13
|
+
const clearPendingTimeout = useCallback(() => {
|
|
14
|
+
if (timeoutRef.current) {
|
|
15
|
+
clearTimeout(timeoutRef.current)
|
|
16
|
+
timeoutRef.current = undefined
|
|
17
|
+
}
|
|
18
|
+
}, [])
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
return () => {
|
|
22
|
+
clearPendingTimeout()
|
|
23
|
+
}
|
|
24
|
+
}, [clearPendingTimeout])
|
|
25
|
+
|
|
26
|
+
return useCallback(() => {
|
|
27
|
+
const now = Date.now()
|
|
28
|
+
const elapsed = now - lastPressRef.current
|
|
29
|
+
const isDouble = elapsed <= DOUBLE_PRESS_TIMEOUT_MS && timeoutRef.current !== undefined
|
|
30
|
+
|
|
31
|
+
if (isDouble) {
|
|
32
|
+
clearPendingTimeout()
|
|
33
|
+
setPending(false)
|
|
34
|
+
onDoublePress()
|
|
35
|
+
} else {
|
|
36
|
+
setPending(true)
|
|
37
|
+
clearPendingTimeout()
|
|
38
|
+
timeoutRef.current = setTimeout(() => {
|
|
39
|
+
setPending(false)
|
|
40
|
+
timeoutRef.current = undefined
|
|
41
|
+
}, DOUBLE_PRESS_TIMEOUT_MS)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
lastPressRef.current = now
|
|
45
|
+
}, [setPending, onDoublePress, clearPendingTimeout])
|
|
46
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react'
|
|
2
|
+
import { useApp } from 'ink'
|
|
3
|
+
import { useKeybinding } from '../keybindings/KeybindingProvider.js'
|
|
4
|
+
import { useDoublePress } from './useDoublePress.js'
|
|
5
|
+
|
|
6
|
+
export type ExitState = {
|
|
7
|
+
pending: boolean
|
|
8
|
+
keyName: 'ctrl+c' | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type Options = {
|
|
12
|
+
isActive?: boolean
|
|
13
|
+
onInterrupt?: () => boolean
|
|
14
|
+
onExit?: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useExitOnCtrlC({ isActive = true, onInterrupt, onExit }: Options = {}): ExitState {
|
|
18
|
+
const { exit } = useApp()
|
|
19
|
+
const [state, setState] = useState<ExitState>({ pending: false, keyName: null })
|
|
20
|
+
|
|
21
|
+
const exitFn = useMemo(() => onExit ?? exit, [onExit, exit])
|
|
22
|
+
|
|
23
|
+
const ctrlCDouble = useDoublePress(
|
|
24
|
+
pending => setState({ pending, keyName: 'ctrl+c' }),
|
|
25
|
+
exitFn,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const handleInterrupt = useCallback(() => {
|
|
29
|
+
if (onInterrupt?.()) return
|
|
30
|
+
ctrlCDouble()
|
|
31
|
+
}, [onInterrupt, ctrlCDouble])
|
|
32
|
+
|
|
33
|
+
useKeybinding('app:interrupt', handleInterrupt, { context: 'Global', isActive })
|
|
34
|
+
|
|
35
|
+
return state
|
|
36
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react'
|
|
2
|
+
import { useStdin, useStdout } from 'ink'
|
|
3
|
+
import type { AppInputEvent } from './appInputParser.js'
|
|
4
|
+
import {
|
|
5
|
+
BRACKETED_PASTE_DISABLE,
|
|
6
|
+
BRACKETED_PASTE_ENABLE,
|
|
7
|
+
createAppInputParseState,
|
|
8
|
+
DISABLE_KITTY_KEYBOARD,
|
|
9
|
+
DISABLE_MODIFY_OTHER_KEYS,
|
|
10
|
+
ENABLE_KITTY_KEYBOARD,
|
|
11
|
+
ENABLE_MODIFY_OTHER_KEYS,
|
|
12
|
+
hasPendingAppInput,
|
|
13
|
+
parseAppInput,
|
|
14
|
+
} from './appInputParser.js'
|
|
15
|
+
|
|
16
|
+
type InputHandler = (input: string, key: AppInputEvent['key'], event: AppInputEvent) => void
|
|
17
|
+
|
|
18
|
+
type HandlerEntry = {
|
|
19
|
+
handlerRef: React.MutableRefObject<InputHandler>
|
|
20
|
+
isActiveRef: React.MutableRefObject<boolean>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type AppInputContextValue = {
|
|
24
|
+
register(entry: HandlerEntry): () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const AppInputContext = createContext<AppInputContextValue | null>(null)
|
|
28
|
+
const PENDING_ESCAPE_FLUSH_MS = 50
|
|
29
|
+
|
|
30
|
+
export const AppInputProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
31
|
+
const { stdin } = useStdin()
|
|
32
|
+
const { stdout } = useStdout()
|
|
33
|
+
const handlersRef = useRef<Set<HandlerEntry>>(new Set())
|
|
34
|
+
const parseStateRef = useRef(createAppInputParseState())
|
|
35
|
+
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
36
|
+
|
|
37
|
+
const dispatch = useCallback((event: AppInputEvent) => {
|
|
38
|
+
for (const entry of handlersRef.current) {
|
|
39
|
+
if (!entry.isActiveRef.current) continue
|
|
40
|
+
entry.handlerRef.current(event.input, event.key, event)
|
|
41
|
+
}
|
|
42
|
+
}, [])
|
|
43
|
+
|
|
44
|
+
const flushPending = useCallback(() => {
|
|
45
|
+
flushTimerRef.current = null
|
|
46
|
+
const result = parseAppInput(parseStateRef.current, null)
|
|
47
|
+
parseStateRef.current = result.state
|
|
48
|
+
for (const event of result.events) dispatch(event)
|
|
49
|
+
}, [dispatch])
|
|
50
|
+
|
|
51
|
+
const scheduleFlush = useCallback(() => {
|
|
52
|
+
if (flushTimerRef.current) clearTimeout(flushTimerRef.current)
|
|
53
|
+
flushTimerRef.current = setTimeout(flushPending, PENDING_ESCAPE_FLUSH_MS)
|
|
54
|
+
}, [flushPending])
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return
|
|
58
|
+
|
|
59
|
+
const handleData = (chunk: Buffer | string) => {
|
|
60
|
+
if (flushTimerRef.current) {
|
|
61
|
+
clearTimeout(flushTimerRef.current)
|
|
62
|
+
flushTimerRef.current = null
|
|
63
|
+
}
|
|
64
|
+
const result = parseAppInput(parseStateRef.current, chunk)
|
|
65
|
+
parseStateRef.current = result.state
|
|
66
|
+
for (const event of result.events) dispatch(event)
|
|
67
|
+
if (hasPendingAppInput(parseStateRef.current)) scheduleFlush()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
stdin.setEncoding('utf8')
|
|
71
|
+
stdin.setRawMode(true)
|
|
72
|
+
stdin.ref()
|
|
73
|
+
stdin.on('data', handleData)
|
|
74
|
+
stdin.resume()
|
|
75
|
+
stdout.write(BRACKETED_PASTE_ENABLE)
|
|
76
|
+
stdout.write(ENABLE_KITTY_KEYBOARD)
|
|
77
|
+
stdout.write(ENABLE_MODIFY_OTHER_KEYS)
|
|
78
|
+
|
|
79
|
+
return () => {
|
|
80
|
+
if (flushTimerRef.current) clearTimeout(flushTimerRef.current)
|
|
81
|
+
stdout.write(DISABLE_MODIFY_OTHER_KEYS)
|
|
82
|
+
stdout.write(DISABLE_KITTY_KEYBOARD)
|
|
83
|
+
stdout.write(BRACKETED_PASTE_DISABLE)
|
|
84
|
+
stdin.off('data', handleData)
|
|
85
|
+
stdin.setRawMode(false)
|
|
86
|
+
stdin.pause()
|
|
87
|
+
stdin.unref()
|
|
88
|
+
}
|
|
89
|
+
}, [dispatch, scheduleFlush, stdin, stdout])
|
|
90
|
+
|
|
91
|
+
const value = useMemo<AppInputContextValue>(() => ({
|
|
92
|
+
register(entry) {
|
|
93
|
+
handlersRef.current.add(entry)
|
|
94
|
+
return () => {
|
|
95
|
+
handlersRef.current.delete(entry)
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
}), [])
|
|
99
|
+
|
|
100
|
+
return <AppInputContext.Provider value={value}>{children}</AppInputContext.Provider>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function useAppInput(
|
|
104
|
+
handler: InputHandler,
|
|
105
|
+
options: { isActive?: boolean } = {},
|
|
106
|
+
): void {
|
|
107
|
+
const ctx = useContext(AppInputContext)
|
|
108
|
+
if (!ctx) throw new Error('useAppInput must be used inside AppInputProvider')
|
|
109
|
+
|
|
110
|
+
const handlerRef = useRef(handler)
|
|
111
|
+
const isActiveRef = useRef(options.isActive !== false)
|
|
112
|
+
|
|
113
|
+
useEffect(() => { handlerRef.current = handler }, [handler])
|
|
114
|
+
useEffect(() => { isActiveRef.current = options.isActive !== false }, [options.isActive])
|
|
115
|
+
useEffect(() => ctx.register({ handlerRef, isActiveRef }), [ctx])
|
|
116
|
+
}
|