ethagent 0.2.1 → 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 +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 +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,247 @@
1
+ import {
2
+ cursorColumn,
3
+ cursorOnLastLineAtColumn,
4
+ getVisualLines,
5
+ moveVerticalVisualCursor,
6
+ moveVerticalCursor,
7
+ normalizeCursor,
8
+ } from './textCursor.js'
9
+
10
+ export type ChatBuffer = {
11
+ value: string
12
+ cursor: number
13
+ }
14
+
15
+ export type FileMentionToken = {
16
+ start: number
17
+ end: number
18
+ query: string
19
+ }
20
+
21
+ export type HistoryPreviewState = {
22
+ historyIndex: number | null
23
+ historyPreviewActive: boolean
24
+ draftBuffer: ChatBuffer
25
+ preferredColumn: number | null
26
+ }
27
+
28
+ export type VerticalMoveResult =
29
+ | { kind: 'moved'; cursor: number; preferredColumn: number }
30
+ | { kind: 'boundary-top'; cursor: number; preferredColumn: number }
31
+ | { kind: 'boundary-bottom'; cursor: number; preferredColumn: number }
32
+
33
+ export function emptyBuffer(): ChatBuffer {
34
+ return { value: '', cursor: 0 }
35
+ }
36
+
37
+ export function bufferFromValue(value: string, cursor = value.length): ChatBuffer {
38
+ const next = normalizeCursor(value, cursor)
39
+ return { value: next.value, cursor: next.offset }
40
+ }
41
+
42
+ export function canNavigateHistory(
43
+ _buffer: ChatBuffer,
44
+ historyLength: number,
45
+ _historyIndex: number | null,
46
+ _historyPreviewActive: boolean,
47
+ ): boolean {
48
+ return historyLength > 0
49
+ }
50
+
51
+ export function beginHistoryPreview(
52
+ buffer: ChatBuffer,
53
+ history: string[],
54
+ direction: 1 | -1,
55
+ _preferredColumn: number | null,
56
+ ): { preview: HistoryPreviewState; buffer: ChatBuffer } | null {
57
+ if (history.length === 0) return null
58
+ const nextIndex = direction === -1 ? history.length - 1 : 0
59
+ const chosen = history[nextIndex] ?? ''
60
+ return {
61
+ preview: {
62
+ historyIndex: nextIndex,
63
+ historyPreviewActive: true,
64
+ draftBuffer: buffer,
65
+ preferredColumn: null,
66
+ },
67
+ buffer: bufferFromValue(chosen),
68
+ }
69
+ }
70
+
71
+ export function moveThroughHistory(
72
+ history: string[],
73
+ historyIndex: number,
74
+ direction: 1 | -1,
75
+ draftBuffer: ChatBuffer,
76
+ preferredColumn: number | null,
77
+ ): { preview: HistoryPreviewState; buffer: ChatBuffer } {
78
+ const next = historyIndex + direction
79
+ if (next < 0) {
80
+ const chosen = history[0] ?? ''
81
+ return {
82
+ preview: {
83
+ historyIndex: 0,
84
+ historyPreviewActive: true,
85
+ draftBuffer,
86
+ preferredColumn: null,
87
+ },
88
+ buffer: bufferFromValue(chosen),
89
+ }
90
+ }
91
+ if (next >= history.length) {
92
+ return {
93
+ preview: {
94
+ historyIndex: null,
95
+ historyPreviewActive: false,
96
+ draftBuffer,
97
+ preferredColumn: null,
98
+ },
99
+ buffer: bufferFromValue(draftBuffer.value),
100
+ }
101
+ }
102
+ const chosen = history[next] ?? ''
103
+ return {
104
+ preview: {
105
+ historyIndex: next,
106
+ historyPreviewActive: true,
107
+ draftBuffer,
108
+ preferredColumn: null,
109
+ },
110
+ buffer: bufferFromValue(chosen),
111
+ }
112
+ }
113
+
114
+ export function exitHistoryPreview(
115
+ buffer: ChatBuffer,
116
+ ): { historyIndex: null; historyPreviewActive: false; draftBuffer: ChatBuffer; preferredColumn: null } {
117
+ return {
118
+ historyIndex: null,
119
+ historyPreviewActive: false,
120
+ draftBuffer: buffer,
121
+ preferredColumn: null,
122
+ }
123
+ }
124
+
125
+ export function bufferFromLastLine(value: string, preferredColumn: number | null = null): ChatBuffer {
126
+ const next = cursorOnLastLineAtColumn(value, preferredColumn ?? 0)
127
+ return { value: next.value, cursor: next.offset }
128
+ }
129
+
130
+ export function deleteToLineStart(buffer: ChatBuffer, wrapWidth: number): ChatBuffer {
131
+ const normalized = normalizeCursor(buffer.value, buffer.cursor)
132
+ const { value, offset } = normalized
133
+ if (offset <= 0) return { value, cursor: 0 }
134
+
135
+ if (value[offset - 1] === '\n') {
136
+ return {
137
+ value: value.slice(0, offset - 1) + value.slice(offset),
138
+ cursor: offset - 1,
139
+ }
140
+ }
141
+
142
+ const lineStart = visualLineStart(value, offset, wrapWidth)
143
+ return {
144
+ value: value.slice(0, lineStart) + value.slice(offset),
145
+ cursor: lineStart,
146
+ }
147
+ }
148
+
149
+ export function moveVertical(
150
+ text: string,
151
+ cursor: number,
152
+ direction: 1 | -1,
153
+ preferredColumn: number | null = null,
154
+ ): VerticalMoveResult {
155
+ const normalized = normalizeCursor(text, cursor)
156
+ const nextColumn = preferredColumn ?? cursorColumn(normalized.value, normalized.offset)
157
+ const moved = moveVerticalCursor(normalized, direction, nextColumn)
158
+ if (moved.moved) {
159
+ return { kind: 'moved', cursor: moved.cursor.offset, preferredColumn: nextColumn }
160
+ }
161
+ return {
162
+ kind: direction === -1 ? 'boundary-top' : 'boundary-bottom',
163
+ cursor: normalized.offset,
164
+ preferredColumn: nextColumn,
165
+ }
166
+ }
167
+
168
+ export function moveVerticalVisual(
169
+ text: string,
170
+ cursor: number,
171
+ direction: 1 | -1,
172
+ wrapWidth: number,
173
+ preferredColumn: number | null = null,
174
+ ): VerticalMoveResult {
175
+ const normalized = normalizeCursor(text, cursor)
176
+ const position = visualPosition(normalized.value, normalized.offset, wrapWidth)
177
+ const nextColumn = preferredColumn ?? position.column
178
+ const moved = moveVerticalVisualCursor(normalized, direction, wrapWidth, nextColumn)
179
+ if (moved.moved) {
180
+ return { kind: 'moved', cursor: moved.cursor.offset, preferredColumn: nextColumn }
181
+ }
182
+ return {
183
+ kind: direction === -1 ? 'boundary-top' : 'boundary-bottom',
184
+ cursor: normalized.offset,
185
+ preferredColumn: nextColumn,
186
+ }
187
+ }
188
+
189
+ export function detectActiveFileMention(value: string, cursor: number): FileMentionToken | undefined {
190
+ const safeCursor = Math.max(0, Math.min(cursor, value.length))
191
+ const left = value.slice(0, safeCursor)
192
+ const atIndex = left.lastIndexOf('@')
193
+ if (atIndex === -1) return undefined
194
+ const before = atIndex === 0 ? '' : value[atIndex - 1]
195
+ if (before && !/\s|\(|\[|\{/.test(before)) return undefined
196
+ const between = value.slice(atIndex + 1, safeCursor)
197
+ if (/\s/.test(between)) return undefined
198
+ let end = safeCursor
199
+ while (end < value.length && !/\s/.test(value[end]!)) end += 1
200
+ return { start: atIndex, end, query: between }
201
+ }
202
+
203
+ export function replaceActiveFileMention(buffer: ChatBuffer, replacementPath: string): ChatBuffer {
204
+ const mention = detectActiveFileMention(buffer.value, buffer.cursor)
205
+ if (!mention) return buffer
206
+ const replacement = `@${replacementPath}`
207
+ const value = buffer.value.slice(0, mention.start) + replacement + buffer.value.slice(mention.end)
208
+ return { value, cursor: mention.start + replacement.length }
209
+ }
210
+
211
+ function visualPosition(value: string, offset: number, wrapWidth: number): { column: number } {
212
+ const lines = getVisualLines(value, wrapWidth)
213
+ for (const entry of lines) {
214
+ if (entry.start === entry.end && offset === entry.start) {
215
+ return { column: 0 }
216
+ }
217
+ if (offset >= entry.start && offset < entry.end) {
218
+ return { column: offset - entry.start }
219
+ }
220
+ }
221
+
222
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
223
+ const entry = lines[index]!
224
+ if (offset === entry.end) {
225
+ return { column: entry.end - entry.start }
226
+ }
227
+ }
228
+
229
+ return { column: 0 }
230
+ }
231
+
232
+ function visualLineStart(value: string, offset: number, wrapWidth: number): number {
233
+ const lines = getVisualLines(value, wrapWidth)
234
+ for (let index = 0; index < lines.length; index += 1) {
235
+ const entry = lines[index]!
236
+ if (entry.start === entry.end && offset === entry.start) return entry.start
237
+ if (offset > entry.start && offset <= entry.end) return entry.start
238
+ if (offset === entry.start) {
239
+ const previous = lines[index - 1]
240
+ if (previous && previous.end === entry.start) return previous.start
241
+ return entry.start
242
+ }
243
+ }
244
+
245
+ const last = lines[lines.length - 1]
246
+ return last ? last.start : 0
247
+ }
@@ -0,0 +1,49 @@
1
+ export const LARGE_PASTE_THRESHOLD = 800
2
+
3
+ export type PastedTextRef = {
4
+ id: number
5
+ content: string
6
+ }
7
+
8
+ export function normalizePastedText(text: string): string {
9
+ return text
10
+ .replaceAll('\x1b[200~', '')
11
+ .replaceAll('\x1b[201~', '')
12
+ .replace(/(^|\n)(?:\[?200~|\[?201~)(?=\n|$)/g, '$1')
13
+ .replace(/\r\n/g, '\n')
14
+ .replace(/\r/g, '\n')
15
+ }
16
+
17
+ export function shouldCollapsePastedText(
18
+ text: string,
19
+ maxInlineLines: number,
20
+ threshold = LARGE_PASTE_THRESHOLD,
21
+ ): boolean {
22
+ return text.length > threshold || countPastedTextLineBreaks(text) > maxInlineLines
23
+ }
24
+
25
+ export function countPastedTextLineBreaks(text: string): number {
26
+ return (text.match(/\n/g) ?? []).length
27
+ }
28
+
29
+ export function formatPastedTextRef(id: number, chars: number): string {
30
+ return `[Pasted Content ${chars} chars #${id}]`
31
+ }
32
+
33
+ export function expandPastedTextRefs(input: string, refs: Map<number, PastedTextRef>): string {
34
+ const matches = [...input.matchAll(/\[Pasted Content (\d+) chars #(\d+)\]/g)]
35
+ let expanded = input
36
+
37
+ for (let index = matches.length - 1; index >= 0; index -= 1) {
38
+ const match = matches[index]!
39
+ const id = Number(match[2])
40
+ const ref = refs.get(id)
41
+ if (!ref) continue
42
+ expanded =
43
+ expanded.slice(0, match.index) +
44
+ ref.content +
45
+ expanded.slice((match.index ?? 0) + match[0].length)
46
+ }
47
+
48
+ return expanded
49
+ }
@@ -0,0 +1,187 @@
1
+ import { systemMessage } from '../utils/messages.js'
2
+ import { buildSystemPrompt } from '../runtime/systemPrompt.js'
3
+ import type { Message } from '../providers/contracts.js'
4
+ import { isLocalProvider } from '../providers/registry.js'
5
+ import type { SessionMode } from '../runtime/sessionMode.js'
6
+ import type { EthagentConfig } from '../storage/config.js'
7
+ import {
8
+ latestUserMessageCorrectsToolState,
9
+ sessionMessagesToProviderMessages,
10
+ TOOL_CORRECTION_CONTEXT_MESSAGE,
11
+ type SessionMessage,
12
+ } from '../storage/sessions.js'
13
+ import type { MessageRow } from './MessageList.js'
14
+ import { hidesSuccessfulToolResultContent } from './toolResultDisplay.js'
15
+
16
+ export type TurnCheckpoint = {
17
+ sessionId: string
18
+ turnId: string
19
+ messageRole: 'user'
20
+ promptSnippet: string
21
+ checkpointLabel: string
22
+ }
23
+
24
+ export function buildBaseMessages(
25
+ sessionMessages: SessionMessage[],
26
+ config: EthagentConfig,
27
+ hasTools: boolean,
28
+ cwd: string,
29
+ mode: SessionMode = 'chat',
30
+ options: { preserveTurnId?: string; compactToolHistory?: boolean } = {},
31
+ ): Message[] {
32
+ const compactToolHistory = options.compactToolHistory ?? isLocalProvider(config.provider)
33
+ const correctionContext = latestUserMessageCorrectsToolState(sessionMessages)
34
+ ? [systemMessage(TOOL_CORRECTION_CONTEXT_MESSAGE)]
35
+ : []
36
+ return [
37
+ systemMessage(buildSystemPrompt({
38
+ cwd,
39
+ model: config.model,
40
+ provider: config.provider,
41
+ hasTools,
42
+ hasIdentity: Boolean(config.identity),
43
+ mode,
44
+ })),
45
+ ...correctionContext,
46
+ ...sessionMessagesToProviderMessages(sessionMessages, {
47
+ compactToolHistory,
48
+ preserveTurnId: options.preserveTurnId,
49
+ }),
50
+ ]
51
+ }
52
+
53
+ export function sessionMessagesToRows(messages: SessionMessage[], nextRowId: () => string): MessageRow[] {
54
+ const restored: MessageRow[] = []
55
+ for (const msg of messages) {
56
+ if (msg.role === 'user') restored.push({ role: 'user', id: nextRowId(), content: msg.content })
57
+ else if (msg.role === 'assistant') restored.push({ role: 'assistant', id: nextRowId(), content: msg.content })
58
+ else if (msg.role === 'tool_use') {
59
+ restored.push({
60
+ role: 'tool_use',
61
+ id: nextRowId(),
62
+ name: msg.name,
63
+ summary: msg.name,
64
+ input: summarizeToolInput(msg.input),
65
+ })
66
+ } else if (msg.role === 'tool_result') {
67
+ restored.push({
68
+ role: 'tool_result',
69
+ id: nextRowId(),
70
+ name: msg.name,
71
+ summary: msg.isError ? `${msg.name} failed` : `${msg.name} completed`,
72
+ content: toolResultContentForRow(msg.name, msg.content, msg.isError),
73
+ isError: msg.isError,
74
+ })
75
+ }
76
+ }
77
+ return restored
78
+ }
79
+
80
+ export function summarizeToolInput(input: Record<string, unknown>): string {
81
+ try {
82
+ const text = JSON.stringify(input)
83
+ if (text.length <= 160) return text
84
+ return `${text.slice(0, 157)}...`
85
+ } catch {
86
+ return '[unserializable input]'
87
+ }
88
+ }
89
+
90
+ export function truncateForRow(text: string, max = 1200): string {
91
+ if (text.length <= max) return text
92
+ return `${text.slice(0, max - 3)}...`
93
+ }
94
+
95
+ export function toolResultContentForRow(name: string, content: string, isError?: boolean): string {
96
+ return hidesSuccessfulToolResultContent(name, isError) ? '' : truncateForRow(content)
97
+ }
98
+
99
+ export function splitStreamingContent(text: string): { committed: string; liveTail: string } {
100
+ if (!text) return { committed: '', liveTail: '' }
101
+ const boundary = findStableBoundary(text)
102
+ if (boundary <= 0 || boundary >= text.length) {
103
+ return { committed: boundary >= text.length ? text : '', liveTail: boundary >= text.length ? '' : text }
104
+ }
105
+ return { committed: text.slice(0, boundary), liveTail: text.slice(boundary) }
106
+ }
107
+
108
+ export function findStableBoundary(text: string): number {
109
+ let lastStructural = 0
110
+ let lastSentence = 0
111
+ let inFence = false
112
+ let offset = 0
113
+ const lines = text.match(/[^\n]*\n?|$/g)?.filter(Boolean) ?? []
114
+
115
+ for (const lineWithEnding of lines) {
116
+ const line = lineWithEnding.endsWith('\n') ? lineWithEnding.slice(0, -1) : lineWithEnding
117
+ const trimmed = line.trim()
118
+ const nextOffset = offset + lineWithEnding.length
119
+
120
+ if (/^```/.test(trimmed)) {
121
+ inFence = !inFence
122
+ if (!inFence) lastStructural = nextOffset
123
+ offset = nextOffset
124
+ continue
125
+ }
126
+
127
+ if (!inFence) {
128
+ if (!trimmed) {
129
+ lastStructural = nextOffset
130
+ } else if (/^(#{1,3}\s|>\s?|[-*+]\s|\d+\.\s)/.test(trimmed)) {
131
+ lastStructural = nextOffset
132
+ }
133
+
134
+ let match: RegExpExecArray | null
135
+ const sentencePattern = /[.!?]["')\]]?(?=\s|$)/g
136
+ while ((match = sentencePattern.exec(line)) !== null) {
137
+ lastSentence = offset + match.index + match[0].length
138
+ }
139
+ }
140
+
141
+ offset = nextOffset
142
+ }
143
+
144
+ if (inFence) return lastStructural
145
+ if (lastStructural > 0) return lastStructural
146
+ if (text.length > 220 && lastSentence > 0) return lastSentence
147
+ if (text.length > 320) {
148
+ const fallbackSpace = text.lastIndexOf(' ', Math.max(160, text.length - 80))
149
+ if (fallbackSpace > 80) return fallbackSpace + 1
150
+ }
151
+ return 0
152
+ }
153
+
154
+ export function formatBytes(bytes: number): string {
155
+ if (bytes <= 0) return '0B'
156
+ const gb = bytes / (1024 * 1024 * 1024)
157
+ if (gb >= 1) return `${gb.toFixed(2)}GB`
158
+ const mb = bytes / (1024 * 1024)
159
+ if (mb >= 1) return `${mb.toFixed(0)}MB`
160
+ const kb = bytes / 1024
161
+ return `${kb.toFixed(0)}KB`
162
+ }
163
+
164
+ export function createTurnCheckpoint(sessionId: string, userText: string): TurnCheckpoint {
165
+ const promptSnippet = summarizePrompt(userText)
166
+ return {
167
+ sessionId,
168
+ turnId: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
169
+ messageRole: 'user',
170
+ promptSnippet,
171
+ checkpointLabel: promptSnippet || 'Untitled checkpoint',
172
+ }
173
+ }
174
+
175
+ export function summarizePrompt(text: string): string {
176
+ const normalized = text.replace(/\s+/g, ' ').trim()
177
+ if (!normalized) return ''
178
+ return normalized.length <= 96 ? normalized : `${normalized.slice(0, 93)}...`
179
+ }
180
+
181
+ export function buildDeleteCommand(targetPath: string): string {
182
+ const escaped = targetPath.replace(/"/g, '""')
183
+ if (process.platform === 'win32') {
184
+ return `del /f /q "${escaped}"`
185
+ }
186
+ return `rm -f -- "${targetPath.replace(/"/g, '\\"')}"`
187
+ }
@@ -0,0 +1,142 @@
1
+ import { localProviderBaseUrlFor, type EthagentConfig } from '../storage/config.js'
2
+ import type { SessionMetadata, SessionMessage } from '../storage/sessions.js'
3
+ import type { SessionMode } from '../runtime/sessionMode.js'
4
+ import type { MessageRow } from './MessageList.js'
5
+ import type { ModelPickerSelection } from '../models/ModelPicker.js'
6
+ import { sessionMessagesToRows } from './chatScreenUtils.js'
7
+ import { formatModelDisplayName } from '../models/modelDisplay.js'
8
+
9
+ export type ModelSelectionResolution =
10
+ | { kind: 'noop' }
11
+ | {
12
+ kind: 'switch'
13
+ config: EthagentConfig
14
+ notice: string
15
+ tone: 'info' | 'dim'
16
+ }
17
+
18
+ export type ResumedSessionState = {
19
+ cwd: string
20
+ mode: SessionMode
21
+ rows: MessageRow[]
22
+ promptHistory: string[]
23
+ statusStartedAt: number
24
+ }
25
+
26
+ const MAX_PROMPT_HISTORY = 500
27
+
28
+ export function resolveModelSelection(
29
+ selection: ModelPickerSelection,
30
+ currentConfig: EthagentConfig,
31
+ ): ModelSelectionResolution {
32
+ if (selection.kind === 'llamacpp') {
33
+ const baseUrl = localProviderBaseUrlFor(
34
+ 'llamacpp',
35
+ currentConfig.provider === 'llamacpp' ? currentConfig.baseUrl : undefined,
36
+ )
37
+ if (
38
+ selection.model === currentConfig.model
39
+ && currentConfig.provider === 'llamacpp'
40
+ && currentConfig.baseUrl === baseUrl
41
+ ) {
42
+ return { kind: 'noop' }
43
+ }
44
+ return {
45
+ kind: 'switch',
46
+ config: {
47
+ ...currentConfig,
48
+ provider: 'llamacpp',
49
+ model: selection.model,
50
+ baseUrl,
51
+ },
52
+ notice: `local Hugging Face model ready. now using ${formatModelDisplayName('llamacpp', selection.model, { maxLength: 64 })}.`,
53
+ tone: 'info',
54
+ }
55
+ }
56
+
57
+ const nextProvider = selection.provider
58
+ const nextBaseUrl =
59
+ nextProvider === 'openai' && currentConfig.provider === 'openai'
60
+ ? currentConfig.baseUrl
61
+ : undefined
62
+ const nextConfig: EthagentConfig = {
63
+ ...currentConfig,
64
+ provider: nextProvider,
65
+ model: selection.model,
66
+ baseUrl: nextBaseUrl,
67
+ }
68
+
69
+ return {
70
+ kind: 'switch',
71
+ config: nextConfig,
72
+ notice: `${selection.keyJustSet ? `${selection.provider} key saved.` : `${selection.provider} ready.`} now using ${nextConfig.provider} · ${formatModelDisplayName(nextConfig.provider, nextConfig.model, { maxLength: 64 })}.`,
73
+ tone: 'dim',
74
+ }
75
+ }
76
+
77
+ export function buildResumedSessionState(args: {
78
+ messages: SessionMessage[]
79
+ metadata: SessionMetadata | null
80
+ fallbackCwd: string
81
+ nextRowId: () => string
82
+ }): ResumedSessionState {
83
+ const cwd = args.metadata?.lastCwd ?? args.metadata?.workspaceRoot ?? args.fallbackCwd
84
+ return {
85
+ cwd,
86
+ mode: args.metadata?.mode ?? 'chat',
87
+ rows: [
88
+ ...sessionMessagesToRows(args.messages, args.nextRowId),
89
+ {
90
+ role: 'note',
91
+ id: args.nextRowId(),
92
+ kind: 'dim',
93
+ content: formatResumeNote(args.metadata),
94
+ },
95
+ ],
96
+ promptHistory: promptHistoryFromSessionMessages(args.messages),
97
+ statusStartedAt: Date.now(),
98
+ }
99
+ }
100
+
101
+ function formatResumeNote(metadata: SessionMetadata | null): string {
102
+ const id = metadata?.id?.slice(0, 8) ?? ''
103
+ const source = metadata?.compactedFromSessionId ? ` summarized from ${metadata.compactedFromSessionId.slice(0, 8)}` : ''
104
+ return `resumed from session ${id}.${source}`.trim()
105
+ }
106
+
107
+ export function restoreConversationState(
108
+ messages: SessionMessage[],
109
+ turnId: string,
110
+ nextRowId: () => string,
111
+ ): {
112
+ messages: SessionMessage[]
113
+ rows: MessageRow[]
114
+ promptHistory: string[]
115
+ truncated: boolean
116
+ } {
117
+ const firstIndex = messages.findIndex(message => message.turnId === turnId)
118
+ const nextMessages =
119
+ firstIndex >= 0
120
+ ? messages.slice(0, firstIndex)
121
+ : messages.filter(message => message.turnId !== turnId)
122
+
123
+ return {
124
+ messages: nextMessages,
125
+ rows: sessionMessagesToRows(nextMessages, nextRowId),
126
+ promptHistory: promptHistoryFromSessionMessages(nextMessages),
127
+ truncated: firstIndex >= 0,
128
+ }
129
+ }
130
+
131
+ export function promptHistoryFromSessionMessages(messages: SessionMessage[]): string[] {
132
+ const prompts: string[] = []
133
+ for (const message of messages) {
134
+ if (message.role !== 'user') continue
135
+ if (message.synthetic) continue
136
+ const prompt = message.content.trim()
137
+ if (!prompt) continue
138
+ if (prompts[prompts.length - 1] === prompt) continue
139
+ prompts.push(prompt)
140
+ }
141
+ return prompts.slice(-MAX_PROMPT_HISTORY)
142
+ }