ethagent 0.2.0 → 1.0.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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -32
  3. package/bin/ethagent.js +11 -2
  4. package/package.json +30 -8
  5. package/src/app/FirstRun.tsx +412 -0
  6. package/src/app/hooks/useCancelRequest.ts +22 -0
  7. package/src/app/hooks/useDoublePress.ts +46 -0
  8. package/src/app/hooks/useExitOnCtrlC.ts +36 -0
  9. package/src/app/input/AppInputProvider.tsx +116 -0
  10. package/src/app/input/appInputParser.ts +279 -0
  11. package/src/app/keybindings/KeybindingProvider.tsx +134 -0
  12. package/src/app/keybindings/resolver.ts +42 -0
  13. package/src/app/keybindings/types.ts +26 -0
  14. package/src/chat/ChatBottomPane.tsx +280 -0
  15. package/src/chat/ChatInput.tsx +722 -0
  16. package/src/chat/ChatScreen.tsx +1575 -0
  17. package/src/chat/ContextLimitView.tsx +95 -0
  18. package/src/chat/ContinuityEditReviewView.tsx +48 -0
  19. package/src/chat/ConversationStack.tsx +47 -0
  20. package/src/chat/CopyPicker.tsx +52 -0
  21. package/src/chat/MessageList.tsx +609 -0
  22. package/src/chat/PermissionPrompt.tsx +153 -0
  23. package/src/chat/PermissionsView.tsx +159 -0
  24. package/src/chat/PlanApprovalView.tsx +91 -0
  25. package/src/chat/ResumeView.tsx +267 -0
  26. package/src/chat/RewindView.tsx +386 -0
  27. package/src/chat/SessionStatus.tsx +51 -0
  28. package/src/chat/TranscriptView.tsx +202 -0
  29. package/src/chat/chatInputState.ts +247 -0
  30. package/src/chat/chatPaste.ts +49 -0
  31. package/src/chat/chatScreenUtils.ts +187 -0
  32. package/src/chat/chatSessionState.ts +142 -0
  33. package/src/chat/chatTurnOrchestrator.ts +701 -0
  34. package/src/chat/commands.ts +673 -0
  35. package/src/chat/textCursor.ts +202 -0
  36. package/src/chat/toolResultDisplay.ts +8 -0
  37. package/src/chat/transcriptViewport.ts +247 -0
  38. package/src/cli/ResetConfirmView.tsx +61 -0
  39. package/src/cli/main.tsx +177 -0
  40. package/src/cli/preview.tsx +19 -0
  41. package/src/cli/reset.ts +106 -0
  42. package/src/identity/continuity/editor.ts +149 -0
  43. package/src/identity/continuity/envelope.ts +345 -0
  44. package/src/identity/continuity/history.ts +153 -0
  45. package/src/identity/continuity/privateEdit.ts +334 -0
  46. package/src/identity/continuity/publicSkills.ts +173 -0
  47. package/src/identity/continuity/snapshots.ts +183 -0
  48. package/src/identity/continuity/storage.ts +507 -0
  49. package/src/identity/crypto/backupEnvelope.ts +486 -0
  50. package/src/identity/crypto/eth.ts +137 -0
  51. package/src/identity/hub/IdentityHub.tsx +868 -0
  52. package/src/identity/hub/identityHubEffects.ts +1146 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +212 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
  57. package/src/identity/hub/screens/CreateFlow.tsx +206 -0
  58. package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
  59. package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
  60. package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
  61. package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
  62. package/src/identity/hub/screens/MenuScreen.tsx +117 -0
  63. package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
  64. package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
  65. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
  66. package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
  67. package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
  68. package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
  69. package/src/identity/profile/imagePicker.ts +180 -0
  70. package/src/identity/registry/erc8004.ts +1106 -0
  71. package/src/identity/registry/registryConfig.ts +69 -0
  72. package/src/identity/storage/ipfs.ts +212 -0
  73. package/src/identity/storage/pinataJwt.ts +53 -0
  74. package/src/identity/wallet/browserWallet.ts +393 -0
  75. package/src/identity/wallet/wallet-page/wallet.html +1082 -0
  76. package/src/mcp/approvals.ts +113 -0
  77. package/src/mcp/config.ts +235 -0
  78. package/src/mcp/manager.ts +541 -0
  79. package/src/mcp/names.ts +19 -0
  80. package/src/mcp/output.ts +96 -0
  81. package/src/models/ModelPicker.tsx +1446 -0
  82. package/src/models/catalog.ts +296 -0
  83. package/src/models/huggingface.ts +651 -0
  84. package/src/models/llamacpp.ts +810 -0
  85. package/src/models/llamacppPreflight.ts +150 -0
  86. package/src/models/modelDisplay.ts +105 -0
  87. package/src/models/modelPickerOptions.ts +421 -0
  88. package/src/models/modelRecommendation.ts +140 -0
  89. package/src/models/runtimeDetection.ts +81 -0
  90. package/src/models/uncensoredCatalog.ts +86 -0
  91. package/src/providers/anthropic.ts +259 -0
  92. package/src/providers/contracts.ts +62 -0
  93. package/src/providers/errors.ts +62 -0
  94. package/src/providers/gemini.ts +152 -0
  95. package/src/providers/openai-chat.ts +472 -0
  96. package/src/providers/registry.ts +42 -0
  97. package/src/providers/retry.ts +58 -0
  98. package/src/providers/sse.ts +93 -0
  99. package/src/runtime/compaction.ts +389 -0
  100. package/src/runtime/cwd.ts +43 -0
  101. package/src/runtime/sessionMode.ts +55 -0
  102. package/src/runtime/systemPrompt.ts +209 -0
  103. package/src/runtime/toolClaimGuards.ts +143 -0
  104. package/src/runtime/toolExecution.ts +304 -0
  105. package/src/runtime/toolIntent.ts +163 -0
  106. package/src/runtime/turn.ts +858 -0
  107. package/src/storage/atomicWrite.ts +68 -0
  108. package/src/storage/config.ts +189 -0
  109. package/src/storage/factoryReset.ts +130 -0
  110. package/src/storage/history.ts +58 -0
  111. package/src/storage/identity.ts +99 -0
  112. package/src/storage/permissions.ts +76 -0
  113. package/src/storage/rewind.ts +246 -0
  114. package/src/storage/secrets.ts +181 -0
  115. package/src/storage/sessionExport.ts +49 -0
  116. package/src/storage/sessions.ts +482 -0
  117. package/src/tools/bashSafety.ts +174 -0
  118. package/src/tools/bashTool.ts +140 -0
  119. package/src/tools/changeDirectoryTool.ts +213 -0
  120. package/src/tools/contracts.ts +179 -0
  121. package/src/tools/deleteFileTool.ts +111 -0
  122. package/src/tools/editTool.ts +160 -0
  123. package/src/tools/editUtils.ts +170 -0
  124. package/src/tools/listDirectoryTool.ts +55 -0
  125. package/src/tools/mcpResourceTools.ts +95 -0
  126. package/src/tools/permissionRules.ts +85 -0
  127. package/src/tools/privateContinuityEditTool.ts +178 -0
  128. package/src/tools/privateContinuityReadTool.ts +107 -0
  129. package/src/tools/readTool.ts +85 -0
  130. package/src/tools/registry.ts +67 -0
  131. package/src/tools/writeFileTool.ts +142 -0
  132. package/src/ui/BrandSplash.tsx +193 -0
  133. package/src/ui/ProgressBar.tsx +34 -0
  134. package/src/ui/Select.tsx +143 -0
  135. package/src/ui/Spinner.tsx +269 -0
  136. package/src/ui/Surface.tsx +47 -0
  137. package/src/ui/TextInput.tsx +97 -0
  138. package/src/ui/theme.ts +59 -0
  139. package/src/utils/clipboard.ts +216 -0
  140. package/src/utils/markdownSegments.ts +51 -0
  141. package/src/utils/messages.ts +35 -0
  142. package/src/utils/withRetry.ts +280 -0
  143. 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
+ }