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,279 @@
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
+ return {
179
+ kind: 'event',
180
+ event: createInputEvent(next, { meta: true }, source.slice(0, 2)),
181
+ length: 2,
182
+ }
183
+ }
184
+
185
+ return {
186
+ kind: 'event',
187
+ event: createInputEvent('', { escape: true, meta: true }, ESC),
188
+ length: 1,
189
+ }
190
+ }
191
+
192
+ function createTextEvent(text: string): AppInputEvent {
193
+ if (text === '\r' || text === '\n') return createInputEvent('', { return: true }, text)
194
+ if (text === '\t') return createInputEvent('', { tab: true }, text)
195
+ if (text === '\x7f' || text === '\b') return createInputEvent('', { backspace: true }, text)
196
+ if (text.length === 1) {
197
+ const code = text.charCodeAt(0)
198
+ if (code > 0 && code <= 26) {
199
+ return createInputEvent(
200
+ String.fromCharCode('a'.charCodeAt(0) + code - 1),
201
+ { ctrl: true },
202
+ text,
203
+ )
204
+ }
205
+ if (/[A-Z]/.test(text)) return createInputEvent(text, { shift: true }, text)
206
+ }
207
+ return createInputEvent(text, {}, text)
208
+ }
209
+
210
+ function keycodeEvent(raw: string, codepoint: number, modifier: number): AppInputEvent {
211
+ const key = decodeModifier(modifier)
212
+ if (codepoint === 9) return createInputEvent('', { ...key, tab: true }, raw)
213
+ if (codepoint === 13) return createInputEvent('', { ...key, return: true }, raw)
214
+ if (codepoint === 27) return createInputEvent('', { ...key, escape: true, meta: true }, raw)
215
+ const char = String.fromCodePoint(codepoint)
216
+ return createInputEvent(char, key, raw)
217
+ }
218
+
219
+ function decodeModifier(modifier: number): Partial<Key> {
220
+ const normalized = Math.max(1, modifier) - 1
221
+ return {
222
+ shift: Boolean(normalized & 1),
223
+ meta: Boolean(normalized & 2),
224
+ ctrl: Boolean(normalized & 4),
225
+ super: Boolean(normalized & 8),
226
+ }
227
+ }
228
+
229
+ function createInputEvent(
230
+ input: string,
231
+ key: Partial<Key>,
232
+ raw: string,
233
+ isPasted = false,
234
+ ): AppInputEvent {
235
+ return {
236
+ input,
237
+ key: {
238
+ upArrow: false,
239
+ downArrow: false,
240
+ leftArrow: false,
241
+ rightArrow: false,
242
+ pageDown: false,
243
+ pageUp: false,
244
+ home: false,
245
+ end: false,
246
+ return: false,
247
+ escape: false,
248
+ ctrl: false,
249
+ shift: false,
250
+ tab: false,
251
+ backspace: false,
252
+ delete: false,
253
+ meta: false,
254
+ ...key,
255
+ } as Key,
256
+ raw,
257
+ isPasted,
258
+ }
259
+ }
260
+
261
+ function stringifyChunk(chunk: Buffer | string): string {
262
+ return Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk
263
+ }
264
+
265
+ function isPendingPrefix(source: string): boolean {
266
+ return PENDING_PREFIXES.some(sequence => sequence.startsWith(source) && sequence !== source)
267
+ }
268
+
269
+ function longestSuffixPrefix(source: string, markers: string[]): string {
270
+ let best = ''
271
+ for (const marker of markers) {
272
+ const maxLength = Math.min(source.length, marker.length - 1)
273
+ for (let length = 1; length <= maxLength; length += 1) {
274
+ const suffix = source.slice(source.length - length)
275
+ if (marker.startsWith(suffix) && suffix.length > best.length) best = suffix
276
+ }
277
+ }
278
+ return best
279
+ }
@@ -0,0 +1,134 @@
1
+ import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
2
+ import type { Key } from 'ink'
3
+ import { useAppInput } from '../input/AppInputProvider.js'
4
+ import type { Action, Binding, KeybindingContextName } from './types.js'
5
+ import { resolveKey } from './resolver.js'
6
+
7
+ type HandlerEntry = {
8
+ handler: () => void
9
+ context: KeybindingContextName
10
+ isActive: boolean
11
+ }
12
+
13
+ type KeybindingContextValue = {
14
+ register(action: Action, entry: HandlerEntry): () => void
15
+ activateContext(ctx: KeybindingContextName): () => void
16
+ }
17
+
18
+ const Ctx = createContext<KeybindingContextValue | null>(null)
19
+
20
+ type ProviderProps = {
21
+ bindings?: Binding[]
22
+ children: React.ReactNode
23
+ }
24
+
25
+ const DEFAULT_BINDINGS: Binding[] = [
26
+ { context: 'Global', chord: { key: 'c', ctrl: true }, action: 'app:interrupt' },
27
+ { context: 'Global', chord: { key: 'l', ctrl: true }, action: 'app:redraw' },
28
+ { context: 'Chat', chord: { key: 'escape' }, action: 'chat:cancel' },
29
+ { context: 'Chat', chord: { key: 'p', meta: true }, action: 'chat:modelPicker' },
30
+ { context: 'Chat', chord: { key: 'i', meta: true }, action: 'chat:identityHub' },
31
+ { context: 'Chat', chord: { key: 't', meta: true }, action: 'chat:toggleReasoning' },
32
+ { context: 'Chat', chord: { key: 'tab', shift: true }, action: 'chat:cycleMode' },
33
+ ]
34
+
35
+ export const KeybindingProvider: React.FC<ProviderProps> = ({ bindings = DEFAULT_BINDINGS, children }) => {
36
+ const registryRef = useRef<Map<Action, Set<HandlerEntry>>>(new Map())
37
+ const [activeContexts, setActiveContexts] = useState<Set<KeybindingContextName>>(
38
+ () => new Set<KeybindingContextName>(['Global']),
39
+ )
40
+
41
+ const register = useCallback((action: Action, entry: HandlerEntry) => {
42
+ let bucket = registryRef.current.get(action)
43
+ if (!bucket) {
44
+ bucket = new Set()
45
+ registryRef.current.set(action, bucket)
46
+ }
47
+ bucket.add(entry)
48
+ return () => {
49
+ const set = registryRef.current.get(action)
50
+ if (!set) return
51
+ set.delete(entry)
52
+ if (set.size === 0) registryRef.current.delete(action)
53
+ }
54
+ }, [])
55
+
56
+ const activateContext = useCallback((ctx: KeybindingContextName) => {
57
+ setActiveContexts(prev => {
58
+ if (prev.has(ctx)) return prev
59
+ const next = new Set(prev)
60
+ next.add(ctx)
61
+ return next
62
+ })
63
+ return () => {
64
+ setActiveContexts(prev => {
65
+ if (!prev.has(ctx)) return prev
66
+ const next = new Set(prev)
67
+ next.delete(ctx)
68
+ return next
69
+ })
70
+ }
71
+ }, [])
72
+
73
+ const value = useMemo<KeybindingContextValue>(() => ({ register, activateContext }), [register, activateContext])
74
+
75
+ useAppInput((input, key) => {
76
+ const action = resolveKey(input, key as Key, Array.from(activeContexts), bindings)
77
+ if (!action) return
78
+ const bucket = registryRef.current.get(action)
79
+ if (!bucket || bucket.size === 0) return
80
+ for (const entry of bucket) {
81
+ if (!entry.isActive) continue
82
+ if (!activeContexts.has(entry.context)) continue
83
+ entry.handler()
84
+ return
85
+ }
86
+ })
87
+
88
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>
89
+ }
90
+
91
+ export function useKeybindingContext(): KeybindingContextValue {
92
+ const ctx = useContext(Ctx)
93
+ if (!ctx) throw new Error('useKeybindingContext requires KeybindingProvider')
94
+ return ctx
95
+ }
96
+
97
+ export function useOptionalKeybindingContext(): KeybindingContextValue | null {
98
+ return useContext(Ctx)
99
+ }
100
+
101
+ type UseKeybindingOptions = {
102
+ context?: KeybindingContextName
103
+ isActive?: boolean
104
+ }
105
+
106
+ export function useKeybinding(
107
+ action: Action,
108
+ handler: () => void,
109
+ options: UseKeybindingOptions = {},
110
+ ): void {
111
+ const ctx = useOptionalKeybindingContext()
112
+ const context = options.context ?? 'Chat'
113
+ const isActive = options.isActive ?? true
114
+ const handlerRef = useRef(handler)
115
+ useEffect(() => { handlerRef.current = handler }, [handler])
116
+
117
+ useEffect(() => {
118
+ if (!ctx) return
119
+ const entry: HandlerEntry = {
120
+ handler: () => handlerRef.current(),
121
+ context,
122
+ isActive,
123
+ }
124
+ return ctx.register(action, entry)
125
+ }, [ctx, action, context, isActive])
126
+ }
127
+
128
+ export function useRegisterKeybindingContext(context: KeybindingContextName, isActive = true): void {
129
+ const ctx = useOptionalKeybindingContext()
130
+ useEffect(() => {
131
+ if (!ctx || !isActive) return
132
+ return ctx.activateContext(context)
133
+ }, [ctx, context, isActive])
134
+ }
@@ -0,0 +1,42 @@
1
+ import type { Key } from 'ink'
2
+ import type { Action, Binding, KeybindingContextName } from './types.js'
3
+
4
+ export function resolveKey(
5
+ input: string,
6
+ key: Key,
7
+ activeContexts: KeybindingContextName[],
8
+ bindings: Binding[],
9
+ ): Action | null {
10
+ const ctxSet = new Set(activeContexts)
11
+ let match: Binding | undefined
12
+ for (const binding of bindings) {
13
+ if (!ctxSet.has(binding.context)) continue
14
+ if (matchesChord(input, key, binding.chord)) {
15
+ match = binding
16
+ }
17
+ }
18
+ return match?.action ?? null
19
+ }
20
+
21
+ function matchesChord(input: string, key: Key, chord: Binding['chord']): boolean {
22
+ if ((chord.ctrl ?? false) !== key.ctrl) return false
23
+ if ((chord.shift ?? false) !== key.shift) return false
24
+ if ((chord.meta ?? false) !== (key.meta && !key.escape)) return false
25
+ return keyNameOf(input, key) === chord.key
26
+ }
27
+
28
+ function keyNameOf(input: string, key: Key): string | null {
29
+ if (key.escape) return 'escape'
30
+ if (key.return) return 'return'
31
+ if (key.tab) return 'tab'
32
+ if (key.upArrow) return 'up'
33
+ if (key.downArrow) return 'down'
34
+ if (key.leftArrow) return 'left'
35
+ if (key.rightArrow) return 'right'
36
+ if (key.backspace) return 'backspace'
37
+ if (key.delete) return 'delete'
38
+ if (key.pageUp) return 'pageup'
39
+ if (key.pageDown) return 'pagedown'
40
+ if (!input) return null
41
+ return input.length === 1 ? input.toLowerCase() : input
42
+ }
@@ -0,0 +1,26 @@
1
+ export type KeybindingContextName = 'Global' | 'Chat' | 'Overlay'
2
+
3
+ export const ACTIONS = [
4
+ 'app:interrupt',
5
+ 'app:redraw',
6
+ 'chat:cancel',
7
+ 'chat:modelPicker',
8
+ 'chat:identityHub',
9
+ 'chat:cycleMode',
10
+ 'chat:toggleReasoning',
11
+ ] as const
12
+
13
+ export type Action = (typeof ACTIONS)[number]
14
+
15
+ export type Keystroke = {
16
+ key: string
17
+ ctrl?: boolean
18
+ shift?: boolean
19
+ meta?: boolean
20
+ }
21
+
22
+ export type Binding = {
23
+ context: KeybindingContextName
24
+ chord: Keystroke
25
+ action: Action
26
+ }