ethagent 4.3.0 → 4.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ethagent",
3
- "version": "4.3.0",
3
+ "version": "4.3.2",
4
4
  "description": "Portable Ethereum identity for your AI agent. Its soul, memory, and skills live onchain via ERC-8004 + IPFS and snap back into any session.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,135 +1,16 @@
1
- import fs from 'node:fs'
2
- import os from 'node:os'
3
- import path from 'node:path'
4
- import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react'
5
- import { useStdin, useStdout } from 'ink'
6
- import type { AppInputEvent } from './appInputParser.js'
7
- import {
8
- BRACKETED_PASTE_DISABLE,
9
- BRACKETED_PASTE_ENABLE,
10
- createAppInputParseState,
11
- DISABLE_KITTY_KEYBOARD,
12
- DISABLE_MODIFY_OTHER_KEYS,
13
- ENABLE_KITTY_KEYBOARD,
14
- ENABLE_MODIFY_OTHER_KEYS,
15
- hasPendingAppInput,
16
- parseAppInput,
17
- } from './appInputParser.js'
1
+ import { useInput, type Key } from 'ink'
18
2
 
19
- const DEBUG_INPUT = Boolean(process.env.ETHAGENT_DEBUG_INPUT)
20
- const DEBUG_LOG_PATH = path.join(os.homedir(), '.ethagent', 'input-debug.log')
3
+ export type { Key }
21
4
 
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
-
34
- type InputHandler = (input: string, key: AppInputEvent['key'], event: AppInputEvent) => void
35
-
36
- type HandlerEntry = {
37
- handlerRef: React.MutableRefObject<InputHandler>
38
- isActiveRef: React.MutableRefObject<boolean>
39
- }
40
-
41
- type AppInputContextValue = {
42
- register(entry: HandlerEntry): () => void
43
- }
44
-
45
- const AppInputContext = createContext<AppInputContextValue | null>(null)
46
- const PENDING_ESCAPE_FLUSH_MS = 50
47
-
48
- export const AppInputProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
49
- const { stdin } = useStdin()
50
- const { stdout } = useStdout()
51
- const handlersRef = useRef<Set<HandlerEntry>>(new Set())
52
- const parseStateRef = useRef(createAppInputParseState())
53
- const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
54
-
55
- const dispatch = useCallback((event: AppInputEvent) => {
56
- for (const entry of handlersRef.current) {
57
- if (!entry.isActiveRef.current) continue
58
- entry.handlerRef.current(event.input, event.key, event)
59
- }
60
- }, [])
5
+ export type InputHandler = (input: string, key: Key) => void
61
6
 
62
- const flushPending = useCallback(() => {
63
- flushTimerRef.current = null
64
- const result = parseAppInput(parseStateRef.current, null)
65
- parseStateRef.current = result.state
66
- for (const event of result.events) dispatch(event)
67
- }, [dispatch])
68
-
69
- const scheduleFlush = useCallback(() => {
70
- if (flushTimerRef.current) clearTimeout(flushTimerRef.current)
71
- flushTimerRef.current = setTimeout(flushPending, PENDING_ESCAPE_FLUSH_MS)
72
- }, [flushPending])
73
-
74
- useEffect(() => {
75
- if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return
76
-
77
- const handleData = (chunk: Buffer | string) => {
78
- logRawChunk(chunk)
79
- if (flushTimerRef.current) {
80
- clearTimeout(flushTimerRef.current)
81
- flushTimerRef.current = null
82
- }
83
- const result = parseAppInput(parseStateRef.current, chunk)
84
- parseStateRef.current = result.state
85
- for (const event of result.events) dispatch(event)
86
- if (hasPendingAppInput(parseStateRef.current)) scheduleFlush()
87
- }
88
-
89
- stdin.setEncoding('utf8')
90
- stdin.setRawMode(true)
91
- stdin.ref()
92
- stdin.on('data', handleData)
93
- stdin.resume()
94
- stdout.write(BRACKETED_PASTE_ENABLE)
95
- stdout.write(ENABLE_KITTY_KEYBOARD)
96
- stdout.write(ENABLE_MODIFY_OTHER_KEYS)
97
-
98
- return () => {
99
- if (flushTimerRef.current) clearTimeout(flushTimerRef.current)
100
- stdout.write(DISABLE_MODIFY_OTHER_KEYS)
101
- stdout.write(DISABLE_KITTY_KEYBOARD)
102
- stdout.write(BRACKETED_PASTE_DISABLE)
103
- stdin.off('data', handleData)
104
- stdin.setRawMode(false)
105
- stdin.pause()
106
- stdin.unref()
107
- }
108
- }, [dispatch, scheduleFlush, stdin, stdout])
109
-
110
- const value = useMemo<AppInputContextValue>(() => ({
111
- register(entry) {
112
- handlersRef.current.add(entry)
113
- return () => {
114
- handlersRef.current.delete(entry)
115
- }
116
- },
117
- }), [])
118
-
119
- return <AppInputContext.Provider value={value}>{children}</AppInputContext.Provider>
7
+ export type UseAppInputOptions = {
8
+ isActive?: boolean
120
9
  }
