ethagent 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -32
  3. package/bin/ethagent.js +11 -2
  4. package/package.json +25 -7
  5. package/src/app/FirstRun.tsx +412 -0
  6. package/src/app/hooks/useCancelRequest.ts +22 -0
  7. package/src/app/hooks/useDoublePress.ts +46 -0
  8. package/src/app/hooks/useExitOnCtrlC.ts +36 -0
  9. package/src/app/input/AppInputProvider.tsx +116 -0
  10. package/src/app/input/appInputParser.ts +279 -0
  11. package/src/app/keybindings/KeybindingProvider.tsx +134 -0
  12. package/src/app/keybindings/resolver.ts +42 -0
  13. package/src/app/keybindings/types.ts +26 -0
  14. package/src/chat/ChatBottomPane.tsx +280 -0
  15. package/src/chat/ChatInput.tsx +722 -0
  16. package/src/chat/ChatScreen.tsx +1575 -0
  17. package/src/chat/ContextLimitView.tsx +95 -0
  18. package/src/chat/ContinuityEditReviewView.tsx +48 -0
  19. package/src/chat/ConversationStack.tsx +47 -0
  20. package/src/chat/CopyPicker.tsx +52 -0
  21. package/src/chat/MessageList.tsx +609 -0
  22. package/src/chat/PermissionPrompt.tsx +153 -0
  23. package/src/chat/PermissionsView.tsx +159 -0
  24. package/src/chat/PlanApprovalView.tsx +91 -0
  25. package/src/chat/ResumeView.tsx +267 -0
  26. package/src/chat/RewindView.tsx +386 -0
  27. package/src/chat/SessionStatus.tsx +51 -0
  28. package/src/chat/TranscriptView.tsx +202 -0
  29. package/src/chat/chatInputState.ts +247 -0
  30. package/src/chat/chatPaste.ts +49 -0
  31. package/src/chat/chatScreenUtils.ts +187 -0
  32. package/src/chat/chatSessionState.ts +142 -0
  33. package/src/chat/chatTurnOrchestrator.ts +701 -0
  34. package/src/chat/commands.ts +673 -0
  35. package/src/chat/textCursor.ts +202 -0
  36. package/src/chat/toolResultDisplay.ts +8 -0
  37. package/src/chat/transcriptViewport.ts +247 -0
  38. package/src/cli/ResetConfirmView.tsx +61 -0
  39. package/src/cli/main.tsx +177 -0
  40. package/src/cli/preview.tsx +19 -0
  41. package/src/cli/reset.ts +106 -0
  42. package/src/identity/continuity/editor.ts +149 -0
  43. package/src/identity/continuity/envelope.ts +345 -0
  44. package/src/identity/continuity/history.ts +153 -0
  45. package/src/identity/continuity/privateEdit.ts +334 -0
  46. package/src/identity/continuity/publicSkills.ts +173 -0
  47. package/src/identity/continuity/snapshots.ts +183 -0
  48. package/src/identity/continuity/storage.ts +507 -0
  49. package/src/identity/crypto/backupEnvelope.ts +486 -0
  50. package/src/identity/crypto/eth.ts +137 -0
  51. package/src/identity/hub/IdentityHub.tsx +868 -0
  52. package/src/identity/hub/identityHubEffects.ts +1146 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +212 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
  57. package/src/identity/hub/screens/CreateFlow.tsx +206 -0
  58. package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
  59. package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
  60. package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
  61. package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
  62. package/src/identity/hub/screens/MenuScreen.tsx +117 -0
  63. package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
  64. package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
  65. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
  66. package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
  67. package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
  68. package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
  69. package/src/identity/profile/imagePicker.ts +180 -0
  70. package/src/identity/registry/erc8004.ts +1106 -0
  71. package/src/identity/registry/registryConfig.ts +69 -0
  72. package/src/identity/storage/ipfs.ts +212 -0
  73. package/src/identity/storage/pinataJwt.ts +53 -0
  74. package/src/identity/wallet/browserWallet.ts +393 -0
  75. package/src/identity/wallet/wallet-page/wallet.html +1082 -0
  76. package/src/mcp/approvals.ts +113 -0
  77. package/src/mcp/config.ts +235 -0
  78. package/src/mcp/manager.ts +541 -0
  79. package/src/mcp/names.ts +19 -0
  80. package/src/mcp/output.ts +96 -0
  81. package/src/models/ModelPicker.tsx +1446 -0
  82. package/src/models/catalog.ts +296 -0
  83. package/src/models/huggingface.ts +651 -0
  84. package/src/models/llamacpp.ts +810 -0
  85. package/src/models/llamacppPreflight.ts +150 -0
  86. package/src/models/modelDisplay.ts +105 -0
  87. package/src/models/modelPickerOptions.ts +421 -0
  88. package/src/models/modelRecommendation.ts +140 -0
  89. package/src/models/runtimeDetection.ts +81 -0
  90. package/src/models/uncensoredCatalog.ts +86 -0
  91. package/src/providers/anthropic.ts +259 -0
  92. package/src/providers/contracts.ts +62 -0
  93. package/src/providers/errors.ts +62 -0
  94. package/src/providers/gemini.ts +152 -0
  95. package/src/providers/openai-chat.ts +472 -0
  96. package/src/providers/registry.ts +42 -0
  97. package/src/providers/retry.ts +58 -0
  98. package/src/providers/sse.ts +93 -0
  99. package/src/runtime/compaction.ts +389 -0
  100. package/src/runtime/cwd.ts +43 -0
  101. package/src/runtime/sessionMode.ts +55 -0
  102. package/src/runtime/systemPrompt.ts +209 -0
  103. package/src/runtime/toolClaimGuards.ts +143 -0
  104. package/src/runtime/toolExecution.ts +304 -0
  105. package/src/runtime/toolIntent.ts +163 -0
  106. package/src/runtime/turn.ts +858 -0
  107. package/src/storage/atomicWrite.ts +68 -0
  108. package/src/storage/config.ts +189 -0
  109. package/src/storage/factoryReset.ts +130 -0
  110. package/src/storage/history.ts +58 -0
  111. package/src/storage/identity.ts +99 -0
  112. package/src/storage/permissions.ts +76 -0
  113. package/src/storage/rewind.ts +246 -0
  114. package/src/storage/secrets.ts +181 -0
  115. package/src/storage/sessionExport.ts +49 -0
  116. package/src/storage/sessions.ts +482 -0
  117. package/src/tools/bashSafety.ts +174 -0
  118. package/src/tools/bashTool.ts +140 -0
  119. package/src/tools/changeDirectoryTool.ts +213 -0
  120. package/src/tools/contracts.ts +179 -0
  121. package/src/tools/deleteFileTool.ts +111 -0
  122. package/src/tools/editTool.ts +160 -0
  123. package/src/tools/editUtils.ts +170 -0
  124. package/src/tools/listDirectoryTool.ts +55 -0
  125. package/src/tools/mcpResourceTools.ts +95 -0
  126. package/src/tools/permissionRules.ts +85 -0
  127. package/src/tools/privateContinuityEditTool.ts +178 -0
  128. package/src/tools/privateContinuityReadTool.ts +107 -0
  129. package/src/tools/readTool.ts +85 -0
  130. package/src/tools/registry.ts +67 -0
  131. package/src/tools/writeFileTool.ts +142 -0
  132. package/src/ui/BrandSplash.tsx +193 -0
  133. package/src/ui/ProgressBar.tsx +34 -0
  134. package/src/ui/Select.tsx +143 -0
  135. package/src/ui/Spinner.tsx +269 -0
  136. package/src/ui/Surface.tsx +47 -0
  137. package/src/ui/TextInput.tsx +97 -0
  138. package/src/ui/theme.ts +59 -0
  139. package/src/utils/clipboard.ts +216 -0
  140. package/src/utils/markdownSegments.ts +51 -0
  141. package/src/utils/messages.ts +35 -0
  142. package/src/utils/withRetry.ts +280 -0
  143. 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
+ }