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.
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 +845 -0
  52. package/src/identity/hub/identityHubEffects.ts +1100 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +209 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -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,722 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
+ import { Box, Text, useStdout } from 'ink'
5
+ import { theme } from '../ui/theme.js'
6
+ import { readClipboardImage } from '../utils/clipboard.js'
7
+ import { useAppInput } from '../app/input/AppInputProvider.js'
8
+ import type { SlashSuggestion } from './commands.js'
9
+ import {
10
+ beginHistoryPreview,
11
+ canNavigateHistory as canNavigateHistoryState,
12
+ deleteToLineStart,
13
+ emptyBuffer,
14
+ exitHistoryPreview,
15
+ detectActiveFileMention,
16
+ moveThroughHistory,
17
+ moveVerticalVisual,
18
+ replaceActiveFileMention,
19
+ type ChatBuffer,
20
+ type FileMentionToken,
21
+ } from './chatInputState.js'
22
+ import {
23
+ getVisibleVisualLineWindow,
24
+ getVisualLineIndex,
25
+ getVisualLines,
26
+ } from './textCursor.js'
27
+ import {
28
+ countPastedTextLineBreaks,
29
+ expandPastedTextRefs,
30
+ formatPastedTextRef,
31
+ LARGE_PASTE_THRESHOLD,
32
+ normalizePastedText,
33
+ shouldCollapsePastedText,
34
+ type PastedTextRef,
35
+ } from './chatPaste.js'
36
+
37
+ type PromptInputProps = {
38
+ onSubmit: (value: string) => void
39
+ history: string[]
40
+ disabled?: boolean
41
+ placeholderHints?: string[]
42
+ queuedMessages?: string[]
43
+ prefix?: string
44
+ slashSuggestions?: SlashSuggestion[]
45
+ mode?: 'prompt' | 'bash'
46
+ onModeChange?: (mode: 'prompt' | 'bash') => void
47
+ footerRight?: React.ReactNode
48
+ cwd?: string
49
+ }
50
+
51
+ const MAX_LENGTH = 32_768
52
+ const PLACEHOLDER_ROTATE_MS = 8000
53
+ const PASTE_BURST_MS = 100
54
+ const PASTE_FLUSH_LIMIT = 4096
55
+ const MIN_INPUT_VIEWPORT_LINES = 3
56
+ const PROMPT_FOOTER_LINES = 5
57
+ const MAX_INLINE_PASTE_LINES = 2
58
+ const STACK_HORIZONTAL_PADDING = 2
59
+ const INPUT_BORDER_WIDTH = 2
60
+ const INPUT_HORIZONTAL_PADDING = 4
61
+ const PROMPT_PREFIX_WIDTH = 2
62
+
63
+ export const ChatInput: React.FC<PromptInputProps> = ({
64
+ onSubmit,
65
+ history,
66
+ disabled,
67
+ placeholderHints,
68
+ queuedMessages = [],
69
+ prefix = '\u203a',
70
+ slashSuggestions = [],
71
+ mode = 'prompt',
72
+ onModeChange,
73
+ footerRight,
74
+ cwd,
75
+ }) => {
76
+ const { stdout } = useStdout()
77
+ const [buffer, setBuffer] = useState<ChatBuffer>(emptyBuffer)
78
+ const { value, cursor } = buffer
79
+ const [columns, setColumns] = useState<number>(() => stdout.columns ?? process.stdout.columns ?? 80)
80
+ const [rows, setRows] = useState<number>(() => stdout.rows ?? process.stdout.rows ?? 24)
81
+ const [historyIndex, setHistoryIndex] = useState<number | null>(null)
82
+ const [draftBuffer, setDraftBuffer] = useState<ChatBuffer>(emptyBuffer)
83
+ const [historyPreviewActive, setHistoryPreviewActive] = useState(false)
84
+ const [preferredColumn, setPreferredColumn] = useState<number | null>(null)
85
+ const [placeholderIdx, setPlaceholderIdx] = useState(0)
86
+ const [suggestionIdx, setSuggestionIdx] = useState(0)
87
+ const [fileSuggestionIdx, setFileSuggestionIdx] = useState(0)
88
+ const [fileSuggestions, setFileSuggestions] = useState<FileMentionSuggestion[]>([])
89
+
90
+ const bufferRef = useRef<ChatBuffer>(buffer)
91
+ const historyIndexRef = useRef<number | null>(historyIndex)
92
+ const draftBufferRef = useRef<ChatBuffer>(draftBuffer)
93
+ const historyPreviewActiveRef = useRef(historyPreviewActive)
94
+ const preferredColumnRef = useRef<number | null>(preferredColumn)
95
+ const pasteBufferRef = useRef('')
96
+ const pasteTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
97
+ const pastedTextRefsRef = useRef<Map<number, PastedTextRef>>(new Map())
98
+ const nextPastedTextRefIdRef = useRef(1)
99
+
100
+ useEffect(() => { bufferRef.current = buffer }, [buffer])
101
+ useEffect(() => { historyIndexRef.current = historyIndex }, [historyIndex])
102
+ useEffect(() => { draftBufferRef.current = draftBuffer }, [draftBuffer])
103
+ useEffect(() => { historyPreviewActiveRef.current = historyPreviewActive }, [historyPreviewActive])
104
+ useEffect(() => { preferredColumnRef.current = preferredColumn }, [preferredColumn])
105
+
106
+ useEffect(() => {
107
+ const handleResize = () => {
108
+ setColumns(stdout.columns ?? process.stdout.columns ?? 80)
109
+ setRows(stdout.rows ?? process.stdout.rows ?? 24)
110
+ }
111
+ stdout.on('resize', handleResize)
112
+ return () => {
113
+ stdout.off('resize', handleResize)
114
+ }
115
+ }, [stdout])
116
+
117
+ const applyBuffer = useCallback((next: ChatBuffer) => {
118
+ bufferRef.current = next
119
+ setBuffer(next)
120
+ }, [])
121
+
122
+ const applyHistoryState = useCallback((next: {
123
+ historyIndex: number | null
124
+ historyPreviewActive: boolean
125
+ draftBuffer: ChatBuffer
126
+ preferredColumn: number | null
127
+ }) => {
128
+ historyIndexRef.current = next.historyIndex
129
+ draftBufferRef.current = next.draftBuffer
130
+ historyPreviewActiveRef.current = next.historyPreviewActive
131
+ preferredColumnRef.current = next.preferredColumn
132
+ setHistoryIndex(next.historyIndex)
133
+ setDraftBuffer(next.draftBuffer)
134
+ setHistoryPreviewActive(next.historyPreviewActive)
135
+ setPreferredColumn(next.preferredColumn)
136
+ }, [])
137
+
138
+ const applyTextEdit = useCallback((next: ChatBuffer) => {
139
+ bufferRef.current = next
140
+ setBuffer(next)
141
+ applyHistoryState(exitHistoryPreview(next))
142
+ }, [applyHistoryState])
143
+
144
+ useEffect(() => {
145
+ if (!placeholderHints || placeholderHints.length < 2) return
146
+ const timer = setInterval(() => {
147
+ setPlaceholderIdx(i => (i + 1) % placeholderHints.length)
148
+ }, PLACEHOLDER_ROTATE_MS)
149
+ return () => clearInterval(timer)
150
+ }, [placeholderHints])
151
+
152
+ const showingSlash = value.startsWith('/') && !value.includes(' ') && slashSuggestions.length > 0
153
+ const activeFileMention = useMemo(() => detectActiveFileMention(value, cursor), [value, cursor])
154
+ const showingFiles = Boolean(activeFileMention && cwd && fileSuggestions.length > 0)
155
+ const filteredSuggestions = useMemo(() => {
156
+ if (!showingSlash) return []
157
+ const prefixValue = value.slice(1).toLowerCase()
158
+ return slashSuggestions.filter(s => s.name.toLowerCase().startsWith(prefixValue)).slice(0, 6)
159
+ }, [showingSlash, value, slashSuggestions])
160
+
161
+ useEffect(() => {
162
+ if (suggestionIdx >= filteredSuggestions.length) setSuggestionIdx(0)
163
+ }, [filteredSuggestions.length, suggestionIdx])
164
+
165
+ useEffect(() => {
166
+ if (!activeFileMention || !cwd) {
167
+ setFileSuggestions([])
168
+ setFileSuggestionIdx(0)
169
+ return
170
+ }
171
+ let cancelled = false
172
+ void (async () => {
173
+ const suggestions = await listFileMentionSuggestions(cwd, activeFileMention)
174
+ if (cancelled) return
175
+ setFileSuggestions(suggestions)
176
+ setFileSuggestionIdx(0)
177
+ })()
178
+ return () => { cancelled = true }
179
+ }, [activeFileMention?.query, activeFileMention?.start, cwd])
180
+
181
+ const insertText = useCallback((text: string) => {
182
+ const prev = bufferRef.current
183
+ const nextValue = (prev.value.slice(0, prev.cursor) + text + prev.value.slice(prev.cursor)).slice(0, MAX_LENGTH)
184
+ const nextCursor = Math.min(prev.cursor + text.length, nextValue.length)
185
+ applyTextEdit({ value: nextValue, cursor: nextCursor })
186
+ }, [applyTextEdit])
187
+
188
+ const completeFileMention = useCallback(() => {
189
+ const picked = fileSuggestions[fileSuggestionIdx]
190
+ if (!picked) return false
191
+ const next = replaceActiveFileMention(bufferRef.current, picked.path)
192
+ applyTextEdit(next)
193
+ setPreferredColumn(null)
194
+ return true
195
+ }, [applyTextEdit, fileSuggestionIdx, fileSuggestions])
196
+
197
+ const resetBuffer = useCallback(() => {
198
+ applyBuffer(emptyBuffer())
199
+ applyHistoryState({
200
+ historyIndex: null,
201
+ historyPreviewActive: false,
202
+ draftBuffer: emptyBuffer(),
203
+ preferredColumn: null,
204
+ })
205
+ pastedTextRefsRef.current.clear()
206
+ nextPastedTextRefIdRef.current = 1
207
+ }, [applyBuffer, applyHistoryState])
208
+
209
+ const handlePaste = useCallback((text: string) => {
210
+ const normalized = normalizePastedText(text)
211
+ if (shouldCollapsePastedText(normalized, MAX_INLINE_PASTE_LINES)) {
212
+ const id = nextPastedTextRefIdRef.current++
213
+ pastedTextRefsRef.current.set(id, { id, content: normalized })
214
+ insertText(formatPastedTextRef(id, normalized.length))
215
+ return
216
+ }
217
+ insertText(normalized)
218
+ }, [insertText])
219
+
220
+ const flushPasteBuffer = useCallback(() => {
221
+ if (pasteTimerRef.current) {
222
+ clearTimeout(pasteTimerRef.current)
223
+ pasteTimerRef.current = null
224
+ }
225
+ const text = pasteBufferRef.current
226
+ pasteBufferRef.current = ''
227
+ if (text) handlePaste(text)
228
+ }, [handlePaste])
229
+
230
+ const enqueuePasteChunk = useCallback((text: string) => {
231
+ pasteBufferRef.current += text
232
+ if (pasteBufferRef.current.length >= PASTE_FLUSH_LIMIT) {
233
+ flushPasteBuffer()
234
+ return
235
+ }
236
+ if (pasteTimerRef.current) clearTimeout(pasteTimerRef.current)
237
+ pasteTimerRef.current = setTimeout(flushPasteBuffer, PASTE_BURST_MS)
238
+ }, [flushPasteBuffer])
239
+
240
+ useEffect(() => () => {
241
+ if (pasteTimerRef.current) clearTimeout(pasteTimerRef.current)
242
+ }, [])
243
+
244
+ const submit = useCallback(() => {
245
+ const trimmed = value.trim()
246
+ if (!trimmed) return
247
+ onSubmit(expandPastedTextRefs(trimmed, pastedTextRefsRef.current))
248
+ resetBuffer()
249
+ }, [value, onSubmit, resetBuffer])
250
+
251
+ const canNavigateHistory = useCallback(() => {
252
+ return canNavigateHistoryState(
253
+ bufferRef.current,
254
+ history.length,
255
+ historyIndexRef.current,
256
+ historyPreviewActiveRef.current,
257
+ )
258
+ }, [history.length])
259
+
260
+ const showPreviousHistory = useCallback((force = false) => {
261
+ if (!force && !canNavigateHistory()) return
262
+ if (history.length === 0) return
263
+ const currentBuffer = bufferRef.current
264
+ const currentHistoryIndex = historyIndexRef.current
265
+ const currentDraftBuffer = draftBufferRef.current
266
+ const currentPreferredColumn = preferredColumnRef.current
267
+ if (currentHistoryIndex === null) {
268
+ const next = beginHistoryPreview(currentBuffer, history, -1, currentPreferredColumn)
269
+ if (!next) return
270
+ applyHistoryState(next.preview)
271
+ applyBuffer(next.buffer)
272
+ return
273
+ }
274
+ const next = moveThroughHistory(history, currentHistoryIndex, -1, currentDraftBuffer, currentPreferredColumn)
275
+ applyHistoryState(next.preview)
276
+ applyBuffer(next.buffer)
277
+ }, [applyBuffer, applyHistoryState, canNavigateHistory, history])
278
+
279
+ const showNextHistory = useCallback((force = false) => {
280
+ const currentHistoryIndex = historyIndexRef.current
281
+ if (!force && currentHistoryIndex === null) return
282
+ if (currentHistoryIndex === null) return
283
+ const next = moveThroughHistory(
284
+ history,
285
+ currentHistoryIndex,
286
+ 1,
287
+ draftBufferRef.current,
288
+ preferredColumnRef.current,
289
+ )
290
+ applyHistoryState(next.preview)
291
+ applyBuffer(next.buffer)
292
+ }, [applyBuffer, applyHistoryState, history])
293
+
294
+ const wrapWidth = inputWrapWidth(columns)
295
+ const maxVisibleInputLines = Math.max(MIN_INPUT_VIEWPORT_LINES, Math.floor(rows / 2) - PROMPT_FOOTER_LINES)
296
+
297
+ useAppInput((input, key, event) => {
298
+ if (disabled) return
299
+
300
+ const pastePending = pasteTimerRef.current !== null
301
+ if (event.isPasted || pastePending || isFallbackPasteInput(input)) {
302
+ if (input) enqueuePasteChunk(input)
303
+ return
304
+ }
305
+ const inputText = input
306
+
307
+ const wantsSoftBreak = isSoftBreak(key)
308
+
309
+ if (showingFiles) {
310
+ if (key.tab || (key.return && !wantsSoftBreak)) {
311
+ if (completeFileMention()) return
312
+ }
313
+ if (key.upArrow) {
314
+ setFileSuggestionIdx(i => Math.max(0, i - 1))
315
+ return
316
+ }
317
+ if (key.downArrow) {
318
+ setFileSuggestionIdx(i => Math.min(fileSuggestions.length - 1, i + 1))
319
+ return
320
+ }
321
+ }
322
+
323
+ if (showingSlash && filteredSuggestions.length > 0) {
324
+ if (key.tab || (key.return && filteredSuggestions.length > 0 && !wantsSoftBreak)) {
325
+ const picked = filteredSuggestions[suggestionIdx]
326
+ if (picked && key.tab) {
327
+ const next = picked.completion
328
+ applyTextEdit({ value: next, cursor: next.length })
329
+ setPreferredColumn(null)
330
+ return
331
+ }
332
+ if (picked && key.return && !wantsSoftBreak) {
333
+ const next = picked.completion
334
+ if (picked.executeOnEnter) {
335
+ onSubmit(next)
336
+ resetBuffer()
337
+ } else {
338
+ applyTextEdit({ value: next, cursor: next.length })
339
+ setPreferredColumn(null)
340
+ }
341
+ return
342
+ }
343
+ }
344
+ if (key.upArrow) {
345
+ setSuggestionIdx(i => Math.max(0, i - 1))
346
+ return
347
+ }
348
+ if (key.downArrow) {
349
+ setSuggestionIdx(i => Math.min(filteredSuggestions.length - 1, i + 1))
350
+ return
351
+ }
352
+ }
353
+
354
+ if (wantsSoftBreak) {
355
+ insertText('\n')
356
+ return
357
+ }
358
+ if (key.return) {
359
+ submit()
360
+ return
361
+ }
362
+ if (key.escape && mode === 'bash') {
363
+ onModeChange?.('prompt')
364
+ return
365
+ }
366
+ if (!value && inputText === '!' && mode === 'prompt' && onModeChange) {
367
+ onModeChange('bash')
368
+ return
369
+ }
370
+ if (key.ctrl && inputText === 'u') {
371
+ applyTextEdit(deleteToLineStart(bufferRef.current, wrapWidth))
372
+ setPreferredColumn(null)
373
+ return
374
+ }
375
+ if (key.meta && inputText === 'v') {
376
+ void (async () => {
377
+ const image = await readClipboardImage()
378
+ if (image.ok) insertText(`[image: ${image.path}]`)
379
+ })()
380
+ return
381
+ }
382
+ if (key.ctrl && inputText === 'a') {
383
+ applyBuffer({ value: bufferRef.current.value, cursor: 0 })
384
+ setPreferredColumn(null)
385
+ return
386
+ }
387
+ if (key.ctrl && inputText === 'e') {
388
+ const currentBuffer = bufferRef.current
389
+ applyBuffer({ value: currentBuffer.value, cursor: currentBuffer.value.length })
390
+ setPreferredColumn(null)
391
+ return
392
+ }
393
+ if (key.ctrl && inputText === 'k') {
394
+ const currentBuffer = bufferRef.current
395
+ applyTextEdit({ value: currentBuffer.value.slice(0, currentBuffer.cursor), cursor: currentBuffer.cursor })
396
+ setPreferredColumn(null)
397
+ return
398
+ }
399
+ if (key.ctrl && inputText === 'w') {
400
+ const currentBuffer = bufferRef.current
401
+ const left = currentBuffer.value.slice(0, currentBuffer.cursor)
402
+ const right = currentBuffer.value.slice(currentBuffer.cursor)
403
+ const newLeft = left.replace(/\S+\s*$/, '')
404
+ applyTextEdit({ value: newLeft + right, cursor: newLeft.length })
405
+ setPreferredColumn(null)
406
+ return
407
+ }
408
+ if (key.ctrl && inputText === 'p') {
409
+ showPreviousHistory()
410
+ return
411
+ }
412
+ if (key.ctrl && inputText === 'n') {
413
+ showNextHistory()
414
+ return
415
+ }
416
+
417
+ if (key.leftArrow) {
418
+ const currentBuffer = bufferRef.current
419
+ applyBuffer({ value: currentBuffer.value, cursor: Math.max(0, currentBuffer.cursor - 1) })
420
+ setPreferredColumn(null)
421
+ return
422
+ }
423
+ if (key.rightArrow) {
424
+ const currentBuffer = bufferRef.current
425
+ applyBuffer({
426
+ value: currentBuffer.value,
427
+ cursor: Math.min(currentBuffer.value.length, currentBuffer.cursor + 1),
428
+ })
429
+ setPreferredColumn(null)
430
+ return
431
+ }
432
+ if (key.upArrow) {
433
+ const currentBuffer = bufferRef.current
434
+ const nextMove = moveVerticalVisual(
435
+ currentBuffer.value,
436
+ currentBuffer.cursor,
437
+ -1,
438
+ wrapWidth,
439
+ preferredColumnRef.current,
440
+ )
441
+ preferredColumnRef.current = nextMove.preferredColumn
442
+ setPreferredColumn(nextMove.preferredColumn)
443
+ if (nextMove.kind === 'moved') {
444
+ applyBuffer({ value: currentBuffer.value, cursor: nextMove.cursor })
445
+ return
446
+ }
447
+ if (nextMove.kind === 'boundary-top') {
448
+ if (historyPreviewActiveRef.current || historyIndexRef.current !== null || canNavigateHistory()) {
449
+ showPreviousHistory(true)
450
+ }
451
+ return
452
+ }
453
+ return
454
+ }
455
+ if (key.downArrow) {
456
+ const currentBuffer = bufferRef.current
457
+ const nextMove = moveVerticalVisual(
458
+ currentBuffer.value,
459
+ currentBuffer.cursor,
460
+ 1,
461
+ wrapWidth,
462
+ preferredColumnRef.current,
463
+ )
464
+ preferredColumnRef.current = nextMove.preferredColumn
465
+ setPreferredColumn(nextMove.preferredColumn)
466
+ if (nextMove.kind === 'moved') {
467
+ applyBuffer({ value: currentBuffer.value, cursor: nextMove.cursor })
468
+ return
469
+ }
470
+ if (nextMove.kind === 'boundary-bottom') {
471
+ if (historyPreviewActiveRef.current || historyIndexRef.current !== null) {
472
+ showNextHistory(true)
473
+ }
474
+ return
475
+ }
476
+ return
477
+ }
478
+ if (key.backspace || key.delete) {
479
+ const currentBuffer = bufferRef.current
480
+ if (currentBuffer.cursor === 0) return
481
+ applyTextEdit({
482
+ value: currentBuffer.value.slice(0, currentBuffer.cursor - 1) + currentBuffer.value.slice(currentBuffer.cursor),
483
+ cursor: Math.max(0, currentBuffer.cursor - 1),
484
+ })
485
+ return
486
+ }
487
+ if (key.tab || key.escape) return
488
+ if (key.ctrl || key.meta) return
489
+
490
+ if (inputText) {
491
+ insertText(inputText)
492
+ setPreferredColumn(null)
493
+ }
494
+ })
495
+
496
+ const showPlaceholder = value.length === 0 && !disabled && placeholderHints && placeholderHints.length > 0
497
+ const placeholder = showPlaceholder ? (placeholderHints[placeholderIdx] ?? placeholderHints[0] ?? '') : ''
498
+ const promptColor = mode === 'bash' ? theme.accentWarm : (disabled ? theme.dim : theme.accentMint)
499
+ const promptChar = mode === 'bash' ? '!' : prefix
500
+
501
+ const display = useMemo(
502
+ () => renderWithCursor(value, cursor, !disabled, wrapWidth, maxVisibleInputLines),
503
+ [value, cursor, disabled, wrapWidth, maxVisibleInputLines],
504
+ )
505
+ const borderColor = disabled ? theme.border : theme.accentMint
506
+
507
+ return (
508
+ <Box flexDirection="column" width="100%">
509
+ <Box
510
+ borderStyle="round"
511
+ borderColor={borderColor}
512
+ paddingX={2}
513
+ width="100%"
514
+ flexDirection="column"
515
+ >
516
+ {showPlaceholder ? (
517
+ <Text>
518
+ <Text color={promptColor}>{promptChar} </Text>
519
+ <Text color={theme.dim}>{placeholder}</Text>
520
+ </Text>
521
+ ) : (
522
+ <>
523
+ {display.hiddenAbove > 0 ? (
524
+ <Text color={theme.dim}>{` ↑ ${display.hiddenAbove} earlier line${display.hiddenAbove === 1 ? '' : 's'}`}</Text>
525
+ ) : null}
526
+ <Box flexDirection="column" height={display.visibleLineCount} overflowY="hidden">
527
+ {display.lines.map(line => (
528
+ <Box key={line.visualLineIndex} flexDirection="row">
529
+ {line.visualLineIndex === 0 ? (
530
+ <Text color={promptColor}>{promptChar} </Text>
531
+ ) : (
532
+ <Text color={theme.dim}>{' '}</Text>
533
+ )}
534
+ <Box width={wrapWidth}>{line.node}</Box>
535
+ </Box>
536
+ ))}
537
+ </Box>
538
+ {display.hiddenBelow > 0 ? (
539
+ <Text color={theme.dim}>{` ↓ ${display.hiddenBelow} later line${display.hiddenBelow === 1 ? '' : 's'}`}</Text>
540
+ ) : null}
541
+ </>
542
+ )}
543
+ </Box>
544
+ {showingSlash && filteredSuggestions.length > 0 ? (
545
+ <Box marginLeft={2} flexDirection="column">
546
+ {filteredSuggestions.map((s, i) => (
547
+ <Text key={s.name} color={i === suggestionIdx ? theme.accentPrimary : theme.dim}>
548
+ {i === suggestionIdx ? '\u203a ' : ' '}/{s.name}
549
+ <Text color={theme.dim}> {s.summary}{i === suggestionIdx ? (s.executeOnEnter ? ' · enter runs' : ' · enter fills') : ''}</Text>
550
+ </Text>
551
+ ))}
552
+ </Box>
553
+ ) : null}
554
+ {showingFiles ? (
555
+ <Box marginLeft={2} flexDirection="column">
556
+ {fileSuggestions.slice(0, 8).map((s, i) => (
557
+ <Text key={s.path} color={i === fileSuggestionIdx ? theme.accentPrimary : theme.dim}>
558
+ {i === fileSuggestionIdx ? '\u203a ' : ' '}@{s.path}
559
+ <Text color={theme.dim}> {i === fileSuggestionIdx ? 'tab/enter completes' : s.hint}</Text>
560
+ </Text>
561
+ ))}
562
+ {fileSuggestions.length > 8 ? (
563
+ <Text color={theme.dim}>+{fileSuggestions.length - 8} more matches</Text>
564
+ ) : null}
565
+ </Box>
566
+ ) : null}
567
+ {queuedMessages.length > 0 ? (
568
+ <Box marginLeft={2} flexDirection="column">
569
+ <Text color={theme.dim}>
570
+ {queuedMessages.length === 1 ? '1 message queued for next turn' : `${queuedMessages.length} messages queued for next turns`}
571
+ </Text>
572
+ {queuedMessages.slice(0, 3).map((message, i) => (
573
+ <Text key={`${i}-${message.slice(0, 24)}`}>
574
+ <Text color={theme.accentMint}>{i === 0 ? '» ' : ' '}</Text>
575
+ <Text color={theme.textSubtle}>{summarizeQueuedMessage(message)}</Text>
576
+ </Text>
577
+ ))}
578
+ {queuedMessages.length > 3 ? (
579
+ <Text color={theme.dim}>+{queuedMessages.length - 3} more</Text>
580
+ ) : null}
581
+ </Box>
582
+ ) : null}
583
+ {footerRight ? (
584
+ <Box marginLeft={2}>
585
+ {footerRight}
586
+ </Box>
587
+ ) : null}
588
+ </Box>
589
+ )
590
+ }
591
+
592
+ function isSoftBreak(key: { return: boolean; meta?: boolean; shift?: boolean }): boolean {
593
+ return key.return && Boolean(key.meta || key.shift)
594
+ }
595
+
596
+ type RenderedVisualLine = {
597
+ visualLineIndex: number
598
+ node: React.ReactNode
599
+ }
600
+
601
+ type RenderedInputViewport = {
602
+ lines: RenderedVisualLine[]
603
+ hiddenAbove: number
604
+ hiddenBelow: number
605
+ visibleLineCount: number
606
+ }
607
+
608
+ export function renderWithCursor(
609
+ value: string,
610
+ cursor: number,
611
+ showCursor: boolean,
612
+ wrapWidth: number,
613
+ maxVisibleLines: number,
614
+ ): RenderedInputViewport {
615
+ const lines = getVisualLines(value, wrapWidth)
616
+ const cursorLine = getVisualLineIndex(lines, cursor)
617
+ const window = getVisibleVisualLineWindow(lines.length, cursorLine, maxVisibleLines)
618
+ const visibleLines = lines.slice(window.start, window.end)
619
+
620
+ if (!showCursor) {
621
+ return {
622
+ lines: visibleLines.map((line, i) => ({
623
+ visualLineIndex: window.start + i,
624
+ node: (
625
+ <Text color={theme.text} wrap="wrap">
626
+ {value.slice(line.start, line.end) || ' '}
627
+ </Text>
628
+ ),
629
+ })),
630
+ hiddenAbove: window.start,
631
+ hiddenBelow: lines.length - window.end,
632
+ visibleLineCount: Math.max(1, visibleLines.length),
633
+ }
634
+ }
635
+
636
+ return {
637
+ lines: visibleLines.map((line, i) => {
638
+ const visualLineIndex = window.start + i
639
+ const text = value.slice(line.start, line.end)
640
+ if (visualLineIndex !== cursorLine) {
641
+ return {
642
+ visualLineIndex,
643
+ node: <Text color={theme.text} wrap="wrap">{text || ' '}</Text>,
644
+ }
645
+ }
646
+ const column = Math.max(0, Math.min(cursor - line.start, text.length))
647
+ const before = text.slice(0, column)
648
+ const atChar = text[column] ?? ' '
649
+ const after = text.slice(column + 1)
650
+ return {
651
+ visualLineIndex,
652
+ node: (
653
+ <Text color={theme.text} wrap="wrap">
654
+ {before}
655
+ <Text backgroundColor={theme.accentMint} color="#08110c">{atChar}</Text>
656
+ {after}
657
+ </Text>
658
+ ),
659
+ }
660
+ }),
661
+ hiddenAbove: window.start,
662
+ hiddenBelow: lines.length - window.end,
663
+ visibleLineCount: Math.max(1, visibleLines.length),
664
+ }
665
+ }
666
+
667
+ export function inputWrapWidth(columns: number): number {
668
+ const fixedChromeWidth =
669
+ STACK_HORIZONTAL_PADDING
670
+ + INPUT_BORDER_WIDTH
671
+ + INPUT_HORIZONTAL_PADDING
672
+ + PROMPT_PREFIX_WIDTH
673
+ return Math.max(1, Math.floor(columns) - fixedChromeWidth)
674
+ }
675
+
676
+ function isFallbackPasteInput(input: string): boolean {
677
+ if (!input) return false
678
+ return input.length > LARGE_PASTE_THRESHOLD
679
+ || countPastedTextLineBreaks(normalizePastedText(input)) > MAX_INLINE_PASTE_LINES
680
+ }
681
+
682
+ function summarizeQueuedMessage(text: string): string {
683
+ const normalized = text.replace(/\s+/g, ' ').trim()
684
+ if (!normalized) return ''
685
+ if (normalized.length <= 72) return normalized
686
+ return `${normalized.slice(0, 69)}...`
687
+ }
688
+
689
+ type FileMentionSuggestion = {
690
+ path: string
691
+ hint: string
692
+ }
693
+
694
+ async function listFileMentionSuggestions(
695
+ cwd: string,
696
+ mention: FileMentionToken,
697
+ ): Promise<FileMentionSuggestion[]> {
698
+ const query = mention.query.replace(/\\/g, '/')
699
+ const lastSlash = query.lastIndexOf('/')
700
+ const queryDir = lastSlash >= 0 ? query.slice(0, lastSlash + 1) : ''
701
+ const basenameQuery = lastSlash >= 0 ? query.slice(lastSlash + 1).toLowerCase() : query.toLowerCase()
702
+ const baseDir = path.resolve(cwd, queryDir || '.')
703
+
704
+ let entries: Array<{ name: string; isFile: () => boolean }>
705
+ try {
706
+ entries = await fs.readdir(baseDir, { withFileTypes: true })
707
+ } catch {
708
+ return []
709
+ }
710
+
711
+ return entries
712
+ .filter(entry => entry.isFile() && entry.name.toLowerCase().startsWith(basenameQuery))
713
+ .sort((left, right) => left.name.localeCompare(right.name))
714
+ .slice(0, 32)
715
+ .map(entry => {
716
+ const relative = (queryDir + entry.name).replace(/\\/g, '/')
717
+ return {
718
+ path: relative,
719
+ hint: path.extname(entry.name).slice(1) || 'file',
720
+ }
721
+ })
722
+ }