ethagent 2.1.1 → 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 (177) 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/auth/openaiOAuth/credentials.ts +47 -0
  5. package/src/auth/openaiOAuth/crypto.ts +23 -0
  6. package/src/auth/openaiOAuth/index.ts +238 -0
  7. package/src/auth/openaiOAuth/landingPage.ts +125 -0
  8. package/src/auth/openaiOAuth/listener.ts +151 -0
  9. package/src/auth/openaiOAuth/refresh.ts +70 -0
  10. package/src/auth/openaiOAuth/shared.ts +115 -0
  11. package/src/chat/ChatBottomPane.tsx +20 -11
  12. package/src/chat/ChatScreen.tsx +160 -35
  13. package/src/chat/ConversationStack.tsx +1 -1
  14. package/src/chat/MessageList.tsx +185 -72
  15. package/src/chat/SessionStatus.tsx +3 -1
  16. package/src/chat/chatScreenUtils.ts +11 -15
  17. package/src/chat/chatSessionState.ts +3 -2
  18. package/src/chat/chatTurnOrchestrator.ts +1 -7
  19. package/src/chat/commands.ts +28 -27
  20. package/src/chat/display/DiffView.tsx +193 -0
  21. package/src/chat/display/SyntaxText.tsx +192 -0
  22. package/src/chat/display/toolCallDisplay.ts +103 -0
  23. package/src/chat/display/toolResultDisplay.ts +19 -0
  24. package/src/chat/{ChatInput.tsx → input/ChatInput.tsx} +36 -23
  25. package/src/chat/{TranscriptView.tsx → transcript/TranscriptView.tsx} +24 -50
  26. package/src/chat/{transcriptViewport.ts → transcript/transcriptViewport.ts} +12 -30
  27. package/src/chat/{ContextLimitView.tsx → views/ContextLimitView.tsx} +3 -3
  28. package/src/chat/{ContinuityEditReviewView.tsx → views/ContinuityEditReviewView.tsx} +11 -3
  29. package/src/chat/{CopyPicker.tsx → views/CopyPicker.tsx} +4 -5
  30. package/src/chat/{PermissionPrompt.tsx → views/PermissionPrompt.tsx} +16 -17
  31. package/src/chat/{PermissionsView.tsx → views/PermissionsView.tsx} +6 -6
  32. package/src/chat/{PlanApprovalView.tsx → views/PlanApprovalView.tsx} +4 -4
  33. package/src/chat/{ResumeView.tsx → views/ResumeView.tsx} +35 -35
  34. package/src/chat/views/RewindView.tsx +410 -0
  35. package/src/identity/continuity/privateEdit/diff.ts +2 -78
  36. package/src/identity/ens/agentRecords.ts +5 -19
  37. package/src/identity/ens/ensAutomation/setup.ts +0 -1
  38. package/src/identity/ens/ensAutomation/types.ts +0 -1
  39. package/src/identity/hub/OperationalRoutes.tsx +23 -32
  40. package/src/identity/hub/Routes.tsx +13 -13
  41. package/src/identity/hub/{flows/continuity → continuity}/ContinuityDashboardScreen.tsx +9 -9
  42. package/src/identity/hub/{flows/continuity → continuity}/RebackupStorageScreen.tsx +2 -2
  43. package/src/identity/hub/{flows/continuity → continuity}/RecoveryConfirmScreen.tsx +5 -5
  44. package/src/identity/hub/{flows/continuity → continuity}/SavePromptScreen.tsx +5 -5
  45. package/src/identity/hub/{effects/rebackup/runRebackup.ts → continuity/effects.ts} +19 -19
  46. package/src/identity/hub/{effects/rebackup → continuity}/index.ts +1 -1
  47. package/src/identity/hub/{effects/shared → continuity}/snapshot.ts +8 -8
  48. package/src/identity/hub/{effects/rebackup → continuity}/vault.ts +15 -15
  49. package/src/identity/hub/{flows/create → create}/CreateFlow.tsx +13 -13
  50. package/src/identity/hub/{effects/create.ts → create/effects.ts} +4 -4
  51. package/src/identity/hub/{flows/custody → custody}/CustodyEditFlow.tsx +10 -48
  52. package/src/identity/hub/{flows/custody/custodyFlowActions.ts → custody/actions.ts} +11 -9
  53. package/src/identity/hub/{flows/custody/custodyFlowHelpers.ts → custody/helpers.ts} +4 -4
  54. package/src/identity/hub/{effects/vault → custody}/preflight.ts +5 -5
  55. package/src/identity/hub/{flows/custody/custodyFlowRoutes.tsx → custody/routes.tsx} +8 -8
  56. package/src/identity/hub/{flows/custody/custodyEffects.ts → custody/transactions.ts} +9 -9
  57. package/src/identity/hub/{flows/custody/custodyFlowTypes.ts → custody/types.ts} +6 -6
  58. package/src/identity/hub/{flows/custody/custodyFlowEffects.ts → custody/useCustodyEffects.ts} +7 -7
  59. package/src/identity/hub/{flows/custody → custody}/useCustodyFlow.tsx +5 -5
  60. package/src/identity/hub/ens/EnsEditAdvancedScreens.tsx +241 -0
  61. package/src/identity/hub/{flows/ens → ens}/EnsEditFlow.tsx +27 -82
  62. package/src/identity/hub/{flows/ens → ens}/EnsEditMaintenanceScreens.tsx +25 -65
  63. package/src/identity/hub/{flows/ens → ens}/EnsEditReviewScreens.tsx +12 -30
  64. package/src/identity/hub/ens/EnsEditRunners.tsx +62 -0
  65. package/src/identity/hub/{flows/ens → ens}/EnsEditShared.tsx +15 -14
  66. package/src/identity/hub/{flows/ens → ens}/EnsEditSimpleScreens.tsx +68 -217
  67. package/src/identity/hub/{flows/ens/IdentityHubEnsFlow.tsx → ens/EnsFlow.tsx} +18 -11
  68. package/src/identity/hub/{flows/ens/OperatorWalletsScreen.tsx → ens/EnsOperatorWalletsScreen.tsx} +17 -48
  69. package/src/identity/hub/{advancedEnsValidation.ts → ens/advancedEnsValidation.ts} +2 -2
  70. package/src/identity/hub/{flows/ens/ensEditCopy.ts → ens/editCopy.ts} +4 -4
  71. package/src/identity/hub/{effects/ens/flows.ts → ens/effects.ts} +7 -7
  72. package/src/identity/hub/{effects/ens → ens}/index.ts +1 -1
  73. package/src/identity/hub/{model/ens.ts → ens/state.ts} +1 -1
  74. package/src/identity/hub/{effects/ens → ens}/transactions.ts +232 -232
  75. package/src/identity/hub/{flows/ens/ensEditTypes.ts → ens/types.ts} +12 -26
  76. package/src/identity/hub/identityHubReducer.ts +3 -3
  77. package/src/identity/hub/{flows/profile → profile}/EditProfileFlow.tsx +17 -10
  78. package/src/identity/hub/{effects/publicProfile/runPublicProfileSave.ts → profile/effects.ts} +55 -177
  79. package/src/identity/hub/{model → profile}/identity.ts +3 -3
  80. package/src/identity/hub/{effects/profile/profileState.ts → profile/state.ts} +181 -173
  81. package/src/identity/hub/{flows/restore → restore}/RestoreFlow.tsx +21 -21
  82. package/src/identity/hub/{effects/restore → restore}/apply.ts +10 -10
  83. package/src/identity/hub/{effects/restore → restore}/auth.ts +7 -7
  84. package/src/identity/hub/{effects/restore → restore}/discover.ts +6 -6
  85. package/src/identity/hub/{effects/restore → restore}/envelopes.ts +2 -2
  86. package/src/identity/hub/{effects/restore → restore}/fetch.ts +3 -3
  87. package/src/identity/hub/{effects/restore/shared.ts → restore/helpers.ts} +6 -6
  88. package/src/identity/hub/{effects/restore → restore}/recovery.ts +10 -10
  89. package/src/identity/hub/{effects/restore → restore}/resolve.ts +4 -4
  90. package/src/identity/hub/restore/restoreAdmin.ts +34 -0
  91. package/src/identity/hub/{flows/restore/useRestoreFlowEffects.ts → restore/useRestoreEffects.ts} +5 -5
  92. package/src/identity/hub/{flows/settings → settings}/StorageCredentialScreen.tsx +5 -5
  93. package/src/identity/hub/{components → shared/components}/BusyScreen.tsx +4 -4
  94. package/src/identity/hub/{components → shared/components}/DetailsScreen.tsx +4 -4
  95. package/src/identity/hub/{components → shared/components}/ErrorScreen.tsx +4 -4
  96. package/src/identity/hub/{components → shared/components}/FlowTimeline.tsx +1 -1
  97. package/src/identity/hub/{components → shared/components}/IdentitySummary.tsx +16 -11
  98. package/src/identity/hub/{components → shared/components}/MenuScreen.tsx +8 -9
  99. package/src/identity/hub/{components → shared/components}/NetworkScreen.tsx +4 -4
  100. package/src/identity/hub/{components → shared/components}/PinataJwtInput.tsx +4 -4
  101. package/src/identity/hub/{components → shared/components}/UnlinkedIdentityScreen.tsx +5 -5
  102. package/src/identity/hub/{components → shared/components}/WalletApprovalScreen.tsx +6 -6
  103. package/src/identity/hub/{components → shared/components}/menuFlagsFromReconciliation.ts +2 -4
  104. package/src/identity/hub/{effects/shared → shared/effects}/profilePrep.ts +1 -1
  105. package/src/identity/hub/{effects → shared/effects}/receipts.ts +2 -2
  106. package/src/identity/hub/{effects/shared → shared/effects}/sync.ts +6 -47
  107. package/src/identity/hub/{effects → shared/effects}/types.ts +3 -3
  108. package/src/identity/hub/{model → shared/model}/copy.ts +2 -2
  109. package/src/identity/hub/{model → shared/model}/errors.ts +5 -5
  110. package/src/identity/hub/{model → shared/model}/network.ts +3 -3
  111. package/src/identity/hub/{operatorWallets.ts → shared/operatorWallets.ts} +1 -1
  112. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/hook.ts +1 -2
  113. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/ownership.ts +2 -2
  114. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/run.ts +7 -40
  115. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/types.ts +0 -4
  116. package/src/identity/hub/{reconciliation → shared/reconciliation}/index.ts +0 -7
  117. package/src/identity/hub/shared/reconciliation/walletSetup.ts +27 -0
  118. package/src/identity/hub/{utils.ts → shared/utils.ts} +5 -5
  119. package/src/identity/hub/{flows/token-transfer/IdentityHubTokenTransferFlow.tsx → transfer/TokenTransferFlow.tsx} +8 -8
  120. package/src/identity/hub/{flows/token-transfer → transfer}/TokenTransferScreens.tsx +14 -14
  121. package/src/identity/hub/{effects/token-transfer/runTokenTransfer.ts → transfer/effects.ts} +16 -16
  122. package/src/identity/hub/{effects/token-transfer → transfer}/progress.ts +1 -1
  123. package/src/identity/hub/useIdentityHubController.ts +11 -11
  124. package/src/identity/hub/useIdentityHubSideEffects.ts +11 -11
  125. package/src/identity/wallet/browserWallet/types.ts +0 -5
  126. package/src/identity/wallet/page/copy.ts +1 -31
  127. package/src/identity/wallet/walletPurposeCompat.ts +0 -2
  128. package/src/models/ModelPicker.tsx +248 -8
  129. package/src/models/catalog.ts +29 -1
  130. package/src/models/modelPickerOptions.ts +12 -10
  131. package/src/models/providerDisplay.ts +16 -0
  132. package/src/providers/errors.ts +6 -4
  133. package/src/providers/openai-chat.ts +2 -1
  134. package/src/providers/openai-responses-format.ts +156 -0
  135. package/src/providers/openai-responses.ts +276 -0
  136. package/src/providers/registry.ts +85 -8
  137. package/src/runtime/sessionMode.ts +1 -1
  138. package/src/runtime/systemPrompt.ts +4 -2
  139. package/src/runtime/toolExecution.ts +9 -6
  140. package/src/runtime/turn.ts +29 -1
  141. package/src/storage/rewind.ts +20 -0
  142. package/src/storage/secrets.ts +4 -1
  143. package/src/storage/sessions.ts +2 -1
  144. package/src/tools/bashSafety.ts +7 -3
  145. package/src/tools/bashTool.ts +1 -1
  146. package/src/tools/contracts.ts +3 -0
  147. package/src/tools/deleteFileTool.ts +8 -3
  148. package/src/tools/editTool.ts +10 -5
  149. package/src/tools/fileDiff.ts +261 -0
  150. package/src/tools/privateContinuityEditTool.ts +11 -1
  151. package/src/tools/writeFileTool.ts +8 -3
  152. package/src/ui/Spinner.tsx +25 -3
  153. package/src/ui/TextInput.tsx +2 -2
  154. package/src/ui/theme.ts +17 -0
  155. package/src/utils/clipboard.ts +10 -7
  156. package/src/utils/openExternal.ts +20 -10
  157. package/src/chat/RewindView.tsx +0 -386
  158. package/src/chat/toolResultDisplay.ts +0 -8
  159. package/src/identity/ens/ensRegistration.ts +0 -199
  160. package/src/identity/hub/effects/index.ts +0 -74
  161. package/src/identity/hub/effects/publicProfile/index.ts +0 -5
  162. package/src/identity/hub/effects/restore/restoreEffects.ts +0 -22
  163. package/src/identity/hub/effects/restoreAdmin.ts +0 -93
  164. package/src/identity/hub/effects/token-transfer/index.ts +0 -6
  165. package/src/identity/hub/flows/ens/EnsEditAdvancedScreens.tsx +0 -336
  166. package/src/identity/hub/flows/ens/EnsEditRunners.tsx +0 -198
  167. package/src/identity/hub/reconciliation/walletSetup.ts +0 -220
  168. /package/src/chat/{chatInputState.ts → input/chatInputState.ts} +0 -0
  169. /package/src/chat/{chatPaste.ts → input/chatPaste.ts} +0 -0
  170. /package/src/chat/{textCursor.ts → input/textCursor.ts} +0 -0
  171. /package/src/identity/hub/{model/continuity.ts → continuity/state.ts} +0 -0
  172. /package/src/identity/hub/{model/custody.ts → custody/state.ts} +0 -0
  173. /package/src/identity/hub/{effects/restore → restore}/index.ts +0 -0
  174. /package/src/identity/hub/{model → shared/model}/format.ts +0 -0
  175. /package/src/identity/hub/{reconciliation → shared/reconciliation}/useAgentReconciliation.ts +0 -0
  176. /package/src/identity/hub/{txGuard.ts → shared/txGuard.ts} +0 -0
  177. /package/src/identity/hub/{model/transfer.ts → transfer/state.ts} +0 -0
