setIsExpanded(!isExpanded)}>
-
▶
+
+
+
{headerText} · {fileList.length} files
diff --git a/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx b/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx
index c6d560148d..bb469d016d 100644
--- a/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx
+++ b/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx
@@ -3,11 +3,11 @@ import React, { memo, useCallback, useMemo, useState } from 'react';
import { useInjectable } from '@opensumi/ide-core-browser';
import { Button, Icon } from '@opensumi/ide-core-browser/lib/components';
import { localize } from '@opensumi/ide-core-common';
-import { stripAnsi } from '@opensumi/ide-utils/lib/ansi';
import { IMCPServerToolComponentProps } from '../../../types';
import { RunCommandHandler } from '../handlers/RunCommand';
+import { computeAnsiLogString } from './computeAnsiLogString';
import styles from './index.module.less';
function getResult(raw: string) {
@@ -63,19 +63,17 @@ export const TerminalToolComponent = memo((props: IMCPServerToolComponentProps)
<>
- {localize('ai.native.mcp.terminal.command')}
+ {localize('ai.native.mcp.terminal.command')}:
$ {args.command}
>
)}
-
- {localize('ai.native.mcp.terminal.output')}
-
{output ? (
- {stripAnsi(output.text)}
+
+
) : (
''
diff --git a/packages/ai-native/src/browser/mcp/tools/components/computeAnsiLogString.ts b/packages/ai-native/src/browser/mcp/tools/components/computeAnsiLogString.ts
new file mode 100644
index 0000000000..664d2d5004
--- /dev/null
+++ b/packages/ai-native/src/browser/mcp/tools/components/computeAnsiLogString.ts
@@ -0,0 +1,24 @@
+import AnsiUp from 'ansi_up';
+
+import filterEraseMultipleLine from './filterEraseMultipleLine';
+
+type LogContent = string;
+
+const ansiUp = new AnsiUp();
+
+export function computeAnsiLogString(logs: LogContent, enableEraseLineFilter = true, hideEmptyLine = false): string {
+ const splittedLogs = logs.split('\n');
+ // 处理清空上行逻辑
+ // 上移 cursor + 清空整行
+ let filteredLogs = enableEraseLineFilter ? filterEraseMultipleLine(splittedLogs) : splittedLogs;
+ if (hideEmptyLine) {
+ filteredLogs = filteredLogs.map((line) => line.replace('\r', '')).filter((line) => !!line);
+ }
+
+ const htmlLogLines = filteredLogs.map((line) => {
+ const htmlLog = ansiUp.ansi_to_html(line);
+
+ return htmlLog;
+ });
+ return htmlLogLines.join('\n');
+}
diff --git a/packages/ai-native/src/browser/mcp/tools/components/filterEraseMultipleLine.ts b/packages/ai-native/src/browser/mcp/tools/components/filterEraseMultipleLine.ts
new file mode 100644
index 0000000000..168991eaa2
--- /dev/null
+++ b/packages/ai-native/src/browser/mcp/tools/components/filterEraseMultipleLine.ts
@@ -0,0 +1,67 @@
+import ansiEscapes from 'ansi-escapes';
+
+/**
+ * 处理过滤清空上行,清空本行逻辑。
+ *
+ * 关于清空上 n 行:
+ * 一般在日志中,出现覆盖上行的情况,ascii 编码为 2K [1A 2K ...] 1G 的样式。 如 \u001b[2K\u001b[1A\u001b[2K\u001b[1A\u001b[2K\u001b[G\r\n,代表清空上两行。
+ * 其中,2K 代表清空整行,1A 代表光标上移,配合下一个 2K 则最终效果为清空上行,而 1G 是移动光标到本行开始(位置 1)。
+ * 在日志过滤过程中,可以只处理 1A 2K 这个序列,遇到后把该日志的上一行删掉即可。
+ *
+ * 关于清空本行,按顺序执行:
+ * 1. 在当前行没有 Cursor 操作符(如上移时),匹配最后一个 [2K (清空本行)或 \r[K(指针回 0,再清空本行到末尾,相当于清空本行),只输出 [2K 后的内容,
+ * 2. 在当前行没有 Cursor 操作符时且有多个 \r (carriage return charactor,移动光标到行首)时,reduce 按 \r \x1b[G 或 \x1b[1G 切分的片断,不段用后一 part 的部分从头覆盖得出结果。
+ */
+export default function filterEraseMultipleLine(logs: string[]) {
+ // 上移 cursor + 清空整行
+ const eraseLastLine = ansiEscapes.cursorUp(1) + ansiEscapes.eraseLine;
+ const eraseCurrentLine = ansiEscapes.eraseLine;
+ const eraseCurrentLine2 = `\r${ansiEscapes.eraseEndLine}`;
+
+ const moveCursorToLeftRegStrs = ['\\r', '\\u001b\\[G', '\\u001b\\[1G'];
+ const moveCursorToLeftRegStr = new RegExp(`${moveCursorToLeftRegStrs.join('|')}`);
+
+ const filteredLogs = logs.reduce((acc: string[], nowLine) => {
+ // 当前清空上行搜索指针
+ let pos = 0;
+ const step = eraseLastLine.length;
+
+ while (true) {
+ pos = nowLine.indexOf(eraseLastLine, pos);
+ // 出现清空上行
+ if (pos >= 0) {
+ pos += step;
+ acc.pop();
+ } else {
+ break;
+ }
+ }
+
+ // 对单行日志的重写做处理
+ // 简单处理,不去解析真正的 Cursor 所在行,否则逻辑过于麻烦
+ // 处理 [2K
+ let lastErasePos = nowLine.lastIndexOf(eraseCurrentLine);
+ if (lastErasePos < 0) {
+ // 处理 \r[K
+ lastErasePos = nowLine.lastIndexOf(eraseCurrentLine2);
+ }
+ if (lastErasePos > 0) {
+ // 从后向前搜索最后一个清行操作
+ nowLine = nowLine.slice(lastErasePos);
+ }
+
+ // 处理多 \r 情况,当 \r 连续时,切分出的空字段无用,过滤掉
+ const carriageRewrites = nowLine.split(moveCursorToLeftRegStr).filter((part) => !!part);
+ if (carriageRewrites.length > 1) {
+ nowLine = carriageRewrites.reduce((nextNowLine, nowPart) => {
+ const leftPart = nextNowLine.slice(nowPart.length);
+ return nowPart + leftPart;
+ }, '');
+ }
+
+ acc.push(nowLine);
+ return acc;
+ }, []);
+
+ return filteredLogs;
+}
diff --git a/packages/ai-native/src/browser/mcp/tools/components/index.module.less b/packages/ai-native/src/browser/mcp/tools/components/index.module.less
index c85fd99c4d..438e23ab1e 100644
--- a/packages/ai-native/src/browser/mcp/tools/components/index.module.less
+++ b/packages/ai-native/src/browser/mcp/tools/components/index.module.less
@@ -96,7 +96,7 @@
}
.header {
- padding: 8px 12px;
+ padding: 4px;
background-color: var(--design-block-background);
border-bottom: 1px solid var(--vscode-commandCenter-inactiveBorder);
cursor: pointer;
@@ -104,7 +104,7 @@
align-items: center;
gap: 4px;
color: var(--design-text-foreground);
- font-size: 12px;
+ font-size: 11px;
}
.fileList {
@@ -162,14 +162,17 @@
.command_title {
display: flex;
align-items: center;
+ font-size: 11px;
span {
margin-left: 5px;
}
}
.command_content {
- padding: 4px;
- font-size: 12px;
+ max-height: 200px;
+ overflow-y: auto;
+ padding: 2px 4px;
+ font-size: 11px;
color: var(--design-text-foreground);
margin: 0px;
background-color: var(--terminal-background);
@@ -178,7 +181,7 @@
overflow: auto;
code {
- font-size: 12px;
+ font-size: 11px;
white-space: pre;
}
}
diff --git a/packages/components/src/markdown-react/parse.tsx b/packages/components/src/markdown-react/parse.tsx
index 61ba08bc68..1bb71e50fb 100644
--- a/packages/components/src/markdown-react/parse.tsx
+++ b/packages/components/src/markdown-react/parse.tsx
@@ -59,9 +59,12 @@ export class MarkdownReactParser extends marked.Renderer {
listItemChildren.push(this.parse(item.tokens));
- return React.cloneElement(this.renderer.listItem(listItemChildren) as React.ReactElement, {
- key: `list-item-${itemIndex}`,
- });
+ return React.cloneElement(
+ this.renderer.listItem(listItemChildren) as React.ReactElement,
+ {
+ key: `list-item-${itemIndex}`,
+ },
+ );
});
return this.renderer.list(children, token.ordered);
@@ -83,9 +86,12 @@ export class MarkdownReactParser extends marked.Renderer {
),
);
- const headerRow = React.cloneElement(this.renderer.tableRow(headerCells) as React.ReactElement, {
- key: 'header-row',
- });
+ const headerRow = React.cloneElement(
+ this.renderer.tableRow(headerCells) as React.ReactElement,
+ {
+ key: 'header-row',
+ },
+ );
const header = this.renderer.tableHeader(headerRow);
const bodyChilren = tableToken.rows.map((row, rowIndex) => {
@@ -99,9 +105,12 @@ export class MarkdownReactParser extends marked.Renderer {
),
);
- return React.cloneElement(this.renderer.tableRow(rowChildren) as React.ReactElement, {
- key: `body-row-${rowIndex}`,
- });
+ return React.cloneElement(
+ this.renderer.tableRow(rowChildren) as React.ReactElement,
+ {
+ key: `body-row-${rowIndex}`,
+ },
+ );
});
const body = this.renderer.tableBody(bodyChilren);
@@ -128,20 +137,11 @@ export class MarkdownReactParser extends marked.Renderer {
});
}
- private unescapeInfo = new Map
([
- ['"', '"'],
- [' ', ' '],
- ['&', '&'],
- [''', "'"],
- ['<', '<'],
- ['>', '>'],
- ]);
-
parseInline(tokens: marked.Token[] = []): ReactNode[] {
return tokens.map((token) => {
switch (token.type) {
case 'text': {
- const text = token.text.replace(/&(#\d+|[a-zA-Z]+);/g, (m) => this.unescapeInfo.get(m) ?? m);
+ const text = htmlUnescape(token.text);
return this.renderer.text(text);
}
@@ -190,3 +190,11 @@ export class MarkdownReactParser extends marked.Renderer {
});
}
}
+
+function htmlUnescape(htmlStr) {
+ return htmlStr
+ .replace(/(\d+);/g, (match, dec) => String.fromCharCode(dec))
+ .replace(/([0-9A-Fa-f]+);/g, (match, hex) =>
+ String.fromCharCode(parseInt(hex, 16)),
+ );
+}
diff --git a/packages/connection/src/common/connection/drivers/frame-decoder.ts b/packages/connection/src/common/connection/drivers/frame-decoder.ts
index 5a2505d36d..3010bbdcde 100644
--- a/packages/connection/src/common/connection/drivers/frame-decoder.ts
+++ b/packages/connection/src/common/connection/drivers/frame-decoder.ts
@@ -1,7 +1,7 @@
/* eslint-disable no-console */
import { BinaryWriter } from '@furyjs/fury/dist/lib/writer';
-import { MaybeNull, readUInt32LE } from '@opensumi/ide-core-common';
+import { MaybeNull, readUInt32LE, setImmediate } from '@opensumi/ide-core-common';
import { Buffers } from '../../buffers/buffers';
diff --git a/packages/i18n/src/common/en-US.lang.ts b/packages/i18n/src/common/en-US.lang.ts
index 3db5645ad4..017c1305c5 100644
--- a/packages/i18n/src/common/en-US.lang.ts
+++ b/packages/i18n/src/common/en-US.lang.ts
@@ -1615,5 +1615,9 @@ export const localizationBundle = {
'ai.native.mcp.name.isRequired': 'Server name is required',
'ai.native.mcp.command.isRequired': 'Command is required',
'ai.native.mcp.serverHost.isRequired': 'SSE URL is required',
+
+ // MCP View
+ 'ai.native.mcp.tool.arguments': 'Arguments',
+ 'ai.native.mcp.tool.results': 'Result',
},
};
diff --git a/packages/i18n/src/common/zh-CN.lang.ts b/packages/i18n/src/common/zh-CN.lang.ts
index 3d0fc90336..550674943b 100644
--- a/packages/i18n/src/common/zh-CN.lang.ts
+++ b/packages/i18n/src/common/zh-CN.lang.ts
@@ -1378,5 +1378,9 @@ export const localizationBundle = {
'ai.native.mcp.name.isRequired': '服务名称不能为空',
'ai.native.mcp.command.isRequired': '命令不能为空',
'ai.native.mcp.serverHost.isRequired': 'SSE URL 不能为空',
+
+ // MCP View
+ 'ai.native.mcp.tool.arguments': '参数',
+ 'ai.native.mcp.tool.results': '返回',
},
};
diff --git a/yarn.lock b/yarn.lock
index 8f97ec1ee2..dcf937a0d4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3418,7 +3418,9 @@ __metadata:
"@opensumi/ide-workspace": "workspace:*"
"@xterm/xterm": "npm:5.5.0"
ai: "npm:^4.1.45"
+ ansi-escapes: "npm:5.0.0"
ansi-regex: "npm:^2.0.0"
+ ansi_up: "npm:^5.1.0"
diff: "npm:^7.0.0"
dom-align: "npm:^1.7.0"
eventsource: "npm:^3.0.5"
@@ -6991,6 +6993,15 @@ __metadata:
languageName: node
linkType: hard
+"ansi-escapes@npm:5.0.0":
+ version: 5.0.0
+ resolution: "ansi-escapes@npm:5.0.0"
+ dependencies:
+ type-fest: "npm:^1.0.2"
+ checksum: 10/cbfb95f9f6d8a1ffc89f50fcda3313effae2d9ac2f357f89f626815b4d95fdc3f10f74e0887614ff850d01f805b7505eb1e7ebfdd26144bbfc26c5de08e19195
+ languageName: node
+ linkType: hard
+
"ansi-escapes@npm:^4.2.1":
version: 4.3.2
resolution: "ansi-escapes@npm:4.3.2"
@@ -7076,6 +7087,13 @@ __metadata:
languageName: node
linkType: hard
+"ansi_up@npm:^5.1.0":
+ version: 5.2.1
+ resolution: "ansi_up@npm:5.2.1"
+ checksum: 10/abf4f2e4abc1f54ef41d5668b22967c33117faf31308211858ff3bb898f4839f97a7bde081d6eb988532a90a1ccd37f1ef5c0f0f3e409d1b96ed4c5bd41d11c7
+ languageName: node
+ linkType: hard
+
"antd@npm:^5.21.4":
version: 5.21.4
resolution: "antd@npm:5.21.4"
@@ -24094,6 +24112,13 @@ __metadata:
languageName: node
linkType: hard
+"type-fest@npm:^1.0.2":
+ version: 1.4.0
+ resolution: "type-fest@npm:1.4.0"
+ checksum: 10/89875c247564601c2650bacad5ff80b859007fbdb6c9e43713ae3ffa3f584552eea60f33711dd762e16496a1ab4debd409822627be14097d9a17e39c49db591a
+ languageName: node
+ linkType: hard
+
"type-fest@npm:^4.4.0":
version: 4.12.0
resolution: "type-fest@npm:4.12.0"