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.
- package/README.md +11 -0
- package/package.json +2 -1
- package/src/app/FirstRun.tsx +3 -7
- package/src/app/FirstRunTimeline.tsx +1 -1
- package/src/chat/ChatBottomPane.tsx +29 -11
- package/src/chat/ChatScreen.tsx +169 -38
- package/src/chat/ConversationStack.tsx +1 -1
- package/src/chat/MessageList.tsx +185 -72
- package/src/chat/SessionStatus.tsx +3 -1
- package/src/chat/chatScreenUtils.ts +11 -15
- package/src/chat/chatSessionState.ts +5 -2
- package/src/chat/chatTurnOrchestrator.ts +7 -9
- package/src/chat/commands.ts +26 -26
- package/src/chat/display/DiffView.tsx +193 -0
- package/src/chat/display/SyntaxText.tsx +192 -0
- package/src/chat/display/toolCallDisplay.ts +103 -0
- package/src/chat/display/toolResultDisplay.ts +19 -0
- package/src/chat/{ChatInput.tsx → input/ChatInput.tsx} +61 -25
- package/src/chat/input/imageRefs.ts +30 -0
- package/src/chat/{TranscriptView.tsx → transcript/TranscriptView.tsx} +24 -50
- package/src/chat/{transcriptViewport.ts → transcript/transcriptViewport.ts} +12 -30
- package/src/chat/{ContextLimitView.tsx → views/ContextLimitView.tsx} +3 -3
- package/src/chat/{ContinuityEditReviewView.tsx → views/ContinuityEditReviewView.tsx} +11 -3
- package/src/chat/{CopyPicker.tsx → views/CopyPicker.tsx} +4 -5
- package/src/chat/{PermissionPrompt.tsx → views/PermissionPrompt.tsx} +16 -17
- package/src/chat/{PermissionsView.tsx → views/PermissionsView.tsx} +6 -6
- package/src/chat/{PlanApprovalView.tsx → views/PlanApprovalView.tsx} +4 -4
- package/src/chat/{ResumeView.tsx → views/ResumeView.tsx} +50 -41
- package/src/chat/views/RewindView.tsx +410 -0
- package/src/identity/continuity/privateEdit/diff.ts +2 -78
- package/src/identity/hub/OperationalRoutes.tsx +21 -21
- package/src/identity/hub/Routes.tsx +13 -13
- package/src/identity/hub/{flows/continuity → continuity}/ContinuityDashboardScreen.tsx +9 -9
- package/src/identity/hub/{flows/continuity → continuity}/RebackupStorageScreen.tsx +2 -2
- package/src/identity/hub/{flows/continuity → continuity}/RecoveryConfirmScreen.tsx +5 -5
- package/src/identity/hub/{flows/continuity → continuity}/SavePromptScreen.tsx +5 -5
- package/src/identity/hub/{effects/rebackup/runRebackup.ts → continuity/effects.ts} +17 -17
- package/src/identity/hub/{effects/rebackup → continuity}/index.ts +1 -1
- package/src/identity/hub/{effects/shared → continuity}/snapshot.ts +8 -8
- package/src/identity/hub/{effects/rebackup → continuity}/vault.ts +15 -15
- package/src/identity/hub/{flows/create → create}/CreateFlow.tsx +13 -13
- package/src/identity/hub/{effects/create.ts → create/effects.ts} +4 -4
- package/src/identity/hub/{flows/custody → custody}/CustodyEditFlow.tsx +9 -9
- package/src/identity/hub/{flows/custody/custodyFlowActions.ts → custody/actions.ts} +6 -6
- package/src/identity/hub/{flows/custody/custodyFlowHelpers.ts → custody/helpers.ts} +4 -4
- package/src/identity/hub/{effects/vault → custody}/preflight.ts +5 -5
- package/src/identity/hub/{flows/custody/custodyFlowRoutes.tsx → custody/routes.tsx} +8 -8
- package/src/identity/hub/{flows/custody/custodyEffects.ts → custody/transactions.ts} +9 -9
- package/src/identity/hub/{flows/custody/custodyFlowTypes.ts → custody/types.ts} +5 -5
- package/src/identity/hub/{flows/custody/custodyFlowEffects.ts → custody/useCustodyEffects.ts} +7 -7
- package/src/identity/hub/{flows/custody → custody}/useCustodyFlow.tsx +5 -5
- package/src/identity/hub/{flows/ens → ens}/EnsEditAdvancedScreens.tsx +13 -13
- package/src/identity/hub/{flows/ens → ens}/EnsEditFlow.tsx +7 -7
- package/src/identity/hub/{flows/ens → ens}/EnsEditMaintenanceScreens.tsx +10 -10
- package/src/identity/hub/{flows/ens → ens}/EnsEditReviewScreens.tsx +12 -12
- package/src/identity/hub/{flows/ens → ens}/EnsEditRunners.tsx +5 -5
- package/src/identity/hub/{flows/ens → ens}/EnsEditShared.tsx +10 -10
- package/src/identity/hub/{flows/ens → ens}/EnsEditSimpleScreens.tsx +14 -14
- package/src/identity/hub/{flows/ens/IdentityHubEnsFlow.tsx → ens/EnsFlow.tsx} +12 -12
- package/src/identity/hub/{flows/ens/OperatorWalletsScreen.tsx → ens/EnsOperatorWalletsScreen.tsx} +17 -17
- package/src/identity/hub/{advancedEnsValidation.ts → ens/advancedEnsValidation.ts} +2 -2
- package/src/identity/hub/{flows/ens/ensEditCopy.ts → ens/editCopy.ts} +3 -3
- package/src/identity/hub/{effects/ens/flows.ts → ens/effects.ts} +7 -7
- package/src/identity/hub/{effects/ens → ens}/index.ts +1 -1
- package/src/identity/hub/{model/ens.ts → ens/state.ts} +1 -1
- package/src/identity/hub/{effects/ens → ens}/transactions.ts +239 -239
- package/src/identity/hub/{flows/ens/ensEditTypes.ts → ens/types.ts} +7 -7
- package/src/identity/hub/identityHubReducer.ts +3 -3
- package/src/identity/hub/{flows/profile → profile}/EditProfileFlow.tsx +11 -11
- package/src/identity/hub/{effects/publicProfile/runPublicProfileSave.ts → profile/effects.ts} +18 -18
- package/src/identity/hub/{model → profile}/identity.ts +3 -3
- package/src/identity/hub/{effects/profile/profileState.ts → profile/state.ts} +181 -181
- package/src/identity/hub/{flows/restore → restore}/RestoreFlow.tsx +16 -16
- package/src/identity/hub/{effects/restore → restore}/apply.ts +10 -10
- package/src/identity/hub/{effects/restore → restore}/auth.ts +7 -7
- package/src/identity/hub/{effects/restore → restore}/discover.ts +6 -6
- package/src/identity/hub/{effects/restore → restore}/envelopes.ts +2 -2
- package/src/identity/hub/{effects/restore → restore}/fetch.ts +3 -3
- package/src/identity/hub/{effects/restore/shared.ts → restore/helpers.ts} +6 -6
- package/src/identity/hub/{effects/restore → restore}/recovery.ts +10 -10
- package/src/identity/hub/{effects/restore → restore}/resolve.ts +4 -4
- package/src/identity/hub/{effects → restore}/restoreAdmin.ts +1 -1
- package/src/identity/hub/{flows/restore/useRestoreFlowEffects.ts → restore/useRestoreEffects.ts} +5 -5
- package/src/identity/hub/{flows/settings → settings}/StorageCredentialScreen.tsx +5 -5
- package/src/identity/hub/{components → shared/components}/BusyScreen.tsx +4 -4
- package/src/identity/hub/{components → shared/components}/DetailsScreen.tsx +4 -4
- package/src/identity/hub/{components → shared/components}/ErrorScreen.tsx +4 -4
- package/src/identity/hub/{components → shared/components}/FlowTimeline.tsx +1 -1
- package/src/identity/hub/{components → shared/components}/IdentitySummary.tsx +8 -8
- package/src/identity/hub/{components → shared/components}/MenuScreen.tsx +7 -7
- package/src/identity/hub/{components → shared/components}/NetworkScreen.tsx +4 -4
- package/src/identity/hub/{components → shared/components}/PinataJwtInput.tsx +4 -4
- package/src/identity/hub/{components → shared/components}/UnlinkedIdentityScreen.tsx +5 -5
- package/src/identity/hub/{components → shared/components}/WalletApprovalScreen.tsx +6 -6
- package/src/identity/hub/{components → shared/components}/menuFlagsFromReconciliation.ts +1 -1
- package/src/identity/hub/{effects/shared → shared/effects}/profilePrep.ts +1 -1
- package/src/identity/hub/{effects → shared/effects}/receipts.ts +2 -2
- package/src/identity/hub/{effects/shared → shared/effects}/sync.ts +4 -4
- package/src/identity/hub/{effects → shared/effects}/types.ts +3 -3
- package/src/identity/hub/{model → shared/model}/copy.ts +2 -2
- package/src/identity/hub/{model → shared/model}/errors.ts +5 -5
- package/src/identity/hub/{model → shared/model}/network.ts +3 -3
- package/src/identity/hub/{operatorWallets.ts → shared/operatorWallets.ts} +1 -1
- package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/hook.ts +1 -1
- package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/ownership.ts +2 -2
- package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/run.ts +6 -6
- package/src/identity/hub/{utils.ts → shared/utils.ts} +5 -5
- package/src/identity/hub/{flows/token-transfer/IdentityHubTokenTransferFlow.tsx → transfer/TokenTransferFlow.tsx} +8 -8
- package/src/identity/hub/{flows/token-transfer → transfer}/TokenTransferScreens.tsx +14 -14
- package/src/identity/hub/{effects/token-transfer/runTokenTransfer.ts → transfer/effects.ts} +16 -16
- package/src/identity/hub/{effects/token-transfer → transfer}/progress.ts +1 -1
- package/src/identity/hub/useIdentityHubController.ts +11 -11
- package/src/identity/hub/useIdentityHubSideEffects.ts +11 -11
- package/src/models/ModelPicker.tsx +143 -9
- package/src/models/catalog.ts +2 -1
- package/src/models/huggingface.ts +180 -2
- package/src/models/llamacpp.ts +110 -15
- package/src/models/llamacppPreflight.ts +30 -11
- package/src/models/modelPickerOptions.ts +16 -15
- package/src/models/providerDisplay.ts +16 -0
- package/src/providers/anthropic.ts +36 -5
- package/src/providers/contracts.ts +9 -1
- package/src/providers/errors.ts +6 -4
- package/src/providers/gemini.ts +29 -3
- package/src/providers/openai-chat.ts +83 -3
- package/src/providers/openai-responses-format.ts +29 -8
- package/src/providers/openai-responses.ts +22 -7
- package/src/providers/registry.ts +1 -0
- package/src/runtime/sessionMode.ts +1 -1
- package/src/runtime/systemPrompt.ts +3 -1
- package/src/runtime/toolExecution.ts +9 -6
- package/src/runtime/turn.ts +29 -0
- package/src/storage/config.ts +1 -0
- package/src/storage/rewind.ts +20 -0
- package/src/storage/sessions.ts +16 -3
- package/src/tools/bashSafety.ts +7 -3
- package/src/tools/bashTool.ts +1 -1
- package/src/tools/contracts.ts +3 -0
- package/src/tools/deleteFileTool.ts +8 -3
- package/src/tools/editTool.ts +10 -5
- package/src/tools/fileDiff.ts +261 -0
- package/src/tools/privateContinuityEditTool.ts +5 -1
- package/src/tools/writeFileTool.ts +8 -3
- package/src/ui/Spinner.tsx +39 -5
- package/src/ui/TextInput.tsx +2 -2
- package/src/ui/theme.ts +19 -0
- package/src/utils/clipboard.ts +10 -7
- package/src/utils/images.ts +140 -0
- package/src/utils/messages.ts +2 -0
- package/src/chat/RewindView.tsx +0 -386
- package/src/chat/toolResultDisplay.ts +0 -8
- package/src/identity/hub/effects/index.ts +0 -73
- package/src/identity/hub/effects/publicProfile/index.ts +0 -5
- package/src/identity/hub/effects/restore/restoreEffects.ts +0 -22
- package/src/identity/hub/effects/token-transfer/index.ts +0 -6
- /package/src/chat/{chatInputState.ts → input/chatInputState.ts} +0 -0
- /package/src/chat/{chatPaste.ts → input/chatPaste.ts} +0 -0
- /package/src/chat/{textCursor.ts → input/textCursor.ts} +0 -0
- /package/src/identity/hub/{model/continuity.ts → continuity/state.ts} +0 -0
- /package/src/identity/hub/{model/custody.ts → custody/state.ts} +0 -0
- /package/src/identity/hub/{effects/restore → restore}/index.ts +0 -0
- /package/src/identity/hub/{model → shared/model}/format.ts +0 -0
- /package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/types.ts +0 -0
- /package/src/identity/hub/{reconciliation → shared/reconciliation}/index.ts +0 -0
- /package/src/identity/hub/{reconciliation → shared/reconciliation}/useAgentReconciliation.ts +0 -0
- /package/src/identity/hub/{reconciliation → shared/reconciliation}/walletSetup.ts +0 -0
- /package/src/identity/hub/{txGuard.ts → shared/txGuard.ts} +0 -0
- /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 '
|
|
4
|
-
import { Select, type SelectOption } from '
|
|
5
|
-
import { Spinner } from '
|
|
6
|
-
import { Surface } from '
|
|
7
|
-
import { listSessions, type SessionSummary } from '
|
|
8
|
-
import { useAppInput } from '
|
|
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="
|
|
52
|
-
<Spinner label="loading
|
|
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
|
|
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: '
|
|
81
|
-
{ value: 'clear', label: '
|
|
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
|
|
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
|
|
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: '
|
|
162
|
-
hint: '
|
|
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.
|
|
174
|
+
const orderedGroups = [...groups.entries()].sort(([, left], [, right]) => right[0]!.mtimeMs - left[0]!.mtimeMs)
|
|
167
175
|
|
|
168
|
-
for (const group of orderedGroups) {
|
|
169
|
-
const
|
|
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: `
|
|
172
|
-
label:
|
|
173
|
-
hint: compressProjectPath(
|
|
174
|
-
|
|
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
|
|
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(
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
}
|