@@ -56,6 +56,7 @@ function normalize(event: StreamEvent): ProviderTurnEvent {
56
56
  }
57
57
 
58
58
  export const MAX_CONTINUATION_NUDGES = 3
59
+ export const MAX_TOOL_USES_PER_TURN = 25
59
60
 
60
61
  export type ContinuationNudgeReason =
61
62
  | 'continuation'
@@ -63,6 +64,7 @@ export type ContinuationNudgeReason =
63
64
  | 'tool_state_claim'
64
65
  | 'tool_protocol_fake'
65
66
  | 'tool_delegation'
67
+ | 'tool_budget'
66
68
  | 'private_continuity_tool'
67
69
  | 'private_continuity_tool_repair'
68
70
  | 'reasoning_only'
@@ -82,6 +84,9 @@ const TOOL_PROTOCOL_FAKE_NUDGE_TEXT =
82
84
  const TOOL_DELEGATION_NUDGE_TEXT =
83
85
  'Do not ask the user to run native tools. You have access to the tools in this environment. Make exactly one native tool call now.'
84
86
 
87
+ const TOOL_BUDGET_NUDGE_TEXT =
88
+ 'You have reached the tool-call budget for this turn. Do not call any more tools. Produce your final answer now using only what you already know from earlier tool results.'
89
+
85
90
  const PRIVATE_CONTINUITY_NUDGE_TEXT =
