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,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
|
+
}
|