ethagent 0.2.1 → 1.0.1
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/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +25 -7
- package/src/app/FirstRun.tsx +412 -0
- package/src/app/hooks/useCancelRequest.ts +22 -0
- package/src/app/hooks/useDoublePress.ts +46 -0
- package/src/app/hooks/useExitOnCtrlC.ts +36 -0
- package/src/app/input/AppInputProvider.tsx +116 -0
- package/src/app/input/appInputParser.ts +279 -0
- package/src/app/keybindings/KeybindingProvider.tsx +134 -0
- package/src/app/keybindings/resolver.ts +42 -0
- package/src/app/keybindings/types.ts +26 -0
- package/src/chat/ChatBottomPane.tsx +280 -0
- package/src/chat/ChatInput.tsx +722 -0
- package/src/chat/ChatScreen.tsx +1575 -0
- package/src/chat/ContextLimitView.tsx +95 -0
- package/src/chat/ContinuityEditReviewView.tsx +48 -0
- package/src/chat/ConversationStack.tsx +47 -0
- package/src/chat/CopyPicker.tsx +52 -0
- package/src/chat/MessageList.tsx +609 -0
- package/src/chat/PermissionPrompt.tsx +153 -0
- package/src/chat/PermissionsView.tsx +159 -0
- package/src/chat/PlanApprovalView.tsx +91 -0
- package/src/chat/ResumeView.tsx +267 -0
- package/src/chat/RewindView.tsx +386 -0
- package/src/chat/SessionStatus.tsx +51 -0
- package/src/chat/TranscriptView.tsx +202 -0
- package/src/chat/chatInputState.ts +247 -0
- package/src/chat/chatPaste.ts +49 -0
- package/src/chat/chatScreenUtils.ts +187 -0
- package/src/chat/chatSessionState.ts +142 -0
- package/src/chat/chatTurnOrchestrator.ts +701 -0
- package/src/chat/commands.ts +673 -0
- package/src/chat/textCursor.ts +202 -0
- package/src/chat/toolResultDisplay.ts +8 -0
- package/src/chat/transcriptViewport.ts +247 -0
- package/src/cli/ResetConfirmView.tsx +61 -0
- package/src/cli/main.tsx +177 -0
- package/src/cli/preview.tsx +19 -0
- package/src/cli/reset.ts +106 -0
- package/src/identity/continuity/editor.ts +149 -0
- package/src/identity/continuity/envelope.ts +345 -0
- package/src/identity/continuity/history.ts +153 -0
- package/src/identity/continuity/privateEdit.ts +334 -0
- package/src/identity/continuity/publicSkills.ts +173 -0
- package/src/identity/continuity/snapshots.ts +183 -0
- package/src/identity/continuity/storage.ts +507 -0
- package/src/identity/crypto/backupEnvelope.ts +486 -0
- package/src/identity/crypto/eth.ts +137 -0
- package/src/identity/hub/IdentityHub.tsx +845 -0
- package/src/identity/hub/identityHubEffects.ts +1100 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +209 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -0
- package/src/identity/hub/screens/CreateFlow.tsx +206 -0
- package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
- package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
- package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
- package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
- package/src/identity/hub/screens/MenuScreen.tsx +117 -0
- package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
- package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
- package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
- package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
- package/src/identity/profile/imagePicker.ts +180 -0
- package/src/identity/registry/erc8004.ts +1106 -0
- package/src/identity/registry/registryConfig.ts +69 -0
- package/src/identity/storage/ipfs.ts +212 -0
- package/src/identity/storage/pinataJwt.ts +53 -0
- package/src/identity/wallet/browserWallet.ts +393 -0
- package/src/identity/wallet/wallet-page/wallet.html +1082 -0
- package/src/mcp/approvals.ts +113 -0
- package/src/mcp/config.ts +235 -0
- package/src/mcp/manager.ts +541 -0
- package/src/mcp/names.ts +19 -0
- package/src/mcp/output.ts +96 -0
- package/src/models/ModelPicker.tsx +1446 -0
- package/src/models/catalog.ts +296 -0
- package/src/models/huggingface.ts +651 -0
- package/src/models/llamacpp.ts +810 -0
- package/src/models/llamacppPreflight.ts +150 -0
- package/src/models/modelDisplay.ts +105 -0
- package/src/models/modelPickerOptions.ts +421 -0
- package/src/models/modelRecommendation.ts +140 -0
- package/src/models/runtimeDetection.ts +81 -0
- package/src/models/uncensoredCatalog.ts +86 -0
- package/src/providers/anthropic.ts +259 -0
- package/src/providers/contracts.ts +62 -0
- package/src/providers/errors.ts +62 -0
- package/src/providers/gemini.ts +152 -0
- package/src/providers/openai-chat.ts +472 -0
- package/src/providers/registry.ts +42 -0
- package/src/providers/retry.ts +58 -0
- package/src/providers/sse.ts +93 -0
- package/src/runtime/compaction.ts +389 -0
- package/src/runtime/cwd.ts +43 -0
- package/src/runtime/sessionMode.ts +55 -0
- package/src/runtime/systemPrompt.ts +209 -0
- package/src/runtime/toolClaimGuards.ts +143 -0
- package/src/runtime/toolExecution.ts +304 -0
- package/src/runtime/toolIntent.ts +163 -0
- package/src/runtime/turn.ts +858 -0
- package/src/storage/atomicWrite.ts +68 -0
- package/src/storage/config.ts +189 -0
- package/src/storage/factoryReset.ts +130 -0
- package/src/storage/history.ts +58 -0
- package/src/storage/identity.ts +99 -0
- package/src/storage/permissions.ts +76 -0
- package/src/storage/rewind.ts +246 -0
- package/src/storage/secrets.ts +181 -0
- package/src/storage/sessionExport.ts +49 -0
- package/src/storage/sessions.ts +482 -0
- package/src/tools/bashSafety.ts +174 -0
- package/src/tools/bashTool.ts +140 -0
- package/src/tools/changeDirectoryTool.ts +213 -0
- package/src/tools/contracts.ts +179 -0
- package/src/tools/deleteFileTool.ts +111 -0
- package/src/tools/editTool.ts +160 -0
- package/src/tools/editUtils.ts +170 -0
- package/src/tools/listDirectoryTool.ts +55 -0
- package/src/tools/mcpResourceTools.ts +95 -0
- package/src/tools/permissionRules.ts +85 -0
- package/src/tools/privateContinuityEditTool.ts +178 -0
- package/src/tools/privateContinuityReadTool.ts +107 -0
- package/src/tools/readTool.ts +85 -0
- package/src/tools/registry.ts +67 -0
- package/src/tools/writeFileTool.ts +142 -0
- package/src/ui/BrandSplash.tsx +193 -0
- package/src/ui/ProgressBar.tsx +34 -0
- package/src/ui/Select.tsx +143 -0
- package/src/ui/Spinner.tsx +269 -0
- package/src/ui/Surface.tsx +47 -0
- package/src/ui/TextInput.tsx +97 -0
- package/src/ui/theme.ts +59 -0
- package/src/utils/clipboard.ts +216 -0
- package/src/utils/markdownSegments.ts +51 -0
- package/src/utils/messages.ts +35 -0
- package/src/utils/withRetry.ts +280 -0
- package/src/cli.tsx +0 -147
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import { Surface } from '../ui/Surface.js'
|
|
4
|
+
import { Select, type SelectOption } from '../ui/Select.js'
|
|
5
|
+
import { Spinner } from '../ui/Spinner.js'
|
|
6
|
+
import { theme } from '../ui/theme.js'
|
|
7
|
+
import { useAppInput } from '../app/input/AppInputProvider.js'
|
|
8
|
+
import {
|
|
9
|
+
listRewindEntries,
|
|
10
|
+
rewindWorkspaceEditsByEntryIds,
|
|
11
|
+
type RewindEntry,
|
|
12
|
+
} from '../storage/rewind.js'
|
|
13
|
+
|
|
14
|
+
type RestoreAction = 'both' | 'code' | 'conversation'
|
|
15
|
+
|
|
16
|
+
type RewindViewProps = {
|
|
17
|
+
cwd: string
|
|
18
|
+
currentSessionId: string
|
|
19
|
+
onRestoreConversation: (turnId: string) => void
|
|
20
|
+
onDone: (message: string, variant?: 'info' | 'error' | 'dim') => void
|
|
21
|
+
onCancel: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type ReadyState = {
|
|
25
|
+
kind: 'ready'
|
|
26
|
+
entries: RewindEntry[]
|
|
27
|
+
offset: number
|
|
28
|
+
pageSize: number
|
|
29
|
+
hasMore: boolean
|
|
30
|
+
selectedFilePath: string | null
|
|
31
|
+
selectedId: string | null
|
|
32
|
+
selectedAction: RestoreAction
|
|
33
|
+
stage: 'files' | 'entries' | 'actions'
|
|
34
|
+
restoring: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type State =
|
|
38
|
+
| { kind: 'loading' }
|
|
39
|
+
| { kind: 'error'; message: string }
|
|
40
|
+
| ReadyState
|
|
41
|
+
|
|
42
|
+
export const RewindView: React.FC<RewindViewProps> = ({
|
|
43
|
+
cwd,
|
|
44
|
+
currentSessionId,
|
|
45
|
+
onRestoreConversation,
|
|
46
|
+
onDone,
|
|
47
|
+
onCancel,
|
|
48
|
+
}) => {
|
|
49
|
+
const [state, setState] = useState<State>({ kind: 'loading' })
|
|
50
|
+
const pageSize = 12
|
|
51
|
+
|
|
52
|
+
const escActive = state.kind === 'loading' || state.kind === 'error' || (state.kind === 'ready' && state.entries.length === 0)
|
|
53
|
+
useAppInput((_input, key) => {
|
|
54
|
+
if (key.escape) onCancel()
|
|
55
|
+
}, { isActive: escActive })
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
let cancelled = false
|
|
59
|
+
void (async () => {
|
|
60
|
+
try {
|
|
61
|
+
const entries = await listRewindEntries(cwd, { limit: pageSize, offset: 0 })
|
|
62
|
+
if (cancelled) return
|
|
63
|
+
const firstFilePath = entries[0]?.filePath ?? null
|
|
64
|
+
const firstEntryId = entries.find(entry => entry.filePath === firstFilePath)?.id ?? null
|
|
65
|
+
setState({
|
|
66
|
+
kind: 'ready',
|
|
67
|
+
entries,
|
|
68
|
+
offset: entries.length,
|
|
69
|
+
pageSize,
|
|
70
|
+
hasMore: entries.length === pageSize,
|
|
71
|
+
selectedFilePath: firstFilePath,
|
|
72
|
+
selectedId: firstEntryId,
|
|
73
|
+
selectedAction: 'code',
|
|
74
|
+
stage: 'files',
|
|
75
|
+
restoring: false,
|
|
76
|
+
})
|
|
77
|
+
} catch (err: unknown) {
|
|
78
|
+
if (cancelled) return
|
|
79
|
+
setState({ kind: 'error', message: (err as Error).message })
|
|
80
|
+
}
|
|
81
|
+
})()
|
|
82
|
+
return () => { cancelled = true }
|
|
83
|
+
}, [cwd, pageSize])
|
|
84
|
+
|
|
85
|
+
if (state.kind === 'loading') {
|
|
86
|
+
return (
|
|
87
|
+
<Surface title="Rewind" subtitle="loading checkpoints...">
|
|
88
|
+
<Spinner label="loading rewind history..." />
|
|
89
|
+
</Surface>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (state.kind === 'error') {
|
|
94
|
+
return (
|
|
95
|
+
<Surface title="Rewind" tone="muted" footer="esc closes">
|
|
96
|
+
<Text color={theme.dim}>{state.message}</Text>
|
|
97
|
+
</Surface>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (state.entries.length === 0) {
|
|
102
|
+
return (
|
|
103
|
+
<Surface title="Rewind" tone="muted" footer="esc closes">
|
|
104
|
+
<Text color={theme.dim}>No managed edits are available to rewind in this workspace.</Text>
|
|
105
|
+
</Surface>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const fileEntries = dedupeFiles(state.entries)
|
|
110
|
+
const scopedEntries = state.selectedFilePath
|
|
111
|
+
? state.entries.filter(entry => entry.filePath === state.selectedFilePath)
|
|
112
|
+
: []
|
|
113
|
+
const selectedFile = fileEntries.find(entry => entry.filePath === state.selectedFilePath) ?? fileEntries[0]!
|
|
114
|
+
const selectedEntry = scopedEntries.find(entry => entry.id === state.selectedId) ?? scopedEntries[0] ?? selectedFile
|
|
115
|
+
const canRestoreConversation = Boolean(selectedEntry.turnId && selectedEntry.sessionId === currentSessionId)
|
|
116
|
+
|
|
117
|
+
const executeRestore = async (action: RestoreAction) => {
|
|
118
|
+
setState(prev => prev.kind === 'ready' ? { ...prev, restoring: true, selectedAction: action } : prev)
|
|
119
|
+
try {
|
|
120
|
+
if (action === 'conversation') {
|
|
121
|
+
if (!selectedEntry.turnId || !canRestoreConversation) {
|
|
122
|
+
onDone('conversation restore is not available for this checkpoint.', 'error')
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
onRestoreConversation(selectedEntry.turnId)
|
|
126
|
+
onDone(`restored conversation to before: ${selectedEntry.checkpointLabel}`, 'dim')
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const result = await rewindWorkspaceEditsByEntryIds(cwd, [selectedEntry.id])
|
|
131
|
+
if (result.reverted === 0) {
|
|
132
|
+
onDone('no matching rewind entry was found.', 'error')
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (action === 'both') {
|
|
137
|
+
if (!selectedEntry.turnId || !canRestoreConversation) {
|
|
138
|
+
onDone('conversation restore is not available for this checkpoint.', 'error')
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
onRestoreConversation(selectedEntry.turnId)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const fileList = result.files.map(file => file.split(/[\\/]/).at(-1) ?? file).join(', ')
|
|
145
|
+
const prefix = action === 'both' ? 'restored code and conversation' : 'restored code'
|
|
146
|
+
onDone(`${prefix}: ${fileList}`, 'dim')
|
|
147
|
+
} catch (err: unknown) {
|
|
148
|
+
onDone(`rewind failed: ${(err as Error).message}`, 'error')
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const loadMoreEntries = async () => {
|
|
153
|
+
if (state.kind !== 'ready' || !state.hasMore || state.restoring) return
|
|
154
|
+
try {
|
|
155
|
+
const nextEntries = await listRewindEntries(cwd, { limit: state.pageSize, offset: state.offset })
|
|
156
|
+
setState(prev => {
|
|
157
|
+
if (prev.kind !== 'ready') return prev
|
|
158
|
+
const merged = dedupeEntryIds([...prev.entries, ...nextEntries])
|
|
159
|
+
const selectedFilePath = prev.selectedFilePath ?? merged[0]?.filePath ?? null
|
|
160
|
+
const selectedId =
|
|
161
|
+
prev.selectedId && merged.some(entry => entry.id === prev.selectedId)
|
|
162
|
+
? prev.selectedId
|
|
163
|
+
: merged.find(entry => entry.filePath === selectedFilePath)?.id ?? merged[0]?.id ?? null
|
|
164
|
+
return {
|
|
165
|
+
...prev,
|
|
166
|
+
entries: merged,
|
|
167
|
+
offset: prev.offset + nextEntries.length,
|
|
168
|
+
hasMore: nextEntries.length === prev.pageSize,
|
|
169
|
+
selectedFilePath,
|
|
170
|
+
selectedId,
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
} catch (err: unknown) {
|
|
174
|
+
onDone(`failed to load older checkpoints: ${(err as Error).message}`, 'error')
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const handleCancel = () => {
|
|
179
|
+
if (state.stage === 'actions') {
|
|
180
|
+
setState(prev => prev.kind === 'ready' ? { ...prev, stage: 'entries' } : prev)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
if (state.stage === 'entries') {
|
|
184
|
+
setState(prev => prev.kind === 'ready' ? { ...prev, stage: 'files' } : prev)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
onCancel()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<Surface
|
|
192
|
+
title="Rewind"
|
|
193
|
+
subtitle={buildSubtitle(state.stage, selectedEntry.relativePath)}
|
|
194
|
+
footer={buildFooter(state.stage, state.restoring)}
|
|
195
|
+
>
|
|
196
|
+
{state.stage === 'files' ? (
|
|
197
|
+
<>
|
|
198
|
+
<Select
|
|
199
|
+
options={buildFileOptions(fileEntries, state.hasMore)}
|
|
200
|
+
onSubmit={filePath => {
|
|
201
|
+
if (filePath === LOAD_MORE_VALUE) {
|
|
202
|
+
void loadMoreEntries()
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
const firstEntry = state.entries.find(entry => entry.filePath === filePath) ?? null
|
|
206
|
+
setState(prev => prev.kind === 'ready'
|
|
207
|
+
? {
|
|
208
|
+
...prev,
|
|
209
|
+
selectedFilePath: filePath,
|
|
210
|
+
selectedId: firstEntry?.id ?? null,
|
|
211
|
+
stage: 'entries',
|
|
212
|
+
}
|
|
213
|
+
: prev)
|
|
214
|
+
}}
|
|
215
|
+
onCancel={handleCancel}
|
|
216
|
+
onHighlight={value => {
|
|
217
|
+
if (value === LOAD_MORE_VALUE) return
|
|
218
|
+
setState(prev => prev.kind === 'ready'
|
|
219
|
+
? {
|
|
220
|
+
...prev,
|
|
221
|
+
selectedFilePath: value,
|
|
222
|
+
selectedId: prev.entries.find(entry => entry.filePath === value)?.id ?? null,
|
|
223
|
+
}
|
|
224
|
+
: prev)
|
|
225
|
+
}}
|
|
226
|
+
/>
|
|
227
|
+
<CompactPreview entry={selectedFile} />
|
|
228
|
+
</>
|
|
229
|
+
) : state.stage === 'entries' ? (
|
|
230
|
+
<>
|
|
231
|
+
<Select
|
|
232
|
+
options={buildEntryOptions(scopedEntries, state.hasMore)}
|
|
233
|
+
onSubmit={entryId => {
|
|
234
|
+
if (entryId === LOAD_MORE_VALUE) {
|
|
235
|
+
void loadMoreEntries()
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
setState(prev => prev.kind === 'ready'
|
|
239
|
+
? { ...prev, selectedId: entryId, stage: 'actions' }
|
|
240
|
+
: prev)
|
|
241
|
+
}}
|
|
242
|
+
onCancel={handleCancel}
|
|
243
|
+
onHighlight={value => {
|
|
244
|
+
if (value === LOAD_MORE_VALUE) return
|
|
245
|
+
setState(prev => prev.kind === 'ready'
|
|
246
|
+
? { ...prev, selectedId: value }
|
|
247
|
+
: prev)
|
|
248
|
+
}}
|
|
249
|
+
/>
|
|
250
|
+
<CompactPreview entry={selectedEntry} />
|
|
251
|
+
</>
|
|
252
|
+
) : (
|
|
253
|
+
<>
|
|
254
|
+
<Select
|
|
255
|
+
options={buildActionOptions(canRestoreConversation)}
|
|
256
|
+
onSubmit={value => { void executeRestore(value) }}
|
|
257
|
+
onCancel={handleCancel}
|
|
258
|
+
onHighlight={value => setState(prev => prev.kind === 'ready'
|
|
259
|
+
? { ...prev, selectedAction: value }
|
|
260
|
+
: prev)}
|
|
261
|
+
/>
|
|
262
|
+
<ActionPreview entry={selectedEntry} selectedAction={state.selectedAction} canRestoreConversation={canRestoreConversation} />
|
|
263
|
+
</>
|
|
264
|
+
)}
|
|
265
|
+
</Surface>
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const LOAD_MORE_VALUE = '__load_more__'
|
|
270
|
+
|
|
271
|
+
function buildFileOptions(entries: RewindEntry[], hasMore: boolean): Array<SelectOption<string>> {
|
|
272
|
+
const options = entries.map(entry => ({
|
|
273
|
+
value: entry.filePath,
|
|
274
|
+
label: entry.relativePath,
|
|
275
|
+
hint: formatTimestamp(entry.createdAt),
|
|
276
|
+
}))
|
|
277
|
+
if (hasMore) {
|
|
278
|
+
options.push({
|
|
279
|
+
value: LOAD_MORE_VALUE,
|
|
280
|
+
label: 'show older checkpoints',
|
|
281
|
+
hint: 'load more file history from this directory',
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
return options
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function buildEntryOptions(entries: RewindEntry[], hasMore: boolean): Array<SelectOption<string>> {
|
|
288
|
+
const options = entries.map(entry => ({
|
|
289
|
+
value: entry.id,
|
|
290
|
+
label: entry.checkpointLabel || 'checkpoint',
|
|
291
|
+
hint: `${formatTimestamp(entry.createdAt)} · ${entry.changeSummary}`,
|
|
292
|
+
}))
|
|
293
|
+
if (hasMore) {
|
|
294
|
+
options.push({
|
|
295
|
+
value: LOAD_MORE_VALUE,
|
|
296
|
+
label: 'show older checkpoints',
|
|
297
|
+
hint: 'load more checkpoints for this directory',
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
return options
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function buildActionOptions(canRestoreConversation: boolean): Array<SelectOption<RestoreAction>> {
|
|
304
|
+
const options: Array<SelectOption<RestoreAction>> = []
|
|
305
|
+
if (canRestoreConversation) {
|
|
306
|
+
options.push({ value: 'both', label: 'restore code and conversation', hint: 'full rewind' })
|
|
307
|
+
options.push({ value: 'conversation', label: 'restore conversation only', hint: 'keep current files unchanged' })
|
|
308
|
+
}
|
|
309
|
+
options.push({ value: 'code', label: 'restore code only', hint: 'revert the selected file checkpoint only' })
|
|
310
|
+
return options
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const CompactPreview: React.FC<{ entry: RewindEntry }> = ({ entry }) => (
|
|
314
|
+
<Box flexDirection="column" marginTop={1}>
|
|
315
|
+
<Text color={theme.accentPrimary}>{entry.relativePath}</Text>
|
|
316
|
+
<Text color={theme.dim}>{entry.promptSnippet || '(prompt snippet unavailable for older checkpoints)'}</Text>
|
|
317
|
+
</Box>
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
const ActionPreview: React.FC<{
|
|
321
|
+
entry: RewindEntry
|
|
322
|
+
selectedAction: RestoreAction
|
|
323
|
+
canRestoreConversation: boolean
|
|
324
|
+
}> = ({ entry, selectedAction, canRestoreConversation }) => (
|
|
325
|
+
<Box flexDirection="column" marginTop={1}>
|
|
326
|
+
<Text color={theme.accentPrimary}>{entry.relativePath}</Text>
|
|
327
|
+
<Text color={theme.dim}>{formatTimestamp(entry.createdAt)} · {entry.changeSummary}</Text>
|
|
328
|
+
<Text color={theme.textSubtle}>
|
|
329
|
+
{selectedAction === 'both'
|
|
330
|
+
? 'restore the selected file checkpoint and roll the current conversation back to before this prompt.'
|
|
331
|
+
: selectedAction === 'conversation'
|
|
332
|
+
? 'restore only the conversation state to before this prompt.'
|
|
333
|
+
: 'restore only the selected file checkpoint.'}
|
|
334
|
+
</Text>
|
|
335
|
+
{!canRestoreConversation ? (
|
|
336
|
+
<Text color={theme.dim}>Conversation restore is only available for checkpoints from the current session.</Text>
|
|
337
|
+
) : null}
|
|
338
|
+
<Text color={theme.textSubtle}>{previewContent(entry.previousContent)}</Text>
|
|
339
|
+
</Box>
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
function dedupeFiles(entries: RewindEntry[]): RewindEntry[] {
|
|
343
|
+
const seen = new Set<string>()
|
|
344
|
+
const out: RewindEntry[] = []
|
|
345
|
+
for (const entry of entries) {
|
|
346
|
+
if (seen.has(entry.filePath)) continue
|
|
347
|
+
seen.add(entry.filePath)
|
|
348
|
+
out.push(entry)
|
|
349
|
+
}
|
|
350
|
+
return out
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function dedupeEntryIds(entries: RewindEntry[]): RewindEntry[] {
|
|
354
|
+
const seen = new Set<string>()
|
|
355
|
+
const out: RewindEntry[] = []
|
|
356
|
+
for (const entry of entries) {
|
|
357
|
+
if (seen.has(entry.id)) continue
|
|
358
|
+
seen.add(entry.id)
|
|
359
|
+
out.push(entry)
|
|
360
|
+
}
|
|
361
|
+
return out
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function previewContent(text: string): string {
|
|
365
|
+
if (!text.trim()) return '(empty before this edit)'
|
|
366
|
+
const normalized = text.replace(/\s+$/g, '')
|
|
367
|
+
return normalized.length <= 140 ? normalized : `${normalized.slice(0, 137)}...`
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function formatTimestamp(iso: string): string {
|
|
371
|
+
const date = new Date(iso)
|
|
372
|
+
return Number.isNaN(date.getTime()) ? iso : date.toLocaleString()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function buildSubtitle(stage: ReadyState['stage'], relativePath: string): string {
|
|
376
|
+
if (stage === 'files') return 'choose a file with saved checkpoints.'
|
|
377
|
+
if (stage === 'entries') return `checkpoints for ${relativePath}`
|
|
378
|
+
return `choose how to restore ${relativePath}`
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function buildFooter(stage: ReadyState['stage'], restoring: boolean): string {
|
|
382
|
+
if (restoring) return 'restoring...'
|
|
383
|
+
if (stage === 'files') return 'enter selects a file · esc closes'
|
|
384
|
+
if (stage === 'entries') return 'enter chooses a checkpoint · esc back'
|
|
385
|
+
return 'enter restores · esc back'
|
|
386
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import type { ContextUsage } from '../runtime/compaction.js'
|
|
4
|
+
import { theme } from '../ui/theme.js'
|
|
5
|
+
import { formatModelDisplayName } from '../models/modelDisplay.js'
|
|
6
|
+
|
|
7
|
+
type StatusBarProps = {
|
|
8
|
+
provider: string
|
|
9
|
+
model: string
|
|
10
|
+
turns: number
|
|
11
|
+
approxTokens: number
|
|
12
|
+
startedAt: number
|
|
13
|
+
contextUsage: ContextUsage
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SessionStatusInner: React.FC<StatusBarProps> = ({
|
|
17
|
+
provider,
|
|
18
|
+
model,
|
|
19
|
+
turns,
|
|
20
|
+
approxTokens,
|
|
21
|
+
startedAt,
|
|
22
|
+
contextUsage,
|
|
23
|
+
}) => {
|
|
24
|
+
const displayModel = formatModelDisplayName(provider, model, { maxLength: 24 })
|
|
25
|
+
return (
|
|
26
|
+
<Box flexDirection="row">
|
|
27
|
+
<Text color={theme.dim}>
|
|
28
|
+
{provider} · {displayModel} · {turns} {turns === 1 ? 'turn' : 'turns'} · ~{formatTokens(approxTokens)} tokens · Context {contextUsage.percent}% (~{formatTokens(contextUsage.usedTokens)} / {formatTokens(contextUsage.windowTokens)}) · {formatElapsed(Date.now() - startedAt)}
|
|
29
|
+
</Text>
|
|
30
|
+
</Box>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const SessionStatus = React.memo(SessionStatusInner)
|
|
35
|
+
|
|
36
|
+
export function formatTokens(count: number): string {
|
|
37
|
+
if (count < 1000) return String(count)
|
|
38
|
+
if (count < 10_000) return `${(count / 1000).toFixed(1)}k`
|
|
39
|
+
return `${Math.round(count / 1000)}k`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatElapsed(ms: number): string {
|
|
43
|
+
const seconds = Math.max(0, Math.floor(ms / 1000))
|
|
44
|
+
if (seconds < 60) return `${seconds}s`
|
|
45
|
+
const minutes = Math.floor(seconds / 60)
|
|
46
|
+
const rem = seconds % 60
|
|
47
|
+
if (minutes < 60) return `${minutes}m${rem.toString().padStart(2, '0')}s`
|
|
48
|
+
const hours = Math.floor(minutes / 60)
|
|
49
|
+
const minRem = minutes % 60
|
|
50
|
+
return `${hours}h${minRem.toString().padStart(2, '0')}m`
|
|
51
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { Box, Text, useStdout } from 'ink'
|
|
3
|
+
import { useAppInput } from '../app/input/AppInputProvider.js'
|
|
4
|
+
import { MessageList, type MessageRow } from './MessageList.js'
|
|
5
|
+
import { theme } from '../ui/theme.js'
|
|
6
|
+
import {
|
|
7
|
+
anchorForScrollTop,
|
|
8
|
+
buildLineOffsets,
|
|
9
|
+
clampLine,
|
|
10
|
+
estimateMessageRowHeight,
|
|
11
|
+
promptScrollTopForPageDown,
|
|
12
|
+
promptScrollTopForPageUp,
|
|
13
|
+
resolveScrollTopFromAnchor,
|
|
14
|
+
selectRowsForScrollTop,
|
|
15
|
+
type TranscriptWindowSelection,
|
|
16
|
+
type TranscriptViewportState,
|
|
17
|
+
} from './transcriptViewport.js'
|
|
18
|
+
|
|
19
|
+
type TranscriptViewProps = {
|
|
20
|
+
rows: MessageRow[]
|
|
21
|
+
active?: boolean
|
|
22
|
+
bottomVariant?: 'prompt' | 'overlay'
|
|
23
|
+
onVisibleReasoningIdsChange?: (ids: string[]) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const PROMPT_RESERVED_LINES = 11
|
|
27
|
+
const OVERLAY_RESERVED_LINES = 16
|
|
28
|
+
const MIN_TRANSCRIPT_LINES = 6
|
|
29
|
+
const MAX_TRANSCRIPT_LINES = 120
|
|
30
|
+
|
|
31
|
+
export const TranscriptView: React.FC<TranscriptViewProps> = ({
|
|
32
|
+
rows,
|
|
33
|
+
active = true,
|
|
34
|
+
bottomVariant = 'prompt',
|
|
35
|
+
onVisibleReasoningIdsChange,
|
|
36
|
+
}) => {
|
|
37
|
+
const { stdout } = useStdout()
|
|
38
|
+
const columns = stdout.columns ?? process.stdout.columns ?? 80
|
|
39
|
+
const terminalRows = stdout.rows ?? process.stdout.rows ?? 24
|
|
40
|
+
const reservedLines = bottomVariant === 'overlay' ? OVERLAY_RESERVED_LINES : PROMPT_RESERVED_LINES
|
|
41
|
+
const maxLines = Math.min(
|
|
42
|
+
MAX_TRANSCRIPT_LINES,
|
|
43
|
+
Math.max(MIN_TRANSCRIPT_LINES, terminalRows - reservedLines),
|
|
44
|
+
)
|
|
45
|
+
const [viewportState, setViewportState] = useState<TranscriptViewportState>({
|
|
46
|
+
scrollTopLine: 0,
|
|
47
|
+
followTail: true,
|
|
48
|
+
anchor: null,
|
|
49
|
+
})
|
|
50
|
+
const metrics = useMemo(() => {
|
|
51
|
+
const heights = rows.map(row => Math.max(1, estimateMessageRowHeight(row, columns)))
|
|
52
|
+
const offsets = buildLineOffsets(heights)
|
|
53
|
+
const totalLines = offsets[offsets.length - 1] ?? 0
|
|
54
|
+
return {
|
|
55
|
+
rowIds: rows.map(row => row.id),
|
|
56
|
+
offsets,
|
|
57
|
+
maxScrollTop: Math.max(0, totalLines - maxLines),
|
|
58
|
+
}
|
|
59
|
+
}, [columns, maxLines, rows])
|
|
60
|
+
const resolvedViewportState = useMemo(
|
|
61
|
+
() => resolveViewportState(viewportState, metrics.rowIds, metrics.offsets, metrics.maxScrollTop),
|
|
62
|
+
[metrics, viewportState],
|
|
63
|
+
)
|
|
64
|
+
const selection = useMemo(
|
|
65
|
+
() => trimSelectionToFocusedTurn(
|
|
66
|
+
selectRowsForScrollTop(
|
|
67
|
+
rows,
|
|
68
|
+
maxLines,
|
|
69
|
+
resolvedViewportState.scrollTopLine,
|
|
70
|
+
row => estimateMessageRowHeight(row, columns),
|
|
71
|
+
),
|
|
72
|
+
rows,
|
|
73
|
+
resolvedViewportState,
|
|
74
|
+
),
|
|
75
|
+
[columns, maxLines, resolvedViewportState, rows],
|
|
76
|
+
)
|
|
77
|
+
const visibleReasoningIds = useMemo(
|
|
78
|
+
() => selection.rows
|
|
79
|
+
.filter((row): row is Extract<MessageRow, { role: 'thinking' }> => row.role === 'thinking')
|
|
80
|
+
.map(row => row.id),
|
|
81
|
+
[selection.rows],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
setViewportState(prev => sameViewportState(prev, resolvedViewportState) ? prev : resolvedViewportState)
|
|
86
|
+
}, [resolvedViewportState])
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
onVisibleReasoningIdsChange?.(visibleReasoningIds)
|
|
90
|
+
}, [onVisibleReasoningIdsChange, visibleReasoningIds])
|
|
91
|
+
|
|
92
|
+
useAppInput((_input, key) => {
|
|
93
|
+
if (key.pageUp) {
|
|
94
|
+
const target = promptScrollTopForPageUp(
|
|
95
|
+
rows,
|
|
96
|
+
metrics.offsets,
|
|
97
|
+
resolvedViewportState.scrollTopLine,
|
|
98
|
+
metrics.maxScrollTop,
|
|
99
|
+
resolvedViewportState.followTail,
|
|
100
|
+
)
|
|
101
|
+
setViewportState(viewportForScrollTop(
|
|
102
|
+
target,
|
|
103
|
+
metrics.rowIds,
|
|
104
|
+
metrics.offsets,
|
|
105
|
+
metrics.maxScrollTop,
|
|
106
|
+
))
|
|
107
|
+
} else if (key.pageDown) {
|
|
108
|
+
const target = promptScrollTopForPageDown(
|
|
109
|
+
rows,
|
|
110
|
+
metrics.offsets,
|
|
111
|
+
resolvedViewportState.scrollTopLine,
|
|
112
|
+
metrics.maxScrollTop,
|
|
113
|
+
)
|
|
114
|
+
setViewportState(viewportForScrollTop(
|
|
115
|
+
target,
|
|
116
|
+
metrics.rowIds,
|
|
117
|
+
metrics.offsets,
|
|
118
|
+
metrics.maxScrollTop,
|
|
119
|
+
))
|
|
120
|
+
}
|
|
121
|
+
}, { isActive: active })
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<Box flexDirection="column">
|
|
125
|
+
<Box marginBottom={1}>
|
|
126
|
+
<Text> </Text>
|
|
127
|
+
</Box>
|
|
128
|
+
{selection.hiddenBefore > 0 ? (
|
|
129
|
+
<Text color={theme.dim}>
|
|
130
|
+
{` ${selection.hiddenBefore} earlier message${selection.hiddenBefore === 1 ? '' : 's'} above this view`}
|
|
131
|
+
</Text>
|
|
132
|
+
) : null}
|
|
133
|
+
<MessageList rows={selection.rows} />
|
|
134
|
+
{selection.hiddenAfter > 0 ? (
|
|
135
|
+
<Text color={theme.dim}>
|
|
136
|
+
{` ${selection.hiddenAfter} later message${selection.hiddenAfter === 1 ? '' : 's'} below · pgdn to return`}
|
|
137
|
+
</Text>
|
|
138
|
+
) : null}
|
|
139
|
+
</Box>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function resolveViewportState(
|
|
144
|
+
state: TranscriptViewportState,
|
|
145
|
+
rowIds: string[],
|
|
146
|
+
offsets: number[],
|
|
147
|
+
maxScrollTop: number,
|
|
148
|
+
): TranscriptViewportState {
|
|
149
|
+
if (rowIds.length === 0) {
|
|
150
|
+
return { scrollTopLine: 0, followTail: true, anchor: null }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const scrollTopLine = state.followTail
|
|
154
|
+
? maxScrollTop
|
|
155
|
+
: resolveScrollTopFromAnchor(rowIds, offsets, state.anchor, maxScrollTop)
|
|
156
|
+
?? clampLine(state.scrollTopLine, maxScrollTop)
|
|
157
|
+
|
|
158
|
+
return viewportForScrollTop(scrollTopLine, rowIds, offsets, maxScrollTop)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function viewportForScrollTop(
|
|
162
|
+
scrollTopLine: number,
|
|
163
|
+
rowIds: string[],
|
|
164
|
+
offsets: number[],
|
|
165
|
+
maxScrollTop: number,
|
|
166
|
+
): TranscriptViewportState {
|
|
167
|
+
const clamped = clampLine(scrollTopLine, maxScrollTop)
|
|
168
|
+
const followTail = clamped >= maxScrollTop
|
|
169
|
+
return {
|
|
170
|
+
scrollTopLine: clamped,
|
|
171
|
+
followTail,
|
|
172
|
+
anchor: followTail ? null : anchorForScrollTop(rowIds, offsets, clamped),
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function trimSelectionToFocusedTurn(
|
|
177
|
+
selection: TranscriptWindowSelection<MessageRow>,
|
|
178
|
+
rows: MessageRow[],
|
|
179
|
+
state: TranscriptViewportState,
|
|
180
|
+
): TranscriptWindowSelection<MessageRow> {
|
|
181
|
+
if (state.followTail || state.anchor?.offset !== 0) return selection
|
|
182
|
+
const focusedIndex = rows.findIndex(row => row.id === state.anchor?.rowId)
|
|
183
|
+
if (focusedIndex === -1 || rows[focusedIndex]?.role !== 'user') return selection
|
|
184
|
+
const firstSelected = selection.rows[0]
|
|
185
|
+
if (!firstSelected || firstSelected.id !== state.anchor.rowId) return selection
|
|
186
|
+
|
|
187
|
+
const nextPromptIndex = selection.rows.findIndex((row, index) => index > 0 && row.role === 'user')
|
|
188
|
+
if (nextPromptIndex === -1) return selection
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
...selection,
|
|
192
|
+
rows: selection.rows.slice(0, nextPromptIndex),
|
|
193
|
+
hiddenAfter: selection.hiddenAfter + selection.rows.length - nextPromptIndex,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function sameViewportState(left: TranscriptViewportState, right: TranscriptViewportState): boolean {
|
|
198
|
+
return left.scrollTopLine === right.scrollTopLine
|
|
199
|
+
&& left.followTail === right.followTail
|
|
200
|
+
&& left.anchor?.rowId === right.anchor?.rowId
|
|
201
|
+
&& left.anchor?.offset === right.anchor?.offset
|
|
202
|
+
}
|