121
10
 
122
- export function useAppInput(
123
- handler: InputHandler,
124
- options: { isActive?: boolean } = {},
125
- ): void {
126
- const ctx = useContext(AppInputContext)
127
- if (!ctx) throw new Error('Hook useAppInput must be used inside AppInputProvider')
128
-
129
- const handlerRef = useRef(handler)
130
- const isActiveRef = useRef(options.isActive !== false)
131
-
132
- useEffect(() => { handlerRef.current = handler }, [handler])
133
- useEffect(() => { isActiveRef.current = options.isActive !== false }, [options.isActive])
134
- useEffect(() => ctx.register({ handlerRef, isActiveRef }), [ctx])
11
+ // Thin wrapper over Ink's built-in useInput. Ink's parseKeypress already decodes
12
+ // modified arrows (shift/ctrl/meta), Home/End, and the Kitty keyboard protocol, so
13
+ // we no longer hand-roll terminal escape-sequence parsing or manage raw mode here.
14
+ export function useAppInput(handler: InputHandler, options: UseAppInputOptions = {}): void {
15
+ useInput(handler, options)
135
16
  }
package/src/cli/main.tsx CHANGED
@@ -7,7 +7,6 @@ import { fileURLToPath } from 'node:url'
7
7
  import { theme } from '../ui/theme.js'
8
8
  import { Spinner } from '../ui/Spinner.js'
9
9
  import { KeybindingProvider } from '../app/keybindings/KeybindingProvider.js'
10
- import { AppInputProvider } from '../app/input/AppInputProvider.js'
11
10
  import { IdentityManager } from '../identity/manager/IdentityManager.js'
12
11
  import type { IdentityManagerResult } from '../identity/manager/IdentityManager.js'
13
12
  import { loadConfig, saveConfig, type EthagentConfig } from '../storage/config.js'
@@ -114,11 +113,9 @@ const Root: React.FC<RootProps> = ({ setExit, initialConfig }) => {
114
113
  async function renderHub(initialConfig: EthagentConfig | null | undefined): Promise<number> {
115
114
  let exitCode = 0
116
115
  const instance = render(
117
- <AppInputProvider>
118
- <KeybindingProvider>
119
- <Root setExit={n => { exitCode = n }} initialConfig={initialConfig} />
120
- </KeybindingProvider>
121
- </AppInputProvider>,
116
+ <KeybindingProvider>
117
+ <Root setExit={n => { exitCode = n }} initialConfig={initialConfig} />
118
+ </KeybindingProvider>,
122
119
  { exitOnCtrlC: false },
123
120
  )
124
121
  const guard = (reason: unknown): void => {
package/src/cli/reset.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import React from 'react'
2
2
  import { render } from 'ink'
3
3
  import { stdout, stderr } from 'node:process'
4
- import { AppInputProvider } from '../app/input/AppInputProvider.js'
5
4
  import { ResetConfirmView } from './ResetConfirmView.js'
6
5
  import { resetPlan, runReset } from '../storage/reset.js'
7
6
  import { clearHarnessManagedBlocks } from './syncAdapters/index.js'
@@ -44,14 +43,10 @@ async function finishReset(): Promise<void> {
44
43
  async function confirmWithInk(configDir: string, secretAccounts: string[]): Promise<boolean> {
45
44
  let confirmed = false
46
45
  const instance = render(
47
- React.createElement(
48
- AppInputProvider,
49
- null,
50
- React.createElement(ResetConfirmView, {
51
- plan: { configDir, secretAccounts },
52
- onDone: (value: boolean) => { confirmed = value },
53
- }),
54
- ),
46
+ React.createElement(ResetConfirmView, {
47
+ plan: { configDir, secretAccounts },
48
+ onDone: (value: boolean) => { confirmed = value },
49
+ }),
55
50
  { exitOnCtrlC: false },
56
51
  )
57
52
  try {
package/src/ui/Select.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import React, { useEffect, useMemo, useState } from 'react'
2
2
  import { Box, Text } from 'ink'
3
- import { theme, PANEL_WIDTH } from './theme.js'
3
+ import { theme, PANEL_WIDTH, gradientColor } from './theme.js'
4
4
  import { useAppInput } from '../app/input/AppInputProvider.js'
5
5
 
6
6
  const CONTENT_WIDTH = PANEL_WIDTH - 4
@@ -11,6 +11,10 @@ function fitHint(hint: string, budget: number): string {
11
11
  return `${hint.slice(0, budget - 1)}…`
12
12
  }
13
13
 
14
+ function rainbowColor(index: number, total: number): string {
15
+ return gradientColor(total <= 1 ? 0 : index / (total - 1))
16
+ }
17
+
14
18
  export type SelectOption<T> = {
15
19
  value: T
16
20
  label: string
@@ -135,17 +139,18 @@ export function Select<T>({
135
139
  const belowHintText = belowHint
136
140
  ? fitHint(option.hint ?? '', CONTENT_WIDTH - rowIndent - 2)
137
141
  : ''
138
- const showHeadHighlight = isActive && selectable && !isSection && option.label.length > 0
142
+ const showActiveGradient = isActive && selectable && !isSection && option.label.length > 0
139
143
  return (
140
144
  <Box key={absoluteIndex} flexDirection="column">
141
145
  <Box flexDirection="row" marginLeft={rowIndent}>
142
146
  <Text color={prefixColor}>{cursor} </Text>
143
147
  {prefix ? <Text color={prefixColor}>{prefix}</Text> : null}
144
- {showHeadHighlight ? (
145
- <>
146
- <Text color={theme.accentHighlight} bold>{option.label[0]}</Text>
147
- <Text color={labelColor} bold={bold}>{option.label.slice(1)}</Text>
148
- </>
148
+ {showActiveGradient ? (
149
+ <Text>
150
+ {option.label.split('').map((ch, ci) => (
151
+ <Text key={ci} color={rainbowColor(ci, option.label.length)}>{ch}</Text>
152
+ ))}
153
+ </Text>
149
154
  ) : (
150
155
  <Text color={labelColor} bold={bold}>{option.label}</Text>
151
156
  )}
@@ -78,6 +78,15 @@ export function TextArea({
78
78
  setCursor(lineStart)
79
79
  return
80
80
  }
81
+ if (key.home || (key.ctrl && input === 'a')) {
82
+ setCursor(Math.max(0, val.lastIndexOf('\n', cur - 1) + 1))
83
+ return
84
+ }
85
+ if (key.end || (key.ctrl && input === 'e')) {
86
+ const nextNewline = val.indexOf('\n', cur)
87
+ setCursor(nextNewline === -1 ? val.length : nextNewline)
88
+ return
89
+ }
81
90
  if (key.ctrl || key.meta || key.tab) return
82
91
  if (input) {
83
92
  const clean = input.replace(/\r/g, '')
@@ -109,6 +109,14 @@ export function TextInput({
109
109
  if (error) setError(null)
110
110
  return
111
111
  }
112
+ if (key.home || (key.ctrl && input === 'a')) {
113
+ setCursor(0)
114
+ return
115
+ }
116
+ if (key.end || (key.ctrl && input === 'e')) {
117
+ setCursor(val.length)
118
+ return
119
+ }
112
120
  if (key.ctrl || key.meta || key.upArrow || key.downArrow || key.tab) {
113
121
  return
114
122
  }
@@ -1,294 +0,0 @@
1
- import type { Key } from 'ink'
2
-
3
- export const BRACKETED_PASTE_ENABLE = '\x1b[?2004h'
4
- export const BRACKETED_PASTE_DISABLE = '\x1b[?2004l'
5
- export const BRACKETED_PASTE_START = '\x1b[200~'
6
- export const BRACKETED_PASTE_END = '\x1b[201~'
7
- export const ENABLE_KITTY_KEYBOARD = '\x1b[>1u'
8
- export const DISABLE_KITTY_KEYBOARD = '\x1b[<u'
9
- export const ENABLE_MODIFY_OTHER_KEYS = '\x1b[>4;2m'
10
- export const DISABLE_MODIFY_OTHER_KEYS = '\x1b[>4m'
11
-
12
- export type AppInputEvent = {
13
- input: string
14
- key: Key
15
- isPasted: boolean
16
- raw: string
17
- }
18
-
19
- export type AppInputParseState = {
20
- mode: 'normal' | 'paste'
21
- pending: string
22
- pasteBuffer: string
23
- }
24
-
25
- type ParseResult = {
26
- events: AppInputEvent[]
27
- state: AppInputParseState
28
- }
29
-
30
- const ESC = '\x1b'
31
- const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/
32
- const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~/
33
-
34
- const KNOWN_SEQUENCES: Array<{ sequence: string; key: Partial<Key> }> = [
35
- { sequence: '\x1b[A', key: { upArrow: true } },
36
- { sequence: '\x1b[B', key: { downArrow: true } },
37
- { sequence: '\x1b[C', key: { rightArrow: true } },
38
- { sequence: '\x1b[D', key: { leftArrow: true } },
39
- { sequence: '\x1b[Z', key: { tab: true, shift: true } },
40
- { sequence: '\x1b[3~', key: { delete: true } },
41
- { sequence: '\x1b[5~', key: { pageUp: true } },
42
- { sequence: '\x1b[6~', key: { pageDown: true } },
43
- { sequence: '\x1b[H', key: { home: true } },
44
- { sequence: '\x1b[F', key: { end: true } },
45
- ]
46
-
47
- const PENDING_PREFIXES = [
48
- BRACKETED_PASTE_START,
49
- BRACKETED_PASTE_END,
50
- ...KNOWN_SEQUENCES.map(entry => entry.sequence),
51
- '\x1b[13;2u',
52
- '\x1b[27;2;13~',
53
- ]
54
-
55
- export function createAppInputParseState(): AppInputParseState {
56
- return { mode: 'normal', pending: '', pasteBuffer: '' }
57
- }
58
-
59
- export function hasPendingAppInput(state: AppInputParseState): boolean {
60
- return state.pending.length > 0
61
- }
62
-
63
- export function parseAppInput(
64
- previous: AppInputParseState,
65
- chunk: Buffer | string | null,
66
- ): ParseResult {
67
- const source = previous.pending + (chunk === null ? '' : stringifyChunk(chunk))
68
- const events: AppInputEvent[] = []
69
- const state: AppInputParseState = {
70
- mode: previous.mode,
71
- pending: '',
72
- pasteBuffer: previous.pasteBuffer,
73
- }
74
-
75
- let rest = source
76
- while (rest.length > 0) {
77
- if (state.mode === 'paste') {
78
- const endIndex = rest.indexOf(BRACKETED_PASTE_END)
79
- if (endIndex === -1) {
80
- const pending = chunk === null ? '' : longestSuffixPrefix(rest, [BRACKETED_PASTE_END])
81
- state.pasteBuffer += rest.slice(0, rest.length - pending.length)
82
- state.pending = pending
83
- rest = ''
84
- break
85
- }
86
-
87
- state.pasteBuffer += rest.slice(0, endIndex)
88
- events.push(createInputEvent(state.pasteBuffer, {}, state.pasteBuffer, true))
89
- state.mode = 'normal'
90
- state.pasteBuffer = ''
91
- rest = rest.slice(endIndex + BRACKETED_PASTE_END.length)
92
- continue
93
- }
94
-
95
- if (rest.startsWith(BRACKETED_PASTE_START)) {
96
- state.mode = 'paste'
97
- state.pasteBuffer = ''
98
- rest = rest.slice(BRACKETED_PASTE_START.length)
99
- continue
100
- }
101
-
102
- if (rest.startsWith(BRACKETED_PASTE_END)) {
103
- rest = rest.slice(BRACKETED_PASTE_END.length)
104
- continue
105
- }
106
-
107
- const parsed = parseNormalInput(rest, chunk === null)
108
- if (parsed.kind === 'pending') {
109
- state.pending = parsed.pending
110
- rest = ''
111
- break
112
- }
113
- events.push(parsed.event)
114
- rest = rest.slice(parsed.length)
115
- }
116
-
117
- return { events, state }
118
- }
119
-
120
- type NormalParseResult =
121
- | { kind: 'event'; event: AppInputEvent; length: number }
122
- | { kind: 'pending'; pending: string }
123
-
124
- function parseNormalInput(source: string, flushing: boolean): NormalParseResult {
125
- if (!source.startsWith(ESC)) {
126
- const nextEscape = source.indexOf(ESC)
127
- const text = nextEscape === -1 ? source : source.slice(0, nextEscape)
128
- return { kind: 'event', event: createTextEvent(text), length: text.length }
129
- }
130
-
131
- if (!flushing && isPendingPrefix(source)) {
132
- return { kind: 'pending', pending: source }
133
- }
134
-
135
- const csiU = CSI_U_RE.exec(source)
136
- if (csiU) {
137
- const codepoint = Number(csiU[1])
138
- const modifier = csiU[2] ? Number(csiU[2]) : 1
139
- return {
140
- kind: 'event',
141
- event: keycodeEvent(csiU[0], codepoint, modifier),
142
- length: csiU[0].length,
143
- }
144
- }
145
-
146
- const modifyOtherKeys = MODIFY_OTHER_KEYS_RE.exec(source)
147
- if (modifyOtherKeys) {
148
- return {
149
- kind: 'event',
150
- event: keycodeEvent(
151
- modifyOtherKeys[0],
152
- Number(modifyOtherKeys[2]),
153
- Number(modifyOtherKeys[1]),
154
- ),
155
- length: modifyOtherKeys[0].length,
156
- }
157
- }
158
-
159
- const known = KNOWN_SEQUENCES.find(entry => source.startsWith(entry.sequence))
160
- if (known) {
161
- return {
162
- kind: 'event',
163
- event: createInputEvent('', known.key, known.sequence),
164
- length: known.sequence.length,
165
- }
166
- }
167
-
168
- if (source.startsWith('\x1b\r') || source.startsWith('\x1b\n')) {
169
- return {
170
- kind: 'event',
171
- event: createInputEvent('', { meta: true, return: true }, source.slice(0, 2)),
172
- length: 2,
173
- }
174
- }
175
-
176
- if (source.length >= 2) {
177
- const next = source.slice(1, 2)
178
- const code = next.charCodeAt(0)
179
- const altInput = code >= 0x20 && code <= 0x7e ? next : ''
180
- return {
181
- kind: 'event',
182
- event: createInputEvent(altInput, { meta: true }, source.slice(0, 2)),
183
- length: 2,
184
- }
185
- }
186
-
187
- return {
188
- kind: 'event',
189
- event: createInputEvent('', { escape: true, meta: true }, ESC),
190
- length: 1,
191
- }
192
- }
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
-
206
- function createTextEvent(text: string): AppInputEvent {
207
- if (text === '\r' || text === '\n') return createInputEvent('', { return: true }, text)
208
- if (text === '\t') return createInputEvent('', { tab: true }, text)
209
- if (text === '\x7f' || text === '\b') return createInputEvent('', { backspace: true }, text)
210
- if (text.length === 1) {
211
- const code = text.charCodeAt(0)
212
- if (code > 0 && code <= 26) {
213
- return createInputEvent(
214
- String.fromCharCode('a'.charCodeAt(0) + code - 1),
215
- { ctrl: true },
216
- text,
217
- )
218
- }
219
- if (/[A-Z]/.test(text)) return createInputEvent(text, { shift: true }, text)
220
- }
221
- const cleaned = stripNoise(text)
222
- return createInputEvent(cleaned, {}, text)
223
- }
224
-
225
- function keycodeEvent(raw: string, codepoint: number, modifier: number): AppInputEvent {
226
- const key = decodeModifier(modifier)
227
- if (codepoint === 9) return createInputEvent('', { ...key, tab: true }, raw)
228
- if (codepoint === 13) return createInputEvent('', { ...key, return: true }, raw)
229
- if (codepoint === 27) return createInputEvent('', { ...key, escape: true, meta: true }, raw)
230
- const printable = codepoint >= 0x20 && codepoint <= 0x7e
231
- return createInputEvent(printable ? String.fromCodePoint(codepoint) : '', key, raw)
232
- }
233
-
234
- function decodeModifier(modifier: number): Partial<Key> {
235
- const normalized = Math.max(1, modifier) - 1
236
- return {
237
- shift: Boolean(normalized & 1),
238
- meta: Boolean(normalized & 2),
239
- ctrl: Boolean(normalized & 4),
240
- super: Boolean(normalized & 8),
241
- }
242
- }
243
-
244
- function createInputEvent(
245
- input: string,
246
- key: Partial<Key>,
247
- raw: string,
248
- isPasted = false,
249
- ): AppInputEvent {
250
- return {
251
- input,
252
- key: {
253
- upArrow: false,
254
- downArrow: false,
255
- leftArrow: false,
256
- rightArrow: false,
257
- pageDown: false,
258
- pageUp: false,
259
- home: false,
260
- end: false,
261
- return: false,
262
- escape: false,
263
- ctrl: false,
264
- shift: false,
265
- tab: false,
266
- backspace: false,
267
- delete: false,
268
- meta: false,
269
- ...key,
270
- } as Key,
271
- raw,
272
- isPasted,
273
- }
274
- }
275
-
276
- function stringifyChunk(chunk: Buffer | string): string {
277
- return Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk
278
- }
279
-
280
- function isPendingPrefix(source: string): boolean {
281
- return PENDING_PREFIXES.some(sequence => sequence.startsWith(source) && sequence !== source)
282
- }
283
-
284
- function longestSuffixPrefix(source: string, markers: string[]): string {
285
- let best = ''
286
- for (const marker of markers) {
287
- const maxLength = Math.min(source.length, marker.length - 1)
288
- for (let length = 1; length <= maxLength; length += 1) {
289
- const suffix = source.slice(source.length - length)
290
- if (marker.startsWith(suffix) && suffix.length > best.length) best = suffix
291
- }
292
- }
293
- return best
294
- }