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
@@ -1,11 +1,11 @@
1
1
  import React, { useEffect, useState } from 'react'
2
2
  import { Box, Text } from 'ink'
3
- import { theme } from '../ui/theme.js'
4
- import { Select, type SelectOption } from '../ui/Select.js'
5
- import { Spinner } from '../ui/Spinner.js'
6
- import { Surface } from '../ui/Surface.js'
7
- import { listSessions, type SessionSummary } from '../storage/sessions.js'
8
- import { useAppInput } from '../app/input/AppInputProvider.js'
3
+ import { theme } from '../../ui/theme.js'
4
+ import { Select, type SelectOption } from '../../ui/Select.js'
5
+ import { Spinner } from '../../ui/Spinner.js'
6
+ import { Surface } from '../../ui/Surface.js'
7
+ import { listSessions, type SessionSummary } from '../../storage/sessions.js'
8
+ import { useAppInput } from '../../app/input/AppInputProvider.js'
9
9
 
10
10
  type ResumeViewProps = {
11
11
  currentSessionId: string
@@ -48,8 +48,8 @@ export const ResumeView: React.FC<ResumeViewProps> = ({ currentSessionId, onResu
48
48
 
49
49
  if (state.kind === 'loading') {
50
50
  return (
51
- <Surface title="Resume Session" subtitle="Loading projects and directories...">
52
- <Spinner label="loading sessions..." />
51
+ <Surface title="Resume Session" subtitle="Recent chats and directories." footer="esc closes">
52
+ <Spinner label="loading..." />
53
53
  </Surface>
54
54
  )
55
55
  }
@@ -65,7 +65,7 @@ export const ResumeView: React.FC<ResumeViewProps> = ({ currentSessionId, onResu
65
65
  if (state.kind === 'confirmClear') {
66
66
  return (
67
67
  <Surface
68
- title="Clear All Chat Logs?"
68
+ title="Clear All Saved Sessions?"
69
69
  subtitle={`${state.sessions.length} saved session${state.sessions.length === 1 ? '' : 's'} will be removed.`}
70
70
  tone="error"
71
71
  footer="enter selects · esc returns to resume"
@@ -76,9 +76,10 @@ export const ResumeView: React.FC<ResumeViewProps> = ({ currentSessionId, onResu
76
76
  {state.error ? <Text color={theme.accentError}>{state.error}</Text> : null}
77
77
  </Box>
78
78
  <Select<'back' | 'clear'>
79
+ hintLayout="inline"
79
80
  options={[
80
- { value: 'back', label: 'back to sessions' },
81
- { value: 'clear', label: 'clear all chat logs', hint: 'cannot be undone' },
81
+ { value: 'back', label: 'Back to Sessions' },
82
+ { value: 'clear', label: 'Clear All Saved Sessions', hint: 'Cannot be undone' },
82
83
  ]}
83
84
  onSubmit={choice => {
84
85
  if (choice === 'back') {
@@ -114,11 +115,11 @@ export const ResumeView: React.FC<ResumeViewProps> = ({ currentSessionId, onResu
114
115
  return (
115
116
  <Surface
116
117
  title="Resume Session"
117
- subtitle="Grouped by project, then working directory."
118
+ subtitle="Grouped by working directory."
118
119
  footer="enter resumes · esc closes"
119
120
  >
120
121
  <Box flexDirection="column" marginBottom={1}>
121
- <Text color={theme.dim}>Recent projects</Text>
122
+ <Text color={theme.dim}>Recent directories</Text>
122
123
  </Box>
123
124
  <Select
124
125
  options={options}
@@ -143,7 +144,7 @@ export function buildResumeOptions(
143
144
  ): Array<SelectOption<string>> {
144
145
  const groups = new Map<string, SessionSummary[]>()
145
146
  for (const session of sessions) {
146
- const key = session.projectRoot
147
+ const key = session.lastCwd || session.workspaceRoot || session.projectRoot
147
148
  const existing = groups.get(key) ?? []
148
149
  existing.push(session)
149
150
  groups.set(key, existing)
@@ -155,39 +156,36 @@ export function buildResumeOptions(
155
156
  label: '',
156
157
  disabled: true,
157
158
  }
159
+ const manageHeader: SelectOption<string> = {
160
+ value: 'separator:manage',
161
+ label: 'Manage',
162
+ role: 'section',
163
+ bold: true,
164
+ disabled: true,
165
+ }
158
166
 
159
167
  const clearOption: SelectOption<string> = {
160
168
  value: CLEAR_ALL_SESSIONS_VALUE,
161
- label: 'clear all chat logs',
162
- hint: 'removes saved chats and resume context',
169
+ label: 'Clear All Saved Sessions',
170
+ hint: 'Removes saved chats and resume context',
163
171
  role: 'utility',
164
172
  }
165
173
 
166
- const orderedGroups = [...groups.values()].sort((left, right) => right[0]!.mtimeMs - left[0]!.mtimeMs)
174
+ const orderedGroups = [...groups.entries()].sort(([, left], [, right]) => right[0]!.mtimeMs - left[0]!.mtimeMs)
167
175
 
168
- for (const group of orderedGroups) {
169
- const head = group[0]!
176
+ for (const [directoryKey, group] of orderedGroups) {
177
+ const sorted = [...group].sort((left, right) => right.mtimeMs - left.mtimeMs)
178
+ const sessionCount = sorted.length
179
+ const lastActivity = formatRelative(sorted[0]!.mtimeMs)
170
180
  options.push({
171
- value: `header:${head.projectRoot}`,
172
- label: head.projectLabel,
173
- hint: compressProjectPath(head.projectRoot),
174
- disabled: true,
181
+ value: `directory:${directoryKey}`,
182
+ label: lastPathSegment(directoryKey) || compressProjectPath(directoryKey),
183
+ hint: `${compressProjectPath(directoryKey)} · ${sessionCount} session${sessionCount === 1 ? '' : 's'} · last ${lastActivity}`,
184
+ role: 'section',
185
+ bold: true,
175
186
  })
176
187
 
177
- const byDirectory = [...group].sort((left, right) => right.mtimeMs - left.mtimeMs)
178
- let lastDirectoryLabel: string | null = null
179
-
180
- for (const session of byDirectory) {
181
- if (session.directoryLabel !== lastDirectoryLabel) {
182
- lastDirectoryLabel = session.directoryLabel
183
- options.push({
184
- value: `directory:${head.projectRoot}:${session.directoryLabel}`,
185
- label: `in ${formatDirectoryDisplay(session.directoryLabel)}`,
186
- hint: undefined,
187
- disabled: true,
188
- })
189
- }
190
-
188
+ for (const session of sorted) {
191
189
  const baseLabel = formatFirstLine(session.firstUserMessage) || '(empty session)'
192
190
  const markers = [
193
191
  session.id === currentSessionId ? 'current' : '',
@@ -206,11 +204,13 @@ export function buildResumeOptions(
206
204
  value: session.id,
207
205
  label,
208
206
  hint: hintParts.join(' · '),
207
+ indent: 2,
209
208
  })
210
209
  }
211
210
  }
212
211
 
213
212
  options.push(manageSpacer)
213
+ options.push(manageHeader)
214
214
  options.push(clearOption)
215
215
 
216
216
  return options
@@ -219,7 +219,15 @@ export function buildResumeOptions(
219
219
  function findInitialIndex(options: Array<SelectOption<string>>, currentSessionId: string): number {
220
220
  const currentIndex = options.findIndex(option => option.value === currentSessionId)
221
221
  if (currentIndex >= 0) return currentIndex
222
- return Math.max(0, options.findIndex(option => !option.disabled && option.value !== CLEAR_ALL_SESSIONS_VALUE))
222
+ return Math.max(
223
+ 0,
224
+ options.findIndex(option =>
225
+ !option.disabled
226
+ && option.role !== 'section'
227
+ && option.role !== 'group'
228
+ && option.value !== CLEAR_ALL_SESSIONS_VALUE,
229
+ ),
230
+ )
223
231
  }
224
232
 
225
233
  async function clearAll(
@@ -240,9 +248,10 @@ function compressProjectPath(input: string): string {
240
248
  return home && input.startsWith(home) ? `~${input.slice(home.length)}` : input
241
249
  }
242
250
 
243
- function formatDirectoryDisplay(input: string): string {
244
- if (input === '.' || input === '') return './'
245
- return input.startsWith('./') ? input : `./${input}`
251
+ function lastPathSegment(input: string): string {
252
+ const trimmed = input.replace(/[\\/]+$/, '')
253
+ const slash = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
254
+ return slash >= 0 ? trimmed.slice(slash + 1) : trimmed
246
255
  }
247
256
 
248
257
  function formatFirstLine(text: string): string {
@@ -0,0 +1,410 @@
1
+ import React, { useEffect, useState } from 'react'
2
+ import fs from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import { Box, Text } from 'ink'
5
+ import { Surface } from '../../ui/Surface.js'
6
+ import { Select, type SelectOption } from '../../ui/Select.js'
7
+ import { Spinner } from '../../ui/Spinner.js'
8
+ import { theme } from '../../ui/theme.js'
9
+ import { useAppInput } from '../../app/input/AppInputProvider.js'
10
+ import {
11
+ groupRewindEntriesByTurn,
12
+ rewindWorkspaceEditsByEntryIds,
13
+ type RewindEntry,
14
+ } from '../../storage/rewind.js'
15
+ import { loadSession } from '../../storage/sessions.js'
16
+ import { computeLineDiffStats } from '../../tools/fileDiff.js'
17
+
18
+ type RestoreAction = 'both' | 'conversation' | 'code' | 'summarize'
19
+ type ConfirmOption = RestoreAction | 'nevermind'
20
+
21
+ type RewindViewProps = {
22
+ cwd: string
23
+ currentSessionId: string
24
+ onRestoreConversation: (turnId: string, promptText?: string) => void
25
+ onSummarizeFromTurn: (turnId: string) => void | Promise<unknown>
26
+ onDone: (message: string, variant?: 'info' | 'error' | 'dim') => void
27
+ onCancel: () => void
28
+ }
29
+
30
+ type DiffStats = {
31
+ files: string[]
32
+ inserts: number
33
+ deletes: number
34
+ }
35
+
36
+ type PromptRow = {
37
+ turnId: string
38
+ text: string
39
+ createdAt: string
40
+ entries: RewindEntry[]
41
+ stats: DiffStats
42
+ }
43
+
44
+ type State =
45
+ | { kind: 'loading' }
46
+ | { kind: 'error'; message: string }
47
+ | { kind: 'picker' }
48
+ | { kind: 'confirm'; turnId: string; selectedAction: ConfirmOption }
49
+ | { kind: 'restoring' }
50
+
51
+ export const RewindView: React.FC<RewindViewProps> = ({
52
+ cwd,
53
+ currentSessionId,
54
+ onRestoreConversation,
55
+ onSummarizeFromTurn,
56
+ onDone,
57
+ onCancel,
58
+ }) => {
59
+ const [state, setState] = useState<State>({ kind: 'loading' })
60
+ const [rows, setRows] = useState<PromptRow[]>([])
61
+
62
+ useEffect(() => {
63
+ let cancelled = false
64
+ void (async () => {
65
+ try {
66
+ const [sessionMessages, grouped] = await Promise.all([
67
+ loadSession(currentSessionId),
68
+ groupRewindEntriesByTurn(cwd, currentSessionId),
69
+ ])
70
+ const prompts: PromptRow[] = []
71
+ for (const msg of sessionMessages) {
72
+ if (msg.role !== 'user') continue
73
+ if (msg.synthetic) continue
74
+ if (!msg.turnId) continue
75
+ const entries = grouped.get(msg.turnId) ?? []
76
+ const stats = await computeDiffStatsForEntries(entries)
77
+ prompts.push({
78
+ turnId: msg.turnId,
79
+ text: msg.content,
80
+ createdAt: msg.createdAt,
81
+ entries,
82
+ stats,
83
+ })
84
+ }
85
+ if (cancelled) return
86
+ setRows(prompts)
87
+ setState({ kind: 'picker' })
88
+ } catch (err: unknown) {
89
+ if (cancelled) return
90
+ setState({ kind: 'error', message: (err as Error).message })
91
+ }
92
+ })()
93
+ return () => { cancelled = true }
94
+ }, [cwd, currentSessionId])
95
+
96
+ const escActive =
97
+ state.kind === 'loading' ||
98
+ state.kind === 'error' ||
99
+ (state.kind === 'picker' && rows.length === 0)
100
+ useAppInput((_input, key) => {
101
+ if (key.escape) onCancel()
102
+ }, { isActive: escActive })
103
+
104
+ if (state.kind === 'loading') {
105
+ return (
106
+ <Surface title="Rewind" subtitle="Loading session history...">
107
+ <Spinner label="Loading rewind history..." />
108
+ </Surface>
109
+ )
110
+ }
111
+
112
+ if (state.kind === 'error') {
113
+ return (
114
+ <Surface title="Rewind" tone="muted" footer="Esc closes.">
115
+ <Text color={theme.dim}>{state.message}</Text>
116
+ </Surface>
117
+ )
118
+ }
119
+
120
+ if (state.kind === 'restoring') {
121
+ return (
122
+ <Surface title="Rewind" subtitle="Restoring..." footer="Restoring...">
123
+ <Spinner label="Restoring..." />
124
+ </Surface>
125
+ )
126
+ }
127
+
128
+ if (rows.length === 0) {
129
+ return (
130
+ <Surface title="Rewind" tone="muted" footer="Esc closes.">
131
+ <Text color={theme.dim}>Nothing to rewind to yet.</Text>
132
+ </Surface>
133
+ )
134
+ }
135
+
136
+ if (state.kind === 'confirm') {
137
+ const selectedRow = rows.find(row => row.turnId === state.turnId)
138
+ if (!selectedRow) {
139
+ setState({ kind: 'picker' })
140
+ return null
141
+ }
142
+ const canRestoreCode = selectedRow.entries.length > 0
143
+
144
+ const actionOptions: Array<SelectOption<ConfirmOption>> = [
145
+ { value: 'both', prefix: '1.', label: 'Restore code and conversation', disabled: !canRestoreCode },
146
+ { value: 'conversation', prefix: '2.', label: 'Restore conversation' },
147
+ { value: 'code', prefix: '3.', label: 'Restore code', disabled: !canRestoreCode },
148
+ { value: 'summarize', prefix: '4.', label: 'Summarize from here' },
149
+ { value: 'nevermind', prefix: '5.', label: 'Never mind' },
150
+ ]
151
+ const defaultValue: ConfirmOption = canRestoreCode ? 'both' : 'conversation'
152
+ const initialIndex = actionOptions.findIndex(option => option.value === defaultValue && !option.disabled)
153
+
154
+ const runRestore = async (action: RestoreAction) => {
155
+ if (action === 'summarize') {
156
+ try {
157
+ await onSummarizeFromTurn(state.turnId)
158
+ onDone('Summarizing the conversation from this point...', 'dim')
159
+ } catch (err: unknown) {
160
+ onDone(`Summarize failed: ${(err as Error).message}`, 'error')
161
+ }
162
+ return
163
+ }
164
+ setState({ kind: 'restoring' })
165
+ const codeIds = selectedRow.entries.map(entry => entry.id)
166
+ let codeError: Error | null = null
167
+ let codeFiles: string[] = []
168
+ let conversationError: Error | null = null
169
+
170
+ if (action === 'code' || action === 'both') {
171
+ try {
172
+ const result = await rewindWorkspaceEditsByEntryIds(cwd, codeIds)
173
+ codeFiles = result.files
174
+ if (result.reverted === 0) {
175
+ codeError = new Error('No matching rewind entries were found for this prompt.')
176
+ }
177
+ } catch (err: unknown) {
178
+ codeError = err as Error
179
+ }
180
+ }
181
+ if (action === 'conversation' || action === 'both') {
182
+ try {
183
+ onRestoreConversation(state.turnId, selectedRow.text)
184
+ } catch (err: unknown) {
185
+ conversationError = err as Error
186
+ }
187
+ }
188
+
189
+ if (codeError && conversationError) {
190
+ onDone(`Rewind failed: ${codeError.message}; ${conversationError.message}`, 'error')
191
+ return
192
+ }
193
+ if (codeError) {
194
+ onDone(`Code restore failed: ${codeError.message}`, 'error')
195
+ return
196
+ }
197
+ if (conversationError) {
198
+ onDone(`Conversation restore failed: ${conversationError.message}`, 'error')
199
+ return
200
+ }
201
+
202
+ if (action === 'both') {
203
+ const files = describeFileList(codeFiles, cwd)
204
+ onDone(`Restored code and conversation to before this prompt.${files ? ` Files: ${files}.` : ''}`, 'dim')
205
+ } else if (action === 'code') {
206
+ const files = describeFileList(codeFiles, cwd)
207
+ onDone(`Restored code to before this prompt.${files ? ` Files: ${files}.` : ''}`, 'dim')
208
+ } else {
209
+ onDone('Restored conversation to before this prompt.', 'dim')
210
+ }
211
+ }
212
+
213
+ return (
214
+ <Surface
215
+ title="Rewind"
216
+ subtitle="Confirm you want to restore to the point before you sent this message."
217
+ footer="Enter to restore. Esc to go back."
218
+ >
219
+ <Box flexDirection="column">
220
+ <Text color={theme.accentPeriwinkle}>{truncate(selectedRow.text, 280)}</Text>
221
+ <Text color={theme.dim}>
222
+ {formatTimestamp(selectedRow.createdAt)}
223
+ {describeStats(selectedRow.stats, cwd) ? ` · ${describeStats(selectedRow.stats, cwd)}` : ''}
224
+ </Text>
225
+ </Box>
226
+ <Box marginTop={1}>
227
+ <Select
228
+ options={actionOptions}
229
+ initialIndex={initialIndex < 0 ? 1 : initialIndex}
230
+ onSubmit={value => {
231
+ if (value === 'nevermind') {
232
+ setState({ kind: 'picker' })
233
+ return
234
+ }
235
+ void runRestore(value)
236
+ }}
237
+ onCancel={() => setState({ kind: 'picker' })}
238
+ onHighlight={value => {
239
+ setState(prev => prev.kind === 'confirm'
240
+ ? { ...prev, selectedAction: value }
241
+ : prev)
242
+ }}
243
+ />
244
+ </Box>
245
+ <Box marginTop={1} flexDirection="column">
246
+ <ConfirmDescription
247
+ action={state.selectedAction}
248
+ stats={selectedRow.stats}
249
+ cwd={cwd}
250
+ canRestoreCode={canRestoreCode}
251
+ />
252
+ </Box>
253
+ </Surface>
254
+ )
255
+ }
256
+
257
+ const pickerOptions = buildPickerOptions(rows, cwd)
258
+ const lastIndex = Math.max(0, rows.length - 1)
259
+
260
+ return (
261
+ <Surface
262
+ title="Rewind"
263
+ subtitle="Restore the code and/or conversation to the point before a prior prompt."
264
+ footer="Enter to continue. Esc to exit."
265
+ >
266
+ <Select
267
+ options={pickerOptions}
268
+ initialIndex={lastIndex}
269
+ maxVisible={7}
270
+ onSubmit={turnId => {
271
+ const row = rows.find(r => r.turnId === turnId)
272
+ const canRestoreCode = (row?.entries.length ?? 0) > 0
273
+ setState({
274
+ kind: 'confirm',
275
+ turnId,
276
+ selectedAction: canRestoreCode ? 'both' : 'conversation',
277
+ })
278
+ }}
279
+ onCancel={onCancel}
280
+ />
281
+ </Surface>
282
+ )
283
+ }
284
+
285
+ const ConfirmDescription: React.FC<{
286
+ action: ConfirmOption
287
+ stats: DiffStats
288
+ cwd: string
289
+ canRestoreCode: boolean
290
+ }> = ({ action, stats, cwd, canRestoreCode }) => {
291
+ if (action === 'nevermind') {
292
+ return <Text color={theme.textSubtle}>The conversation and code will be unchanged.</Text>
293
+ }
294
+ if (action === 'summarize') {
295
+ return (
296
+ <Text color={theme.textSubtle}>
297
+ Messages from this point forward will be summarized into a handoff. The earlier conversation will remain unchanged.
298
+ </Text>
299
+ )
300
+ }
301
+ const lines: React.ReactNode[] = []
302
+ if (action === 'both') {
303
+ lines.push(<Text key="0" color={theme.textSubtle}>The conversation will be forked.</Text>)
304
+ lines.push(<CodeRestoreLine key="1" stats={stats} cwd={cwd} canRestoreCode={canRestoreCode} />)
305
+ } else if (action === 'conversation') {
306
+ lines.push(<Text key="0" color={theme.textSubtle}>The conversation will be forked.</Text>)
307
+ lines.push(<Text key="1" color={theme.textSubtle}>The code will be unchanged.</Text>)
308
+ } else {
309
+ lines.push(<Text key="0" color={theme.textSubtle}>The conversation will be unchanged.</Text>)
310
+ lines.push(<CodeRestoreLine key="1" stats={stats} cwd={cwd} canRestoreCode={canRestoreCode} />)
311
+ }
312
+ if ((action === 'code' || action === 'both') && canRestoreCode) {
313
+ lines.push(
314
+ <Text key="footnote" color={theme.dim}>
315
+ Rewinding does not affect files edited manually or via bash.
316
+ </Text>,
317
+ )
318
+ }
319
+ return <>{lines}</>
320
+ }
321
+
322
+ const CodeRestoreLine: React.FC<{ stats: DiffStats; cwd: string; canRestoreCode: boolean }> = ({ stats, cwd, canRestoreCode }) => {
323
+ if (!canRestoreCode) {
324
+ return <Text color={theme.textSubtle}>The code has not changed (nothing will be restored).</Text>
325
+ }
326
+ if (stats.inserts === 0 && stats.deletes === 0) {
327
+ return <Text color={theme.textSubtle}>The code will be restored in {describeFiles(stats.files, cwd)}.</Text>
328
+ }
329
+ return (
330
+ <Text color={theme.textSubtle}>
331
+ The code will be restored{' '}
332
+ <Text color={theme.diffAdded}>+{stats.inserts} </Text>
333
+ <Text color={theme.diffRemoved}>-{stats.deletes}</Text>
334
+ {' '}in {describeFiles(stats.files, cwd)}.
335
+ </Text>
336
+ )
337
+ }
338
+
339
+ function buildPickerOptions(rows: PromptRow[], cwd: string): Array<SelectOption<string>> {
340
+ return rows.map(row => ({
341
+ value: row.turnId,
342
+ label: truncate(row.text, 80),
343
+ subtext: describeStats(row.stats, cwd) || 'No code changes',
344
+ hint: formatTimestamp(row.createdAt),
345
+ }))
346
+ }
347
+
348
+ async function computeDiffStatsForEntries(entries: RewindEntry[]): Promise<DiffStats> {
349
+ const files: string[] = []
350
+ const seen = new Set<string>()
351
+ let inserts = 0
352
+ let deletes = 0
353
+ for (const entry of entries) {
354
+ if (!seen.has(entry.filePath)) {
355
+ seen.add(entry.filePath)
356
+ files.push(entry.filePath)
357
+ }
358
+ let currentContent = ''
359
+ try {
360
+ currentContent = await fs.readFile(entry.filePath, 'utf8')
361
+ } catch (err: unknown) {
362
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
363
+ }
364
+ const stats = computeLineDiffStats(entry.previousContent, currentContent)
365
+ inserts += stats.inserts
366
+ deletes += stats.deletes
367
+ }
368
+ return { files, inserts, deletes }
369
+ }
370
+
371
+ function describeStats(stats: DiffStats, cwd: string): string {
372
+ if (stats.files.length === 0) return ''
373
+ const fileLabel = describeFiles(stats.files, cwd)
374
+ if (stats.inserts === 0 && stats.deletes === 0) return fileLabel
375
+ return `${fileLabel} +${stats.inserts} -${stats.deletes}`
376
+ }
377
+
378
+ function describeFiles(files: string[], cwd: string): string {
379
+ if (files.length === 0) return ''
380
+ const formatted = files.map(file => formatRelative(file, cwd))
381
+ if (formatted.length === 1) return formatted[0]!
382
+ if (formatted.length === 2) return `${formatted[0]} and ${formatted[1]}`
383
+ return `${formatted[0]} and ${formatted.length - 1} other files`
384
+ }
385
+
386
+ function describeFileList(files: string[], cwd: string): string {
387
+ if (files.length === 0) return ''
388
+ return files.map(file => formatRelative(file, cwd)).join(', ')
389
+ }
390
+
391
+ function formatRelative(filePath: string, cwd: string): string {
392
+ try {
393
+ const rel = path.relative(cwd, filePath)
394
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) return rel
395
+ } catch {
396
+ return path.basename(filePath)
397
+ }
398
+ return path.basename(filePath)
399
+ }
400
+
401
+ function formatTimestamp(iso: string): string {
402
+ const date = new Date(iso)
403
+ return Number.isNaN(date.getTime()) ? iso : date.toLocaleString()
404
+ }
405
+
406
+ function truncate(text: string, limit: number): string {
407
+ const normalized = text.replace(/\s+/g, ' ').trim()
408
+ if (normalized.length <= limit) return normalized
409
+ return `${normalized.slice(0, Math.max(0, limit - 3))}...`
410
+ }
@@ -1,82 +1,6 @@
1
1
  import type { PrivateContinuityFile } from '../storage.js'
2
+ import { renderUnifiedFileDiff } from '../../../tools/fileDiff.js'
2
3
 
3
4
  export function renderPrivateContinuityDiff(file: PrivateContinuityFile, before: string, after: string): string {
4
- if (before === after) return '(no changes)'
5
- const changedLines = changedMarkdownLines(before, after)
6
- const lines = [
7
- `--- ${file}`,
8
- `+++ ${file}`,
9
- ...(changedLines.length > 0 ? changedLines : ['(only whitespace or line ending changes)']),
10
- ]
11
- const diff = lines.join('\n')
12
- return diff.length <= 2400 ? diff : `${diff.slice(0, 2397)}...`
13
- }
14
-
15
- function changedMarkdownLines(before: string, after: string): string[] {
16
- const beforeLines = markdownLines(before)
17
- const afterLines = markdownLines(after)
18
- const lengths = lcsLengths(beforeLines, afterLines)
19
- const changed: string[] = []
20
- let beforeIndex = 0
21
- let afterIndex = 0
22
-
23
- while (beforeIndex < beforeLines.length && afterIndex < afterLines.length) {
24
- if (beforeLines[beforeIndex] === afterLines[afterIndex]) {
25
- beforeIndex += 1
26
- afterIndex += 1
27
- continue
28
- }
29
-
30
- const deleteScore = lengths[beforeIndex + 1]![afterIndex]!
31
- const insertScore = lengths[beforeIndex]![afterIndex + 1]!
32
- const deleteRevealsMatch = beforeLines[beforeIndex + 1] === afterLines[afterIndex]
33
- const insertRevealsMatch = beforeLines[beforeIndex] === afterLines[afterIndex + 1]
34
-
35
- if (insertRevealsMatch && insertScore >= deleteScore) {
36
- changed.push(`+${afterLines[afterIndex]}`)
37
- afterIndex += 1
38
- } else if (deleteRevealsMatch && deleteScore >= insertScore) {
39
- changed.push(`-${beforeLines[beforeIndex]}`)
40
- beforeIndex += 1
41
- } else if (deleteScore >= insertScore) {
42
- changed.push(`-${beforeLines[beforeIndex]}`)
43
- beforeIndex += 1
44
- } else {
45
- changed.push(`+${afterLines[afterIndex]}`)
46
- afterIndex += 1
47
- }
48
- }
49
-
50
- while (beforeIndex < beforeLines.length) {
51
- changed.push(`-${beforeLines[beforeIndex]}`)
52
- beforeIndex += 1
53
- }
54
- while (afterIndex < afterLines.length) {
55
- changed.push(`+${afterLines[afterIndex]}`)
56
- afterIndex += 1
57
- }
58
-
59
- return changed
60
- }
61
-
62
- function lcsLengths(beforeLines: string[], afterLines: string[]): number[][] {
63
- const lengths = Array.from(
64
- { length: beforeLines.length + 1 },
65
- () => Array<number>(afterLines.length + 1).fill(0),
66
- )
67
-
68
- for (let beforeIndex = beforeLines.length - 1; beforeIndex >= 0; beforeIndex -= 1) {
69
- for (let afterIndex = afterLines.length - 1; afterIndex >= 0; afterIndex -= 1) {
70
- lengths[beforeIndex]![afterIndex] = beforeLines[beforeIndex] === afterLines[afterIndex]
71
- ? lengths[beforeIndex + 1]![afterIndex + 1]! + 1
72
- : Math.max(lengths[beforeIndex + 1]![afterIndex]!, lengths[beforeIndex]![afterIndex + 1]!)
73
- }
74
- }
75
-
76
- return lengths
77
- }
78
-
79
- function markdownLines(value: string): string[] {
80
- const normalized = value.replace(/\r\n?/g, '\n')
81
- return normalized.split('\n')
5
+ return renderUnifiedFileDiff({ filePath: file, before, after })
82
6
  }