@kirosnn/mosaic 0.73.0 → 0.75.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/README.md +1 -1
- package/package.json +2 -2
- package/src/agent/prompts/systemPrompt.ts +1 -1
- package/src/agent/prompts/toolsPrompt.ts +75 -5
- package/src/agent/tools/explore.ts +4 -4
- package/src/agent/tools/exploreExecutor.ts +56 -4
- package/src/components/Main.tsx +1480 -1459
- package/src/components/main/ChatPage.tsx +858 -858
- package/src/index.tsx +32 -5
- package/src/mcp/approvalPolicy.ts +155 -147
- package/src/mcp/cli/doctor.ts +0 -3
- package/src/mcp/config.ts +234 -223
- package/src/mcp/processManager.ts +303 -298
- package/src/mcp/servers/navigation/browser.ts +151 -0
- package/src/mcp/servers/navigation/index.ts +23 -0
- package/src/mcp/servers/navigation/tools.ts +263 -0
- package/src/mcp/servers/navigation/types.ts +17 -0
- package/src/mcp/servers/navigation/utils.ts +20 -0
- package/src/mcp/toolCatalog.ts +181 -168
- package/src/mcp/types.ts +115 -94
- package/src/utils/history.ts +82 -82
- package/src/utils/markdown.tsx +60 -26
- package/src/utils/toolFormatting.ts +55 -6
- package/src/web/components/MessageItem.tsx +44 -13
- package/src/mcp/servers/navigation.ts +0 -854
package/src/utils/history.ts
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, unlinkSync } from 'fs';
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, unlinkSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
4
|
|
|
5
|
-
export interface ConversationStep {
|
|
6
|
-
type: 'user' | 'assistant' | 'tool';
|
|
7
|
-
content: string;
|
|
8
|
-
images?: import("./images").ImageAttachment[];
|
|
9
|
-
toolName?: string;
|
|
10
|
-
toolArgs?: Record<string, unknown>;
|
|
11
|
-
toolResult?: unknown;
|
|
12
|
-
timestamp: number;
|
|
13
|
-
responseDuration?: number;
|
|
14
|
-
blendWord?: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface ConversationHistory {
|
|
18
|
-
id: string;
|
|
19
|
-
timestamp: number;
|
|
20
|
-
steps: ConversationStep[];
|
|
21
|
-
totalSteps: number;
|
|
22
|
-
title?: string | null;
|
|
23
|
-
workspace?: string | null;
|
|
24
|
-
totalTokens?: {
|
|
25
|
-
prompt: number;
|
|
26
|
-
completion: number;
|
|
27
|
-
total: number;
|
|
28
|
-
};
|
|
5
|
+
export interface ConversationStep {
|
|
6
|
+
type: 'user' | 'assistant' | 'tool';
|
|
7
|
+
content: string;
|
|
8
|
+
images?: import("./images").ImageAttachment[];
|
|
9
|
+
toolName?: string;
|
|
10
|
+
toolArgs?: Record<string, unknown>;
|
|
11
|
+
toolResult?: unknown;
|
|
12
|
+
timestamp: number;
|
|
13
|
+
responseDuration?: number;
|
|
14
|
+
blendWord?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ConversationHistory {
|
|
18
|
+
id: string;
|
|
19
|
+
timestamp: number;
|
|
20
|
+
steps: ConversationStep[];
|
|
21
|
+
totalSteps: number;
|
|
22
|
+
title?: string | null;
|
|
23
|
+
workspace?: string | null;
|
|
24
|
+
totalTokens?: {
|
|
25
|
+
prompt: number;
|
|
26
|
+
completion: number;
|
|
27
|
+
total: number;
|
|
28
|
+
};
|
|
29
29
|
model?: string;
|
|
30
30
|
provider?: string;
|
|
31
31
|
}
|
|
@@ -41,49 +41,49 @@ export function getHistoryDir(): string {
|
|
|
41
41
|
return historyDir;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
export function saveConversation(conversation: ConversationHistory): void {
|
|
45
|
-
const historyDir = getHistoryDir();
|
|
46
|
-
const filename = `${conversation.id}.json`;
|
|
47
|
-
const filepath = join(historyDir, filename);
|
|
48
|
-
|
|
49
|
-
writeFileSync(filepath, JSON.stringify(conversation, null, 2), 'utf-8');
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function updateConversationTitle(id: string, title: string | null): boolean {
|
|
53
|
-
const historyDir = getHistoryDir();
|
|
54
|
-
const filepath = join(historyDir, `${id}.json`);
|
|
55
|
-
|
|
56
|
-
if (!existsSync(filepath)) {
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
try {
|
|
61
|
-
const content = readFileSync(filepath, 'utf-8');
|
|
62
|
-
const data = JSON.parse(content) as ConversationHistory;
|
|
63
|
-
data.title = title;
|
|
64
|
-
writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf-8');
|
|
65
|
-
return true;
|
|
66
|
-
} catch (error) {
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function deleteConversation(id: string): boolean {
|
|
72
|
-
const historyDir = getHistoryDir();
|
|
73
|
-
const filepath = join(historyDir, `${id}.json`);
|
|
74
|
-
|
|
75
|
-
if (!existsSync(filepath)) {
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
unlinkSync(filepath);
|
|
81
|
-
return true;
|
|
82
|
-
} catch (error) {
|
|
83
|
-
return false;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
44
|
+
export function saveConversation(conversation: ConversationHistory): void {
|
|
45
|
+
const historyDir = getHistoryDir();
|
|
46
|
+
const filename = `${conversation.id}.json`;
|
|
47
|
+
const filepath = join(historyDir, filename);
|
|
48
|
+
|
|
49
|
+
writeFileSync(filepath, JSON.stringify(conversation, null, 2), 'utf-8');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function updateConversationTitle(id: string, title: string | null): boolean {
|
|
53
|
+
const historyDir = getHistoryDir();
|
|
54
|
+
const filepath = join(historyDir, `${id}.json`);
|
|
55
|
+
|
|
56
|
+
if (!existsSync(filepath)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const content = readFileSync(filepath, 'utf-8');
|
|
62
|
+
const data = JSON.parse(content) as ConversationHistory;
|
|
63
|
+
data.title = title;
|
|
64
|
+
writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf-8');
|
|
65
|
+
return true;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function deleteConversation(id: string): boolean {
|
|
72
|
+
const historyDir = getHistoryDir();
|
|
73
|
+
const filepath = join(historyDir, `${id}.json`);
|
|
74
|
+
|
|
75
|
+
if (!existsSync(filepath)) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
unlinkSync(filepath);
|
|
81
|
+
return true;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
87
|
export function loadConversations(): ConversationHistory[] {
|
|
88
88
|
const historyDir = getHistoryDir();
|
|
89
89
|
|
|
@@ -91,19 +91,19 @@ export function loadConversations(): ConversationHistory[] {
|
|
|
91
91
|
return [];
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
const files = readdirSync(historyDir).filter(f => f.endsWith('.json') && f !== 'inputs.json');
|
|
95
|
-
const conversations: ConversationHistory[] = [];
|
|
96
|
-
|
|
97
|
-
for (const file of files) {
|
|
98
|
-
try {
|
|
99
|
-
const content = readFileSync(join(historyDir, file), 'utf-8');
|
|
100
|
-
const parsed = JSON.parse(content) as ConversationHistory;
|
|
101
|
-
if (!parsed || !Array.isArray(parsed.steps)) continue;
|
|
102
|
-
conversations.push(parsed);
|
|
103
|
-
} catch (error) {
|
|
104
|
-
console.error(`Failed to load ${file}:`, error);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
94
|
+
const files = readdirSync(historyDir).filter(f => f.endsWith('.json') && f !== 'inputs.json');
|
|
95
|
+
const conversations: ConversationHistory[] = [];
|
|
96
|
+
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
try {
|
|
99
|
+
const content = readFileSync(join(historyDir, file), 'utf-8');
|
|
100
|
+
const parsed = JSON.parse(content) as ConversationHistory;
|
|
101
|
+
if (!parsed || !Array.isArray(parsed.steps)) continue;
|
|
102
|
+
conversations.push(parsed);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error(`Failed to load ${file}:`, error);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
107
|
|
|
108
108
|
return conversations.sort((a, b) => b.timestamp - a.timestamp);
|
|
109
109
|
}
|
|
@@ -145,4 +145,4 @@ export function addInputToHistory(input: string): void {
|
|
|
145
145
|
|
|
146
146
|
saveInputHistory(history);
|
|
147
147
|
}
|
|
148
|
-
}
|
|
148
|
+
}
|
package/src/utils/markdown.tsx
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { TextAttributes } from "@opentui/core";
|
|
2
2
|
|
|
3
|
-
export interface MarkdownSegment {
|
|
4
|
-
type: 'text' | 'bold' | 'italic' | 'code' | 'heading' | 'listitem';
|
|
5
|
-
content: string;
|
|
6
|
-
level?: number;
|
|
7
|
-
|
|
3
|
+
export interface MarkdownSegment {
|
|
4
|
+
type: 'text' | 'bold' | 'italic' | 'code' | 'heading' | 'listitem' | 'link';
|
|
5
|
+
content: string;
|
|
6
|
+
level?: number;
|
|
7
|
+
href?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const linkSchemePattern = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
|
|
11
|
+
|
|
12
|
+
function normalizeLinkUri(href: string) {
|
|
13
|
+
const trimmed = href.trim();
|
|
14
|
+
if (!trimmed) return trimmed;
|
|
15
|
+
if (linkSchemePattern.test(trimmed)) return trimmed;
|
|
16
|
+
if (trimmed.startsWith('//')) return `https:${trimmed}`;
|
|
17
|
+
if (trimmed.startsWith('/') || trimmed.startsWith('#') || trimmed.startsWith('.') || trimmed.startsWith('?')) return trimmed;
|
|
18
|
+
return `https://${trimmed}`;
|
|
19
|
+
}
|
|
8
20
|
|
|
9
21
|
function parseInline(text: string): MarkdownSegment[] {
|
|
10
22
|
const segments: MarkdownSegment[] = [];
|
|
@@ -30,21 +42,36 @@ function parseInline(text: string): MarkdownSegment[] {
|
|
|
30
42
|
}
|
|
31
43
|
}
|
|
32
44
|
|
|
33
|
-
if (text.substring(i, i + 2) === '**') {
|
|
34
|
-
const j = text.indexOf('**', i + 2);
|
|
35
|
-
if (j !== -1) {
|
|
36
|
-
flushText();
|
|
37
|
-
segments.push({ type: 'bold', content: text.substring(i + 2, j) });
|
|
38
|
-
i = j + 2;
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (text[i] === '
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
if (text.substring(i, i + 2) === '**') {
|
|
46
|
+
const j = text.indexOf('**', i + 2);
|
|
47
|
+
if (j !== -1) {
|
|
48
|
+
flushText();
|
|
49
|
+
segments.push({ type: 'bold', content: text.substring(i + 2, j) });
|
|
50
|
+
i = j + 2;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (text[i] === '[') {
|
|
56
|
+
const labelEnd = text.indexOf(']', i + 1);
|
|
57
|
+
if (labelEnd !== -1 && text[labelEnd + 1] === '(') {
|
|
58
|
+
const urlEnd = text.indexOf(')', labelEnd + 2);
|
|
59
|
+
if (urlEnd !== -1) {
|
|
60
|
+
const label = text.substring(i + 1, labelEnd);
|
|
61
|
+
const href = text.substring(labelEnd + 2, urlEnd).trim();
|
|
62
|
+
flushText();
|
|
63
|
+
segments.push({ type: 'link', content: label, href });
|
|
64
|
+
i = urlEnd + 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (text[i] === '*' && text.substring(i, i + 2) !== '**') {
|
|
71
|
+
const j = text.indexOf('*', i + 1);
|
|
72
|
+
if (j !== -1) {
|
|
73
|
+
flushText();
|
|
74
|
+
segments.push({ type: 'italic', content: text.substring(i + 1, j) });
|
|
48
75
|
i = j + 1;
|
|
49
76
|
continue;
|
|
50
77
|
}
|
|
@@ -82,12 +109,19 @@ export function renderMarkdownSegment(segment: MarkdownSegment, key: number) {
|
|
|
82
109
|
case 'italic':
|
|
83
110
|
return <text key={key} fg="white" attributes={TextAttributes.DIM}>{segment.content}</text>;
|
|
84
111
|
|
|
85
|
-
case 'code':
|
|
86
|
-
return <text key={key} fg="#ffdd80">{`${segment.content}`}</text>;
|
|
87
|
-
|
|
88
|
-
case 'heading':
|
|
89
|
-
return <text key={key} fg="#ffca38" attributes={TextAttributes.BOLD}>{segment.content}</text>;
|
|
90
|
-
|
|
112
|
+
case 'code':
|
|
113
|
+
return <text key={key} fg="#ffdd80">{`${segment.content}`}</text>;
|
|
114
|
+
|
|
115
|
+
case 'heading':
|
|
116
|
+
return <text key={key} fg="#ffca38" attributes={TextAttributes.BOLD}>{segment.content}</text>;
|
|
117
|
+
|
|
118
|
+
case 'link':
|
|
119
|
+
return (
|
|
120
|
+
<text key={key} fg="#7fbfff" attributes={TextAttributes.UNDERLINE}>
|
|
121
|
+
<a href={normalizeLinkUri(segment.href || '')}>{segment.content}</a>
|
|
122
|
+
</text>
|
|
123
|
+
);
|
|
124
|
+
|
|
91
125
|
case 'listitem':
|
|
92
126
|
return (
|
|
93
127
|
<box key={key} flexDirection="row">
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { formatWriteToolResult, formatEditToolResult } from './diff';
|
|
2
|
+
import { isNativeMcpTool, getNativeMcpToolName } from '../mcp/types';
|
|
2
3
|
|
|
3
4
|
const TOOL_BODY_INDENT = 2;
|
|
4
5
|
|
|
@@ -32,7 +33,21 @@ function getMcpToolDisplayName(tool: string): string {
|
|
|
32
33
|
return words.split(' ').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
function getNativeToolDisplayName(safeId: string): string | null {
|
|
37
|
+
const toolName = getNativeMcpToolName(safeId);
|
|
38
|
+
if (!toolName) return null;
|
|
39
|
+
const mcp = parseMcpSafeId(safeId);
|
|
40
|
+
if (!mcp) return null;
|
|
41
|
+
const serverPrefix = mcp.serverId + '_';
|
|
42
|
+
const stripped = toolName.startsWith(serverPrefix) ? toolName.slice(serverPrefix.length) : toolName;
|
|
43
|
+
return getMcpToolDisplayName(stripped);
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
function getToolDisplayName(toolName: string): string {
|
|
47
|
+
if (isNativeMcpTool(toolName)) {
|
|
48
|
+
const nativeName = getNativeToolDisplayName(toolName);
|
|
49
|
+
if (nativeName) return nativeName;
|
|
50
|
+
}
|
|
36
51
|
const mcp = parseMcpSafeId(toolName);
|
|
37
52
|
if (mcp) {
|
|
38
53
|
return getMcpToolDisplayName(mcp.tool);
|
|
@@ -143,6 +158,15 @@ function formatKnownToolArgs(toolName: string, args: Record<string, unknown>): s
|
|
|
143
158
|
}
|
|
144
159
|
}
|
|
145
160
|
|
|
161
|
+
function formatNativeMcpError(errorText: string): string[] {
|
|
162
|
+
const statusMatch = errorText.match(/status code (\d+)/i);
|
|
163
|
+
if (statusMatch) {
|
|
164
|
+
return [`Error ${statusMatch[1]}`];
|
|
165
|
+
}
|
|
166
|
+
const short = errorText.length > 80 ? errorText.slice(0, 80) + '...' : errorText;
|
|
167
|
+
return [`Error: ${short}`];
|
|
168
|
+
}
|
|
169
|
+
|
|
146
170
|
export function isToolSuccess(result: unknown): boolean {
|
|
147
171
|
if (result === null || result === undefined) return false;
|
|
148
172
|
|
|
@@ -211,6 +235,9 @@ function formatToolHeader(toolName: string, args: Record<string, unknown>): stri
|
|
|
211
235
|
default: {
|
|
212
236
|
if (toolName.startsWith('mcp__')) {
|
|
213
237
|
const info = getMcpHeaderInfo('', args);
|
|
238
|
+
if (isNativeMcpTool(toolName)) {
|
|
239
|
+
return info ? `${displayName} (${info})` : displayName;
|
|
240
|
+
}
|
|
214
241
|
return info ? `${displayName} ("${info}")` : displayName;
|
|
215
242
|
}
|
|
216
243
|
return displayName;
|
|
@@ -297,6 +324,10 @@ export function parseToolHeader(toolName: string, args: Record<string, unknown>)
|
|
|
297
324
|
}
|
|
298
325
|
}
|
|
299
326
|
|
|
327
|
+
export function isNativeMcpToolName(toolName: string): boolean {
|
|
328
|
+
return isNativeMcpTool(toolName);
|
|
329
|
+
}
|
|
330
|
+
|
|
300
331
|
function getLineCount(text: string): number {
|
|
301
332
|
if (!text) return 0;
|
|
302
333
|
return text.split(/\r?\n/).length;
|
|
@@ -384,6 +415,24 @@ function getToolErrorText(result: unknown): string | null {
|
|
|
384
415
|
return typeof error === 'string' && error.trim() ? error.trim() : null;
|
|
385
416
|
}
|
|
386
417
|
|
|
418
|
+
function formatSearchResultBody(result: unknown): string[] {
|
|
419
|
+
if (typeof result !== 'string') return [];
|
|
420
|
+
try {
|
|
421
|
+
const parsed = JSON.parse(result);
|
|
422
|
+
if (typeof parsed !== 'object' || parsed === null) return [];
|
|
423
|
+
|
|
424
|
+
if (typeof parsed.error === 'string') {
|
|
425
|
+
return [`Error: ${parsed.error}`];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const count = typeof parsed.resultCount === 'number' ? parsed.resultCount : 0;
|
|
429
|
+
if (count === 0) return ['No results'];
|
|
430
|
+
return [`${count} results`];
|
|
431
|
+
} catch {
|
|
432
|
+
return [];
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
387
436
|
function formatMcpResultBody(result: unknown): string[] {
|
|
388
437
|
if (typeof result !== 'string') {
|
|
389
438
|
if (result && typeof result === 'object') {
|
|
@@ -545,12 +594,7 @@ function formatToolBodyLines(toolName: string, args: Record<string, unknown>, re
|
|
|
545
594
|
}
|
|
546
595
|
}
|
|
547
596
|
if (toolName.startsWith('mcp__')) {
|
|
548
|
-
|
|
549
|
-
if (statusMatch) {
|
|
550
|
-
return [`Error ${statusMatch[1]}`];
|
|
551
|
-
}
|
|
552
|
-
const short = errorText.length > 80 ? errorText.slice(0, 80) + '...' : errorText;
|
|
553
|
-
return [`Error: ${short}`];
|
|
597
|
+
return formatNativeMcpError(errorText);
|
|
554
598
|
}
|
|
555
599
|
return [`Tool error: ${errorText}`];
|
|
556
600
|
}
|
|
@@ -696,6 +740,11 @@ function formatToolBodyLines(toolName: string, args: Record<string, unknown>, re
|
|
|
696
740
|
|
|
697
741
|
default: {
|
|
698
742
|
if (toolName.startsWith('mcp__')) {
|
|
743
|
+
const nativeName = getNativeMcpToolName(toolName);
|
|
744
|
+
if (nativeName === 'navigation_search') {
|
|
745
|
+
const searchLines = formatSearchResultBody(result);
|
|
746
|
+
if (searchLines.length > 0) return searchLines;
|
|
747
|
+
}
|
|
699
748
|
return formatMcpResultBody(result);
|
|
700
749
|
}
|
|
701
750
|
const toolResultText = formatToolResult(result);
|
|
@@ -6,15 +6,33 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
|
6
6
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
7
7
|
import { Message } from '../types';
|
|
8
8
|
import { toDataUrl } from '../../utils/images';
|
|
9
|
-
import { parseDiffLine, getDiffLineColors } from '../utils';
|
|
10
|
-
import '../assets/css/global.css'
|
|
11
|
-
|
|
12
|
-
interface MessageItemProps {
|
|
13
|
-
message: Message;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
import { parseDiffLine, getDiffLineColors } from '../utils';
|
|
10
|
+
import '../assets/css/global.css'
|
|
11
|
+
|
|
12
|
+
interface MessageItemProps {
|
|
13
|
+
message: Message;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const linkSchemePattern = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
|
|
17
|
+
|
|
18
|
+
function normalizeLinkUri(href: string) {
|
|
19
|
+
const trimmed = href.trim();
|
|
20
|
+
if (!trimmed) return trimmed;
|
|
21
|
+
if (linkSchemePattern.test(trimmed)) return trimmed;
|
|
22
|
+
if (trimmed.startsWith('//')) return `https:${trimmed}`;
|
|
23
|
+
if (trimmed.startsWith('/') || trimmed.startsWith('#') || trimmed.startsWith('.') || trimmed.startsWith('?')) return trimmed;
|
|
24
|
+
return `https://${trimmed}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function handleMarkdownLinkClick(event: React.MouseEvent<HTMLAnchorElement>, href?: string | null) {
|
|
28
|
+
if (!href) return;
|
|
29
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
window.open(href, "_blank", "noopener,noreferrer");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function renderDiffLine(line: string, index: number): React.ReactElement {
|
|
35
|
+
const parsed = parseDiffLine(line);
|
|
18
36
|
|
|
19
37
|
if (!parsed.isDiffLine) {
|
|
20
38
|
return (
|
|
@@ -126,10 +144,23 @@ export function MessageItem({ message }: MessageItemProps) {
|
|
|
126
144
|
</details>
|
|
127
145
|
)}
|
|
128
146
|
<div className="markdown-content">
|
|
129
|
-
<ReactMarkdown
|
|
130
|
-
remarkPlugins={[remarkGfm]}
|
|
131
|
-
|
|
132
|
-
|
|
147
|
+
<ReactMarkdown
|
|
148
|
+
remarkPlugins={[remarkGfm]}
|
|
149
|
+
transformLinkUri={normalizeLinkUri}
|
|
150
|
+
components={{
|
|
151
|
+
a({ href, children, ...props }) {
|
|
152
|
+
const normalized = normalizeLinkUri(href || '');
|
|
153
|
+
return (
|
|
154
|
+
<a
|
|
155
|
+
href={normalized}
|
|
156
|
+
onClick={(event) => handleMarkdownLinkClick(event, normalized)}
|
|
157
|
+
{...props}
|
|
158
|
+
>
|
|
159
|
+
{children}
|
|
160
|
+
</a>
|
|
161
|
+
);
|
|
162
|
+
},
|
|
163
|
+
code({ node, className, children, ...props }) {
|
|
133
164
|
const match = /language-(\w+)/.exec(className || '');
|
|
134
165
|
const { ref, ...rest } = props as any;
|
|
135
166
|
return match ? (
|