ethagent 2.2.0 → 2.3.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 (154) hide show
  1. package/package.json +2 -1
  2. package/src/app/FirstRun.tsx +1 -7
  3. package/src/app/FirstRunTimeline.tsx +1 -1
  4. package/src/chat/ChatBottomPane.tsx +20 -11
  5. package/src/chat/ChatScreen.tsx +160 -35
  6. package/src/chat/ConversationStack.tsx +1 -1
  7. package/src/chat/MessageList.tsx +185 -72
  8. package/src/chat/SessionStatus.tsx +3 -1
  9. package/src/chat/chatScreenUtils.ts +11 -15
  10. package/src/chat/chatSessionState.ts +1 -1
  11. package/src/chat/chatTurnOrchestrator.ts +1 -7
  12. package/src/chat/commands.ts +26 -26
  13. package/src/chat/display/DiffView.tsx +193 -0
  14. package/src/chat/display/SyntaxText.tsx +192 -0
  15. package/src/chat/display/toolCallDisplay.ts +103 -0
  16. package/src/chat/display/toolResultDisplay.ts +19 -0
  17. package/src/chat/{ChatInput.tsx → input/ChatInput.tsx} +36 -23
  18. package/src/chat/{TranscriptView.tsx → transcript/TranscriptView.tsx} +24 -50
  19. package/src/chat/{transcriptViewport.ts → transcript/transcriptViewport.ts} +12 -30
  20. package/src/chat/{ContextLimitView.tsx → views/ContextLimitView.tsx} +3 -3
  21. package/src/chat/{ContinuityEditReviewView.tsx → views/ContinuityEditReviewView.tsx} +11 -3
  22. package/src/chat/{CopyPicker.tsx → views/CopyPicker.tsx} +4 -5
  23. package/src/chat/{PermissionPrompt.tsx → views/PermissionPrompt.tsx} +16 -17
  24. package/src/chat/{PermissionsView.tsx → views/PermissionsView.tsx} +6 -6
  25. package/src/chat/{PlanApprovalView.tsx → views/PlanApprovalView.tsx} +4 -4
  26. package/src/chat/{ResumeView.tsx → views/ResumeView.tsx} +35 -35
  27. package/src/chat/views/RewindView.tsx +410 -0
  28. package/src/identity/continuity/privateEdit/diff.ts +2 -78
  29. package/src/identity/hub/OperationalRoutes.tsx +21 -21
  30. package/src/identity/hub/Routes.tsx +13 -13
  31. package/src/identity/hub/{flows/continuity → continuity}/ContinuityDashboardScreen.tsx +9 -9
  32. package/src/identity/hub/{flows/continuity → continuity}/RebackupStorageScreen.tsx +2 -2
  33. package/src/identity/hub/{flows/continuity → continuity}/RecoveryConfirmScreen.tsx +5 -5
  34. package/src/identity/hub/{flows/continuity → continuity}/SavePromptScreen.tsx +5 -5
  35. package/src/identity/hub/{effects/rebackup/runRebackup.ts → continuity/effects.ts} +17 -17
  36. package/src/identity/hub/{effects/rebackup → continuity}/index.ts +1 -1
  37. package/src/identity/hub/{effects/shared → continuity}/snapshot.ts +8 -8
  38. package/src/identity/hub/{effects/rebackup → continuity}/vault.ts +15 -15
  39. package/src/identity/hub/{flows/create → create}/CreateFlow.tsx +13 -13
  40. package/src/identity/hub/{effects/create.ts → create/effects.ts} +4 -4
  41. package/src/identity/hub/{flows/custody → custody}/CustodyEditFlow.tsx +9 -9
  42. package/src/identity/hub/{flows/custody/custodyFlowActions.ts → custody/actions.ts} +6 -6
  43. package/src/identity/hub/{flows/custody/custodyFlowHelpers.ts → custody/helpers.ts} +4 -4
  44. package/src/identity/hub/{effects/vault → custody}/preflight.ts +5 -5
  45. package/src/identity/hub/{flows/custody/custodyFlowRoutes.tsx → custody/routes.tsx} +8 -8
  46. package/src/identity/hub/{flows/custody/custodyEffects.ts → custody/transactions.ts} +9 -9
  47. package/src/identity/hub/{flows/custody/custodyFlowTypes.ts → custody/types.ts} +5 -5
  48. package/src/identity/hub/{flows/custody/custodyFlowEffects.ts → custody/useCustodyEffects.ts} +7 -7
  49. package/src/identity/hub/{flows/custody → custody}/useCustodyFlow.tsx +5 -5
  50. package/src/identity/hub/{flows/ens → ens}/EnsEditAdvancedScreens.tsx +13 -13
  51. package/src/identity/hub/{flows/ens → ens}/EnsEditFlow.tsx +7 -7
  52. package/src/identity/hub/{flows/ens → ens}/EnsEditMaintenanceScreens.tsx +10 -10
  53. package/src/identity/hub/{flows/ens → ens}/EnsEditReviewScreens.tsx +12 -12
  54. package/src/identity/hub/{flows/ens → ens}/EnsEditRunners.tsx +5 -5
  55. package/src/identity/hub/{flows/ens → ens}/EnsEditShared.tsx +10 -10
  56. package/src/identity/hub/{flows/ens → ens}/EnsEditSimpleScreens.tsx +14 -14
  57. package/src/identity/hub/{flows/ens/IdentityHubEnsFlow.tsx → ens/EnsFlow.tsx} +12 -12
  58. package/src/identity/hub/{flows/ens/OperatorWalletsScreen.tsx → ens/EnsOperatorWalletsScreen.tsx} +17 -17
  59. package/src/identity/hub/{advancedEnsValidation.ts → ens/advancedEnsValidation.ts} +2 -2
  60. package/src/identity/hub/{flows/ens/ensEditCopy.ts → ens/editCopy.ts} +3 -3
  61. package/src/identity/hub/{effects/ens/flows.ts → ens/effects.ts} +7 -7
  62. package/src/identity/hub/{effects/ens → ens}/index.ts +1 -1
  63. package/src/identity/hub/{model/ens.ts → ens/state.ts} +1 -1
  64. package/src/identity/hub/{effects/ens → ens}/transactions.ts +239 -239
  65. package/src/identity/hub/{flows/ens/ensEditTypes.ts → ens/types.ts} +7 -7
  66. package/src/identity/hub/identityHubReducer.ts +3 -3
  67. package/src/identity/hub/{flows/profile → profile}/EditProfileFlow.tsx +11 -11
  68. package/src/identity/hub/{effects/publicProfile/runPublicProfileSave.ts → profile/effects.ts} +18 -18
  69. package/src/identity/hub/{model → profile}/identity.ts +3 -3
  70. package/src/identity/hub/{effects/profile/profileState.ts → profile/state.ts} +181 -181
  71. package/src/identity/hub/{flows/restore → restore}/RestoreFlow.tsx +16 -16
  72. package/src/identity/hub/{effects/restore → restore}/apply.ts +10 -10
  73. package/src/identity/hub/{effects/restore → restore}/auth.ts +7 -7
  74. package/src/identity/hub/{effects/restore → restore}/discover.ts +6 -6
  75. package/src/identity/hub/{effects/restore → restore}/envelopes.ts +2 -2
  76. package/src/identity/hub/{effects/restore → restore}/fetch.ts +3 -3
  77. package/src/identity/hub/{effects/restore/shared.ts → restore/helpers.ts} +6 -6
  78. package/src/identity/hub/{effects/restore → restore}/recovery.ts +10 -10
  79. package/src/identity/hub/{effects/restore → restore}/resolve.ts +4 -4
  80. package/src/identity/hub/{effects → restore}/restoreAdmin.ts +1 -1
  81. package/src/identity/hub/{flows/restore/useRestoreFlowEffects.ts → restore/useRestoreEffects.ts} +5 -5
  82. package/src/identity/hub/{flows/settings → settings}/StorageCredentialScreen.tsx +5 -5
  83. package/src/identity/hub/{components → shared/components}/BusyScreen.tsx +4 -4
  84. package/src/identity/hub/{components → shared/components}/DetailsScreen.tsx +4 -4
  85. package/src/identity/hub/{components → shared/components}/ErrorScreen.tsx +4 -4
  86. package/src/identity/hub/{components → shared/components}/FlowTimeline.tsx +1 -1
  87. package/src/identity/hub/{components → shared/components}/IdentitySummary.tsx +8 -8
  88. package/src/identity/hub/{components → shared/components}/MenuScreen.tsx +7 -7
  89. package/src/identity/hub/{components → shared/components}/NetworkScreen.tsx +4 -4
  90. package/src/identity/hub/{components → shared/components}/PinataJwtInput.tsx +4 -4
  91. package/src/identity/hub/{components → shared/components}/UnlinkedIdentityScreen.tsx +5 -5
  92. package/src/identity/hub/{components → shared/components}/WalletApprovalScreen.tsx +6 -6
  93. package/src/identity/hub/{components → shared/components}/menuFlagsFromReconciliation.ts +1 -1
  94. package/src/identity/hub/{effects/shared → shared/effects}/profilePrep.ts +1 -1
  95. package/src/identity/hub/{effects → shared/effects}/receipts.ts +2 -2
  96. package/src/identity/hub/{effects/shared → shared/effects}/sync.ts +4 -4
  97. package/src/identity/hub/{effects → shared/effects}/types.ts +3 -3
  98. package/src/identity/hub/{model → shared/model}/copy.ts +2 -2
  99. package/src/identity/hub/{model → shared/model}/errors.ts +5 -5
  100. package/src/identity/hub/{model → shared/model}/network.ts +3 -3
  101. package/src/identity/hub/{operatorWallets.ts → shared/operatorWallets.ts} +1 -1
  102. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/hook.ts +1 -1
  103. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/ownership.ts +2 -2
  104. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/run.ts +6 -6
  105. package/src/identity/hub/{utils.ts → shared/utils.ts} +5 -5
  106. package/src/identity/hub/{flows/token-transfer/IdentityHubTokenTransferFlow.tsx → transfer/TokenTransferFlow.tsx} +8 -8
  107. package/src/identity/hub/{flows/token-transfer → transfer}/TokenTransferScreens.tsx +14 -14
  108. package/src/identity/hub/{effects/token-transfer/runTokenTransfer.ts → transfer/effects.ts} +16 -16
  109. package/src/identity/hub/{effects/token-transfer → transfer}/progress.ts +1 -1
  110. package/src/identity/hub/useIdentityHubController.ts +11 -11
  111. package/src/identity/hub/useIdentityHubSideEffects.ts +11 -11
  112. package/src/models/ModelPicker.tsx +5 -3
  113. package/src/models/catalog.ts +2 -1
  114. package/src/models/modelPickerOptions.ts +2 -14
  115. package/src/models/providerDisplay.ts +16 -0
  116. package/src/providers/errors.ts +6 -4
  117. package/src/providers/openai-chat.ts +2 -1
  118. package/src/runtime/sessionMode.ts +1 -1
  119. package/src/runtime/systemPrompt.ts +3 -1
  120. package/src/runtime/toolExecution.ts +9 -6
  121. package/src/runtime/turn.ts +29 -0
  122. package/src/storage/rewind.ts +20 -0
  123. package/src/storage/sessions.ts +2 -1
  124. package/src/tools/bashSafety.ts +7 -3
  125. package/src/tools/bashTool.ts +1 -1
  126. package/src/tools/contracts.ts +3 -0
  127. package/src/tools/deleteFileTool.ts +8 -3
  128. package/src/tools/editTool.ts +10 -5
  129. package/src/tools/fileDiff.ts +261 -0
  130. package/src/tools/privateContinuityEditTool.ts +5 -1
  131. package/src/tools/writeFileTool.ts +8 -3
  132. package/src/ui/Spinner.tsx +25 -3
  133. package/src/ui/TextInput.tsx +2 -2
  134. package/src/ui/theme.ts +17 -0
  135. package/src/utils/clipboard.ts +10 -7
  136. package/src/chat/RewindView.tsx +0 -386
  137. package/src/chat/toolResultDisplay.ts +0 -8
  138. package/src/identity/hub/effects/index.ts +0 -73
  139. package/src/identity/hub/effects/publicProfile/index.ts +0 -5
  140. package/src/identity/hub/effects/restore/restoreEffects.ts +0 -22
  141. package/src/identity/hub/effects/token-transfer/index.ts +0 -6
  142. /package/src/chat/{chatInputState.ts → input/chatInputState.ts} +0 -0
  143. /package/src/chat/{chatPaste.ts → input/chatPaste.ts} +0 -0
  144. /package/src/chat/{textCursor.ts → input/textCursor.ts} +0 -0
  145. /package/src/identity/hub/{model/continuity.ts → continuity/state.ts} +0 -0
  146. /package/src/identity/hub/{model/custody.ts → custody/state.ts} +0 -0
  147. /package/src/identity/hub/{effects/restore → restore}/index.ts +0 -0
  148. /package/src/identity/hub/{model → shared/model}/format.ts +0 -0
  149. /package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/types.ts +0 -0
  150. /package/src/identity/hub/{reconciliation → shared/reconciliation}/index.ts +0 -0
  151. /package/src/identity/hub/{reconciliation → shared/reconciliation}/useAgentReconciliation.ts +0 -0
  152. /package/src/identity/hub/{reconciliation → shared/reconciliation}/walletSetup.ts +0 -0
  153. /package/src/identity/hub/{txGuard.ts → shared/txGuard.ts} +0 -0
  154. /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
  )
@@ -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,23 @@ export const theme = {
11
11
  accentBlue: '#e8eefd',
12
12
  accentWhite: '#f5f8ff',
13
13
  accentError: '#d99898',
14
+ diffAdded: '#8fd49d',
15
+ diffRemoved: '#d99898',
16
+ diffAddedBackground: '#16351f',
17
+ diffRemovedBackground: '#3a1717',
18
+ blockBackground: '#0b0d12',
19
+ codeKeyword: '#e8eefd',
20
+ codeString: '#c9e08f',
21
+ codeNumber: '#d8dcfa',
22
+ codeComment: '#777777',
23
+ codeFunction: '#8fc7ff',
24
+ codeType: '#f2d087',
25
+ codeBuiltin: '#b8a7ff',
26
+ codeProperty: '#91dcc0',
27
+ codeOperator: '#d8dcfa',
28
+ codePunctuation: '#aeb4c8',
29
+ codeTag: '#ffb3b3',
30
+ codeAttribute: '#f2d087',
14
31
  border: '#555555',
15
32
  dim: '#777777',
16
33
  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 {