86
91
  'SOUL.md and MEMORY.md are existing private identity-vault scaffold files. Do not search workspace folders, read plans/, create files, or overwrite them. If exact private text is needed for a surgical removal or targeted replacement, call read_private_continuity_file with {"file":"MEMORY.md"} or {"file":"SOUL.md"}. If the user wants private continuity changed, call propose_private_continuity_edit. For memory/preferences use {"file":"MEMORY.md","appendToSection":"Durable User Preferences","appendText":"- User preference or memory note."}. For persona use {"file":"SOUL.md","appendToSection":"Persona","appendText":"- Persona or standing behavior note."}.'
87
92
 
@@ -164,6 +169,7 @@ export async function* runRuntimeTurn(
164
169
  let continuationNudges = 0
165
170
  let iterationIndex = 0
166
171
  let priorIterationHadTools = false
172
+ let cumulativeToolUseCount = 0
167
173
  const toolEvidenceThisTurn: ToolEvidence[] = []
168
174
 
169
175
  // eslint-disable-next-line no-constant-condition
@@ -395,6 +401,29 @@ export async function* runRuntimeTurn(
395
401
  return
396
402
  }
397
403
 
404
+ if (cumulativeToolUseCount + pendingToolUses.length > MAX_TOOL_USES_PER_TURN) {
405
+ if (continuationNudges < maxContinuationNudges) {
406
+ continuationNudges += 1
407
+ yield {
408
+ type: 'continuation_nudge',
409
+ attempt: continuationNudges,
410
+ reason: 'tool_budget',
411
+ }
412
+ workingMessages = [
413
+ ...await rebuildMessages(),
414
+ { role: 'user', content: TOOL_BUDGET_NUDGE_TEXT },
415
+ ]
416
+ continue
417
+ }
418
+ yield {
419
+ type: 'error',
420
+ message: `tool budget exceeded (${MAX_TOOL_USES_PER_TURN} max per turn); ask again with a narrower request`,
421
+ }
422
+ yield doneEvent(false, stopReason)
423
+ return
424
+ }
425
+ cumulativeToolUseCount += pendingToolUses.length
426
+
398
427
  const batch = await runToolBatch(pendingToolUses)
399
428
  for (const completed of batch.completedTools) {
400
429
  toolEvidenceThisTurn.push({
@@ -461,7 +490,6 @@ function nextToolResultRepairNudge(
461
490
  completedTools: ExecutedToolUse[],
462
491
  ): string | null {
463
492
  if (!provider.supportsTools) return null
464
- if (provider.id !== 'llamacpp') return null
465
493
  const failedPrivateEdit = completedTools.some(completed =>
466
494
  completed.name === 'propose_private_continuity_edit'
467
495
  && !completed.result.ok
@@ -131,6 +131,26 @@ export async function listRewindEntries(
131
131
  .slice(offset, offset + limit)
132
132
  }
133
133
 
134
+ export async function groupRewindEntriesByTurn(
135
+ workspaceRoot: string,
136
+ sessionId: string,
137
+ ): Promise<Map<string, RewindEntry[]>> {
138
+ const normalizedWorkspaceRoot = path.resolve(workspaceRoot)
139
+ const snapshots = await loadSnapshots()
140
+ const grouped = new Map<string, RewindEntry[]>()
141
+ for (const snapshot of snapshots) {
142
+ if (isIdentityMarkdownSnapshot(snapshot)) continue
143
+ if (!isSnapshotWithinScope(snapshot, normalizedWorkspaceRoot)) continue
144
+ if (snapshot.sessionId !== sessionId) continue
145
+ if (!snapshot.turnId) continue
146
+ const entry = toEntry(snapshot)
147
+ const bucket = grouped.get(snapshot.turnId)
148
+ if (bucket) bucket.push(entry)
149
+ else grouped.set(snapshot.turnId, [entry])
150
+ }
151
+ return grouped
152
+ }
153
+
134
154
  export async function rewindWorkspaceEditsByEntryIds(
135
155
  workspaceRoot: string,
136
156
  entryIds: string[],
@@ -129,8 +129,11 @@ export async function getSecret(account: string): Promise<string | null> {
129
129
  }
130
130
 
131
131
  export async function setSecret(account: string, value: string): Promise<KeyBackend> {
132
+ if (typeof value !== 'string') {
133
+ throw new Error(`setSecret(${account}): value is ${typeof value}, expected string`)
134
+ }
132
135
  const trimmed = value.trim()
133
- if (!trimmed) throw new Error('Secret value is empty')
136
+ if (!trimmed) throw new Error(`setSecret(${account}): value is empty`)
134
137
  const keytar = await loadKeytar()
135
138
  if (keytar) {
136
139
  await keytar.setPassword(KEYTAR_SERVICE, account, trimmed)
@@ -6,6 +6,7 @@ import type { Message } from '../providers/contracts.js'
6
6
  import { getCwd } from '../runtime/cwd.js'
7
7
  import type { SessionMode } from '../runtime/sessionMode.js'
8
8
  import { atomicWriteText } from './atomicWrite.js'
9
+ import { stripFileChangeResultDiff } from '../tools/fileDiff.js'
9
10
  import {
10
11
  isUserCorrectionOfToolState,
11
12
  looksLikeToolStateClaim,
@@ -282,7 +283,7 @@ export function sessionMessagesToProviderMessages(
282
283
  content: [{
283
284
  type: 'tool_result',
284
285
  toolUseId: message.toolUseId,
285
- content: message.content,
286
+ content: stripFileChangeResultDiff(message.content),
286
287
  isError: message.isError,
287
288
  }],
288
289
  })
@@ -98,16 +98,16 @@ export function assessBashCommand(command: string): BashSafetyAssessment {
98
98
  const triggeredChecks = RISKY_PATTERN_CHECKS.filter(check => check.pattern.test(command)).map(check => check.message)
99
99
 
100
100
  const warning = triggeredChecks.length > 0
101
- ? `warning: ${triggeredChecks[0]}. reusable approval is limited for this command.`
101
+ ? `Warning: ${sentenceCase(triggeredChecks[0] ?? 'command is risky')}. Reusable approval is limited for this command.`
102
102
  : highRisk
103
- ? `warning: ${firstToken} is a high-impact command. reusable approval is limited for this command.`
103
+ ? `Warning: ${sentenceCase(firstToken ?? '')} is a high-impact command. Reusable approval is limited for this command.`
104
104
  : undefined
105
105
 
106
106
  return {
107
107
  warning,
108
108
  canPersistExact: triggeredChecks.length === 0 && !nonPersistable,
109
109
  canPersistPrefix: triggeredChecks.length === 0 && !highRisk && Boolean(firstToken),
110
- commandPrefix: firstToken,
110
+ commandPrefix: firstToken ?? '',
111
111
  }
112
112
  }
113
113
 
@@ -180,3 +180,7 @@ function normalizeCommandToken(token: string): string {
180
180
  .toLowerCase()
181
181
  .replace(/[^a-z0-9_.:-]/g, '') ?? ''
182
182
  }
183
+
184
+ function sentenceCase(value: string): string {
185
+ return value ? value[0]!.toUpperCase() + value.slice(1) : value
186
+ }
@@ -39,7 +39,7 @@ export const bashTool: Tool<typeof schema> = {
39
39
  command: input.command,
40
40
  commandPrefix: safety.commandPrefix,
41
41
  cwd,
42
- title: 'allow shell command?',
42
+ title: 'Allow shell command?',
43
43
  subtitle: `${input.command}\n${cwd}`,
44
44
  warning: safety.warning,
45
45
  canPersistExact: safety.canPersistExact,
@@ -23,6 +23,7 @@ export type PermissionRequest =
23
23
  subtitle: string
24
24
  before: string
25
25
  after: string
26
+ diff: string
26
27
  changeSummary: string
27
28
  }
28
29
  | {
@@ -34,6 +35,7 @@ export type PermissionRequest =
34
35
  subtitle: string
35
36
  before: string
36
37
  after: string
38
+ diff: string
37
39
  changeSummary: string
38
40
  }
39
41
  | {
@@ -68,6 +70,7 @@ export type PermissionRequest =
68
70
  subtitle: string
69
71
  before: string
70
72
  after: string
73
+ diff: string
71
74
  changeSummary: string
72
75
  }
73
76
  | {
@@ -3,6 +3,7 @@ import path from 'node:path'
3
3
  import { z } from 'zod'
4
4
  import { recordRewindSnapshot } from '../storage/rewind.js'
5
5
  import type { Tool } from './contracts.js'
6
+ import { formatFileChangeResult, renderUnifiedFileDiff } from './fileDiff.js'
6
7
  import { resolveWorkspacePath } from './readTool.js'
7
8
 
8
9
  const schema = z.object({
@@ -35,6 +36,7 @@ export const deleteFileTool: Tool<typeof schema> = {
35
36
  subtitle: prepared.fullPath,
36
37
  before: preview(prepared.before),
37
38
  after: '(deleted)',
39
+ diff: renderUnifiedFileDiff({ filePath: prepared.relativePath, before: prepared.before, after: '' }),
38
40
  changeSummary: `delete ${prepared.relativePath}`,
39
41
  }
40
42
  },
@@ -58,9 +60,12 @@ export const deleteFileTool: Tool<typeof schema> = {
58
60
  return {
59
61
  ok: true,
60
62
  summary: `deleted ${prepared.relativePath}`,
61
- content: rewindWarning
62
- ? `deleted ${prepared.fullPath}\nwarning: ${rewindWarning}`
63
- : `deleted ${prepared.fullPath}`,
63
+ content: formatFileChangeResult(
64
+ rewindWarning
65
+ ? `deleted ${prepared.fullPath}\nwarning: ${rewindWarning}`
66
+ : `deleted ${prepared.fullPath}`,
67
+ renderUnifiedFileDiff({ filePath: prepared.relativePath, before: prepared.before, after: '' }),
68
+ ),
64
69
  }
65
70
  },
66
71
  }
@@ -5,6 +5,7 @@ import { recordRewindSnapshot } from '../storage/rewind.js'
5
5
  import type { EthagentConfig } from '../storage/config.js'
6
6
  import type { Tool } from './contracts.js'
7
7
  import { applyRequestedEdit } from './editUtils.js'
8
+ import { formatFileChangeResult, renderUnifiedFileDiff } from './fileDiff.js'
8
9
  import { resolveWorkspacePath } from './readTool.js'
9
10
 
10
11
  const schema = z.object({
@@ -44,15 +45,16 @@ export const editTool: Tool<typeof schema> = {
44
45
  subtitle: fullPath,
45
46
  before: applied.previewBefore,
46
47
  after: applied.previewAfter,
48
+ diff: renderUnifiedFileDiff({ filePath: relativePath, before: applied.before, after: applied.after }),
47
49
  changeSummary: applied.summary,
48
50
  }
49
51
  },
50
52
  async execute(input, context) {
51
- const { fullPath, applied, existedBefore, before } = await prepareEdit(input, context)
53
+ const { fullPath, relativePath, applied, existedBefore, before } = await prepareEdit(input, context)
52
54
  const rewindWarning = await tryRecordRewindSnapshot({
53
55
  workspaceRoot: context.workspaceRoot,
54
56
  filePath: fullPath,
55
- relativePath: path.relative(context.workspaceRoot, fullPath) || path.basename(fullPath),
57
+ relativePath,
56
58
  existedBefore,
57
59
  previousContent: before,
58
60
  changeSummary: applied.summary,
@@ -68,9 +70,12 @@ export const editTool: Tool<typeof schema> = {
68
70
  return {
69
71
  ok: true,
70
72
  summary: applied.summary,
71
- content: rewindWarning
72
- ? `updated ${fullPath}\nwarning: ${rewindWarning}`
73
- : `updated ${fullPath}`,
73
+ content: formatFileChangeResult(
74
+ rewindWarning
75
+ ? `updated ${fullPath}\nwarning: ${rewindWarning}`
76
+ : `updated ${fullPath}`,
77
+ renderUnifiedFileDiff({ filePath: relativePath, before: applied.before, after: applied.after }),
78
+ ),
74
79
  }
75
80
  },
76
81
  }
@@ -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
  }
@@ -166,6 +170,12 @@ function normalizePrivateContinuityInput(input: Record<string, unknown>): Record
166
170
  if (normalized.appendText === undefined) {
167
171
  normalized.appendText = normalized.note ?? normalized.text ?? normalized.content
168
172
  }
173
+ for (const key of ['oldText', 'newText', 'appendToSection', 'appendText'] as const) {
174
+ const value = normalized[key]
175
+ if (typeof value === 'string' && value.trim() === '') {
176
+ normalized[key] = undefined
177
+ }
178
+ }
169
179
  return normalized
170
180
  }
171
181
 
@@ -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