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.
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 +25 -7
  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 +845 -0
  52. package/src/identity/hub/identityHubEffects.ts +1100 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +209 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -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,202 @@
1
+ export type TextCursor = {
2
+ value: string
3
+ offset: number
4
+ }
5
+
6
+ export type CursorMoveResult = {
7
+ cursor: TextCursor
8
+ moved: boolean
9
+ }
10
+
11
+ export type VisualLine = {
12
+ start: number
13
+ end: number
14
+ }
15
+
16
+ export type VisualLineWindow = {
17
+ start: number
18
+ end: number
19
+ }
20
+
21
+ export function normalizeCursor(value: string, offset: number): TextCursor {
22
+ return { value, offset: clampOffset(value, offset) }
23
+ }
24
+
25
+ export function moveLeft(cursor: TextCursor): TextCursor {
26
+ return normalizeCursor(cursor.value, cursor.offset - 1)
27
+ }
28
+
29
+ export function moveRight(cursor: TextCursor): TextCursor {
30
+ return normalizeCursor(cursor.value, cursor.offset + 1)
31
+ }
32
+
33
+ export function moveVerticalCursor(
34
+ cursor: TextCursor,
35
+ direction: 1 | -1,
36
+ preferredColumn?: number,
37
+ ): CursorMoveResult {
38
+ const lines = getLogicalLines(cursor.value)
39
+ const position = positionFromOffset(lines, cursor.offset)
40
+ const targetLine = position.line + direction
41
+ if (targetLine < 0 || targetLine >= lines.length) {
42
+ return { cursor, moved: false }
43
+ }
44
+ const targetOffset = offsetFromPosition(lines, targetLine, preferredColumn ?? position.column)
45
+ return {
46
+ cursor: normalizeCursor(cursor.value, targetOffset),
47
+ moved: targetOffset !== cursor.offset,
48
+ }
49
+ }
50
+
51
+ export function getVisualLines(value: string, wrapWidth: number): VisualLine[] {
52
+ const safeWrapWidth = Math.max(1, Math.floor(wrapWidth))
53
+ const lines: VisualLine[] = []
54
+ let start = 0
55
+
56
+ for (let index = 0; index <= value.length; index += 1) {
57
+ if (index !== value.length && value[index] !== '\n') continue
58
+
59
+ const end = index
60
+ if (start === end) {
61
+ lines.push({ start, end })
62
+ } else {
63
+ for (let chunkStart = start; chunkStart < end; chunkStart += safeWrapWidth) {
64
+ lines.push({ start: chunkStart, end: Math.min(chunkStart + safeWrapWidth, end) })
65
+ }
66
+ if (end === value.length && (end - start) % safeWrapWidth === 0) {
67
+ lines.push({ start: end, end })
68
+ }
69
+ }
70
+ start = index + 1
71
+ }
72
+
73
+ return lines.length > 0 ? lines : [{ start: 0, end: 0 }]
74
+ }
75
+
76
+ export function getVisualLineIndex(lines: VisualLine[], offset: number): number {
77
+ return visualPositionFromOffset(lines, offset).line
78
+ }
79
+
80
+ export function getVisibleVisualLineWindow(
81
+ totalLines: number,
82
+ cursorLine: number,
83
+ maxVisibleLines: number,
84
+ ): VisualLineWindow {
85
+ const safeTotal = Math.max(0, Math.floor(totalLines))
86
+ if (safeTotal === 0) return { start: 0, end: 0 }
87
+
88
+ const safeMaxVisible = Math.max(1, Math.floor(maxVisibleLines))
89
+ if (safeTotal <= safeMaxVisible) return { start: 0, end: safeTotal }
90
+
91
+ const safeCursorLine = Math.max(0, Math.min(Math.floor(cursorLine), safeTotal - 1))
92
+ const half = Math.floor(safeMaxVisible / 2)
93
+ const start = Math.max(0, Math.min(
94
+ safeCursorLine - half,
95
+ safeTotal - safeMaxVisible,
96
+ ))
97
+
98
+ return { start, end: start + safeMaxVisible }
99
+ }
100
+
101
+ export function moveVerticalVisualCursor(
102
+ cursor: TextCursor,
103
+ direction: 1 | -1,
104
+ wrapWidth: number,
105
+ preferredColumn?: number,
106
+ ): CursorMoveResult {
107
+ const lines = getVisualLines(cursor.value, wrapWidth)
108
+ const position = visualPositionFromOffset(lines, cursor.offset)
109
+ const targetLine = position.line + direction
110
+ if (targetLine < 0 || targetLine >= lines.length) {
111
+ return { cursor, moved: false }
112
+ }
113
+ const target = lines[targetLine]!
114
+ const targetColumn = Math.min(preferredColumn ?? position.column, target.end - target.start)
115
+ const targetOffset = target.start + Math.max(0, targetColumn)
116
+ return {
117
+ cursor: normalizeCursor(cursor.value, targetOffset),
118
+ moved: targetOffset !== cursor.offset,
119
+ }
120
+ }
121
+
122
+ export function cursorOnLastLine(value: string, offset: number): TextCursor {
123
+ const lines = getLogicalLines(value)
124
+ const lastLine = Math.max(0, lines.length - 1)
125
+ return normalizeCursor(value, offsetFromPosition(lines, lastLine, 0))
126
+ }
127
+
128
+ export function cursorOnLastLineAtColumn(value: string, column: number): TextCursor {
129
+ const lines = getLogicalLines(value)
130
+ const lastLine = Math.max(0, lines.length - 1)
131
+ return normalizeCursor(value, offsetFromPosition(lines, lastLine, column))
132
+ }
133
+
134
+ export function cursorColumn(value: string, offset: number): number {
135
+ return positionFromOffset(getLogicalLines(value), clampOffset(value, offset)).column
136
+ }
137
+
138
+ type LogicalLine = {
139
+ start: number
140
+ end: number
141
+ }
142
+
143
+ function getLogicalLines(value: string): LogicalLine[] {
144
+ const lines: LogicalLine[] = []
145
+ let start = 0
146
+ for (let index = 0; index < value.length; index += 1) {
147
+ if (value[index] === '\n') {
148
+ lines.push({ start, end: index })
149
+ start = index + 1
150
+ }
151
+ }
152
+ lines.push({ start, end: value.length })
153
+ return lines
154
+ }
155
+
156
+ function positionFromOffset(lines: LogicalLine[], rawOffset: number): { line: number; column: number } {
157
+ const offset = Math.max(0, rawOffset)
158
+ for (let line = 0; line < lines.length; line += 1) {
159
+ const entry = lines[line]!
160
+ const next = lines[line + 1]
161
+ if (!next || offset < next.start) {
162
+ return {
163
+ line,
164
+ column: Math.max(0, Math.min(offset, entry.end) - entry.start),
165
+ }
166
+ }
167
+ }
168
+ const last = lines[lines.length - 1]!
169
+ return { line: lines.length - 1, column: last.end - last.start }
170
+ }
171
+
172
+ function offsetFromPosition(lines: LogicalLine[], line: number, column: number): number {
173
+ const entry = lines[Math.max(0, Math.min(line, lines.length - 1))]!
174
+ return entry.start + Math.min(Math.max(0, column), entry.end - entry.start)
175
+ }
176
+
177
+ function visualPositionFromOffset(lines: VisualLine[], rawOffset: number): { line: number; column: number } {
178
+ const offset = Math.max(0, rawOffset)
179
+ for (let line = 0; line < lines.length; line += 1) {
180
+ const entry = lines[line]!
181
+ if (entry.start === entry.end && offset === entry.start) {
182
+ return { line, column: 0 }
183
+ }
184
+ if (offset >= entry.start && offset < entry.end) {
185
+ return { line, column: offset - entry.start }
186
+ }
187
+ }
188
+
189
+ for (let line = lines.length - 1; line >= 0; line -= 1) {
190
+ const entry = lines[line]!
191
+ if (offset === entry.end) {
192
+ return { line, column: entry.end - entry.start }
193
+ }
194
+ }
195
+
196
+ const last = lines[lines.length - 1]!
197
+ return { line: lines.length - 1, column: last.end - last.start }
198
+ }
199
+
200
+ function clampOffset(value: string, offset: number): number {
201
+ return Math.max(0, Math.min(value.length, offset))
202
+ }
@@ -0,0 +1,8 @@
1
+ const COMPACT_SUCCESS_TOOL_RESULTS = new Set([
2
+ 'read_file',
3
+ 'read_private_continuity_file',
4
+ ])
5
+
6
+ export function hidesSuccessfulToolResultContent(name: string, isError?: boolean): boolean {
7
+ return !isError && COMPACT_SUCCESS_TOOL_RESULTS.has(name)
8
+ }
@@ -0,0 +1,247 @@
1
+ import type { MessageRow } from './MessageList.js'
2
+ import { hidesSuccessfulToolResultContent } from './toolResultDisplay.js'
3
+
4
+ export type TranscriptAnchor = {
5
+ rowId: string
6
+ offset: number
7
+ }
8
+
9
+ export type TranscriptViewportState = {
10
+ scrollTopLine: number
11
+ followTail: boolean
12
+ anchor: TranscriptAnchor | null
13
+ }
14
+
15
+ export function buildLineOffsets(rowHeights: number[]): number[] {
16
+ const out = new Array<number>(rowHeights.length + 1).fill(0)
17
+ for (let i = 0; i < rowHeights.length; i += 1) {
18
+ out[i + 1] = out[i]! + (rowHeights[i] ?? 1)
19
+ }
20
+ return out
21
+ }
22
+
23
+ export function findRowIndexAtLine(offsets: number[], line: number): number {
24
+ let low = 0
25
+ let high = offsets.length - 1
26
+ while (low < high) {
27
+ const mid = Math.floor((low + high + 1) / 2)
28
+ if ((offsets[mid] ?? 0) <= line) low = mid
29
+ else high = mid - 1
30
+ }
31
+ return low
32
+ }
33
+
34
+ export function anchorForScrollTop(
35
+ rowIds: string[],
36
+ offsets: number[],
37
+ scrollTopLine: number,
38
+ ): TranscriptAnchor | null {
39
+ if (rowIds.length === 0) return null
40
+ const rowIndex = Math.min(rowIds.length - 1, findRowIndexAtLine(offsets, scrollTopLine))
41
+ const rowId = rowIds[rowIndex]
42
+ if (!rowId) return null
43
+ return {
44
+ rowId,
45
+ offset: Math.max(0, scrollTopLine - (offsets[rowIndex] ?? 0)),
46
+ }
47
+ }
48
+
49
+ export function resolveScrollTopFromAnchor(
50
+ rowIds: string[],
51
+ offsets: number[],
52
+ anchor: TranscriptAnchor | null,
53
+ maxScrollTop: number,
54
+ ): number | null {
55
+ if (!anchor) return null
56
+ const rowIndex = rowIds.indexOf(anchor.rowId)
57
+ if (rowIndex === -1) return null
58
+ return clampLine((offsets[rowIndex] ?? 0) + anchor.offset, maxScrollTop)
59
+ }
60
+
61
+ export function clampLine(line: number, maxScrollTop: number): number {
62
+ return Math.max(0, Math.min(maxScrollTop, Math.floor(line)))
63
+ }
64
+
65
+ export type TranscriptTailSelection<T> = {
66
+ rows: T[]
67
+ hiddenCount: number
68
+ }
69
+
70
+ export type TranscriptWindowSelection<T> = {
71
+ rows: T[]
72
+ hiddenBefore: number
73
+ hiddenAfter: number
74
+ totalLines: number
75
+ maxScrollOffset: number
76
+ }
77
+
78
+ export function selectTailRowsForViewport<T>(
79
+ rows: T[],
80
+ maxLines: number,
81
+ estimateHeight: (row: T) => number,
82
+ ): TranscriptTailSelection<T> {
83
+ if (rows.length === 0) return { rows: [], hiddenCount: 0 }
84
+
85
+ const budget = Math.max(1, Math.floor(maxLines))
86
+ let used = 0
87
+ let start = rows.length - 1
88
+
89
+ for (; start >= 0; start -= 1) {
90
+ const row = rows[start]
91
+ if (!row) break
92
+ const height = Math.max(1, estimateHeight(row))
93
+ if (used > 0 && used + height > budget) break
94
+ used += height
95
+ }
96
+
97
+ const firstVisible = Math.max(0, start + 1)
98
+ return {
99
+ rows: rows.slice(firstVisible),
100
+ hiddenCount: firstVisible,
101
+ }
102
+ }
103
+
104
+ export function selectRowsForScrollOffset<T>(
105
+ rows: T[],
106
+ maxLines: number,
107
+ scrollOffsetFromTail: number,
108
+ estimateHeight: (row: T) => number,
109
+ ): TranscriptWindowSelection<T> {
110
+ if (rows.length === 0) {
111
+ return { rows: [], hiddenBefore: 0, hiddenAfter: 0, totalLines: 0, maxScrollOffset: 0 }
112
+ }
113
+
114
+ const budget = Math.max(1, Math.floor(maxLines))
115
+ const heights = rows.map(row => Math.max(1, estimateHeight(row)))
116
+ const offsets = buildLineOffsets(heights)
117
+ const totalLines = offsets[offsets.length - 1] ?? 0
118
+ const maxScrollOffset = Math.max(0, totalLines - budget)
119
+ const scrollOffset = clampLine(scrollOffsetFromTail, maxScrollOffset)
120
+ const startLine = Math.max(0, totalLines - budget - scrollOffset)
121
+
122
+ return selectRowsForLineWindow(rows, offsets, budget, startLine, totalLines, maxScrollOffset)
123
+ }
124
+
125
+ export function selectRowsForScrollTop<T>(
126
+ rows: T[],
127
+ maxLines: number,
128
+ scrollTopLine: number,
129
+ estimateHeight: (row: T) => number,
130
+ ): TranscriptWindowSelection<T> {
131
+ if (rows.length === 0) {
132
+ return { rows: [], hiddenBefore: 0, hiddenAfter: 0, totalLines: 0, maxScrollOffset: 0 }
133
+ }
134
+
135
+ const budget = Math.max(1, Math.floor(maxLines))
136
+ const heights = rows.map(row => Math.max(1, estimateHeight(row)))
137
+ const offsets = buildLineOffsets(heights)
138
+ const totalLines = offsets[offsets.length - 1] ?? 0
139
+ const maxScrollOffset = Math.max(0, totalLines - budget)
140
+ const startLine = clampLine(scrollTopLine, maxScrollOffset)
141
+
142
+ return selectRowsForLineWindow(rows, offsets, budget, startLine, totalLines, maxScrollOffset)
143
+ }
144
+
145
+ export function promptScrollTopForPageUp(
146
+ rows: MessageRow[],
147
+ offsets: number[],
148
+ scrollTopLine: number,
149
+ maxScrollTop: number,
150
+ followTail: boolean,
151
+ ): number {
152
+ const promptStarts = promptScrollTops(rows, offsets)
153
+ if (promptStarts.length === 0) return clampLine(scrollTopLine, maxScrollTop)
154
+ if (followTail) return clampLine(promptStarts[promptStarts.length - 1]!, maxScrollTop)
155
+
156
+ const currentLine = clampLine(scrollTopLine, maxScrollTop)
157
+ for (let index = promptStarts.length - 1; index >= 0; index -= 1) {
158
+ const line = promptStarts[index]!
159
+ if (line < currentLine) return clampLine(line, maxScrollTop)
160
+ }
161
+ return 0
162
+ }
163
+
164
+ export function promptScrollTopForPageDown(
165
+ rows: MessageRow[],
166
+ offsets: number[],
167
+ scrollTopLine: number,
168
+ maxScrollTop: number,
169
+ ): number {
170
+ const promptStarts = promptScrollTops(rows, offsets)
171
+ if (promptStarts.length === 0) return clampLine(scrollTopLine, maxScrollTop)
172
+
173
+ const currentLine = clampLine(scrollTopLine, maxScrollTop)
174
+ const next = promptStarts.find(line => line > currentLine)
175
+ return next === undefined ? maxScrollTop : clampLine(next, maxScrollTop)
176
+ }
177
+
178
+ function promptScrollTops(rows: MessageRow[], offsets: number[]): number[] {
179
+ const starts: number[] = []
180
+ for (let index = 0; index < rows.length; index += 1) {
181
+ if (rows[index]?.role === 'user') starts.push(offsets[index] ?? 0)
182
+ }
183
+ return starts
184
+ }
185
+
186
+ function selectRowsForLineWindow<T>(
187
+ rows: T[],
188
+ offsets: number[],
189
+ budget: number,
190
+ startLine: number,
191
+ totalLines: number,
192
+ maxScrollOffset: number,
193
+ ): TranscriptWindowSelection<T> {
194
+ const endLine = Math.min(totalLines, startLine + budget)
195
+
196
+ const startIndex = Math.min(rows.length - 1, findRowIndexAtLine(offsets, startLine))
197
+ const lastVisibleLine = Math.max(startLine, endLine - 1)
198
+ const endIndex = endLine >= totalLines
199
+ ? rows.length
200
+ : Math.min(rows.length, findRowIndexAtLine(offsets, lastVisibleLine) + 1)
201
+
202
+ return {
203
+ rows: rows.slice(startIndex, endIndex),
204
+ hiddenBefore: startIndex,
205
+ hiddenAfter: rows.length - endIndex,
206
+ totalLines,
207
+ maxScrollOffset,
208
+ }
209
+ }
210
+
211
+ export function estimateMessageRowHeight(row: MessageRow, columns = 80): number {
212
+ const contentWidth = Math.max(20, columns - 8)
213
+ switch (row.role) {
214
+ case 'user':
215
+ return 1 + wrappedLineCount(row.content, contentWidth)
216
+ case 'assistant':
217
+ return 1 + wrappedLineCount([row.content, row.liveTail ?? ''].filter(Boolean).join('\n'), contentWidth)
218
+ case 'thinking':
219
+ return row.expanded
220
+ ? 3 + wrappedLineCount([row.content, row.liveTail ?? ''].filter(Boolean).join('\n'), contentWidth)
221
+ : 3 + wrappedLineCount(reasoningPreview(row), contentWidth)
222
+ case 'tool_use':
223
+ return 3 + (row.input ? wrappedLineCount(row.input, contentWidth) : 0)
224
+ case 'tool_result':
225
+ return hidesSuccessfulToolResultContent(row.name, row.isError)
226
+ ? 3
227
+ : 3 + wrappedLineCount(row.content, contentWidth)
228
+ case 'note':
229
+ return 1 + wrappedLineCount(row.content, contentWidth)
230
+ case 'progress':
231
+ return 4
232
+ }
233
+ }
234
+
235
+ function reasoningPreview(row: Extract<MessageRow, { role: 'thinking' }>): string {
236
+ const normalized = [row.content, row.liveTail ?? ''].filter(Boolean).join('').replace(/\s+/g, ' ').trim()
237
+ if (!normalized) return 'thinking...'
238
+ if (normalized.length <= 120) return normalized
239
+ return `${normalized.slice(0, 117)}...`
240
+ }
241
+
242
+ function wrappedLineCount(text: string, width: number): number {
243
+ if (!text) return 1
244
+ return text
245
+ .split(/\r?\n/)
246
+ .reduce((total, line) => total + Math.max(1, Math.ceil(line.length / width)), 0)
247
+ }
@@ -0,0 +1,61 @@
1
+ import React from 'react'
2
+ import { Box, Text, useApp } from 'ink'
3
+ import { Surface } from '../ui/Surface.js'
4
+ import { Select } from '../ui/Select.js'
5
+ import { theme } from '../ui/theme.js'
6
+ import type { FactoryResetPlan } from '../storage/factoryReset.js'
7
+
8
+ export const ResetConfirmView: React.FC<{
9
+ plan: FactoryResetPlan
10
+ onDone: (confirmed: boolean) => void
11
+ }> = ({ plan, onDone }) => {
12
+ const { exit } = useApp()
13
+ const finish = (confirmed: boolean) => {
14
+ onDone(confirmed)
15
+ exit()
16
+ }
17
+
18
+ return (
19
+ <Surface title="reset ethagent?" subtitle="are you sure? this only affects this machine." footer="enter select · esc cancel">
20
+ <Box flexDirection="column">
21
+ <Section title="will delete" lines={[
22
+ localDataLine(plan.deletePaths.length),
23
+ 'identity metadata, markdown vaults, sessions, prompt history',
24
+ 'rewind history, permissions, credentials',
25
+ ]} />
26
+ <Section title="will keep" lines={[
27
+ 'installed local LLM assets',
28
+ ...(plan.preservedPaths.length > 0 ? [`${plan.preservedPaths.length} local model path${plan.preservedPaths.length === 1 ? '' : 's'}`] : ['no local model assets found']),
29
+ ]} />
30
+ <Section title="not touched" lines={[
31
+ 'onchain agent tokens',
32
+ 'IPFS-pinned snapshots and public metadata',
33
+ ]} />
34
+ </Box>
35
+ <Box marginTop={1}>
36
+ <Select<'confirm' | 'cancel'>
37
+ options={[
38
+ { value: 'confirm', label: 'reset local data', hint: 'delete local ethagent data now' },
39
+ { value: 'cancel', label: 'cancel', hint: 'leave local data unchanged' },
40
+ ]}
41
+ onSubmit={choice => finish(choice === 'confirm')}
42
+ onCancel={() => finish(false)}
43
+ />
44
+ </Box>
45
+ </Surface>
46
+ )
47
+ }
48
+
49
+ const Section: React.FC<{ title: string; lines: string[] }> = ({ title, lines }) => (
50
+ <Box flexDirection="column" marginBottom={1}>
51
+ <Text color={theme.accentMint}>{title}</Text>
52
+ {lines.map(line => (
53
+ <Text key={line} color={theme.textSubtle}>- {line}</Text>
54
+ ))}
55
+ </Box>
56
+ )
57
+
58
+ function localDataLine(count: number): string {
59
+ if (count === 0) return 'no local ethagent data found'
60
+ return `${count} local path${count === 1 ? '' : 's'} under ~/.ethagent`
61
+ }
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env node
2
+ import React, { useEffect, useState } from 'react'
3
+ import { render, Box, Text, useApp } from 'ink'
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { theme } from '../ui/theme.js'
8
+ import { FirstRun } from '../app/FirstRun.js'
9
+ import { ChatScreen } from '../chat/ChatScreen.js'
10
+ import { KeybindingProvider } from '../app/keybindings/KeybindingProvider.js'
11
+ import { AppInputProvider, useAppInput } from '../app/input/AppInputProvider.js'
12
+ import { loadConfig, type EthagentConfig } from '../storage/config.js'
13
+ import { runResetCommand } from './reset.js'
14
+ import { runPreviewCommand } from './preview.js'
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
17
+
18
+ function readVersion(): string {
19
+ try {
20
+ const pkgPath = path.resolve(__dirname, '..', '..', 'package.json')
21
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version?: string }
22
+ return pkg.version ?? '0.0.0'
23
+ } catch {
24
+ return '0.0.0'
25
+ }
26
+ }
27
+
28
+ function printHelp(): void {
29
+ const lines = [
30
+ 'ethagent: privacy-first AI agent with a portable Ethereum identity',
31
+ '',
32
+ 'usage:',
33
+ ' ethagent start the agent (first run triggers setup)',
34
+ ' ethagent preview show the brand splash and exit',
35
+ ' ethagent reset factory reset local data (local LLMs kept)',
36
+ ' ethagent reset --yes run reset without the confirm prompt',
37
+ ' ethagent --version print version',
38
+ ' ethagent --help print this help',
39
+ '',
40
+ 'inside the agent, type /help for slash commands.',
41
+ ]
42
+ for (const line of lines) process.stdout.write(line + '\n')
43
+ }
44
+
45
+ type AppPhase =
46
+ | { kind: 'loading' }
47
+ | { kind: 'setup' }
48
+ | { kind: 'ready'; config: EthagentConfig }
49
+ | { kind: 'cancelled' }
50
+ | { kind: 'error'; message: string }
51
+
52
+ const AppRoot: React.FC<{ setExitCode: (code: number) => void }> = ({ setExitCode }) => {
53
+ const [phase, setPhase] = useState<AppPhase>({ kind: 'loading' })
54
+ const { exit } = useApp()
55
+
56
+ useEffect(() => {
57
+ if (phase.kind !== 'loading') return
58
+ let cancelled = false
59
+ loadConfig()
60
+ .then(config => {
61
+ if (cancelled) return
62
+ setPhase(config ? { kind: 'ready', config } : { kind: 'setup' })
63
+ })
64
+ .catch((err: unknown) => {
65
+ if (cancelled) return
66
+ setPhase({ kind: 'error', message: (err as Error).message })
67
+ })
68
+ return () => { cancelled = true }
69
+ }, [phase])
70
+
71
+ useEffect(() => {
72
+ if (phase.kind === 'cancelled') {
73
+ setExitCode(1)
74
+ const t = setTimeout(() => exit(), 10)
75
+ return () => clearTimeout(t)
76
+ }
77
+ if (phase.kind === 'error') {
78
+ setExitCode(1)
79
+ const t = setTimeout(() => exit(), 10)
80
+ return () => clearTimeout(t)
81
+ }
82
+ return undefined
83
+ }, [phase, exit, setExitCode])
84
+
85
+ useAppInput((input, key) => {
86
+ if (phase.kind === 'ready') return
87
+ if (key.ctrl && (input === 'c' || input === 'd')) exit()
88
+ })
89
+
90
+ if (phase.kind === 'loading') {
91
+ return (
92
+ <Box padding={1}>
93
+ <Text color={theme.dim}>Preparing session...</Text>
94
+ </Box>
95
+ )
96
+ }
97
+ if (phase.kind === 'setup') {
98
+ return (
99
+ <FirstRun
100
+ onComplete={config => setPhase({ kind: 'ready', config })}
101
+ onCancel={() => setPhase({ kind: 'cancelled' })}
102
+ />
103
+ )
104
+ }
105
+ if (phase.kind === 'cancelled') {
106
+ return (
107
+ <Box padding={1}>
108
+ <Text color={theme.dim}>Setup cancelled.</Text>
109
+ </Box>
110
+ )
111
+ }
112
+ if (phase.kind === 'error') {
113
+ return (
114
+ <Box padding={1}>
115
+ <Text color="#e87070">Error: {phase.message}</Text>
116
+ </Box>
117
+ )
118
+ }
119
+ return (
120
+ <ChatScreen
121
+ config={phase.config}
122
+ onReplaceConfig={next => setPhase({ kind: 'ready', config: next })}
123
+ />
124
+ )
125
+ }
126
+
127
+ async function runDefault(): Promise<number> {
128
+ let exitCode = 0
129
+ const instance = render(
130
+ <AppInputProvider>
131
+ <KeybindingProvider>
132
+ <AppRoot setExitCode={code => { exitCode = code }} />
133
+ </KeybindingProvider>
134
+ </AppInputProvider>,
135
+ {
136
+ exitOnCtrlC: false,
137
+ },
138
+ )
139
+ try {
140
+ await instance.waitUntilExit()
141
+ } catch {
142
+ exitCode = 1
143
+ }
144
+ return exitCode
145
+ }
146
+
147
+ async function main(): Promise<number> {
148
+ const argv = process.argv.slice(2)
149
+ const [cmd, ...rest] = argv
150
+
151
+ if (!cmd) return runDefault()
152
+ if (cmd === '--version' || cmd === '-v') {
153
+ process.stdout.write(`ethagent ${readVersion()}\n`)
154
+ return 0
155
+ }
156
+ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
157
+ printHelp()
158
+ return 0
159
+ }
160
+
161
+ switch (cmd) {
162
+ case 'preview':
163
+ return runPreviewCommand()
164
+ case 'reset':
165
+ return runResetCommand(rest)
166
+ default:
167
+ process.stderr.write(`unknown command: ${cmd}\nrun 'ethagent --help' for usage\n`)
168
+ return 2
169
+ }
170
+ }
171
+
172
+ main()
173
+ .then(code => process.exit(code))
174
+ .catch(err => {
175
+ process.stderr.write(`${(err as Error).message}\n`)
176
+ process.exit(1)
177
+ })