ethagent 2.2.0 → 2.4.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 (168) hide show
  1. package/README.md +11 -0
  2. package/package.json +2 -1
  3. package/src/app/FirstRun.tsx +3 -7
  4. package/src/app/FirstRunTimeline.tsx +1 -1
  5. package/src/chat/ChatBottomPane.tsx +29 -11
  6. package/src/chat/ChatScreen.tsx +169 -38
  7. package/src/chat/ConversationStack.tsx +1 -1
  8. package/src/chat/MessageList.tsx +185 -72
  9. package/src/chat/SessionStatus.tsx +3 -1
  10. package/src/chat/chatScreenUtils.ts +11 -15
  11. package/src/chat/chatSessionState.ts +5 -2
  12. package/src/chat/chatTurnOrchestrator.ts +7 -9
  13. package/src/chat/commands.ts +26 -26
  14. package/src/chat/display/DiffView.tsx +193 -0
  15. package/src/chat/display/SyntaxText.tsx +192 -0
  16. package/src/chat/display/toolCallDisplay.ts +103 -0
  17. package/src/chat/display/toolResultDisplay.ts +19 -0
  18. package/src/chat/{ChatInput.tsx → input/ChatInput.tsx} +61 -25
  19. package/src/chat/input/imageRefs.ts +30 -0
  20. package/src/chat/{TranscriptView.tsx → transcript/TranscriptView.tsx} +24 -50
  21. package/src/chat/{transcriptViewport.ts → transcript/transcriptViewport.ts} +12 -30
  22. package/src/chat/{ContextLimitView.tsx → views/ContextLimitView.tsx} +3 -3
  23. package/src/chat/{ContinuityEditReviewView.tsx → views/ContinuityEditReviewView.tsx} +11 -3
  24. package/src/chat/{CopyPicker.tsx → views/CopyPicker.tsx} +4 -5
  25. package/src/chat/{PermissionPrompt.tsx → views/PermissionPrompt.tsx} +16 -17
  26. package/src/chat/{PermissionsView.tsx → views/PermissionsView.tsx} +6 -6
  27. package/src/chat/{PlanApprovalView.tsx → views/PlanApprovalView.tsx} +4 -4
  28. package/src/chat/{ResumeView.tsx → views/ResumeView.tsx} +50 -41
  29. package/src/chat/views/RewindView.tsx +410 -0
  30. package/src/identity/continuity/privateEdit/diff.ts +2 -78
  31. package/src/identity/hub/OperationalRoutes.tsx +21 -21
  32. package/src/identity/hub/Routes.tsx +13 -13
  33. package/src/identity/hub/{flows/continuity → continuity}/ContinuityDashboardScreen.tsx +9 -9
  34. package/src/identity/hub/{flows/continuity → continuity}/RebackupStorageScreen.tsx +2 -2
  35. package/src/identity/hub/{flows/continuity → continuity}/RecoveryConfirmScreen.tsx +5 -5
  36. package/src/identity/hub/{flows/continuity → continuity}/SavePromptScreen.tsx +5 -5
  37. package/src/identity/hub/{effects/rebackup/runRebackup.ts → continuity/effects.ts} +17 -17
  38. package/src/identity/hub/{effects/rebackup → continuity}/index.ts +1 -1
  39. package/src/identity/hub/{effects/shared → continuity}/snapshot.ts +8 -8
  40. package/src/identity/hub/{effects/rebackup → continuity}/vault.ts +15 -15
  41. package/src/identity/hub/{flows/create → create}/CreateFlow.tsx +13 -13
  42. package/src/identity/hub/{effects/create.ts → create/effects.ts} +4 -4
  43. package/src/identity/hub/{flows/custody → custody}/CustodyEditFlow.tsx +9 -9
  44. package/src/identity/hub/{flows/custody/custodyFlowActions.ts → custody/actions.ts} +6 -6
  45. package/src/identity/hub/{flows/custody/custodyFlowHelpers.ts → custody/helpers.ts} +4 -4
  46. package/src/identity/hub/{effects/vault → custody}/preflight.ts +5 -5
  47. package/src/identity/hub/{flows/custody/custodyFlowRoutes.tsx → custody/routes.tsx} +8 -8
  48. package/src/identity/hub/{flows/custody/custodyEffects.ts → custody/transactions.ts} +9 -9
  49. package/src/identity/hub/{flows/custody/custodyFlowTypes.ts → custody/types.ts} +5 -5
  50. package/src/identity/hub/{flows/custody/custodyFlowEffects.ts → custody/useCustodyEffects.ts} +7 -7
  51. package/src/identity/hub/{flows/custody → custody}/useCustodyFlow.tsx +5 -5
  52. package/src/identity/hub/{flows/ens → ens}/EnsEditAdvancedScreens.tsx +13 -13
  53. package/src/identity/hub/{flows/ens → ens}/EnsEditFlow.tsx +7 -7
  54. package/src/identity/hub/{flows/ens → ens}/EnsEditMaintenanceScreens.tsx +10 -10
  55. package/src/identity/hub/{flows/ens → ens}/EnsEditReviewScreens.tsx +12 -12
  56. package/src/identity/hub/{flows/ens → ens}/EnsEditRunners.tsx +5 -5
  57. package/src/identity/hub/{flows/ens → ens}/EnsEditShared.tsx +10 -10
  58. package/src/identity/hub/{flows/ens → ens}/EnsEditSimpleScreens.tsx +14 -14
  59. package/src/identity/hub/{flows/ens/IdentityHubEnsFlow.tsx → ens/EnsFlow.tsx} +12 -12
  60. package/src/identity/hub/{flows/ens/OperatorWalletsScreen.tsx → ens/EnsOperatorWalletsScreen.tsx} +17 -17
  61. package/src/identity/hub/{advancedEnsValidation.ts → ens/advancedEnsValidation.ts} +2 -2
  62. package/src/identity/hub/{flows/ens/ensEditCopy.ts → ens/editCopy.ts} +3 -3
  63. package/src/identity/hub/{effects/ens/flows.ts → ens/effects.ts} +7 -7
  64. package/src/identity/hub/{effects/ens → ens}/index.ts +1 -1
  65. package/src/identity/hub/{model/ens.ts → ens/state.ts} +1 -1
  66. package/src/identity/hub/{effects/ens → ens}/transactions.ts +239 -239
  67. package/src/identity/hub/{flows/ens/ensEditTypes.ts → ens/types.ts} +7 -7
  68. package/src/identity/hub/identityHubReducer.ts +3 -3
  69. package/src/identity/hub/{flows/profile → profile}/EditProfileFlow.tsx +11 -11
  70. package/src/identity/hub/{effects/publicProfile/runPublicProfileSave.ts → profile/effects.ts} +18 -18
  71. package/src/identity/hub/{model → profile}/identity.ts +3 -3
  72. package/src/identity/hub/{effects/profile/profileState.ts → profile/state.ts} +181 -181
  73. package/src/identity/hub/{flows/restore → restore}/RestoreFlow.tsx +16 -16
  74. package/src/identity/hub/{effects/restore → restore}/apply.ts +10 -10
  75. package/src/identity/hub/{effects/restore → restore}/auth.ts +7 -7
  76. package/src/identity/hub/{effects/restore → restore}/discover.ts +6 -6
  77. package/src/identity/hub/{effects/restore → restore}/envelopes.ts +2 -2
  78. package/src/identity/hub/{effects/restore → restore}/fetch.ts +3 -3
  79. package/src/identity/hub/{effects/restore/shared.ts → restore/helpers.ts} +6 -6
  80. package/src/identity/hub/{effects/restore → restore}/recovery.ts +10 -10
  81. package/src/identity/hub/{effects/restore → restore}/resolve.ts +4 -4
  82. package/src/identity/hub/{effects → restore}/restoreAdmin.ts +1 -1
  83. package/src/identity/hub/{flows/restore/useRestoreFlowEffects.ts → restore/useRestoreEffects.ts} +5 -5
  84. package/src/identity/hub/{flows/settings → settings}/StorageCredentialScreen.tsx +5 -5
  85. package/src/identity/hub/{components → shared/components}/BusyScreen.tsx +4 -4
  86. package/src/identity/hub/{components → shared/components}/DetailsScreen.tsx +4 -4
  87. package/src/identity/hub/{components → shared/components}/ErrorScreen.tsx +4 -4
  88. package/src/identity/hub/{components → shared/components}/FlowTimeline.tsx +1 -1
  89. package/src/identity/hub/{components → shared/components}/IdentitySummary.tsx +8 -8
  90. package/src/identity/hub/{components → shared/components}/MenuScreen.tsx +7 -7
  91. package/src/identity/hub/{components → shared/components}/NetworkScreen.tsx +4 -4
  92. package/src/identity/hub/{components → shared/components}/PinataJwtInput.tsx +4 -4
  93. package/src/identity/hub/{components → shared/components}/UnlinkedIdentityScreen.tsx +5 -5
  94. package/src/identity/hub/{components → shared/components}/WalletApprovalScreen.tsx +6 -6
  95. package/src/identity/hub/{components → shared/components}/menuFlagsFromReconciliation.ts +1 -1
  96. package/src/identity/hub/{effects/shared → shared/effects}/profilePrep.ts +1 -1
  97. package/src/identity/hub/{effects → shared/effects}/receipts.ts +2 -2
  98. package/src/identity/hub/{effects/shared → shared/effects}/sync.ts +4 -4
  99. package/src/identity/hub/{effects → shared/effects}/types.ts +3 -3
  100. package/src/identity/hub/{model → shared/model}/copy.ts +2 -2
  101. package/src/identity/hub/{model → shared/model}/errors.ts +5 -5
  102. package/src/identity/hub/{model → shared/model}/network.ts +3 -3
  103. package/src/identity/hub/{operatorWallets.ts → shared/operatorWallets.ts} +1 -1
  104. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/hook.ts +1 -1
  105. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/ownership.ts +2 -2
  106. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/run.ts +6 -6
  107. package/src/identity/hub/{utils.ts → shared/utils.ts} +5 -5
  108. package/src/identity/hub/{flows/token-transfer/IdentityHubTokenTransferFlow.tsx → transfer/TokenTransferFlow.tsx} +8 -8
  109. package/src/identity/hub/{flows/token-transfer → transfer}/TokenTransferScreens.tsx +14 -14
  110. package/src/identity/hub/{effects/token-transfer/runTokenTransfer.ts → transfer/effects.ts} +16 -16
  111. package/src/identity/hub/{effects/token-transfer → transfer}/progress.ts +1 -1
  112. package/src/identity/hub/useIdentityHubController.ts +11 -11
  113. package/src/identity/hub/useIdentityHubSideEffects.ts +11 -11
  114. package/src/models/ModelPicker.tsx +143 -9
  115. package/src/models/catalog.ts +2 -1
  116. package/src/models/huggingface.ts +180 -2
  117. package/src/models/llamacpp.ts +110 -15
  118. package/src/models/llamacppPreflight.ts +30 -11
  119. package/src/models/modelPickerOptions.ts +16 -15
  120. package/src/models/providerDisplay.ts +16 -0
  121. package/src/providers/anthropic.ts +36 -5
  122. package/src/providers/contracts.ts +9 -1
  123. package/src/providers/errors.ts +6 -4
  124. package/src/providers/gemini.ts +29 -3
  125. package/src/providers/openai-chat.ts +83 -3
  126. package/src/providers/openai-responses-format.ts +29 -8
  127. package/src/providers/openai-responses.ts +22 -7
  128. package/src/providers/registry.ts +1 -0
  129. package/src/runtime/sessionMode.ts +1 -1
  130. package/src/runtime/systemPrompt.ts +3 -1
  131. package/src/runtime/toolExecution.ts +9 -6
  132. package/src/runtime/turn.ts +29 -0
  133. package/src/storage/config.ts +1 -0
  134. package/src/storage/rewind.ts +20 -0
  135. package/src/storage/sessions.ts +16 -3
  136. package/src/tools/bashSafety.ts +7 -3
  137. package/src/tools/bashTool.ts +1 -1
  138. package/src/tools/contracts.ts +3 -0
  139. package/src/tools/deleteFileTool.ts +8 -3
  140. package/src/tools/editTool.ts +10 -5
  141. package/src/tools/fileDiff.ts +261 -0
  142. package/src/tools/privateContinuityEditTool.ts +5 -1
  143. package/src/tools/writeFileTool.ts +8 -3
  144. package/src/ui/Spinner.tsx +39 -5
  145. package/src/ui/TextInput.tsx +2 -2
  146. package/src/ui/theme.ts +19 -0
  147. package/src/utils/clipboard.ts +10 -7
  148. package/src/utils/images.ts +140 -0
  149. package/src/utils/messages.ts +2 -0
  150. package/src/chat/RewindView.tsx +0 -386
  151. package/src/chat/toolResultDisplay.ts +0 -8
  152. package/src/identity/hub/effects/index.ts +0 -73
  153. package/src/identity/hub/effects/publicProfile/index.ts +0 -5
  154. package/src/identity/hub/effects/restore/restoreEffects.ts +0 -22
  155. package/src/identity/hub/effects/token-transfer/index.ts +0 -6
  156. /package/src/chat/{chatInputState.ts → input/chatInputState.ts} +0 -0
  157. /package/src/chat/{chatPaste.ts → input/chatPaste.ts} +0 -0
  158. /package/src/chat/{textCursor.ts → input/textCursor.ts} +0 -0
  159. /package/src/identity/hub/{model/continuity.ts → continuity/state.ts} +0 -0
  160. /package/src/identity/hub/{model/custody.ts → custody/state.ts} +0 -0
  161. /package/src/identity/hub/{effects/restore → restore}/index.ts +0 -0
  162. /package/src/identity/hub/{model → shared/model}/format.ts +0 -0
  163. /package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/types.ts +0 -0
  164. /package/src/identity/hub/{reconciliation → shared/reconciliation}/index.ts +0 -0
  165. /package/src/identity/hub/{reconciliation → shared/reconciliation}/useAgentReconciliation.ts +0 -0
  166. /package/src/identity/hub/{reconciliation → shared/reconciliation}/walletSetup.ts +0 -0
  167. /package/src/identity/hub/{txGuard.ts → shared/txGuard.ts} +0 -0
  168. /package/src/identity/hub/{model/transfer.ts → transfer/state.ts} +0 -0
