aegiscode 3.1.10 → 3.4.2
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/bin/aegis.js +165 -0
- package/package.json +18 -86
- package/LICENSE +0 -21
- package/README.md +0 -394
- package/dist/main.js +0 -2171
- package/gui/run-gui.cjs +0 -13
- package/src/agent/Agent.ts +0 -903
- package/src/agent/SimpleAgent.ts +0 -48
- package/src/agent/index.ts +0 -54
- package/src/agent/orchestrator/AppBuilder.ts +0 -443
- package/src/agent/orchestrator/CouncilAgent.ts +0 -310
- package/src/agent/orchestrator/DiscussionRoom.ts +0 -462
- package/src/agent/orchestrator/OrchestratorAgent.ts +0 -637
- package/src/agent/orchestrator/index.ts +0 -38
- package/src/agent/orchestrator/utils.ts +0 -397
- package/src/agent/pricing.ts +0 -115
- package/src/agent/router.ts +0 -74
- package/src/agent/routerStats.ts +0 -121
- package/src/agent/types.ts +0 -318
- package/src/auth/login.ts +0 -383
- package/src/cli/config.ts +0 -189
- package/src/cli/index.ts +0 -17
- package/src/cli/middleware.ts +0 -119
- package/src/cli/types.ts +0 -75
- package/src/config/ConfigManager.ts +0 -587
- package/src/config/index.ts +0 -7
- package/src/config/types.ts +0 -584
- package/src/context/CompactionService.ts +0 -300
- package/src/context/ContextManager.ts +0 -450
- package/src/context/FileAnalyzer.ts +0 -267
- package/src/context/TokenCounter.ts +0 -265
- package/src/context/index.ts +0 -27
- package/src/context/storage/CacheStore.ts +0 -176
- package/src/context/storage/JSONLStore.ts +0 -201
- package/src/context/storage/MemoryStore.ts +0 -205
- package/src/context/storage/PersistentStore.ts +0 -327
- package/src/context/storage/index.ts +0 -9
- package/src/context/storage/pathUtils.ts +0 -114
- package/src/context/test.ts +0 -309
- package/src/context/types.ts +0 -268
- package/src/hooks/HookExecutor.ts +0 -434
- package/src/hooks/HookManager.ts +0 -596
- package/src/hooks/HookService.ts +0 -269
- package/src/hooks/Matcher.ts +0 -157
- package/src/hooks/index.ts +0 -63
- package/src/hooks/types.ts +0 -424
- package/src/main.tsx +0 -596
- package/src/mcp/HealthMonitor.ts +0 -150
- package/src/mcp/McpClient.ts +0 -491
- package/src/mcp/McpRegistry.ts +0 -321
- package/src/mcp/createMcpTool.ts +0 -251
- package/src/mcp/index.ts +0 -15
- package/src/mcp/server.ts +0 -334
- package/src/mcp/test-server.ts +0 -88
- package/src/mcp/test.ts +0 -372
- package/src/mcp/types.ts +0 -247
- package/src/memory/AgentMemoryBus.ts +0 -432
- package/src/memory/CloudSync.ts +0 -99
- package/src/memory/DriveSync.ts +0 -106
- package/src/memory/SharedMemory.ts +0 -951
- package/src/memory/index.ts +0 -14
- package/src/memory/machineFingerprint.ts +0 -40
- package/src/orchestrator/SubAgentMetadata.ts +0 -136
- package/src/prompts/builder.ts +0 -213
- package/src/prompts/default.ts +0 -144
- package/src/prompts/index.ts +0 -16
- package/src/prompts/plan.ts +0 -64
- package/src/prompts/test.ts +0 -78
- package/src/services/AnthropicChatService.ts +0 -341
- package/src/services/ChatService.ts +0 -347
- package/src/services/ClaudeCliChatService.ts +0 -256
- package/src/services/CloudSync.ts +0 -168
- package/src/services/CostLedger.ts +0 -211
- package/src/services/Heartbeat.ts +0 -135
- package/src/services/LearningCollector.ts +0 -291
- package/src/services/OllamaInstaller.ts +0 -342
- package/src/services/VersionChecker.ts +0 -445
- package/src/services/index.ts +0 -57
- package/src/services/streaming/OpenAIEventAdapter.ts +0 -126
- package/src/services/streaming/RenderingProfile.ts +0 -90
- package/src/services/streaming/StreamEventParser.ts +0 -181
- package/src/services/streaming/ThrottledRenderer.ts +0 -139
- package/src/services/streaming/TranscriptBuffer.ts +0 -574
- package/src/services/streaming/eventStatusMap.ts +0 -52
- package/src/services/streaming/index.ts +0 -46
- package/src/services/streaming/renderFormatting.ts +0 -79
- package/src/services/streaming/types.ts +0 -234
- package/src/skills/SkillLoader.ts +0 -126
- package/src/skills/SkillRegistry.ts +0 -366
- package/src/skills/index.ts +0 -48
- package/src/skills/types.ts +0 -146
- package/src/slash-commands/billing.ts +0 -70
- package/src/slash-commands/build.ts +0 -413
- package/src/slash-commands/builtinCommands.ts +0 -2733
- package/src/slash-commands/clone.ts +0 -242
- package/src/slash-commands/council.ts +0 -125
- package/src/slash-commands/custom/CustomCommandExecutor.ts +0 -143
- package/src/slash-commands/custom/CustomCommandLoader.ts +0 -251
- package/src/slash-commands/custom/CustomCommandRegistry.ts +0 -144
- package/src/slash-commands/custom/index.ts +0 -7
- package/src/slash-commands/debate.ts +0 -254
- package/src/slash-commands/gmail.ts +0 -105
- package/src/slash-commands/index.ts +0 -388
- package/src/slash-commands/mcpCommand.ts +0 -205
- package/src/slash-commands/types.ts +0 -201
- package/src/store/index.ts +0 -76
- package/src/store/selectors.ts +0 -246
- package/src/store/slices/appSlice.ts +0 -205
- package/src/store/slices/commandSlice.ts +0 -115
- package/src/store/slices/configSlice.ts +0 -46
- package/src/store/slices/focusSlice.ts +0 -64
- package/src/store/slices/index.ts +0 -9
- package/src/store/slices/sessionSlice.ts +0 -424
- package/src/store/streaming-buffer.ts +0 -425
- package/src/store/test.ts +0 -296
- package/src/store/types.ts +0 -274
- package/src/store/vanilla.ts +0 -186
- package/src/tools/builtin/bash.ts +0 -236
- package/src/tools/builtin/council.ts +0 -105
- package/src/tools/builtin/edit.ts +0 -213
- package/src/tools/builtin/glob.ts +0 -136
- package/src/tools/builtin/grep.ts +0 -263
- package/src/tools/builtin/index.ts +0 -61
- package/src/tools/builtin/memory.ts +0 -66
- package/src/tools/builtin/read.ts +0 -168
- package/src/tools/builtin/skill.ts +0 -97
- package/src/tools/builtin/snapshot.ts +0 -40
- package/src/tools/builtin/task.ts +0 -106
- package/src/tools/builtin/write.ts +0 -134
- package/src/tools/createTool.ts +0 -221
- package/src/tools/execution/ExecutionPipeline.ts +0 -263
- package/src/tools/execution/index.ts +0 -40
- package/src/tools/execution/stages/CacheStage.ts +0 -131
- package/src/tools/execution/stages/ConfirmationStage.ts +0 -203
- package/src/tools/execution/stages/DiscoveryStage.ts +0 -46
- package/src/tools/execution/stages/ExecutionStage.ts +0 -48
- package/src/tools/execution/stages/FormattingStage.ts +0 -44
- package/src/tools/execution/stages/HookStage.ts +0 -72
- package/src/tools/execution/stages/PermissionStage.ts +0 -287
- package/src/tools/execution/stages/PostHookStage.ts +0 -71
- package/src/tools/execution/stages/index.ts +0 -12
- package/src/tools/execution/test.ts +0 -266
- package/src/tools/execution/types.ts +0 -273
- package/src/tools/index.ts +0 -81
- package/src/tools/registry.ts +0 -304
- package/src/tools/schemas.ts +0 -109
- package/src/tools/test.ts +0 -220
- package/src/tools/types.ts +0 -175
- package/src/tools/validation/PermissionChecker.ts +0 -242
- package/src/tools/validation/SensitiveFileDetector.ts +0 -210
- package/src/tools/validation/index.ts +0 -11
- package/src/ui/App.tsx +0 -166
- package/src/ui/components/AegisInterface.tsx +0 -484
- package/src/ui/components/common/ChatSearch.tsx +0 -150
- package/src/ui/components/common/ErrorBoundary.tsx +0 -82
- package/src/ui/components/common/ExitMessage.tsx +0 -120
- package/src/ui/components/common/LoadingIndicator.tsx +0 -49
- package/src/ui/components/common/index.ts +0 -6
- package/src/ui/components/dialog/ConfirmationPrompt.tsx +0 -208
- package/src/ui/components/dialog/InteractiveSelector.tsx +0 -149
- package/src/ui/components/dialog/SetupWizard.tsx +0 -297
- package/src/ui/components/dialog/UpdatePrompt.tsx +0 -155
- package/src/ui/components/dialog/index.ts +0 -8
- package/src/ui/components/index.ts +0 -28
- package/src/ui/components/input/CommandSuggestions.tsx +0 -139
- package/src/ui/components/input/CustomTextInput.tsx +0 -220
- package/src/ui/components/input/InputArea.tsx +0 -361
- package/src/ui/components/input/PromptSuggestions.tsx +0 -66
- package/src/ui/components/input/index.ts +0 -6
- package/src/ui/components/layout/ChatStatusBar.tsx +0 -90
- package/src/ui/components/layout/ContextBar.tsx +0 -79
- package/src/ui/components/layout/MessageArea.tsx +0 -96
- package/src/ui/components/layout/MessageList.tsx +0 -647
- package/src/ui/components/layout/MessageSeparator.tsx +0 -26
- package/src/ui/components/layout/WelcomeMessage.tsx +0 -93
- package/src/ui/components/layout/index.ts +0 -7
- package/src/ui/components/markdown/CodeHighlighter.tsx +0 -292
- package/src/ui/components/markdown/MessageRenderer.tsx +0 -1211
- package/src/ui/components/markdown/index.ts +0 -8
- package/src/ui/components/markdown/parser.ts +0 -336
- package/src/ui/components/markdown/types.ts +0 -66
- package/src/ui/focus/FocusManager.ts +0 -137
- package/src/ui/focus/index.ts +0 -13
- package/src/ui/focus/types.ts +0 -54
- package/src/ui/focus/useFocus.ts +0 -75
- package/src/ui/hooks/index.ts +0 -11
- package/src/ui/hooks/useAgent.ts +0 -284
- package/src/ui/hooks/useCommandHistory.ts +0 -87
- package/src/ui/hooks/useCommandProcessor.ts +0 -443
- package/src/ui/hooks/useConfirmation.ts +0 -99
- package/src/ui/hooks/useCtrlCHandler.ts +0 -100
- package/src/ui/hooks/useInputBuffer.ts +0 -122
- package/src/ui/hooks/useTerminalSize.ts +0 -68
- package/src/ui/hooks/useTerminalWidth.ts +0 -5
- package/src/ui/hooks/useWindowedList.ts +0 -118
- package/src/ui/render-debugger.ts +0 -621
- package/src/ui/test.ts +0 -189
- package/src/ui/themes/ThemeManager.ts +0 -332
- package/src/ui/themes/aegisTheme.ts +0 -87
- package/src/ui/themes/darkTheme.ts +0 -87
- package/src/ui/themes/defaultTheme.ts +0 -85
- package/src/ui/themes/index.ts +0 -10
- package/src/ui/themes/lightTheme.ts +0 -89
- package/src/ui/themes/popularThemes.ts +0 -187
- package/src/ui/themes/types.ts +0 -130
- package/src/utils/clipboard.ts +0 -48
- package/src/utils/debug.ts +0 -43
- package/src/utils/environment.ts +0 -68
- package/src/utils/index.ts +0 -10
|
@@ -1,1211 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MessageRenderer - renders markdown content with memoization
|
|
3
|
-
*
|
|
4
|
-
* Critical perf note: no useStore hooks inside the memo component!
|
|
5
|
-
* useShowAllThinking is passed as a prop from MessageList to avoid
|
|
6
|
-
* re-rendering every message when the global toggle changes.
|
|
7
|
-
*
|
|
8
|
-
* Supports Content Block model (Claude-style):
|
|
9
|
-
* - text blocks → rendered as markdown
|
|
10
|
-
* - thinking blocks → collapsible with preview
|
|
11
|
-
* - tool_use blocks → formatted tool calls with status
|
|
12
|
-
* - tool_result blocks → tool output
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import React, { useMemo, useState, useEffect, useRef, memo } from 'react'
|
|
16
|
-
import { Box, Text } from 'ink'
|
|
17
|
-
import stringWidth from 'string-width'
|
|
18
|
-
import { parseMarkdown } from './parser.js'
|
|
19
|
-
import { themeManager } from '../../themes/index.js'
|
|
20
|
-
import { CodeHighlighter } from './CodeHighlighter.js'
|
|
21
|
-
import type { ParsedBlock } from './types.js'
|
|
22
|
-
import type { ContentBlock, ToolCallStatus } from '../../../store/types.js'
|
|
23
|
-
|
|
24
|
-
interface MessageRendererProps {
|
|
25
|
-
content: string
|
|
26
|
-
role: 'user' | 'assistant' | 'system' | 'tool'
|
|
27
|
-
terminalWidth?: number
|
|
28
|
-
showPrefix?: boolean
|
|
29
|
-
thinking?: string
|
|
30
|
-
isStreaming?: boolean
|
|
31
|
-
/** Passed from parent to avoid hook call inside memo */
|
|
32
|
-
showAllThinking?: boolean
|
|
33
|
-
/** Content blocks for Claude-style structured rendering */
|
|
34
|
-
contentBlocks?: ContentBlock[]
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Custom comparator: prevent re-renders only when nothing has changed.
|
|
39
|
-
* During streaming, content changes every delta — we MUST re-render to show them.
|
|
40
|
-
* The old code had `!next.isStreaming` which blocked ALL streaming content updates.
|
|
41
|
-
*/
|
|
42
|
-
const messageRendererComparator = (
|
|
43
|
-
prev: MessageRendererProps,
|
|
44
|
-
next: MessageRendererProps
|
|
45
|
-
): boolean => {
|
|
46
|
-
if (prev.role !== next.role) return false
|
|
47
|
-
if (prev.isStreaming !== next.isStreaming) return false
|
|
48
|
-
|
|
49
|
-
if (prev.terminalWidth !== next.terminalWidth) return false
|
|
50
|
-
if (prev.showPrefix !== next.showPrefix) return false
|
|
51
|
-
// Always compare content — even during streaming, we need to show deltas
|
|
52
|
-
if (prev.content !== next.content) return false
|
|
53
|
-
// Compare thinking state
|
|
54
|
-
if (prev.thinking !== next.thinking) return false
|
|
55
|
-
// Compare content blocks
|
|
56
|
-
if (prev.contentBlocks !== next.contentBlocks) return false
|
|
57
|
-
return true
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export const MessageRenderer: React.FC<MessageRendererProps> = memo(
|
|
61
|
-
({
|
|
62
|
-
content,
|
|
63
|
-
role,
|
|
64
|
-
terminalWidth = 80,
|
|
65
|
-
showPrefix = true,
|
|
66
|
-
thinking,
|
|
67
|
-
isStreaming,
|
|
68
|
-
showAllThinking = false,
|
|
69
|
-
contentBlocks,
|
|
70
|
-
}) => {
|
|
71
|
-
const theme = themeManager.getTheme()
|
|
72
|
-
const roleStyle = themeManager.getRoleStyle(role)
|
|
73
|
-
|
|
74
|
-
const isThinkingExpanded = true
|
|
75
|
-
|
|
76
|
-
// Incremental markdown parse cache — avoids re-parsing stable content
|
|
77
|
-
// during streaming by detecting append-only growth and reusing blocks.
|
|
78
|
-
const parseCacheRef = useRef<{ content: string; blocks: ParsedBlock[] }>({ content: '', blocks: [] });
|
|
79
|
-
|
|
80
|
-
const blocks = useMemo(() => {
|
|
81
|
-
const cache = parseCacheRef.current;
|
|
82
|
-
if (content === cache.content) return cache.blocks;
|
|
83
|
-
|
|
84
|
-
// Incremental: content only grew (append-only streaming delta)
|
|
85
|
-
if (content.startsWith(cache.content) && cache.content.length > 0) {
|
|
86
|
-
const delta = content.slice(cache.content.length);
|
|
87
|
-
const deltaFirstLine = delta.split('\n')[0];
|
|
88
|
-
// Fast path: delta is pure text (no block-starting patterns like ```, #, -, >, |)
|
|
89
|
-
if (!/^```|^#{1,6}\s|^[-*+]\s|^\d+\.\s|^>\s|^\s*$|^\|/.test(deltaFirstLine)) {
|
|
90
|
-
const newBlocks = cache.blocks.slice();
|
|
91
|
-
const lastBlock = newBlocks[newBlocks.length - 1];
|
|
92
|
-
if (lastBlock && lastBlock.type === 'text') {
|
|
93
|
-
newBlocks[newBlocks.length - 1] = { ...lastBlock, content: lastBlock.content + delta };
|
|
94
|
-
parseCacheRef.current = { content, blocks: newBlocks };
|
|
95
|
-
return newBlocks;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Full re-parse for structural changes or first render
|
|
101
|
-
const parsed = parseMarkdown(content);
|
|
102
|
-
parseCacheRef.current = { content, blocks: parsed };
|
|
103
|
-
return parsed;
|
|
104
|
-
}, [content])
|
|
105
|
-
|
|
106
|
-
const thinkingBlocks = useMemo(
|
|
107
|
-
() => (thinking ? parseMarkdown(thinking) : []),
|
|
108
|
-
[thinking],
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
const filteredBlocks = useMemo(() => {
|
|
112
|
-
return blocks.filter((block, index) => {
|
|
113
|
-
if (block.type !== 'empty') return true
|
|
114
|
-
// Filter out leading empty blocks
|
|
115
|
-
if (index === 0) return false
|
|
116
|
-
// Deduplicate consecutive empty blocks (keep one for paragraph spacing)
|
|
117
|
-
if (blocks[index - 1].type === 'empty') return false
|
|
118
|
-
// Keep a single empty block between non-empty blocks for spacing
|
|
119
|
-
return true
|
|
120
|
-
})
|
|
121
|
-
}, [blocks])
|
|
122
|
-
|
|
123
|
-
const filteredThinkingBlocks = useMemo(() => {
|
|
124
|
-
return thinkingBlocks.filter((block) => block.type !== 'empty')
|
|
125
|
-
}, [thinkingBlocks])
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const prefixOffset = showPrefix && roleStyle && roleStyle.prefix ? roleStyle.prefix.length + 1 : 0
|
|
130
|
-
|
|
131
|
-
const hasToolBlocks = contentBlocks && contentBlocks.some(b => b.type === 'tool_use' || b.type === 'tool_result')
|
|
132
|
-
|
|
133
|
-
// ===== Content-block-driven rendering (Claude Code style) =====
|
|
134
|
-
// When contentBlocks are present, render from the structured block model
|
|
135
|
-
// instead of the flat markdown string.
|
|
136
|
-
const shouldUseContentBlocks = contentBlocks && contentBlocks.length > 0;
|
|
137
|
-
|
|
138
|
-
// User messages: grey background + "You" label + content
|
|
139
|
-
if (role === 'user') {
|
|
140
|
-
return (
|
|
141
|
-
<Box flexDirection="column" marginBottom={0}>
|
|
142
|
-
<Box backgroundColor={theme.colors.background.secondary} flexDirection="row" paddingX={1}>
|
|
143
|
-
<Box marginRight={1} flexShrink={0}>
|
|
144
|
-
<Text color={theme.colors.primary} bold>You</Text>
|
|
145
|
-
<Text color={theme.colors.border.light}>:</Text>
|
|
146
|
-
</Box>
|
|
147
|
-
<Box flexGrow={1}>
|
|
148
|
-
<Text color={theme.colors.text.primary} wrap="wrap">{content}</Text>
|
|
149
|
-
</Box>
|
|
150
|
-
</Box>
|
|
151
|
-
</Box>
|
|
152
|
-
)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return (
|
|
156
|
-
<Box flexDirection="column" marginBottom={0}>
|
|
157
|
-
{/* Thinking block — rendered inline */}
|
|
158
|
-
{shouldUseContentBlocks ? (
|
|
159
|
-
<ContentBlockRenderer
|
|
160
|
-
contentBlocks={contentBlocks!}
|
|
161
|
-
content={content}
|
|
162
|
-
theme={theme}
|
|
163
|
-
isStreaming={isStreaming}
|
|
164
|
-
roleStyle={roleStyle}
|
|
165
|
-
terminalWidth={terminalWidth}
|
|
166
|
-
prefixOffset={prefixOffset}
|
|
167
|
-
/>
|
|
168
|
-
) : (
|
|
169
|
-
<>
|
|
170
|
-
{/* Legacy path: flat thinking + markdown rendering */}
|
|
171
|
-
{!!thinking && (
|
|
172
|
-
<Box flexDirection="column">
|
|
173
|
-
{isStreaming ? (
|
|
174
|
-
<>
|
|
175
|
-
<Box>
|
|
176
|
-
<Text color={theme.colors.text.muted} dimColor>
|
|
177
|
-
<ThinkingIcon />
|
|
178
|
-
<Text color={theme.colors.text.muted} dimColor italic>thinking</Text>
|
|
179
|
-
</Text>
|
|
180
|
-
</Box>
|
|
181
|
-
<Box flexDirection="column" marginLeft={0}>
|
|
182
|
-
{filteredThinkingBlocks.map((block, index) => (
|
|
183
|
-
<Box key={index}>
|
|
184
|
-
<Text color={theme.colors.text.muted} dimColor italic>
|
|
185
|
-
{block.content}
|
|
186
|
-
</Text>
|
|
187
|
-
</Box>
|
|
188
|
-
))}
|
|
189
|
-
</Box>
|
|
190
|
-
</>
|
|
191
|
-
) : thinking.length > 0 ? (
|
|
192
|
-
<>
|
|
193
|
-
<Box>
|
|
194
|
-
<Text color={theme.colors.text.muted} dimColor>
|
|
195
|
-
<Text color={theme.colors.primary}>{'□ '}</Text>
|
|
196
|
-
<Text color={theme.colors.text.muted} dimColor italic>thought</Text>
|
|
197
|
-
</Text>
|
|
198
|
-
</Box>
|
|
199
|
-
<Box flexDirection="column" marginLeft={0}>
|
|
200
|
-
{filteredThinkingBlocks.map((block, index) => (
|
|
201
|
-
<Box key={index}>
|
|
202
|
-
<Text color={theme.colors.text.muted} dimColor italic>
|
|
203
|
-
{block.content}
|
|
204
|
-
</Text>
|
|
205
|
-
</Box>
|
|
206
|
-
))}
|
|
207
|
-
</Box>
|
|
208
|
-
</>
|
|
209
|
-
) : null}
|
|
210
|
-
</Box>
|
|
211
|
-
)}
|
|
212
|
-
|
|
213
|
-
{filteredBlocks.map((block, index) => (
|
|
214
|
-
<BlockRenderer
|
|
215
|
-
key={index}
|
|
216
|
-
block={block}
|
|
217
|
-
isFirst={index === 0 && filteredThinkingBlocks.length === 0}
|
|
218
|
-
roleStyle={showPrefix ? roleStyle : undefined}
|
|
219
|
-
terminalWidth={terminalWidth}
|
|
220
|
-
theme={theme}
|
|
221
|
-
/>
|
|
222
|
-
))}
|
|
223
|
-
</>
|
|
224
|
-
)}
|
|
225
|
-
|
|
226
|
-
{/* Tool actions — only in legacy path; content block path renders inline */}
|
|
227
|
-
{!shouldUseContentBlocks && hasToolBlocks && (
|
|
228
|
-
<ActionsBlock
|
|
229
|
-
contentBlocks={contentBlocks!}
|
|
230
|
-
theme={theme}
|
|
231
|
-
prefixOffset={prefixOffset}
|
|
232
|
-
/>
|
|
233
|
-
)}
|
|
234
|
-
|
|
235
|
-
{isStreaming && (
|
|
236
|
-
<StreamingCursor prefixOffset={prefixOffset} hasContent={content.length > 0 || (thinking?.length ?? 0) > 0} />
|
|
237
|
-
)}
|
|
238
|
-
</Box>
|
|
239
|
-
)
|
|
240
|
-
},
|
|
241
|
-
messageRendererComparator,
|
|
242
|
-
)
|
|
243
|
-
|
|
244
|
-
MessageRenderer.displayName = 'MessageRenderer'
|
|
245
|
-
|
|
246
|
-
// ===== Content Block Renderer (Claude Code style — inline rendering) =====
|
|
247
|
-
|
|
248
|
-
interface ContentBlockRendererProps {
|
|
249
|
-
contentBlocks: ContentBlock[]
|
|
250
|
-
content: string
|
|
251
|
-
theme: any
|
|
252
|
-
isStreaming?: boolean
|
|
253
|
-
roleStyle?: { color: string; prefix: string; bold?: boolean }
|
|
254
|
-
terminalWidth: number
|
|
255
|
-
prefixOffset: number
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* ContentBlockRenderer — renders structured content blocks inline.
|
|
260
|
-
* Matches Claude Code's rendering: text blocks as markdown, thinking blocks inline,
|
|
261
|
-
* tool_use blocks as ● colored lines, all in sequence.
|
|
262
|
-
*/
|
|
263
|
-
const ContentBlockRenderer: React.FC<ContentBlockRendererProps> = React.memo(({
|
|
264
|
-
contentBlocks,
|
|
265
|
-
content,
|
|
266
|
-
theme,
|
|
267
|
-
isStreaming,
|
|
268
|
-
roleStyle,
|
|
269
|
-
terminalWidth,
|
|
270
|
-
prefixOffset,
|
|
271
|
-
}) => {
|
|
272
|
-
const roleStyleWithPrefix = roleStyle && roleStyle.prefix ? roleStyle : undefined;
|
|
273
|
-
|
|
274
|
-
// Local variable tracks prefix emission within a single render pass.
|
|
275
|
-
// Unlike useRef, a local variable doesn't need reset logic and has no
|
|
276
|
-
// stale-closure risk — it's recreated each render.
|
|
277
|
-
let prefixEmitted = false;
|
|
278
|
-
|
|
279
|
-
const emitPrefix = (): React.ReactNode | null => {
|
|
280
|
-
if (prefixEmitted || !roleStyleWithPrefix) return null;
|
|
281
|
-
prefixEmitted = true;
|
|
282
|
-
return (
|
|
283
|
-
<Box marginRight={1}>
|
|
284
|
-
<Text color={roleStyleWithPrefix.color} bold={roleStyleWithPrefix.bold}>
|
|
285
|
-
{roleStyleWithPrefix.prefix}
|
|
286
|
-
</Text>
|
|
287
|
-
</Box>
|
|
288
|
-
);
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
return (
|
|
292
|
-
<>
|
|
293
|
-
{contentBlocks.map((block, idx) => {
|
|
294
|
-
if (block.type === 'thinking') {
|
|
295
|
-
// A thinking block is "active" during streaming — the buffer's thinking
|
|
296
|
-
// content is still being accumulated. Using idx === last-block check
|
|
297
|
-
// fails when text blocks are merged before tool blocks (thinking is always
|
|
298
|
-
// the first synthesized block, never the last).
|
|
299
|
-
const isActive = isStreaming && block.thinking.length > 0;
|
|
300
|
-
const filtered = parseMarkdown(block.thinking).filter(b => b.type !== 'empty');
|
|
301
|
-
|
|
302
|
-
return (
|
|
303
|
-
<Box key={`cb-think-${idx}`} flexDirection="column">
|
|
304
|
-
{emitPrefix()}
|
|
305
|
-
{isActive ? (
|
|
306
|
-
<>
|
|
307
|
-
<Box>
|
|
308
|
-
<Text color={theme.colors.text.muted} dimColor>
|
|
309
|
-
<ThinkingIcon />
|
|
310
|
-
<Text color={theme.colors.text.muted} dimColor italic>thinking</Text>
|
|
311
|
-
</Text>
|
|
312
|
-
</Box>
|
|
313
|
-
<Box flexDirection="column" marginLeft={2}>
|
|
314
|
-
{filtered.map((b, i) => (
|
|
315
|
-
<Box key={i}>
|
|
316
|
-
<Text color={theme.colors.text.muted} dimColor italic>{b.content}</Text>
|
|
317
|
-
</Box>
|
|
318
|
-
))}
|
|
319
|
-
</Box>
|
|
320
|
-
</>
|
|
321
|
-
) : block.thinking ? (
|
|
322
|
-
<>
|
|
323
|
-
<Box>
|
|
324
|
-
<Text color={theme.colors.text.muted} dimColor>
|
|
325
|
-
<Text color={theme.colors.primary}>{'□ '}</Text>
|
|
326
|
-
<Text color={theme.colors.text.muted} dimColor italic>thought</Text>
|
|
327
|
-
</Text>
|
|
328
|
-
</Box>
|
|
329
|
-
<Box flexDirection="column" marginLeft={2}>
|
|
330
|
-
{filtered.map((b, i) => (
|
|
331
|
-
<Box key={i}>
|
|
332
|
-
<Text color={theme.colors.text.muted} dimColor italic>{b.content}</Text>
|
|
333
|
-
</Box>
|
|
334
|
-
))}
|
|
335
|
-
</Box>
|
|
336
|
-
</>
|
|
337
|
-
) : null}
|
|
338
|
-
</Box>
|
|
339
|
-
);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (block.type === 'text') {
|
|
343
|
-
const parsed = parseMarkdown(block.text);
|
|
344
|
-
const filtered = parsed.filter(b => b.type !== 'empty');
|
|
345
|
-
return (
|
|
346
|
-
<Box key={`cb-text-${idx}`} flexDirection="column">
|
|
347
|
-
{emitPrefix()}
|
|
348
|
-
{filtered.map((b, i) => (
|
|
349
|
-
<BlockRenderer
|
|
350
|
-
key={i}
|
|
351
|
-
block={b}
|
|
352
|
-
isFirst={false}
|
|
353
|
-
roleStyle={undefined}
|
|
354
|
-
terminalWidth={terminalWidth}
|
|
355
|
-
theme={theme}
|
|
356
|
-
/>
|
|
357
|
-
))}
|
|
358
|
-
</Box>
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
if (block.type === 'tool_use') {
|
|
363
|
-
const result = contentBlocks.find(
|
|
364
|
-
b => b.type === 'tool_result' && (b as any).tool_use_id === block.id
|
|
365
|
-
) as (ContentBlock & { type: 'tool_result' }) | undefined
|
|
366
|
-
|
|
367
|
-
return (
|
|
368
|
-
<ToolUseBlock
|
|
369
|
-
key={`cb-tool-${block.id || idx}`}
|
|
370
|
-
block={block}
|
|
371
|
-
result={result}
|
|
372
|
-
theme={theme}
|
|
373
|
-
prefixOffset={prefixOffset}
|
|
374
|
-
/>
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
return null;
|
|
379
|
-
})}
|
|
380
|
-
|
|
381
|
-
{/* Render any remaining text content from the message's content prop
|
|
382
|
-
(not duplicated in contentBlocks as text blocks).
|
|
383
|
-
During streaming, MessageList synthesizes text/thinking blocks into
|
|
384
|
-
contentBlocks so they render inline. After finishStreamingMessage the
|
|
385
|
-
store message's contentBlocks only has tool_use/tool_result blocks
|
|
386
|
-
(no text block) — the text lives in the `content` prop and needs
|
|
387
|
-
this fallback. The null guard prevents crashes if contentBlocks is
|
|
388
|
-
somehow undefined when shouldUseContentBlocks was true. */}
|
|
389
|
-
{content && (!contentBlocks || contentBlocks.every(b => b.type !== 'text')) && (
|
|
390
|
-
<Box key="remaining-text" flexDirection="column">
|
|
391
|
-
{emitPrefix()}
|
|
392
|
-
{parseMarkdown(content)
|
|
393
|
-
.filter(b => b.type !== 'empty')
|
|
394
|
-
.map((b, i) => (
|
|
395
|
-
<BlockRenderer
|
|
396
|
-
key={i}
|
|
397
|
-
block={b}
|
|
398
|
-
isFirst={false}
|
|
399
|
-
roleStyle={undefined}
|
|
400
|
-
terminalWidth={terminalWidth}
|
|
401
|
-
theme={theme}
|
|
402
|
-
/>
|
|
403
|
-
))}
|
|
404
|
-
</Box>
|
|
405
|
-
)}
|
|
406
|
-
</>
|
|
407
|
-
);
|
|
408
|
-
}, (prev, next) =>
|
|
409
|
-
prev.contentBlocks === next.contentBlocks &&
|
|
410
|
-
prev.content === next.content &&
|
|
411
|
-
prev.isStreaming === next.isStreaming &&
|
|
412
|
-
prev.terminalWidth === next.terminalWidth &&
|
|
413
|
-
prev.prefixOffset === next.prefixOffset &&
|
|
414
|
-
prev.theme.colors === next.theme.colors
|
|
415
|
-
);
|
|
416
|
-
|
|
417
|
-
// ===== Streaming Cursor Component (animated) =====
|
|
418
|
-
|
|
419
|
-
// Braille spinner — shown before first token (matches Claude Code style)
|
|
420
|
-
const SPIN_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
421
|
-
const SPIN_INTERVAL = 80
|
|
422
|
-
|
|
423
|
-
// Smooth breathing bar — shown while text is streaming
|
|
424
|
-
const CURSOR_FRAMES = ['▏', '▎', '▍', '▌', '▌', '▍', '▎', '▏', '▏', ' ', ' ', ' ']
|
|
425
|
-
const CURSOR_INTERVAL = 65
|
|
426
|
-
|
|
427
|
-
const StreamingCursor: React.FC<{ prefixOffset: number; hasContent: boolean }> = React.memo(
|
|
428
|
-
({ prefixOffset, hasContent }) => {
|
|
429
|
-
const theme = themeManager.getTheme()
|
|
430
|
-
const [frame, setFrame] = useState(0)
|
|
431
|
-
|
|
432
|
-
useEffect(() => {
|
|
433
|
-
setFrame(0)
|
|
434
|
-
const interval = setInterval(
|
|
435
|
-
() => setFrame(f => (f + 1) % (hasContent ? CURSOR_FRAMES.length : SPIN_FRAMES.length)),
|
|
436
|
-
hasContent ? CURSOR_INTERVAL : SPIN_INTERVAL
|
|
437
|
-
)
|
|
438
|
-
return () => clearInterval(interval)
|
|
439
|
-
}, [hasContent])
|
|
440
|
-
|
|
441
|
-
return (
|
|
442
|
-
<Box marginLeft={prefixOffset}>
|
|
443
|
-
<Text color={theme.colors.primary}>
|
|
444
|
-
{hasContent ? CURSOR_FRAMES[frame] : SPIN_FRAMES[frame]}
|
|
445
|
-
</Text>
|
|
446
|
-
</Box>
|
|
447
|
-
)
|
|
448
|
-
}
|
|
449
|
-
)
|
|
450
|
-
|
|
451
|
-
StreamingCursor.displayName = 'StreamingCursor'
|
|
452
|
-
|
|
453
|
-
// ===== Animated thinking icon =====
|
|
454
|
-
const THINK_FRAMES = ['◌', '○', '◎', '●', '◎', '○']
|
|
455
|
-
const THINK_INTERVAL = 180
|
|
456
|
-
|
|
457
|
-
const ThinkingIcon: React.FC = React.memo(() => {
|
|
458
|
-
const theme = themeManager.getTheme()
|
|
459
|
-
const [frame, setFrame] = useState(0)
|
|
460
|
-
useEffect(() => {
|
|
461
|
-
const t = setInterval(() => setFrame(f => (f + 1) % THINK_FRAMES.length), THINK_INTERVAL)
|
|
462
|
-
return () => clearInterval(t)
|
|
463
|
-
}, [])
|
|
464
|
-
return <Text color={theme.colors.primary}>{THINK_FRAMES[frame]}{' '}</Text>
|
|
465
|
-
})
|
|
466
|
-
ThinkingIcon.displayName = 'ThinkingIcon'
|
|
467
|
-
|
|
468
|
-
// ===== Tool Use Block (shared between ContentBlockRenderer and ActionsBlock) =====
|
|
469
|
-
|
|
470
|
-
interface ToolUseBlockProps {
|
|
471
|
-
block: ContentBlock & { type: 'tool_use' }
|
|
472
|
-
result?: ContentBlock & { type: 'tool_result' }
|
|
473
|
-
theme: any
|
|
474
|
-
prefixOffset: number
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
const ToolUseBlock: React.FC<ToolUseBlockProps> = React.memo(({
|
|
478
|
-
block,
|
|
479
|
-
result,
|
|
480
|
-
theme,
|
|
481
|
-
prefixOffset,
|
|
482
|
-
}) => {
|
|
483
|
-
const isError = block.status === 'error'
|
|
484
|
-
const isRunning = block.status === 'running'
|
|
485
|
-
const dotColor = isError ? DOT_ERR : isRunning ? DOT_RUN : DOT_OK
|
|
486
|
-
const elapsed = block.completedAt ? formatElapsed(block.completedAt - block.startedAt) : null
|
|
487
|
-
const summary = getToolSummary(block.name, block.input)
|
|
488
|
-
const { label, path } = splitToolSummary(summary)
|
|
489
|
-
|
|
490
|
-
const subLines: Array<{ text: string; isUrl: boolean; isDiff?: boolean; diffType?: '+' | '-' }> = []
|
|
491
|
-
if (result?.content) {
|
|
492
|
-
const lines = result.content.split('\n').filter(l => l.trim())
|
|
493
|
-
const diffLines = lines.filter(l => /^[+-]/.test(l) && !l.startsWith('+++') && !l.startsWith('---'))
|
|
494
|
-
if (diffLines.length > 0) {
|
|
495
|
-
const added = diffLines.filter(l => l.startsWith('+')).length
|
|
496
|
-
const removed = diffLines.filter(l => l.startsWith('-')).length
|
|
497
|
-
if (added > 0 || removed > 0) {
|
|
498
|
-
subLines.push({ text: `${added > 0 ? `+${added}` : ''}${removed > 0 ? ` -${removed}` : ''} lines`, isUrl: false })
|
|
499
|
-
}
|
|
500
|
-
diffLines.slice(0, 5).forEach(l => {
|
|
501
|
-
subLines.push({ text: l, isUrl: false, isDiff: true, diffType: l.startsWith('+') ? '+' : '-' })
|
|
502
|
-
})
|
|
503
|
-
} else {
|
|
504
|
-
lines.slice(0, 4).forEach(line => {
|
|
505
|
-
const trimmed = line.length > 100 ? line.slice(0, 97) + '…' : line
|
|
506
|
-
subLines.push({ text: trimmed, isUrl: /^https?:\/\//.test(trimmed) })
|
|
507
|
-
})
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
return (
|
|
512
|
-
<Box flexDirection="column" marginLeft={prefixOffset}>
|
|
513
|
-
<Box>
|
|
514
|
-
<Text color={dotColor} bold>{'● '}</Text>
|
|
515
|
-
<Text color={theme.colors.text.primary} bold>{label}</Text>
|
|
516
|
-
{path && <Text color={isError ? DOT_ERR : DOT_OK}>{path}</Text>}
|
|
517
|
-
{elapsed && <Text color={theme.colors.text.muted} dimColor>{' '}{elapsed}</Text>}
|
|
518
|
-
</Box>
|
|
519
|
-
{subLines.map((sub, i) => (
|
|
520
|
-
<Box key={i} marginLeft={2}>
|
|
521
|
-
<Text color={theme.colors.text.muted} dimColor>
|
|
522
|
-
{i === 0 ? '└ ' : ' '}
|
|
523
|
-
</Text>
|
|
524
|
-
{sub.isDiff ? (
|
|
525
|
-
<Box backgroundColor={sub.diffType === '+' ? '#0d2b0d' : '#2b0d0d'}>
|
|
526
|
-
<Text color={sub.diffType === '+' ? DOT_OK : DOT_ERR}>{sub.text}</Text>
|
|
527
|
-
</Box>
|
|
528
|
-
) : sub.isUrl ? (
|
|
529
|
-
<Text color="#58a6ff" underline>{sub.text}</Text>
|
|
530
|
-
) : (
|
|
531
|
-
<Text color={isError ? DOT_ERR : theme.colors.text.muted} dimColor={!isError}>{sub.text}</Text>
|
|
532
|
-
)}
|
|
533
|
-
</Box>
|
|
534
|
-
))}
|
|
535
|
-
</Box>
|
|
536
|
-
)
|
|
537
|
-
}, (prev, next) =>
|
|
538
|
-
prev.block === next.block &&
|
|
539
|
-
prev.result === next.result &&
|
|
540
|
-
prev.theme === next.theme &&
|
|
541
|
-
prev.prefixOffset === next.prefixOffset
|
|
542
|
-
)
|
|
543
|
-
|
|
544
|
-
ToolUseBlock.displayName = 'ToolUseBlock'
|
|
545
|
-
|
|
546
|
-
// ===== Tool Call Visual Components =====
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
function shortenPath(p: string): string {
|
|
550
|
-
if (p.length <= 45) return p
|
|
551
|
-
const parts = p.split('/')
|
|
552
|
-
return parts.length > 3 ? '…/' + parts.slice(-2).join('/') : p
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
function getToolSummary(name: string, input: string): string {
|
|
556
|
-
if (!input) return name
|
|
557
|
-
try {
|
|
558
|
-
const args = JSON.parse(input)
|
|
559
|
-
switch (name) {
|
|
560
|
-
case 'Read':
|
|
561
|
-
case 'Write':
|
|
562
|
-
case 'Edit':
|
|
563
|
-
return `${name}(${shortenPath(args.file_path || '')})`
|
|
564
|
-
case 'Bash': {
|
|
565
|
-
const cmd = (args.command || '').trim().replace(/\n/g, ' ')
|
|
566
|
-
return `${name}(${cmd.length > 60 ? cmd.slice(0, 57) + '…' : cmd})`
|
|
567
|
-
}
|
|
568
|
-
case 'Glob':
|
|
569
|
-
return `${name}(${args.pattern || ''})`
|
|
570
|
-
case 'Grep':
|
|
571
|
-
return `${name}(${args.pattern || ''}${args.path ? ` in ${shortenPath(args.path)}` : ''})`
|
|
572
|
-
case 'Task': {
|
|
573
|
-
const tasks = Array.isArray(args.tasks) ? args.tasks : []
|
|
574
|
-
if (tasks.length === 0) return name
|
|
575
|
-
const first = String(tasks[0]?.description || '')
|
|
576
|
-
const summary = tasks.length > 1 ? `${first} +${tasks.length - 1} more` : first
|
|
577
|
-
return `${name}(${summary.length > 45 ? summary.slice(0, 42) + '…' : summary})`
|
|
578
|
-
}
|
|
579
|
-
default: {
|
|
580
|
-
const entries = Object.entries(args)
|
|
581
|
-
if (entries.length === 0) return name
|
|
582
|
-
const [, val] = entries[0]
|
|
583
|
-
const valStr = String(val)
|
|
584
|
-
return `${name}(${valStr.length > 45 ? valStr.slice(0, 42) + '…' : valStr})`
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
} catch {
|
|
588
|
-
return name
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
// Split "Name(path)" into {label, path} for separate coloring
|
|
593
|
-
function splitToolSummary(summary: string): { label: string; path: string } {
|
|
594
|
-
const m = summary.match(/^([^(]+)(\(.+\))$/)
|
|
595
|
-
if (m) return { label: m[1], path: m[2] }
|
|
596
|
-
return { label: summary, path: '' }
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
function formatElapsed(ms: number): string {
|
|
600
|
-
if (ms < 1000) return `${ms}ms`
|
|
601
|
-
return `${(ms / 1000).toFixed(1)}s`
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
// ===== Colors =====
|
|
605
|
-
const DOT_OK = '#3fb950'
|
|
606
|
-
const DOT_ERR = '#f85149'
|
|
607
|
-
const DOT_RUN = '#e3b341'
|
|
608
|
-
|
|
609
|
-
// ===== ActionsBlock — delegates to ToolUseBlock =====
|
|
610
|
-
|
|
611
|
-
interface ActionsBlockProps {
|
|
612
|
-
contentBlocks: ContentBlock[]
|
|
613
|
-
theme: any
|
|
614
|
-
prefixOffset: number
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
const ActionsBlock: React.FC<ActionsBlockProps> = React.memo(({ contentBlocks, theme, prefixOffset }) => {
|
|
618
|
-
const toolBlocks = contentBlocks.filter(b => b.type === 'tool_use')
|
|
619
|
-
if (toolBlocks.length === 0) return null
|
|
620
|
-
|
|
621
|
-
return (
|
|
622
|
-
<Box flexDirection="column">
|
|
623
|
-
{contentBlocks.map((block, idx) => {
|
|
624
|
-
if (block.type !== 'tool_use') return null
|
|
625
|
-
|
|
626
|
-
const result = contentBlocks.find(
|
|
627
|
-
b => b.type === 'tool_result' && (b as any).tool_use_id === block.id
|
|
628
|
-
) as (ContentBlock & { type: 'tool_result' }) | undefined
|
|
629
|
-
|
|
630
|
-
return (
|
|
631
|
-
<ToolUseBlock
|
|
632
|
-
key={`action-${block.id || idx}`}
|
|
633
|
-
block={block}
|
|
634
|
-
result={result}
|
|
635
|
-
theme={theme}
|
|
636
|
-
prefixOffset={0}
|
|
637
|
-
/>
|
|
638
|
-
)
|
|
639
|
-
})}
|
|
640
|
-
</Box>
|
|
641
|
-
)
|
|
642
|
-
}, (prev, next) =>
|
|
643
|
-
prev.contentBlocks === next.contentBlocks &&
|
|
644
|
-
prev.theme === next.theme &&
|
|
645
|
-
prev.prefixOffset === next.prefixOffset
|
|
646
|
-
)
|
|
647
|
-
|
|
648
|
-
// ===== Block Renderer =====
|
|
649
|
-
|
|
650
|
-
interface BlockRendererProps {
|
|
651
|
-
block: ParsedBlock
|
|
652
|
-
isFirst: boolean
|
|
653
|
-
roleStyle?: { color: string; prefix: string; bold?: boolean }
|
|
654
|
-
terminalWidth: number
|
|
655
|
-
theme: ReturnType<typeof themeManager.getTheme>
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
const BlockRenderer: React.FC<BlockRendererProps> = React.memo(({
|
|
659
|
-
block,
|
|
660
|
-
isFirst,
|
|
661
|
-
roleStyle,
|
|
662
|
-
terminalWidth,
|
|
663
|
-
theme,
|
|
664
|
-
}) => {
|
|
665
|
-
const prefixWidth = roleStyle?.prefix.length ?? 0
|
|
666
|
-
const contentWidth = terminalWidth - prefixWidth - 2
|
|
667
|
-
|
|
668
|
-
if (block.type === 'empty') {
|
|
669
|
-
return null
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
const roleStyleWithPrefix = roleStyle && roleStyle.prefix ? roleStyle : undefined;
|
|
673
|
-
|
|
674
|
-
return (
|
|
675
|
-
<Box flexDirection="row">
|
|
676
|
-
{isFirst && roleStyleWithPrefix && (
|
|
677
|
-
<Box marginRight={1}>
|
|
678
|
-
<Text color={roleStyleWithPrefix.color} bold={roleStyleWithPrefix.bold}>
|
|
679
|
-
{roleStyleWithPrefix.prefix}
|
|
680
|
-
</Text>
|
|
681
|
-
</Box>
|
|
682
|
-
)}
|
|
683
|
-
{!isFirst && roleStyleWithPrefix && <Box width={prefixWidth + 1} />}
|
|
684
|
-
|
|
685
|
-
<Box flexGrow={1} flexShrink={1}>
|
|
686
|
-
{block.type === 'code' ? (
|
|
687
|
-
<CodeBlock
|
|
688
|
-
content={block.content}
|
|
689
|
-
language={block.language}
|
|
690
|
-
filePath={block.filePath}
|
|
691
|
-
theme={theme}
|
|
692
|
-
/>
|
|
693
|
-
) : block.type === 'heading' ? (
|
|
694
|
-
<Heading
|
|
695
|
-
content={block.content}
|
|
696
|
-
level={block.level || 1}
|
|
697
|
-
theme={theme}
|
|
698
|
-
/>
|
|
699
|
-
) : block.type === 'list' ? (
|
|
700
|
-
<ListItem
|
|
701
|
-
content={block.content}
|
|
702
|
-
listType={block.listType}
|
|
703
|
-
marker={block.marker}
|
|
704
|
-
indent={block.indent}
|
|
705
|
-
theme={theme}
|
|
706
|
-
/>
|
|
707
|
-
) : block.type === 'hr' ? (
|
|
708
|
-
<HorizontalRule width={contentWidth} theme={theme} />
|
|
709
|
-
) : block.type === 'table' && block.tableData ? (
|
|
710
|
-
<TableRenderer
|
|
711
|
-
headers={block.tableData.headers}
|
|
712
|
-
rows={block.tableData.rows}
|
|
713
|
-
alignments={block.tableData.alignments}
|
|
714
|
-
theme={theme}
|
|
715
|
-
maxWidth={contentWidth}
|
|
716
|
-
/>
|
|
717
|
-
) : block.type === 'blockquote' ? (
|
|
718
|
-
<Blockquote content={block.content} theme={theme} />
|
|
719
|
-
) : (
|
|
720
|
-
<TextBlock content={block.content} theme={theme} />
|
|
721
|
-
)}
|
|
722
|
-
</Box>
|
|
723
|
-
</Box>
|
|
724
|
-
)
|
|
725
|
-
}, (prev, next) =>
|
|
726
|
-
prev.block === next.block &&
|
|
727
|
-
prev.isFirst === next.isFirst &&
|
|
728
|
-
prev.terminalWidth === next.terminalWidth &&
|
|
729
|
-
prev.theme === next.theme
|
|
730
|
-
)
|
|
731
|
-
|
|
732
|
-
// =====
|
|
733
|
-
|
|
734
|
-
interface ThemedProps {
|
|
735
|
-
theme: ReturnType<typeof themeManager.getTheme>
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
const CodeBlock: React.FC<
|
|
739
|
-
{ content: string; language?: string; filePath?: string } & ThemedProps
|
|
740
|
-
> = ({ content, language, filePath }) => {
|
|
741
|
-
return (
|
|
742
|
-
<CodeHighlighter
|
|
743
|
-
content={content}
|
|
744
|
-
language={language}
|
|
745
|
-
filePath={filePath}
|
|
746
|
-
showLineNumbers={true}
|
|
747
|
-
/>
|
|
748
|
-
)
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
const Heading: React.FC<{ content: string; level: number } & ThemedProps> = ({
|
|
752
|
-
content,
|
|
753
|
-
level,
|
|
754
|
-
theme,
|
|
755
|
-
}) => {
|
|
756
|
-
const color =
|
|
757
|
-
level === 1
|
|
758
|
-
? theme.colors.primary
|
|
759
|
-
: level === 2
|
|
760
|
-
? theme.colors.secondary
|
|
761
|
-
: level === 3
|
|
762
|
-
? theme.colors.accent
|
|
763
|
-
: theme.colors.text.primary
|
|
764
|
-
|
|
765
|
-
const marginY = level <= 2 ? 1 : 0
|
|
766
|
-
const underline = level === 1
|
|
767
|
-
|
|
768
|
-
const hasInlineFormat = /\*\*|`|~~|\[.*\]\(/.test(content)
|
|
769
|
-
|
|
770
|
-
return (
|
|
771
|
-
<Box flexDirection="column" marginY={marginY}>
|
|
772
|
-
{hasInlineFormat ? (
|
|
773
|
-
<Text color={color} bold underline={underline}>
|
|
774
|
-
<HeadingInlineText
|
|
775
|
-
content={content}
|
|
776
|
-
theme={theme}
|
|
777
|
-
baseColor={color}
|
|
778
|
-
/>
|
|
779
|
-
</Text>
|
|
780
|
-
) : (
|
|
781
|
-
<Text color={color} bold underline={underline}>
|
|
782
|
-
{content}
|
|
783
|
-
</Text>
|
|
784
|
-
)}
|
|
785
|
-
</Box>
|
|
786
|
-
)
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
const ListItem: React.FC<
|
|
790
|
-
{
|
|
791
|
-
content: string
|
|
792
|
-
listType?: 'ul' | 'ol'
|
|
793
|
-
marker?: string
|
|
794
|
-
indent?: number
|
|
795
|
-
} & ThemedProps
|
|
796
|
-
> = ({ content, listType, marker, indent = 0, theme }) => {
|
|
797
|
-
const indentStr = ' '.repeat(Math.floor(indent / 2))
|
|
798
|
-
const bulletColor =
|
|
799
|
-
listType === 'ol' ? theme.colors.info : theme.colors.success
|
|
800
|
-
|
|
801
|
-
return (
|
|
802
|
-
<Box>
|
|
803
|
-
<Text>
|
|
804
|
-
{indentStr}
|
|
805
|
-
<Text color={bulletColor}>{marker || '•'}</Text>{' '}
|
|
806
|
-
</Text>
|
|
807
|
-
<Text wrap="wrap">
|
|
808
|
-
<InlineText content={content} theme={theme} />
|
|
809
|
-
</Text>
|
|
810
|
-
</Box>
|
|
811
|
-
)
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
const HorizontalRule: React.FC<{ width: number } & ThemedProps> = ({
|
|
815
|
-
width,
|
|
816
|
-
theme,
|
|
817
|
-
}) => (
|
|
818
|
-
<Box marginY={1}>
|
|
819
|
-
<Text color={theme.colors.border.light}>
|
|
820
|
-
{'─'.repeat(Math.max(width, 10))}
|
|
821
|
-
</Text>
|
|
822
|
-
</Box>
|
|
823
|
-
)
|
|
824
|
-
|
|
825
|
-
const stripMarkdownForWidth = (text: string): string => {
|
|
826
|
-
return text
|
|
827
|
-
.replace(/\*\*(.+?)\*\*/g, '$1')
|
|
828
|
-
.replace(/\*(.+?)\*/g, '$1')
|
|
829
|
-
.replace(/`([^`]+)`/g, '$1')
|
|
830
|
-
.replace(/~~(.+?)~~/g, '$1')
|
|
831
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
/**
|
|
835
|
-
* Get theme-aware diff color for Before/After table columns.
|
|
836
|
-
* Returns theme.colors.error for "before"-like headers,
|
|
837
|
-
* theme.colors.success for "after"-like headers.
|
|
838
|
-
*/
|
|
839
|
-
function getDiffColumnColor(header: string, theme: ReturnType<typeof themeManager.getTheme>): string | null {
|
|
840
|
-
const h = header.trim().toLowerCase()
|
|
841
|
-
if (/^(before|old|previous|removed?|from)$/.test(h)) return theme.colors.error
|
|
842
|
-
if (/^(after|new|updated?|added?|to)$/.test(h)) return theme.colors.success
|
|
843
|
-
return null
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
const MIN_COL_WIDTH = 6
|
|
847
|
-
const TRUNCATION_MARKER = '…'
|
|
848
|
-
|
|
849
|
-
/** Truncate cell content to fit within `maxWidth`, appending `…` if cut. */
|
|
850
|
-
function truncateCell(content: string, maxWidth: number): string {
|
|
851
|
-
const cleaned = stripMarkdownForWidth(content)
|
|
852
|
-
const raw = stringWidth(cleaned)
|
|
853
|
-
if (raw <= maxWidth) return renderRawCell(content, maxWidth, 'left')
|
|
854
|
-
// Find safe truncation point respecting ansi / markdown
|
|
855
|
-
let visible = 0
|
|
856
|
-
let i = 0
|
|
857
|
-
const chars = [...content]
|
|
858
|
-
for (; i < chars.length; i++) {
|
|
859
|
-
const ch = chars[i]
|
|
860
|
-
visible += stringWidth(ch)
|
|
861
|
-
if (visible > maxWidth - 1) break
|
|
862
|
-
}
|
|
863
|
-
return content.slice(0, i) + TRUNCATION_MARKER
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
function renderRawCell(
|
|
867
|
-
content: string,
|
|
868
|
-
width: number,
|
|
869
|
-
align: 'left' | 'center' | 'right',
|
|
870
|
-
): string {
|
|
871
|
-
const actualWidth = stringWidth(stripMarkdownForWidth(content))
|
|
872
|
-
const padding = Math.max(0, width - actualWidth)
|
|
873
|
-
if (align === 'center') {
|
|
874
|
-
const left = Math.floor(padding / 2)
|
|
875
|
-
const right = padding - left
|
|
876
|
-
return ' '.repeat(left) + content + ' '.repeat(right)
|
|
877
|
-
}
|
|
878
|
-
if (align === 'right') {
|
|
879
|
-
return ' '.repeat(padding) + content
|
|
880
|
-
}
|
|
881
|
-
return content + ' '.repeat(padding)
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
const TableRenderer: React.FC<
|
|
885
|
-
{
|
|
886
|
-
headers: string[]
|
|
887
|
-
rows: string[][]
|
|
888
|
-
alignments: ('left' | 'center' | 'right')[]
|
|
889
|
-
maxWidth: number
|
|
890
|
-
} & ThemedProps
|
|
891
|
-
> = ({ headers, rows, alignments, theme, maxWidth }) => {
|
|
892
|
-
const borderCost = 1 + headers.length * 2 // leading + each col pair (cell│)
|
|
893
|
-
const available = maxWidth - borderCost - 2 // safety margin
|
|
894
|
-
|
|
895
|
-
// 1. Compute natural column widths
|
|
896
|
-
const naturalWidths = headers.map((header, index) => {
|
|
897
|
-
const headerWidth = stringWidth(stripMarkdownForWidth(header))
|
|
898
|
-
const maxRowWidth = Math.max(
|
|
899
|
-
0,
|
|
900
|
-
...rows.map((row) =>
|
|
901
|
-
stringWidth(stripMarkdownForWidth(row[index] || '')),
|
|
902
|
-
),
|
|
903
|
-
)
|
|
904
|
-
return Math.max(headerWidth, maxRowWidth) + 2
|
|
905
|
-
})
|
|
906
|
-
|
|
907
|
-
const totalNatural = naturalWidths.reduce((a, b) => a + b, 0)
|
|
908
|
-
|
|
909
|
-
// 2. Clamp column widths if they exceed available space
|
|
910
|
-
const columnWidths =
|
|
911
|
-
totalNatural <= available
|
|
912
|
-
? naturalWidths
|
|
913
|
-
: naturalWidths.map((w) => Math.max(MIN_COL_WIDTH, Math.floor(w * (available / totalNatural))))
|
|
914
|
-
|
|
915
|
-
const truncated = totalNatural > available
|
|
916
|
-
|
|
917
|
-
// Detect if any column is a diff column (Before/After/etc.)
|
|
918
|
-
const diffColors = headers.map(h => getDiffColumnColor(h, theme))
|
|
919
|
-
const isDiffTable = diffColors.some(c => c !== null)
|
|
920
|
-
|
|
921
|
-
const renderCell = (
|
|
922
|
-
content: string,
|
|
923
|
-
width: number,
|
|
924
|
-
align: 'left' | 'center' | 'right',
|
|
925
|
-
) => {
|
|
926
|
-
if (truncated) {
|
|
927
|
-
return truncateCell(content, width)
|
|
928
|
-
}
|
|
929
|
-
return renderRawCell(content, width, align)
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
return (
|
|
933
|
-
<Box flexDirection="column" marginY={1}>
|
|
934
|
-
<Box>
|
|
935
|
-
<Text color={theme.colors.border.light}>│</Text>
|
|
936
|
-
{headers.map((header, index) => {
|
|
937
|
-
const diffColor = diffColors[index]
|
|
938
|
-
return (
|
|
939
|
-
<React.Fragment key={index}>
|
|
940
|
-
<Text bold color={diffColor ?? theme.colors.primary}>
|
|
941
|
-
{renderCell(header, columnWidths[index], alignments[index] || 'left')}
|
|
942
|
-
</Text>
|
|
943
|
-
<Text color={theme.colors.border.light}>│</Text>
|
|
944
|
-
</React.Fragment>
|
|
945
|
-
)
|
|
946
|
-
})}
|
|
947
|
-
</Box>
|
|
948
|
-
|
|
949
|
-
<Box>
|
|
950
|
-
<Text color={theme.colors.border.light}>├</Text>
|
|
951
|
-
{columnWidths.map((width, index) => (
|
|
952
|
-
<React.Fragment key={index}>
|
|
953
|
-
<Text color={theme.colors.border.light}>{'─'.repeat(width)}</Text>
|
|
954
|
-
<Text color={theme.colors.border.light}>
|
|
955
|
-
{index < columnWidths.length - 1 ? '┼' : '┤'}
|
|
956
|
-
</Text>
|
|
957
|
-
</React.Fragment>
|
|
958
|
-
))}
|
|
959
|
-
</Box>
|
|
960
|
-
|
|
961
|
-
{rows.map((row, rowIndex) => (
|
|
962
|
-
<Box key={rowIndex}>
|
|
963
|
-
<Text color={theme.colors.border.light}>│</Text>
|
|
964
|
-
{headers.map((_, colIndex) => {
|
|
965
|
-
const cellContent = row[colIndex] || ''
|
|
966
|
-
const renderedContent = renderCell(
|
|
967
|
-
cellContent,
|
|
968
|
-
columnWidths[colIndex],
|
|
969
|
-
alignments[colIndex] || 'left',
|
|
970
|
-
)
|
|
971
|
-
const diffColor = diffColors[colIndex]
|
|
972
|
-
const hasInlineFormat = /\*\*|`|~~|\[.*\]\(/.test(cellContent)
|
|
973
|
-
return (
|
|
974
|
-
<React.Fragment key={colIndex}>
|
|
975
|
-
{diffColor ? (
|
|
976
|
-
<Text color={diffColor} dimColor={rowIndex % 2 === 0}>
|
|
977
|
-
{renderedContent}
|
|
978
|
-
</Text>
|
|
979
|
-
) : hasInlineFormat ? (
|
|
980
|
-
<Text>
|
|
981
|
-
<InlineText content={renderedContent} theme={theme} />
|
|
982
|
-
</Text>
|
|
983
|
-
) : (
|
|
984
|
-
<Text>{renderedContent}</Text>
|
|
985
|
-
)}
|
|
986
|
-
<Text color={theme.colors.border.light}>│</Text>
|
|
987
|
-
</React.Fragment>
|
|
988
|
-
)
|
|
989
|
-
})}
|
|
990
|
-
</Box>
|
|
991
|
-
))}
|
|
992
|
-
|
|
993
|
-
{truncated && (
|
|
994
|
-
<Box>
|
|
995
|
-
<Text dimColor color={theme.colors.text.secondary}>
|
|
996
|
-
{` ╚══ ${TRUNCATION_MARKER} columns scaled to fit terminal width (${maxWidth} cols)`}
|
|
997
|
-
</Text>
|
|
998
|
-
</Box>
|
|
999
|
-
)}
|
|
1000
|
-
</Box>
|
|
1001
|
-
)
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
const Blockquote: React.FC<{ content: string } & ThemedProps> = ({
|
|
1005
|
-
content,
|
|
1006
|
-
theme,
|
|
1007
|
-
}) => (
|
|
1008
|
-
<Box>
|
|
1009
|
-
<Text color={theme.colors.border.light}>│ </Text>
|
|
1010
|
-
<Text color={theme.colors.text.muted} italic wrap="wrap">
|
|
1011
|
-
{content}
|
|
1012
|
-
</Text>
|
|
1013
|
-
</Box>
|
|
1014
|
-
)
|
|
1015
|
-
|
|
1016
|
-
const TOOLCALL_RE = /^\s{2,}(\S+)\s*(.*?)\s*(✓|✗.*)$/
|
|
1017
|
-
|
|
1018
|
-
const ToolCallLine: React.FC<{ content: string } & ThemedProps> = ({
|
|
1019
|
-
content,
|
|
1020
|
-
theme,
|
|
1021
|
-
}) => {
|
|
1022
|
-
const m = content.match(TOOLCALL_RE)
|
|
1023
|
-
if (!m) return <Text dimColor>{content}</Text>
|
|
1024
|
-
|
|
1025
|
-
const [, name, args, result] = m
|
|
1026
|
-
const isErr = result.startsWith('✗')
|
|
1027
|
-
|
|
1028
|
-
return (
|
|
1029
|
-
<Box>
|
|
1030
|
-
<Text dimColor color={theme.colors.text.muted}>{' '}</Text>
|
|
1031
|
-
<Text dimColor color={theme.colors.text.secondary}>{name}</Text>
|
|
1032
|
-
{args ? <Text dimColor color={theme.colors.text.muted}>{' '}{args}</Text> : null}
|
|
1033
|
-
<Text dimColor color={isErr ? theme.colors.error : theme.colors.success}>{' '}{result}</Text>
|
|
1034
|
-
</Box>
|
|
1035
|
-
)
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
const TextBlock: React.FC<{ content: string } & ThemedProps> = ({
|
|
1039
|
-
content,
|
|
1040
|
-
theme,
|
|
1041
|
-
}) => {
|
|
1042
|
-
if (TOOLCALL_RE.test(content)) {
|
|
1043
|
-
return <ToolCallLine content={content} theme={theme} />
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
return (
|
|
1047
|
-
<Text wrap="wrap">
|
|
1048
|
-
<InlineText content={content} theme={theme} />
|
|
1049
|
-
</Text>
|
|
1050
|
-
)
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
const HeadingInlineText: React.FC<
|
|
1054
|
-
{ content: string; baseColor: string } & ThemedProps
|
|
1055
|
-
> = ({ content, theme, baseColor }) => {
|
|
1056
|
-
const segments = parseInline(content)
|
|
1057
|
-
|
|
1058
|
-
return (
|
|
1059
|
-
<>
|
|
1060
|
-
{segments.map((seg, i) => {
|
|
1061
|
-
switch (seg.type) {
|
|
1062
|
-
case 'bold':
|
|
1063
|
-
return (
|
|
1064
|
-
<Text key={i} bold color={theme.colors.text.primary}>
|
|
1065
|
-
{seg.text}
|
|
1066
|
-
</Text>
|
|
1067
|
-
)
|
|
1068
|
-
case 'code':
|
|
1069
|
-
return (
|
|
1070
|
-
<Text key={i} color={theme.colors.accent}>
|
|
1071
|
-
{seg.text}
|
|
1072
|
-
</Text>
|
|
1073
|
-
)
|
|
1074
|
-
case 'strikethrough':
|
|
1075
|
-
return (
|
|
1076
|
-
<Text key={i} strikethrough color={theme.colors.text.muted}>
|
|
1077
|
-
{seg.text}
|
|
1078
|
-
</Text>
|
|
1079
|
-
)
|
|
1080
|
-
case 'link':
|
|
1081
|
-
return (
|
|
1082
|
-
<Text key={i} color={theme.colors.info} underline>
|
|
1083
|
-
{seg.text}
|
|
1084
|
-
</Text>
|
|
1085
|
-
)
|
|
1086
|
-
default:
|
|
1087
|
-
return <Text key={i}>{seg.text}</Text>
|
|
1088
|
-
}
|
|
1089
|
-
})}
|
|
1090
|
-
</>
|
|
1091
|
-
)
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
const InlineText: React.FC<{ content: string } & ThemedProps> = ({
|
|
1095
|
-
content,
|
|
1096
|
-
theme,
|
|
1097
|
-
}) => {
|
|
1098
|
-
const segments = parseInline(content)
|
|
1099
|
-
|
|
1100
|
-
return (
|
|
1101
|
-
<>
|
|
1102
|
-
{segments.map((seg, i) => {
|
|
1103
|
-
switch (seg.type) {
|
|
1104
|
-
case 'bold':
|
|
1105
|
-
return (
|
|
1106
|
-
<Text key={i} bold color={theme.colors.text.primary}>
|
|
1107
|
-
{seg.text}
|
|
1108
|
-
</Text>
|
|
1109
|
-
)
|
|
1110
|
-
case 'italic':
|
|
1111
|
-
return (
|
|
1112
|
-
<Text key={i} italic color={theme.colors.text.primary}>
|
|
1113
|
-
{seg.text}
|
|
1114
|
-
</Text>
|
|
1115
|
-
)
|
|
1116
|
-
case 'code':
|
|
1117
|
-
return (
|
|
1118
|
-
<Text key={i} color={theme.colors.accent}>
|
|
1119
|
-
{seg.text}
|
|
1120
|
-
</Text>
|
|
1121
|
-
)
|
|
1122
|
-
case 'strikethrough':
|
|
1123
|
-
return (
|
|
1124
|
-
<Text key={i} strikethrough color={theme.colors.text.muted}>
|
|
1125
|
-
{seg.text}
|
|
1126
|
-
</Text>
|
|
1127
|
-
)
|
|
1128
|
-
case 'link':
|
|
1129
|
-
return (
|
|
1130
|
-
<Text key={i} color={theme.colors.info} underline>
|
|
1131
|
-
{seg.text}
|
|
1132
|
-
</Text>
|
|
1133
|
-
)
|
|
1134
|
-
default:
|
|
1135
|
-
return (
|
|
1136
|
-
<Text key={i} color={theme.colors.text.primary}>
|
|
1137
|
-
{seg.text}
|
|
1138
|
-
</Text>
|
|
1139
|
-
)
|
|
1140
|
-
}
|
|
1141
|
-
})}
|
|
1142
|
-
</>
|
|
1143
|
-
)
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
function parseInline(text: string): Array<{ type: string; text: string }> {
|
|
1147
|
-
const segments: Array<{ type: string; text: string }> = []
|
|
1148
|
-
|
|
1149
|
-
const tokenPatterns: Array<{ type: string; regex: RegExp; group: number }> = [
|
|
1150
|
-
{ type: 'code', regex: /`([^`]+)`/g, group: 1 },
|
|
1151
|
-
{ type: 'bold', regex: /\*\*([^*]+)\*\*/g, group: 1 },
|
|
1152
|
-
{ type: 'strikethrough', regex: /~~([^~]+)~~/g, group: 1 },
|
|
1153
|
-
{ type: 'italic', regex: /(?<!\*)\*([^*]+)\*(?!\*)/g, group: 1 },
|
|
1154
|
-
{ type: 'link', regex: /\[([^\]]+)\]\([^)]+\)/g, group: 1 },
|
|
1155
|
-
]
|
|
1156
|
-
|
|
1157
|
-
interface Token {
|
|
1158
|
-
type: string
|
|
1159
|
-
text: string
|
|
1160
|
-
start: number
|
|
1161
|
-
end: number
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
const tokens: Token[] = []
|
|
1165
|
-
|
|
1166
|
-
for (const { type, regex, group } of tokenPatterns) {
|
|
1167
|
-
let match
|
|
1168
|
-
regex.lastIndex = 0
|
|
1169
|
-
while ((match = regex.exec(text)) !== null) {
|
|
1170
|
-
const start = match.index
|
|
1171
|
-
const end = match.index + match[0].length
|
|
1172
|
-
const overlaps = tokens.some(
|
|
1173
|
-
(t) =>
|
|
1174
|
-
(start >= t.start && start < t.end) ||
|
|
1175
|
-
(end > t.start && end <= t.end),
|
|
1176
|
-
)
|
|
1177
|
-
|
|
1178
|
-
if (!overlaps) {
|
|
1179
|
-
tokens.push({
|
|
1180
|
-
type,
|
|
1181
|
-
text: match[group],
|
|
1182
|
-
start,
|
|
1183
|
-
end,
|
|
1184
|
-
})
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
tokens.sort((a, b) => a.start - b.start)
|
|
1190
|
-
|
|
1191
|
-
let lastEnd = 0
|
|
1192
|
-
for (const token of tokens) {
|
|
1193
|
-
if (token.start > lastEnd) {
|
|
1194
|
-
segments.push({ type: 'text', text: text.slice(lastEnd, token.start) })
|
|
1195
|
-
}
|
|
1196
|
-
segments.push({ type: token.type, text: token.text })
|
|
1197
|
-
lastEnd = token.end
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
if (lastEnd < text.length) {
|
|
1201
|
-
segments.push({ type: 'text', text: text.slice(lastEnd) })
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
if (segments.length === 0) {
|
|
1205
|
-
return [{ type: 'text', text }]
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
return segments
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
export default MessageRenderer
|