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,609 @@
1
+ import React, { useEffect, useMemo, useState } from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { theme } from '../ui/theme.js'
4
+ import { ProgressBar } from '../ui/ProgressBar.js'
5
+ import { Spinner } from '../ui/Spinner.js'
6
+ import { hidesSuccessfulToolResultContent } from './toolResultDisplay.js'
7
+
8
+ export type MessageRow =
9
+ | { role: 'user'; id: string; content: string }
10
+ | { role: 'assistant'; id: string; content: string; liveTail?: string; streaming?: boolean }
11
+ | { role: 'thinking'; id: string; content: string; liveTail?: string; streaming?: boolean; expanded?: boolean; showCursor?: boolean }
12
+ | { role: 'tool_use'; id: string; name: string; summary: string; input?: string }
13
+ | { role: 'tool_result'; id: string; name: string; summary: string; content: string; isError?: boolean }
14
+ | { role: 'note'; id: string; kind: 'info' | 'error' | 'dim'; content: string }
15
+ | {
16
+ role: 'progress'
17
+ id: string
18
+ title: string
19
+ progress: number
20
+ status: string
21
+ suffix?: string
22
+ done?: boolean
23
+ indeterminate?: boolean
24
+ startedAt?: number
25
+ }
26
+
27
+ type MessageListProps = {
28
+ rows: MessageRow[]
29
+ }
30
+
31
+ type MarkdownBlock =
32
+ | { kind: 'heading'; level: 1 | 2 | 3 | 4 | 5 | 6; text: string }
33
+ | { kind: 'paragraph'; text: string }
34
+ | { kind: 'quote'; lines: string[] }
35
+ | { kind: 'list'; ordered: boolean; items: string[] }
36
+ | { kind: 'code'; lang: string | null; code: string; open?: boolean }
37
+
38
+ type InlineToken =
39
+ | { kind: 'text'; text: string }
40
+ | { kind: 'bold'; text: string }
41
+ | { kind: 'italic'; text: string }
42
+ | { kind: 'code'; text: string }
43
+
44
+ const MAX_RENDERED_MESSAGE_CHARS = 12_000
45
+ const MAX_RENDERED_REASONING_CHARS = 10_000
46
+ const ASSISTANT_ACCENT = theme.accentMint
47
+ const UNREADABLE_REASONING_TEXT = 'reasoning output was not readable text'
48
+
49
+ const MessageListInner: React.FC<MessageListProps> = ({ rows }) => (
50
+ <Box flexDirection="column">
51
+ {rows.map(row => <RowView key={row.id} row={row} />)}
52
+ </Box>
53
+ )
54
+
55
+ export const MessageList = React.memo(MessageListInner)
56
+
57
+ export function toggleLatestReasoningRow(rows: MessageRow[]): MessageRow[] {
58
+ return toggleReasoningRow(rows)
59
+ }
60
+
61
+ export function toggleReasoningRow(rows: MessageRow[], rowId?: string): MessageRow[] {
62
+ let index = -1
63
+ if (rowId) {
64
+ index = rows.findIndex(row => row.id === rowId && row.role === 'thinking')
65
+ }
66
+ if (index === -1) {
67
+ for (let cursor = rows.length - 1; cursor >= 0; cursor -= 1) {
68
+ if (rows[cursor]?.role === 'thinking') {
69
+ index = cursor
70
+ break
71
+ }
72
+ }
73
+ }
74
+ if (index === -1) return rows
75
+ const row = rows[index]
76
+ if (!row || row.role !== 'thinking') return rows
77
+ const next = rows.slice()
78
+ next[index] = { ...row, expanded: !row.expanded }
79
+ return next
80
+ }
81
+
82
+ const RowViewInner: React.FC<{ row: MessageRow }> = ({ row }) => {
83
+ if (row.role === 'user') {
84
+ const display = clipTextForDisplay(row.content, MAX_RENDERED_MESSAGE_CHARS)
85
+ const lines = display.text.length === 0 ? [''] : display.text.split('\n')
86
+ return (
87
+ <Box flexDirection="column" marginTop={1}>
88
+ {display.omittedChars > 0 ? (
89
+ <Text color={theme.dim}>{` ${display.omittedChars} earlier characters omitted`}</Text>
90
+ ) : null}
91
+ {lines.map((line, i) => (
92
+ <Text key={i}>
93
+ <Text color={i === 0 ? theme.accentMint : theme.dim}>{i === 0 ? '> ' : ' '}</Text>
94
+ <Text color={theme.textSubtle}>{line}</Text>
95
+ </Text>
96
+ ))}
97
+ </Box>
98
+ )
99
+ }
100
+
101
+ if (row.role === 'assistant') {
102
+ return (
103
+ <Box flexDirection="column" marginTop={1}>
104
+ <AssistantBody content={row.content} liveTail={row.liveTail} streaming={row.streaming} />
105
+ </Box>
106
+ )
107
+ }
108
+
109
+ if (row.role === 'thinking') {
110
+ const text = sanitizeReasoningForDisplay(reasoningText(row))
111
+ const preview = summarizeThinking(text)
112
+ const borderColor = reasoningBorderColor(row)
113
+ const showCursor = reasoningCursorVisible(row)
114
+ if (row.expanded) {
115
+ return (
116
+ <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={borderColor} paddingX={1}>
117
+ <Text>
118
+ <Text color={theme.accentPeach} bold>reasoning</Text>
119
+ <Text color={theme.dim}> · expanded · alt+t collapse</Text>
120
+ </Text>
121
+ <ReasoningBody content={text} showCursor={showCursor} />
122
+ </Box>
123
+ )
124
+ }
125
+ return (
126
+ <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={borderColor} paddingX={1}>
127
+ <Text>
128
+ <Text color={theme.accentPeach} bold>reasoning</Text>
129
+ <Text color={theme.dim}> · collapsed · alt+t inspect</Text>
130
+ </Text>
131
+ <Text color={theme.textSubtle}>
132
+ {preview || 'thinking...'}
133
+ {showCursor ? <ThinkingCursor active hasPreview={Boolean(preview)} /> : null}
134
+ </Text>
135
+ </Box>
136
+ )
137
+ }
138
+
139
+ if (row.role === 'tool_use') {
140
+ return (
141
+ <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={theme.border} paddingX={1}>
142
+ <Text color={theme.accentNeutral} bold>{`tool · ${row.name}`}</Text>
143
+ <Text color={theme.dim}>{row.summary}</Text>
144
+ {row.input ? <Text color={theme.textSubtle}>{row.input}</Text> : null}
145
+ </Box>
146
+ )
147
+ }
148
+
149
+ if (row.role === 'tool_result') {
150
+ const hideContent = hidesSuccessfulToolResultContent(row.name, row.isError)
151
+ return (
152
+ <Box
153
+ flexDirection="column"
154
+ marginTop={1}
155
+ borderStyle="round"
156
+ borderColor={row.isError ? '#a84c4c' : theme.border}
157
+ paddingX={1}
158
+ >
159
+ <Text color={row.isError ? '#e87070' : theme.accentSecondary} bold>{`result · ${row.name}`}</Text>
160
+ <Text color={theme.dim}>{row.summary}</Text>
161
+ {row.isError ? (
162
+ <Text color="#f1b0b0">{row.content}</Text>
163
+ ) : hideContent || !row.content ? null : (
164
+ <AssistantBody content={row.content} />
165
+ )}
166
+ </Box>
167
+ )
168
+ }
169
+
170
+ if (row.role === 'note') {
171
+ const color = row.kind === 'error' ? '#e87070' : row.kind === 'dim' ? theme.dim : theme.accentInfo
172
+ return (
173
+ <Box marginTop={1}>
174
+ <Text color={color}>{row.content}</Text>
175
+ </Box>
176
+ )
177
+ }
178
+
179
+ return (
180
+ <Box flexDirection="column" marginTop={1}>
181
+ <Text color={theme.accentMint} bold>{row.title}</Text>
182
+ {row.indeterminate ? (
183
+ <ProgressSpinner row={row} />
184
+ ) : (
185
+ <>
186
+ <Text color={theme.dim}>{row.status}</Text>
187
+ <ProgressBar progress={row.progress} suffix={row.suffix} />
188
+ </>
189
+ )}
190
+ </Box>
191
+ )
192
+ }
193
+
194
+ const RowView = React.memo(RowViewInner)
195
+
196
+ const ProgressSpinner: React.FC<{ row: Extract<MessageRow, { role: 'progress' }> }> = ({ row }) => {
197
+ return <Spinner active label={row.status} hint={row.suffix} startedAt={row.startedAt} />
198
+ }
199
+
200
+ export function reasoningBorderColor(row: Extract<MessageRow, { role: 'thinking' }>): string {
201
+ return row.streaming ? theme.accentPeach : theme.border
202
+ }
203
+
204
+ export function reasoningCursorVisible(row: Extract<MessageRow, { role: 'thinking' }>): boolean {
205
+ return Boolean(row.streaming && row.showCursor)
206
+ }
207
+
208
+ const ReasoningBody: React.FC<{ content: string; showCursor?: boolean }> = ({ content, showCursor }) => {
209
+ const display = useMemo(
210
+ () => clipTextForDisplay(content, MAX_RENDERED_REASONING_CHARS),
211
+ [content],
212
+ )
213
+ const lines = useMemo(() => {
214
+ const normalized = display.text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
215
+ return normalized.length === 0 ? [''] : normalized.split('\n')
216
+ }, [display.text])
217
+
218
+ return (
219
+ <Box flexDirection="column">
220
+ {display.omittedChars > 0 ? (
221
+ <Text color={theme.dim}>{`${display.omittedChars} earlier reasoning characters omitted`}</Text>
222
+ ) : null}
223
+ {lines.map((line, index) => (
224
+ <Text key={index} color={theme.textSubtle}>
225
+ {line || ' '}
226
+ {showCursor && index === lines.length - 1 ? <ThinkingCursor active hasPreview={line.length > 0} /> : null}
227
+ </Text>
228
+ ))}
229
+ </Box>
230
+ )
231
+ }
232
+
233
+ const AssistantBody: React.FC<{ content: string; liveTail?: string; streaming?: boolean }> = ({
234
+ content,
235
+ liveTail,
236
+ streaming,
237
+ }) => {
238
+ const fullText = liveTail ? content + liveTail : content
239
+ const display = useMemo(
240
+ () => clipTextForDisplay(fullText, MAX_RENDERED_MESSAGE_CHARS),
241
+ [fullText],
242
+ )
243
+ const blocks = useMemo(() => parseMarkdownBlocks(display.text), [display.text])
244
+
245
+ return (
246
+ <Box flexDirection="column">
247
+ {display.omittedChars > 0 ? (
248
+ <Text color={theme.dim}>{`${display.omittedChars} earlier characters omitted`}</Text>
249
+ ) : null}
250
+ {blocks.map((block, index) => (
251
+ <MarkdownBlockView
252
+ key={index}
253
+ block={block}
254
+ streaming={streaming && index === blocks.length - 1}
255
+ />
256
+ ))}
257
+ {streaming && blocks.length === 0 ? (
258
+ <Text color={ASSISTANT_ACCENT}>
259
+ <StreamCursor active />
260
+ </Text>
261
+ ) : null}
262
+ </Box>
263
+ )
264
+ }
265
+
266
+ const MarkdownBlockView: React.FC<{ block: MarkdownBlock; streaming?: boolean }> = ({ block, streaming = false }) => {
267
+ if (block.kind === 'heading') {
268
+ return (
269
+ <Box flexDirection="column" marginTop={1}>
270
+ <Text>
271
+ <InlineText text={block.text} color={ASSISTANT_ACCENT} bold />
272
+ </Text>
273
+ </Box>
274
+ )
275
+ }
276
+
277
+ if (block.kind === 'quote') {
278
+ return (
279
+ <Box flexDirection="column" marginTop={1}>
280
+ {block.lines.map((line, index) => (
281
+ <Text key={index}>
282
+ <Text color={ASSISTANT_ACCENT}>| </Text>
283
+ <InlineText text={line} color={theme.dim} />
284
+ </Text>
285
+ ))}
286
+ </Box>
287
+ )
288
+ }
289
+
290
+ if (block.kind === 'list') {
291
+ return (
292
+ <Box flexDirection="column" marginTop={1}>
293
+ {block.items.map((item, index) => (
294
+ <Text key={index}>
295
+ <Text color={ASSISTANT_ACCENT}>{block.ordered ? `${index + 1}. ` : '- '}</Text>
296
+ <InlineText text={item} color={theme.text} />
297
+ </Text>
298
+ ))}
299
+ </Box>
300
+ )
301
+ }
302
+
303
+ if (block.kind === 'code') {
304
+ const lines = block.code.length === 0 ? [''] : block.code.split('\n')
305
+ const accent = codeAccent(block.lang)
306
+ return (
307
+ <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={accent}>
308
+ <Box paddingX={1}>
309
+ <Text color={accent} bold>{block.lang ? block.lang : 'code'}</Text>
310
+ <Text color={theme.dim}>{block.open ? ' · streaming' : ' · block'}</Text>
311
+ </Box>
312
+ <Box flexDirection="column" paddingX={1}>
313
+ {lines.map((line, index) => (
314
+ <Text key={index}>
315
+ <Text color={theme.dim}>{`${String(index + 1).padStart(2, '0')} `}</Text>
316
+ <Text color={codeLineColor(block.lang, line)}>{line || ' '}</Text>
317
+ </Text>
318
+ ))}
319
+ </Box>
320
+ </Box>
321
+ )
322
+ }
323
+
324
+ return (
325
+ <Box flexDirection="column" marginTop={1}>
326
+ <Text>
327
+ <InlineText text={block.text} color={theme.text} />
328
+ {streaming ? <Text color={ASSISTANT_ACCENT}> <StreamCursor active /></Text> : null}
329
+ </Text>
330
+ </Box>
331
+ )
332
+ }
333
+
334
+ const InlineText: React.FC<{ text: string; color: string; bold?: boolean }> = ({ text, color, bold }) => {
335
+ const tokens = useMemo(() => parseInlineTokens(text), [text])
336
+ return (
337
+ <>
338
+ {tokens.map((token, index) => {
339
+ if (token.kind === 'bold') {
340
+ return (
341
+ <Text key={index} color={ASSISTANT_ACCENT} bold>
342
+ {token.text}
343
+ </Text>
344
+ )
345
+ }
346
+ if (token.kind === 'italic') {
347
+ return (
348
+ <Text key={index} color={ASSISTANT_ACCENT} italic>
349
+ {token.text}
350
+ </Text>
351
+ )
352
+ }
353
+ if (token.kind === 'code') {
354
+ return (
355
+ <Text key={index} color={ASSISTANT_ACCENT} backgroundColor="#202020">
356
+ {token.text}
357
+ </Text>
358
+ )
359
+ }
360
+ return (
361
+ <Text key={index} color={color} bold={bold}>
362
+ {token.text}
363
+ </Text>
364
+ )
365
+ })}
366
+ </>
367
+ )
368
+ }
369
+
370
+ const ThinkingCursor: React.FC<{ active: boolean; hasPreview: boolean }> = ({ active, hasPreview }) => {
371
+ if (!active) return null
372
+ return (
373
+ <Text color={theme.accentPeach}>
374
+ {hasPreview ? ' ' : ''}
375
+ <StreamCursor active />
376
+ </Text>
377
+ )
378
+ }
379
+
380
+ const StreamCursor: React.FC<{ active: boolean }> = ({ active }) => {
381
+ const [visible, setVisible] = useState(true)
382
+
383
+ useEffect(() => {
384
+ if (!active) return
385
+ const timer = setInterval(() => {
386
+ setVisible(v => !v)
387
+ }, 420)
388
+ return () => clearInterval(timer)
389
+ }, [active])
390
+
391
+ return <>{visible ? '|' : ' '}</>
392
+ }
393
+
394
+ function parseMarkdownBlocks(markdown: string): MarkdownBlock[] {
395
+ const text = markdown.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
396
+ if (!text.trim()) return []
397
+
398
+ const blocks: MarkdownBlock[] = []
399
+ const lines = text.split('\n')
400
+ let index = 0
401
+
402
+ while (index < lines.length) {
403
+ const line = lines[index] ?? ''
404
+ const trimmed = line.trim()
405
+
406
+ if (!trimmed) {
407
+ index += 1
408
+ continue
409
+ }
410
+
411
+ const fence = trimmed.match(/^```([\w+-]*)\s*$/)
412
+ if (fence) {
413
+ const lang = fence[1] && fence[1].length > 0 ? fence[1] : null
414
+ index += 1
415
+ const body: string[] = []
416
+ let closed = false
417
+ while (index < lines.length) {
418
+ const nextLine = lines[index] ?? ''
419
+ if (nextLine.trim().match(/^```\s*$/)) {
420
+ closed = true
421
+ index += 1
422
+ break
423
+ }
424
+ body.push(nextLine)
425
+ index += 1
426
+ }
427
+ blocks.push({ kind: 'code', lang, code: body.join('\n'), open: !closed })
428
+ continue
429
+ }
430
+
431
+ const heading = line.match(/^(#{1,6})\s+(.*)$/)
432
+ if (heading) {
433
+ const [, hashes = '#', headingText = ''] = heading
434
+ blocks.push({
435
+ kind: 'heading',
436
+ level: hashes.length as 1 | 2 | 3 | 4 | 5 | 6,
437
+ text: headingText.trim(),
438
+ })
439
+ index += 1
440
+ continue
441
+ }
442
+
443
+ if (/^>\s?/.test(trimmed)) {
444
+ const quoteLines: string[] = []
445
+ while (index < lines.length) {
446
+ const nextLine = lines[index] ?? ''
447
+ if (!/^>\s?/.test(nextLine.trim())) break
448
+ quoteLines.push(nextLine.trim().replace(/^>\s?/, ''))
449
+ index += 1
450
+ }
451
+ blocks.push({ kind: 'quote', lines: quoteLines })
452
+ continue
453
+ }
454
+
455
+ const ordered = trimmed.match(/^\d+\.\s+(.*)$/)
456
+ const unordered = trimmed.match(/^[-*+]\s+(.*)$/)
457
+ if (ordered || unordered) {
458
+ const items: string[] = []
459
+ const orderedList = Boolean(ordered)
460
+ while (index < lines.length) {
461
+ const nextLine = lines[index] ?? ''
462
+ const match = orderedList
463
+ ? nextLine.trim().match(/^\d+\.\s+(.*)$/)
464
+ : nextLine.trim().match(/^[-*+]\s+(.*)$/)
465
+ if (!match) break
466
+ items.push(match[1] ?? '')
467
+ index += 1
468
+ }
469
+ blocks.push({ kind: 'list', ordered: orderedList, items })
470
+ continue
471
+ }
472
+
473
+ const paragraph: string[] = []
474
+ while (index < lines.length) {
475
+ const nextLine = lines[index] ?? ''
476
+ const nextTrimmed = nextLine.trim()
477
+ if (!nextTrimmed) break
478
+ if (nextTrimmed.match(/^```([\w+-]*)\s*$/)) break
479
+ if (nextLine.match(/^(#{1,6})\s+(.*)$/)) break
480
+ if (/^>\s?/.test(nextTrimmed)) break
481
+ if (nextTrimmed.match(/^\d+\.\s+(.*)$/) || nextTrimmed.match(/^[-*+]\s+(.*)$/)) break
482
+ paragraph.push(nextLine)
483
+ index += 1
484
+ }
485
+ blocks.push({ kind: 'paragraph', text: paragraph.join('\n').trim() })
486
+ }
487
+
488
+ return blocks
489
+ }
490
+
491
+ function codeAccent(lang: string | null): string {
492
+ return ASSISTANT_ACCENT
493
+ }
494
+
495
+ function codeLineColor(lang: string | null, line: string): string {
496
+ const trimmed = line.trim()
497
+ if (!trimmed) return theme.textSubtle
498
+ if ((lang === 'json' || lang === 'jsonc') && /^["[{]/.test(trimmed)) return ASSISTANT_ACCENT
499
+ if (/^(\/\/|#|\/\*|\*)/.test(trimmed)) return theme.dim
500
+ if (/\b(function|const|let|return|if|else|class|export|import)\b/.test(trimmed)) return theme.text
501
+ if (/<\/?[A-Za-z]/.test(trimmed)) return ASSISTANT_ACCENT
502
+ if (/^[.#@]/.test(trimmed)) return ASSISTANT_ACCENT
503
+ return theme.textSubtle
504
+ }
505
+
506
+ function parseInlineTokens(text: string): InlineToken[] {
507
+ const tokens: InlineToken[] = []
508
+ const source = normalizeInlineDisplayText(text)
509
+ const pattern = /(`[^`\n]+`|\*\*[^*\n]+?\*\*|__[^_\n]+?__|\*[^*\n]+?\*|_[^_\n]+?_)/g
510
+ let lastIndex = 0
511
+ let match: RegExpExecArray | null
512
+
513
+ while ((match = pattern.exec(source)) !== null) {
514
+ if (match.index > lastIndex) {
515
+ tokens.push({ kind: 'text', text: cleanPlainInlineText(source.slice(lastIndex, match.index)) })
516
+ }
517
+
518
+ const token = match[0]
519
+ if ((token.startsWith('**') && token.endsWith('**')) || (token.startsWith('__') && token.endsWith('__'))) {
520
+ tokens.push({ kind: 'bold', text: cleanPlainInlineText(token.slice(2, -2)) })
521
+ } else if ((token.startsWith('*') && token.endsWith('*')) || (token.startsWith('_') && token.endsWith('_'))) {
522
+ tokens.push({ kind: 'italic', text: cleanPlainInlineText(token.slice(1, -1)) })
523
+ } else if (token.startsWith('`') && token.endsWith('`')) {
524
+ tokens.push({ kind: 'code', text: token.slice(1, -1) })
525
+ }
526
+
527
+ lastIndex = match.index + token.length
528
+ }
529
+
530
+ if (lastIndex < source.length || tokens.length === 0) {
531
+ tokens.push({ kind: 'text', text: cleanPlainInlineText(source.slice(lastIndex)) })
532
+ }
533
+
534
+ return tokens.filter(token => token.text.length > 0)
535
+ }
536
+
537
+ function normalizeInlineDisplayText(text: string): string {
538
+ return text
539
+ .replace(/\\\(/g, '')
540
+ .replace(/\\\)/g, '')
541
+ .replace(/\\\[/g, '')
542
+ .replace(/\\\]/g, '')
543
+ .replace(/\$\$([^$]+)\$\$/g, '$1')
544
+ .replace(/\$([^$\n]+)\$/g, '$1')
545
+ .replace(/\\([{}[\]()])/g, '$1')
546
+ .replace(/\/([{}])/g, '$1')
547
+ }
548
+
549
+ function cleanPlainInlineText(text: string): string {
550
+ return text.replace(/\*+/g, '')
551
+ }
552
+
553
+ function summarizeThinking(text: string): string {
554
+ const sample = text.length > 1000 ? text.slice(-1000) : text
555
+ const normalized = sample.replace(/\s+/g, ' ').trim()
556
+ if (!normalized) return ''
557
+ const prefix = text.length > sample.length ? '...' : ''
558
+ if (normalized.length + prefix.length <= 120) return `${prefix}${normalized}`
559
+ return `${prefix}${normalized.slice(Math.max(0, normalized.length - (120 - prefix.length)))}`
560
+ }
561
+
562
+ function clipTextForDisplay(text: string, maxChars: number): { text: string; omittedChars: number } {
563
+ if (text.length <= maxChars) return { text, omittedChars: 0 }
564
+ const rawStart = Math.max(0, text.length - maxChars)
565
+ const newline = text.indexOf('\n', rawStart)
566
+ const start = newline >= 0 && newline - rawStart <= 240 ? newline + 1 : rawStart
567
+ return {
568
+ text: text.slice(start),
569
+ omittedChars: start,
570
+ }
571
+ }
572
+
573
+ function reasoningText(row: Extract<MessageRow, { role: 'thinking' }>): string {
574
+ return row.liveTail ? row.content + row.liveTail : row.content
575
+ }
576
+
577
+ export function sanitizeReasoningForDisplay(text: string): string {
578
+ const normalized = text
579
+ .replace(/\r\n/g, '\n')
580
+ .replace(/\r/g, '\n')
581
+ .replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '')
582
+ const controlCount = countMatches(normalized, /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\uFFFD]/g)
583
+ const cleaned = normalized
584
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\uFFFD]/g, '')
585
+ .replace(/\t/g, ' ')
586
+ const visibleLength = cleaned.replace(/\s/g, '').length
587
+ if (visibleLength === 0) return ''
588
+ if (controlCount > 0 && controlCount / Math.max(1, text.length) > 0.05) return UNREADABLE_REASONING_TEXT
589
+ if (looksLikeUnreadableReasoning(cleaned)) return UNREADABLE_REASONING_TEXT
590
+ return cleaned
591
+ }
592
+
593
+ function looksLikeUnreadableReasoning(text: string): boolean {
594
+ const visible = text.replace(/\s/g, '')
595
+ if (visible.length < 120) return false
596
+ const letters = countMatches(visible, /[A-Za-z]/g)
597
+ const digits = countMatches(visible, /\d/g)
598
+ const words = text.match(/[A-Za-z]{3,}/g) ?? []
599
+ const wordChars = words.reduce((sum, word) => sum + word.length, 0)
600
+ const whitespace = countMatches(text, /\s/g)
601
+ const symbolDensity = (visible.length - letters - digits) / visible.length
602
+ const wordDensity = wordChars / visible.length
603
+ const whitespaceDensity = whitespace / Math.max(1, text.length)
604
+ return symbolDensity > 0.38 && wordDensity < 0.32 && whitespaceDensity < 0.12
605
+ }
606
+
607
+ function countMatches(text: string, pattern: RegExp): number {
608
+ return text.match(pattern)?.length ?? 0
609
+ }