@@ -0,0 +1,261 @@
1
+ export const DEFAULT_DIFF_CONTEXT_LINES = 3
2
+ export const DEFAULT_DIFF_MAX_CHARS = 2400
3
+ export const FILE_DIFF_RESULT_MARKER = '\n\n<!-- ethagent:file-diff:v1 -->\n'
4
+
5
+ type DiffOp =
6
+ | { type: 'equal'; line: string }
7
+ | { type: 'delete'; line: string }
8
+ | { type: 'insert'; line: string }
9
+
10
+ type HunkRange = {
11
+ start: number
12
+ end: number
13
+ }
14
+
15
+ type RenderUnifiedFileDiffInput = {
16
+ filePath: string
17
+ before: string
18
+ after: string
19
+ contextLines?: number
20
+ maxChars?: number
21
+ }
22
+
23
+ const LARGE_DIFF_MATRIX_LIMIT = 500_000
24
+
25
+ export function renderUnifiedFileDiff(input: RenderUnifiedFileDiffInput): string {
26
+ const contextLines = input.contextLines ?? DEFAULT_DIFF_CONTEXT_LINES
27
+ const maxChars = input.maxChars ?? DEFAULT_DIFF_MAX_CHARS
28
+ const header = [`--- ${input.filePath}`, `+++ ${input.filePath}`]
29
+
30
+ if (input.before === input.after) {
31
+ return header.concat('(no changes)').join('\n')
32
+ }
33
+
34
+ const beforeLines = normalizedLines(input.before)
35
+ const afterLines = normalizedLines(input.after)
36
+ const ops = buildLineDiff(beforeLines, afterLines)
37
+ const hunks = buildHunkRanges(ops, contextLines)
38
+
39
+ if (hunks.length === 0) {
40
+ return header.concat('(only line ending changes)').join('\n')
41
+ }
42
+
43
+ const lines = [...header]
44
+ for (const hunk of hunks) {
45
+ const oldStart = countOldLines(ops, 0, hunk.start) + 1
46
+ const newStart = countNewLines(ops, 0, hunk.start) + 1
47
+ const oldCount = countOldLines(ops, hunk.start, hunk.end)
48
+ const newCount = countNewLines(ops, hunk.start, hunk.end)
49
+ lines.push(`@@ -${formatRange(oldStart, oldCount)} +${formatRange(newStart, newCount)} @@`)
50
+ for (const op of ops.slice(hunk.start, hunk.end)) {
51
+ if (op.type === 'equal') lines.push(` ${op.line}`)
52
+ else if (op.type === 'delete') lines.push(`-${op.line}`)
53
+ else lines.push(`+${op.line}`)
54
+ }
55
+ }
56
+
57
+ return truncateDiff(lines, maxChars)
58
+ }
59
+
60
+ export function formatFileChangeResult(content: string, diff: string): string {
61
+ return `${content}${FILE_DIFF_RESULT_MARKER}${diff}`
62
+ }
63
+
64
+ export function splitFileChangeResult(content: string): { content: string; diff?: string } {
65
+ const markerIndex = content.indexOf(FILE_DIFF_RESULT_MARKER)
66
+ if (markerIndex === -1) return { content }
67
+ const diff = content.slice(markerIndex + FILE_DIFF_RESULT_MARKER.length).trimEnd()
68
+ return {
69
+ content: content.slice(0, markerIndex).trimEnd(),
70
+ diff: diff.length > 0 ? diff : undefined,
71
+ }
72
+ }
73
+
74
+ export function stripFileChangeResultDiff(content: string): string {
75
+ return splitFileChangeResult(content).content
76
+ }
77
+
78
+ export function computeLineDiffStats(before: string, after: string): { inserts: number; deletes: number } {
79
+ if (before === after) return { inserts: 0, deletes: 0 }
80
+ const beforeLines = normalizedLines(before)
81
+ const afterLines = normalizedLines(after)
82
+ const ops = buildLineDiff(beforeLines, afterLines)
83
+ let inserts = 0
84
+ let deletes = 0
85
+ for (const op of ops) {
86
+ if (op.type === 'insert') inserts += 1
87
+ else if (op.type === 'delete') deletes += 1
88
+ }
89
+ return { inserts, deletes }
90
+ }
91
+
92
+ function buildLineDiff(beforeLines: string[], afterLines: string[]): DiffOp[] {
93
+ if (beforeLines.length * afterLines.length > LARGE_DIFF_MATRIX_LIMIT) {
94
+ return buildPrefixSuffixDiff(beforeLines, afterLines)
95
+ }
96
+
97
+ const lengths = lcsLengths(beforeLines, afterLines)
98
+ const ops: DiffOp[] = []
99
+ let beforeIndex = 0
100
+ let afterIndex = 0
101
+
102
+ while (beforeIndex < beforeLines.length && afterIndex < afterLines.length) {
103
+ const beforeLine = beforeLines[beforeIndex]!
104
+ const afterLine = afterLines[afterIndex]!
105
+ if (beforeLine === afterLine) {
106
+ ops.push({ type: 'equal', line: beforeLine })
107
+ beforeIndex += 1
108
+ afterIndex += 1
109
+ continue
110
+ }
111
+
112
+ const deleteScore = lengths[beforeIndex + 1]![afterIndex]!
113
+ const insertScore = lengths[beforeIndex]![afterIndex + 1]!
114
+ const deleteRevealsMatch = beforeLines[beforeIndex + 1] === afterLine
115
+ const insertRevealsMatch = beforeLine === afterLines[afterIndex + 1]
116
+
117
+ if (insertRevealsMatch && insertScore >= deleteScore) {
118
+ ops.push({ type: 'insert', line: afterLine })
119
+ afterIndex += 1
120
+ } else if (deleteRevealsMatch && deleteScore >= insertScore) {
121
+ ops.push({ type: 'delete', line: beforeLine })
122
+ beforeIndex += 1
123
+ } else if (deleteScore >= insertScore) {
124
+ ops.push({ type: 'delete', line: beforeLine })
125
+ beforeIndex += 1
126
+ } else {
127
+ ops.push({ type: 'insert', line: afterLine })
128
+ afterIndex += 1
129
+ }
130
+ }
131
+
132
+ while (beforeIndex < beforeLines.length) {
133
+ ops.push({ type: 'delete', line: beforeLines[beforeIndex]! })
134
+ beforeIndex += 1
135
+ }
136
+ while (afterIndex < afterLines.length) {
137
+ ops.push({ type: 'insert', line: afterLines[afterIndex]! })
138
+ afterIndex += 1
139
+ }
140
+
141
+ return ops
142
+ }
143
+
144
+ function buildPrefixSuffixDiff(beforeLines: string[], afterLines: string[]): DiffOp[] {
145
+ let prefixLength = 0
146
+ while (
147
+ prefixLength < beforeLines.length &&
148
+ prefixLength < afterLines.length &&
149
+ beforeLines[prefixLength] === afterLines[prefixLength]
150
+ ) {
151
+ prefixLength += 1
152
+ }
153
+
154
+ let beforeEnd = beforeLines.length
155
+ let afterEnd = afterLines.length
156
+ while (
157
+ beforeEnd > prefixLength &&
158
+ afterEnd > prefixLength &&
159
+ beforeLines[beforeEnd - 1] === afterLines[afterEnd - 1]
160
+ ) {
161
+ beforeEnd -= 1
162
+ afterEnd -= 1
163
+ }
164
+
165
+ const ops: DiffOp[] = []
166
+ for (let index = 0; index < prefixLength; index += 1) {
167
+ ops.push({ type: 'equal', line: beforeLines[index]! })
168
+ }
169
+ for (let index = prefixLength; index < beforeEnd; index += 1) {
170
+ ops.push({ type: 'delete', line: beforeLines[index]! })
171
+ }
172
+ for (let index = prefixLength; index < afterEnd; index += 1) {
173
+ ops.push({ type: 'insert', line: afterLines[index]! })
174
+ }
175
+ for (let index = beforeEnd; index < beforeLines.length; index += 1) {
176
+ ops.push({ type: 'equal', line: beforeLines[index]! })
177
+ }
178
+ return ops
179
+ }
180
+
181
+ function lcsLengths(beforeLines: string[], afterLines: string[]): number[][] {
182
+ const lengths = Array.from(
183
+ { length: beforeLines.length + 1 },
184
+ () => Array<number>(afterLines.length + 1).fill(0),
185
+ )
186
+
187
+ for (let beforeIndex = beforeLines.length - 1; beforeIndex >= 0; beforeIndex -= 1) {
188
+ for (let afterIndex = afterLines.length - 1; afterIndex >= 0; afterIndex -= 1) {
189
+ lengths[beforeIndex]![afterIndex] = beforeLines[beforeIndex] === afterLines[afterIndex]
190
+ ? lengths[beforeIndex + 1]![afterIndex + 1]! + 1
191
+ : Math.max(lengths[beforeIndex + 1]![afterIndex]!, lengths[beforeIndex]![afterIndex + 1]!)
192
+ }
193
+ }
194
+
195
+ return lengths
196
+ }
197
+
198
+ function buildHunkRanges(ops: DiffOp[], contextLines: number): HunkRange[] {
199
+ const ranges: HunkRange[] = []
200
+ const context = Math.max(0, contextLines)
201
+
202
+ for (let index = 0; index < ops.length; index += 1) {
203
+ if (ops[index]?.type === 'equal') continue
204
+ const start = Math.max(0, index - context)
205
+ const end = Math.min(ops.length, index + context + 1)
206
+ const previous = ranges[ranges.length - 1]
207
+ if (previous && start <= previous.end) {
208
+ previous.end = Math.max(previous.end, end)
209
+ } else {
210
+ ranges.push({ start, end })
211
+ }
212
+ }
213
+
214
+ return ranges
215
+ }
216
+
217
+ function countOldLines(ops: DiffOp[], start: number, end: number): number {
218
+ let count = 0
219
+ for (let index = start; index < end; index += 1) {
220
+ const type = ops[index]?.type
221
+ if (type === 'equal' || type === 'delete') count += 1
222
+ }
223
+ return count
224
+ }
225
+
226
+ function countNewLines(ops: DiffOp[], start: number, end: number): number {
227
+ let count = 0
228
+ for (let index = start; index < end; index += 1) {
229
+ const type = ops[index]?.type
230
+ if (type === 'equal' || type === 'insert') count += 1
231
+ }
232
+ return count
233
+ }
234
+
235
+ function formatRange(start: number, count: number): string {
236
+ if (count === 0) return `${Math.max(0, start - 1)},0`
237
+ if (count === 1) return String(start)
238
+ return `${start},${count}`
239
+ }
240
+
241
+ function normalizedLines(value: string): string[] {
242
+ if (value.length === 0) return []
243
+ const lines = value.replace(/\r\n?/g, '\n').split('\n')
244
+ if (lines[lines.length - 1] === '') lines.pop()
245
+ return lines
246
+ }
247
+
248
+ function truncateDiff(lines: string[], maxChars: number): string {
249
+ const diff = lines.join('\n')
250
+ if (diff.length <= maxChars) return diff
251
+
252
+ const out: string[] = []
253
+ let length = 0
254
+ for (const line of lines) {
255
+ const nextLength = length + (out.length > 0 ? 1 : 0) + line.length
256
+ if (nextLength > maxChars) break
257
+ out.push(line)
258
+ length = nextLength
259
+ }
260
+ return out.join('\n')
261
+ }
@@ -6,6 +6,7 @@ import {
6
6
  import { recordPrivateContinuityHistorySnapshot } from '../identity/continuity/history.js'
7
7
  import { readContinuityFiles, readPublicSkillsFile } from '../identity/continuity/storage.js'
8
8
  import type { Tool } from './contracts.js'
9
+ import { formatFileChangeResult } from './fileDiff.js'
9
10
 
10
11
  const schema = z.object({
11
12
  file: z.preprocess(normalizePrivateContinuityFile, z.enum(['SOUL.md', 'MEMORY.md'])),
@@ -141,7 +142,10 @@ export const privateContinuityEditTool: Tool<typeof schema> = {
141
142
  return {
142
143
  ok: true,
143
144
  summary: prepared.changeSummary,
144
- content: formatPrivateContinuityEditResult(prepared.file, prepared.fullPath),
145
+ content: formatFileChangeResult(
146
+ formatPrivateContinuityEditResult(prepared.file, prepared.fullPath),
147
+ prepared.diff,
148
+ ),
145
149
  }
146
150
  },
147
151
  }
@@ -4,6 +4,7 @@ import { z } from 'zod'
4
4
  import { recordRewindSnapshot } from '../storage/rewind.js'
5
5
  import type { EthagentConfig } from '../storage/config.js'
6
6
  import type { Tool } from './contracts.js'
7
+ import { formatFileChangeResult, renderUnifiedFileDiff } from './fileDiff.js'
7
8
  import { resolveWorkspacePath } from './readTool.js'
8
9
 
9
10
  const schema = z.object({
@@ -40,6 +41,7 @@ export const writeFileTool: Tool<typeof schema> = {
40
41
  subtitle: prepared.fullPath,
41
42
  before: previewText(prepared.before),
42
43
  after: previewText(input.content),
44
+ diff: renderUnifiedFileDiff({ filePath: prepared.relativePath, before: prepared.before, after: input.content }),
43
45
  changeSummary: prepared.existedBefore ? `replace entire ${prepared.relativePath}` : `create ${prepared.relativePath}`,
44
46
  }
45
47
  },
@@ -64,9 +66,12 @@ export const writeFileTool: Tool<typeof schema> = {
64
66
  return {
65
67
  ok: true,
66
68
  summary: prepared.existedBefore ? `replace entire ${prepared.relativePath}` : `create ${prepared.relativePath}`,
67
- content: rewindWarning
68
- ? `updated ${prepared.fullPath}\nwarning: ${rewindWarning}`
69
- : `updated ${prepared.fullPath}`,
69
+ content: formatFileChangeResult(
70
+ rewindWarning
71
+ ? `updated ${prepared.fullPath}\nwarning: ${rewindWarning}`
72
+ : `updated ${prepared.fullPath}`,
73
+ renderUnifiedFileDiff({ filePath: prepared.relativePath, before: prepared.before, after: input.content }),
74
+ ),
70
75
  }
71
76
  },
72
77
  }
@@ -226,6 +226,7 @@ export const Spinner: React.FC<SpinnerProps> = ({
226
226
  const stickyVerbRef = useRef<string | null>(null)
227
227
  const internalStartedAtRef = useRef<number>(Date.now())
228
228
  const [frame, setFrame] = useState(0)
229
+ const [shimmerPos, setShimmerPos] = useState(0)
229
230
 
230
231
  useEffect(() => {
231
232
  if (!active) {
@@ -252,19 +253,40 @@ export const Spinner: React.FC<SpinnerProps> = ({
252
253
  return () => clearInterval(timer)
253
254
  }, [active])
254
255
 
255
- if (!active) return null
256
-
257
256
  const autoLabel = stickyVerbRef.current ?? verb ?? 'thinking'
258
257
  const text = spinnerText(label ?? `${autoLabel}…`)
258
+
259
+ useEffect(() => {
260
+ if (!active) return
261
+ const period = text.length + 12
262
+ const timer = setInterval(() => {
263
+ setShimmerPos(prev => (prev + 1) % period)
264
+ }, 90)
265
+ return () => clearInterval(timer)
266
+ }, [active, text.length])
267
+
268
+ if (!active) return null
269
+
259
270
  const glyph = FRAMES[frame] ?? 'o'
260
271
  const elapsed = showElapsed ? formatElapsedSeconds(Date.now() - (startedAt ?? internalStartedAtRef.current)) : null
261
272
  const renderedHint = [rawHint, elapsed].filter(Boolean).join(' · ')
262
273
  const hint = renderedHint ? spinnerHintText(renderedHint) : ''
263
274
 
275
+ const shimmerStart = shimmerPos - 1
276
+ const shimmerEnd = shimmerPos + 1
277
+ const visibleStart = Math.max(0, shimmerStart)
278
+ const visibleEnd = Math.min(text.length, shimmerEnd + 1)
279
+ const before = text.slice(0, visibleStart)
280
+ const shimmer = shimmerStart < text.length && shimmerEnd >= 0 ? text.slice(visibleStart, visibleEnd) : ''
281
+ const after = text.slice(visibleEnd)
282
+
264
283
  return (
265
284
  <Text>
266
285
  <Text color={color}>{glyph}</Text>
267
- <Text color={theme.dim}> {text}</Text>
286
+ <Text> </Text>
287
+ {before ? <Text color={theme.accentPeriwinkle}>{before}</Text> : null}
288
+ {shimmer ? <Text color={theme.accentWhite} bold>{shimmer}</Text> : null}
289
+ {after ? <Text color={theme.accentPeriwinkle}>{after}</Text> : null}
268
290
  {hint ? <Text color={theme.dim}> · {hint}</Text> : null}
269
291
  </Text>
270
292
  )
@@ -273,8 +295,20 @@ export const Spinner: React.FC<SpinnerProps> = ({
273
295
  function formatElapsedSeconds(milliseconds: number): string {
274
296
  const seconds = Math.max(0, Math.floor(milliseconds / 1000))
275
297
  if (seconds < 60) return `${seconds}s`
276
- const minutes = Math.floor(seconds / 60)
277
- return `${minutes}:${(seconds % 60).toString().padStart(2, '0')}`
298
+
299
+ const hours = Math.floor(seconds / 3600)
300
+ const minutes = Math.floor((seconds % 3600) / 60)
301
+ const remainingSeconds = seconds % 60
302
+
303
+ if (hours > 0) {
304
+ return remainingSeconds > 0
305
+ ? `${hours}h ${minutes}min ${remainingSeconds}s`
306
+ : `${hours}h ${minutes}min`
307
+ }
308
+
309
+ return remainingSeconds > 0
310
+ ? `${minutes}min ${remainingSeconds}s`
311
+ : `${minutes}min`
278
312
  }
279
313
 
280
314
  function restoreSpinnerTerms(value: string): string {
@@ -2,11 +2,11 @@ import React, { useState, useRef, useEffect } from 'react'
2
2
  import { Box, Text, useStdout } from 'ink'
3
3
  import { theme } from './theme.js'
4
4
  import { useAppInput } from '../app/input/AppInputProvider.js'
5
- import { moveVerticalVisual } from '../chat/chatInputState.js'
5
+ import { moveVerticalVisual } from '../chat/input/chatInputState.js'
6
6
  import {
7
7
  getVisualLineIndex,
8
8
  getVisualLines,
9
- } from '../chat/textCursor.js'
9
+ } from '../chat/input/textCursor.js'
10
10
 
11
11
  const DEFAULT_CHROME_WIDTH = 10
12
12
 
package/src/ui/theme.ts CHANGED
@@ -11,6 +11,25 @@ export const theme = {
11
11
  accentBlue: '#e8eefd',
12
12
  accentWhite: '#f5f8ff',
13
13
  accentError: '#d99898',
14
+ modePlan: '#f0c7a8',
15
+ modeAcceptEdits: '#c7b6f2',
16
+ diffAdded: '#8fd49d',
17
+ diffRemoved: '#d99898',
18
+ diffAddedBackground: '#16351f',
19
+ diffRemovedBackground: '#3a1717',
20
+ blockBackground: '#0b0d12',
21
+ codeKeyword: '#e8eefd',
22
+ codeString: '#c9e08f',
23
+ codeNumber: '#d8dcfa',
24
+ codeComment: '#777777',
25
+ codeFunction: '#8fc7ff',
26
+ codeType: '#f2d087',
27
+ codeBuiltin: '#b8a7ff',
28
+ codeProperty: '#91dcc0',
29
+ codeOperator: '#d8dcfa',
30
+ codePunctuation: '#aeb4c8',
31
+ codeTag: '#ffb3b3',
32
+ codeAttribute: '#f2d087',
14
33
  border: '#555555',
15
34
  dim: '#777777',
16
35
  text: '#f1f1f1',
@@ -3,26 +3,29 @@ import { mkdir, stat } from 'node:fs/promises'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
5
 
6
- export type CopyResult = { ok: true; method: string } | { ok: false; error: string }
6
+ export type CopyResult = { ok: true; method: string; chars: number } | { ok: false; error: string }
7
7
  export type ReadResult = { ok: true; text: string; method: string } | { ok: false; error: string }
8
8
  export type ReadImageResult = { ok: true; path: string; method: string } | { ok: false; error: string }
9
9
 
10
+ type CopyAttempt = { ok: true; method: string } | { ok: false; error: string }
11
+
10
12
  export async function copyToClipboard(text: string): Promise<CopyResult> {
13
+ const chars = text.length
11
14
  const native = await tryNative(text)
12
- if (native.ok) return native
15
+ if (native.ok) return { ...native, chars }
13
16
 
14
17
  const tmux = await tryTmux(text)
15
- if (tmux.ok) return tmux
18
+ if (tmux.ok) return { ...tmux, chars }
16
19
 
17
20
  try {
18
21
  process.stdout.write(osc52(text))
19
- return { ok: true, method: 'osc52' }
22
+ return { ok: true, method: 'osc52', chars }
20
23
  } catch (err: unknown) {
21
24
  return { ok: false, error: (err as Error).message || 'osc52 write failed' }
22
25
  }
23
26
  }
24
27
 
25
- async function tryNative(text: string): Promise<CopyResult> {
28
+ async function tryNative(text: string): Promise<CopyAttempt> {
26
29
  if (process.platform === 'darwin') {
27
30
  return pipeTo('pbcopy', [], text, 'pbcopy')
28
31
  }
@@ -38,12 +41,12 @@ async function tryNative(text: string): Promise<CopyResult> {
38
41
  return { ok: false, error: 'no native clipboard tool found' }
39
42
  }
40
43
 
41
- async function tryTmux(text: string): Promise<CopyResult> {
44
+ async function tryTmux(text: string): Promise<CopyAttempt> {
42
45
  if (!process.env['TMUX']) return { ok: false, error: 'not in tmux' }
43
46
  return pipeTo('tmux', ['load-buffer', '-w', '-'], text, 'tmux load-buffer')
44
47
  }
45
48
 
46
- function pipeTo(cmd: string, args: string[], text: string, method: string): Promise<CopyResult> {
49
+ function pipeTo(cmd: string, args: string[], text: string, method: string): Promise<CopyAttempt> {
47
50
  return new Promise(resolve => {
48
51
  let child
49
52
  try {
@@ -0,0 +1,140 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import type { ImageBlock, Message, MessageContentBlock } from '../providers/contracts.js'
4
+
5
+ const IMAGE_MARKER_RE = /\[image:\s*([^\]]+?)\]/gi
6
+ const PLACEHOLDER_RE = /^([<{[].*[>}\]]|#\d+)$/
7
+
8
+ export class ImageLoadError extends Error {
9
+ readonly imagePath: string
10
+ constructor(imagePath: string, message: string) {
11
+ super(message)
12
+ this.name = 'ImageLoadError'
13
+ this.imagePath = imagePath
14
+ }
15
+ }
16
+
17
+ export function collapseImagePathsToRefs(text: string): string {
18
+ let counter = 0
19
+ return text.replace(IMAGE_MARKER_RE, (full, raw: string) => {
20
+ const trimmed = raw.trim()
21
+ if (!trimmed || PLACEHOLDER_RE.test(trimmed)) return full
22
+ counter += 1
23
+ return `[Image #${counter}]`
24
+ })
25
+ }
26
+
27
+ export function modelSupportsImages(
28
+ provider: string,
29
+ model: string,
30
+ extra?: { mmprojPath?: string },
31
+ ): boolean {
32
+ const normalized = model.toLowerCase()
33
+ switch (provider) {
34
+ case 'anthropic':
35
+ return /claude-3|claude-sonnet-4|claude-opus-4|claude-haiku-4/.test(normalized)
36
+ case 'gemini':
37
+ return /gemini-1\.5|gemini-2\.0|gemini-2\.5/.test(normalized)
38
+ case 'openai':
39
+ if (normalized.includes('gpt-3.5')) return false
40
+ return /gpt-4o|gpt-4\.1|gpt-4-turbo|gpt-4-vision|gpt-5|o1|o3|o4|chatgpt-4/.test(normalized)
41
+ case 'llamacpp':
42
+ return Boolean(extra?.mmprojPath)
43
+ default:
44
+ return false
45
+ }
46
+ }
47
+
48
+ export function hasImageBlocks(messages: Message[]): boolean {
49
+ return messages.some(message => Array.isArray(message.content) && message.content.some(block => block.type === 'image'))
50
+ }
51
+
52
+ export function userTextToContentBlocks(text: string): string | MessageContentBlock[] {
53
+ const blocks = parseImageMarkers(text)
54
+ return blocks.length === 1 && blocks[0]?.type === 'text' ? blocks[0].text : blocks
55
+ }
56
+
57
+ export function parseImageMarkers(text: string): MessageContentBlock[] {
58
+ const out: MessageContentBlock[] = []
59
+ let lastIndex = 0
60
+ let match: RegExpExecArray | null
61
+
62
+ while ((match = IMAGE_MARKER_RE.exec(text)) !== null) {
63
+ const full = match[0]
64
+ const rawPath = match[1]?.trim() ?? ''
65
+ if (match.index > lastIndex) {
66
+ const prefix = text.slice(lastIndex, match.index)
67
+ if (prefix) out.push({ type: 'text', text: prefix })
68
+ }
69
+ if (rawPath && !PLACEHOLDER_RE.test(rawPath)) {
70
+ out.push({ type: 'image', path: rawPath })
71
+ } else {
72
+ out.push({ type: 'text', text: full })
73
+ }
74
+ lastIndex = match.index + full.length
75
+ }
76
+
77
+ if (lastIndex < text.length) {
78
+ const suffix = text.slice(lastIndex)
79
+ if (suffix) out.push({ type: 'text', text: suffix })
80
+ }
81
+
82
+ if (out.length === 0) return text ? [{ type: 'text', text }] : []
83
+ return mergeAdjacentTextBlocks(out)
84
+ }
85
+
86
+ export async function loadImageBlock(block: ImageBlock): Promise<ImageBlock> {
87
+ if (block.dataBase64 && block.mimeType) return block
88
+ if (block.url) return block
89
+ const rawPath = block.path?.trim() ?? ''
90
+ if (!rawPath) throw new ImageLoadError(rawPath, 'image path is empty')
91
+ if (PLACEHOLDER_RE.test(rawPath)) {
92
+ throw new ImageLoadError(rawPath, `image path looks like a placeholder, not a real file: ${rawPath}`)
93
+ }
94
+ let file: Buffer
95
+ try {
96
+ file = await fs.readFile(rawPath)
97
+ } catch (err: unknown) {
98
+ const code = (err as NodeJS.ErrnoException).code
99
+ if (code === 'ENOENT') {
100
+ throw new ImageLoadError(rawPath, `image file not found: ${rawPath}`)
101
+ }
102
+ throw new ImageLoadError(rawPath, `could not read image at ${rawPath}: ${(err as Error).message}`)
103
+ }
104
+ const mimeType = block.mimeType ?? mimeTypeForPath(rawPath)
105
+ return {
106
+ ...block,
107
+ path: rawPath,
108
+ mimeType,
109
+ dataBase64: file.toString('base64'),
110
+ }
111
+ }
112
+
113
+ export function imagePlaceholder(pathValue: string): string {
114
+ return `[image: ${path.basename(pathValue)}]`
115
+ }
116
+
117
+ function mergeAdjacentTextBlocks(blocks: MessageContentBlock[]): MessageContentBlock[] {
118
+ const out: MessageContentBlock[] = []
119
+ for (const block of blocks) {
120
+ const prev = out[out.length - 1]
121
+ if (block.type === 'text' && prev?.type === 'text') {
122
+ prev.text += block.text
123
+ continue
124
+ }
125
+ out.push(block)
126
+ }
127
+ return out
128
+ }
129
+
130
+ function mimeTypeForPath(filePath: string): string {
131
+ switch (path.extname(filePath).toLowerCase()) {
132
+ case '.png': return 'image/png'
133
+ case '.jpg':
134
+ case '.jpeg': return 'image/jpeg'
135
+ case '.webp': return 'image/webp'
136
+ case '.gif': return 'image/gif'
137
+ case '.bmp': return 'image/bmp'
138
+ default: return 'application/octet-stream'
139
+ }
140
+ }
@@ -1,3 +1,4 @@
1
+ import path from 'node:path'
1
2
  import type { Message, MessageContentBlock } from '../providers/contracts.js'
2
3
 
3
4
  export function systemMessage(content: string): Message {
@@ -20,6 +21,7 @@ export function blocksToText(blocks: MessageContentBlock[]): string {
20
21
  return blocks
21
22
  .map(block => {
22
23
  if (block.type === 'text') return block.text
24
+ if (block.type === 'image') return `[image attached: ${path.basename(block.path)}]`
23
25
  if (block.type === 'tool_use') return `[tool use: ${block.name}]`
24
26
  return block.isError
25
27
  ? `[tool error: ${block.content}]`