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,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,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
|
+
}
|
package/src/cli/main.tsx
ADDED
|
@@ -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
|
+
})
|