ethagent 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +30 -8
- 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 +868 -0
- package/src/identity/hub/identityHubEffects.ts +1146 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +212 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -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,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
|
+
}
|