feat(codex): refine Codex tool renders (#15651)

* 💄 style(codex): refine file change tool render

*  feat(codex): add web search tool render

*  feat(codex): add mcp tool render

*  feat(codex): improve tool command display

* 💄 style(files): refine explorer tree icons

*  test: fix local file link render props
This commit is contained in:
Arvin Xu
2026-06-10 22:13:56 +08:00
committed by GitHub
parent e2be720726
commit a2ea314cd8
56 changed files with 2661 additions and 632 deletions
+1
View File
@@ -915,6 +915,7 @@
"workflow.toolDisplayName.addPreferenceMemory": "Saved memory",
"workflow.toolDisplayName.calculate": "Calculated",
"workflow.toolDisplayName.callAgent": "Called an agent",
"workflow.toolDisplayName.callMcpTool": "Called MCP tool",
"workflow.toolDisplayName.callSubAgent": "Call SubAgent",
"workflow.toolDisplayName.clearTodos": "Cleared todos",
"workflow.toolDisplayName.copyDocument": "Copied a document",
+18
View File
@@ -1,6 +1,24 @@
{
"arguments.moreParams": "{{count}} params in total",
"arguments.title": "Arguments",
"builtins.codex.apiName.command_execution": "Run command",
"builtins.codex.apiName.file_change": "Edit files",
"builtins.codex.apiName.mcp_tool_call": "Call MCP tool",
"builtins.codex.apiName.todo_list": "Update tasks",
"builtins.codex.apiName.web_search": "Search the web",
"builtins.codex.commandExecution.grep": "Search",
"builtins.codex.commandExecution.noResults": "No results",
"builtins.codex.commandExecution.readFile": "Read file",
"builtins.codex.fileChange.editedFiles_one": "Edited {{count}} file",
"builtins.codex.fileChange.editedFiles_other": "Edited {{count}} files",
"builtins.codex.fileChange.editing": "Editing files",
"builtins.codex.fileChange.noChanges": "No file changes",
"builtins.codex.fileChange.unknownFile": "Unknown file",
"builtins.codex.mcpTool.error": "Error",
"builtins.codex.mcpTool.input": "Input",
"builtins.codex.mcpTool.result": "Result",
"builtins.codex.mcpTool.unknownTool": "MCP tool",
"builtins.codex.webSearch.query": "Query",
"builtins.lobe-activator.apiName.activateTools": "Activate Tools",
"builtins.lobe-activator.inspector.activateTools.notFoundCount": "{{count}} not found",
"builtins.lobe-agent-builder.apiName.getAvailableModels": "Get available models",
+1
View File
@@ -915,6 +915,7 @@
"workflow.toolDisplayName.addPreferenceMemory": "保存了记忆",
"workflow.toolDisplayName.calculate": "完成了计算",
"workflow.toolDisplayName.callAgent": "调用了助理",
"workflow.toolDisplayName.callMcpTool": "调用了 MCP 工具",
"workflow.toolDisplayName.callSubAgent": "Call SubAgent",
"workflow.toolDisplayName.clearTodos": "清空了待办",
"workflow.toolDisplayName.copyDocument": "复制了文档",
+18
View File
@@ -1,6 +1,24 @@
{
"arguments.moreParams": "等 {{count}} 个参数",
"arguments.title": "参数列表",
"builtins.codex.apiName.command_execution": "运行命令",
"builtins.codex.apiName.file_change": "编辑文件",
"builtins.codex.apiName.mcp_tool_call": "调用 MCP 工具",
"builtins.codex.apiName.todo_list": "更新任务",
"builtins.codex.apiName.web_search": "搜索网页",
"builtins.codex.commandExecution.grep": "搜索",
"builtins.codex.commandExecution.noResults": "没有结果",
"builtins.codex.commandExecution.readFile": "读取文件",
"builtins.codex.fileChange.editedFiles_one": "已编辑 {{count}} 个文件",
"builtins.codex.fileChange.editedFiles_other": "已编辑 {{count}} 个文件",
"builtins.codex.fileChange.editing": "正在编辑文件",
"builtins.codex.fileChange.noChanges": "没有文件改动",
"builtins.codex.fileChange.unknownFile": "未知文件",
"builtins.codex.mcpTool.error": "错误",
"builtins.codex.mcpTool.input": "输入",
"builtins.codex.mcpTool.result": "结果",
"builtins.codex.mcpTool.unknownTool": "MCP 工具",
"builtins.codex.webSearch.query": "搜索词",
"builtins.lobe-activator.apiName.activateTools": "激活工具",
"builtins.lobe-activator.inspector.activateTools.notFoundCount": "{{count}} 个未找到",
"builtins.lobe-agent-builder.apiName.getAvailableModels": "获取可用模型",
@@ -1,73 +1,27 @@
'use client';
import { ToolResultCard } from '@lobechat/shared-tool-ui/components';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Highlighter, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { FolderSearch } from 'lucide-react';
import { memo, useMemo } from 'react';
const styles = createStaticStyles(({ css, cssVar }) => ({
count: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
`,
pattern: css`
font-family: ${cssVar.fontFamilyCode};
`,
scope: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
word-break: break-all;
`,
}));
import { Highlighter } from '@lobehub/ui';
import { memo } from 'react';
interface GlobArgs {
path?: string;
pattern?: string;
}
const Glob = memo<BuiltinRenderProps<GlobArgs>>(({ args, content }) => {
const pattern = args?.pattern || '';
const scope = args?.path || '';
const matchCount = useMemo(() => {
if (!content) return 0;
return content.split('\n').filter((line: string) => line.trim().length > 0).length;
}, [content]);
const Glob = memo<BuiltinRenderProps<GlobArgs>>(({ content }) => {
if (!content) return null;
return (
<ToolResultCard
wrapHeader
icon={FolderSearch}
header={
<>
{pattern && (
<Text strong className={styles.pattern}>
{pattern}
</Text>
)}
{scope && (
<Text ellipsis className={styles.scope}>
{scope}
</Text>
)}
{matchCount > 0 && <Text className={styles.count}>{`${matchCount} matches`}</Text>}
</>
}
<Highlighter
wrap
language={'text'}
showLanguage={false}
style={{ maxHeight: 240, overflow: 'auto' }}
variant={'borderless'}
>
{content && (
<Highlighter
wrap
language={'text'}
showLanguage={false}
style={{ maxHeight: 240, overflow: 'auto' }}
variant={'borderless'}
>
{content}
</Highlighter>
)}
</ToolResultCard>
{content}
</Highlighter>
);
});
@@ -1,23 +1,9 @@
'use client';
import { ToolResultCard } from '@lobechat/shared-tool-ui/components';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Highlighter, Tag, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { Search } from 'lucide-react';
import { Highlighter } from '@lobehub/ui';
import { memo } from 'react';
const styles = createStaticStyles(({ css, cssVar }) => ({
pattern: css`
font-family: ${cssVar.fontFamilyCode};
`,
scope: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
word-break: break-all;
`,
}));
interface GrepArgs {
glob?: string;
output_mode?: 'files_with_matches' | 'content' | 'count';
@@ -26,43 +12,19 @@ interface GrepArgs {
type?: string;
}
const Grep = memo<BuiltinRenderProps<GrepArgs>>(({ args, content }) => {
const pattern = args?.pattern || '';
const scope = args?.path || '';
const glob = args?.glob || args?.type;
const Grep = memo<BuiltinRenderProps<GrepArgs>>(({ content }) => {
if (!content) return null;
return (
<ToolResultCard
wrapHeader
icon={Search}
header={
<>
{pattern && (
<Text strong className={styles.pattern}>
{pattern}
</Text>
)}
{glob && <Tag>{glob}</Tag>}
{scope && (
<Text ellipsis className={styles.scope}>
{scope}
</Text>
)}
</>
}
<Highlighter
wrap
language={'text'}
showLanguage={false}
style={{ maxHeight: 240, overflow: 'auto' }}
variant={'borderless'}
>
{content && (
<Highlighter
wrap
language={'text'}
showLanguage={false}
style={{ maxHeight: 240, overflow: 'auto' }}
variant={'borderless'}
>
{content}
</Highlighter>
)}
</ToolResultCard>
{content}
</Highlighter>
);
});
@@ -1,21 +1,10 @@
'use client';
import { ToolResultCard } from '@lobechat/shared-tool-ui/components';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Highlighter, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { FileText } from 'lucide-react';
import { Highlighter } from '@lobehub/ui';
import path from 'path-browserify-esm';
import { memo, useMemo } from 'react';
const styles = createStaticStyles(({ css, cssVar }) => ({
path: css`
min-width: 0;
font-size: 12px;
color: ${cssVar.colorTextTertiary};
`,
}));
interface ReadArgs {
file_path?: string;
limit?: number;
@@ -38,38 +27,21 @@ const stripLineNumbers = (text: string): string => {
const Read = memo<BuiltinRenderProps<ReadArgs>>(({ args, content }) => {
const filePath = args?.file_path || '';
const fileName = filePath ? path.basename(filePath) : '';
const dir = filePath ? path.dirname(filePath) : '';
const ext = filePath ? path.extname(filePath).slice(1).toLowerCase() : '';
const source = useMemo(() => stripLineNumbers(content || ''), [content]);
if (!source) return null;
return (
<ToolResultCard
icon={FileText}
header={
<>
<Text strong>{fileName || 'Read'}</Text>
{dir && dir !== '.' && (
<Text ellipsis className={styles.path}>
{dir}
</Text>
)}
</>
}
<Highlighter
wrap
language={ext || 'text'}
showLanguage={false}
style={{ maxHeight: 240, overflow: 'auto' }}
variant={'borderless'}
>
{source && (
<Highlighter
wrap
language={ext || 'text'}
showLanguage={false}
style={{ maxHeight: 240, overflow: 'auto' }}
variant={'borderless'}
>
{source}
</Highlighter>
)}
</ToolResultCard>
{source}
</Highlighter>
);
});
@@ -1,55 +1,18 @@
'use client';
import { ToolResultCard } from '@lobechat/shared-tool-ui/components';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Markdown, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { Link } from 'lucide-react';
import { Markdown } from '@lobehub/ui';
import { memo } from 'react';
import type { WebFetchArgs } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
prompt: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
word-break: break-all;
`,
url: css`
font-family: ${cssVar.fontFamilyCode};
word-break: break-all;
`,
}));
const WebFetch = memo<BuiltinRenderProps<WebFetchArgs>>(({ args, content }) => {
const url = args?.url || '';
const prompt = args?.prompt || '';
const WebFetch = memo<BuiltinRenderProps<WebFetchArgs>>(({ content }) => {
if (!content) return null;
return (
<ToolResultCard
wrapHeader
icon={Link}
header={
<>
{url && (
<Text ellipsis strong className={styles.url}>
{url}
</Text>
)}
{prompt && (
<Text ellipsis className={styles.prompt}>
{prompt}
</Text>
)}
</>
}
>
{content && (
<Markdown style={{ maxHeight: 240, overflow: 'auto' }} variant={'chat'}>
{content}
</Markdown>
)}
</ToolResultCard>
<Markdown style={{ maxHeight: 240, overflow: 'auto' }} variant={'chat'}>
{content}
</Markdown>
);
});
@@ -1,62 +1,24 @@
'use client';
import { ToolResultCard } from '@lobechat/shared-tool-ui/components';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Highlighter, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { Globe } from 'lucide-react';
import { Highlighter } from '@lobehub/ui';
import { memo } from 'react';
import type { WebSearchArgs } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
domains: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
word-break: break-all;
`,
query: css`
font-family: ${cssVar.fontFamilyCode};
`,
}));
const WebSearch = memo<BuiltinRenderProps<WebSearchArgs>>(({ args, content }) => {
const query = args?.query || '';
const allowed = args?.allowed_domains?.join(', ');
const blocked = args?.blocked_domains?.map((d) => `-${d}`).join(', ');
const scope = [allowed, blocked].filter(Boolean).join(' · ');
const WebSearch = memo<BuiltinRenderProps<WebSearchArgs>>(({ content }) => {
if (!content) return null;
return (
<ToolResultCard
wrapHeader
icon={Globe}
header={
<>
{query && (
<Text strong className={styles.query}>
{query}
</Text>
)}
{scope && (
<Text ellipsis className={styles.domains}>
{scope}
</Text>
)}
</>
}
<Highlighter
wrap
language={'text'}
showLanguage={false}
style={{ maxHeight: 240, overflow: 'auto' }}
variant={'borderless'}
>
{content && (
<Highlighter
wrap
language={'text'}
showLanguage={false}
style={{ maxHeight: 240, overflow: 'auto' }}
variant={'borderless'}
>
{content}
</Highlighter>
)}
</ToolResultCard>
{content}
</Highlighter>
);
});
@@ -1,21 +1,10 @@
'use client';
import { ToolResultCard } from '@lobechat/shared-tool-ui/components';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Highlighter, Markdown, Skeleton, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { FilePlus2 } from 'lucide-react';
import { Highlighter, Markdown, Skeleton } from '@lobehub/ui';
import path from 'path-browserify-esm';
import { memo } from 'react';
const styles = createStaticStyles(({ css, cssVar }) => ({
path: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
word-break: break-all;
`,
}));
interface WriteArgs {
content?: string;
file_path?: string;
@@ -25,7 +14,6 @@ const Write = memo<BuiltinRenderProps<WriteArgs>>(({ args }) => {
if (!args) return <Skeleton active />;
const filePath = args.file_path || '';
const fileName = filePath ? path.basename(filePath) : '';
const ext = filePath ? path.extname(filePath).slice(1).toLowerCase() : '';
const renderContent = () => {
@@ -52,23 +40,7 @@ const Write = memo<BuiltinRenderProps<WriteArgs>>(({ args }) => {
);
};
return (
<ToolResultCard
icon={FilePlus2}
header={
<>
<Text strong>{fileName || 'Write'}</Text>
{filePath && filePath !== fileName && (
<Text ellipsis className={styles.path}>
{filePath}
</Text>
)}
</>
}
>
{renderContent()}
</ToolResultCard>
);
return renderContent();
});
Write.displayName = 'ClaudeCodeWrite';
@@ -0,0 +1,115 @@
'use client';
import {
createGrepContentInspector,
createReadLocalFileInspector,
RunCommandInspector,
} from '@lobechat/shared-tool-ui/inspectors';
import type { RunCommandState } from '@lobechat/tool-runtime';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { memo } from 'react';
import {
getCodexGrepCommandDisplay,
getCodexReadFileCommandDisplay,
} from './commandExecutionUtils';
const COMMAND_EXECUTION_KEY = 'builtins.codex.apiName.command_execution';
const GREP_KEY = 'builtins.codex.commandExecution.grep';
const GREP_NO_RESULTS_KEY = 'builtins.codex.commandExecution.noResults';
const READ_FILE_KEY = 'builtins.codex.commandExecution.readFile';
const SharedGrepInspector = createGrepContentInspector({
noResultsKey: GREP_NO_RESULTS_KEY,
translationKey: GREP_KEY,
});
const SharedReadInspector = createReadLocalFileInspector(READ_FILE_KEY);
interface CommandExecutionArgs {
background?: boolean;
command: string;
description?: string;
timeout?: number;
}
interface ReadFileArgs {
endLine?: number;
path?: string;
startLine?: number;
}
interface GrepArgs {
pattern?: string;
}
const mapCommandToReadArgs = (command?: string): ReadFileArgs | undefined => {
const display = getCodexReadFileCommandDisplay(command);
if (!display) return;
return {
endLine: display.endLine,
path: display.filePath,
startLine: display.startLine,
};
};
const mapCommandToGrepArgs = (command?: string): GrepArgs | undefined => {
const display = getCodexGrepCommandDisplay(command);
if (!display) return;
return { pattern: display.pattern };
};
const CommandExecutionInspector = memo<
BuiltinInspectorProps<CommandExecutionArgs, RunCommandState>
>((props) => {
const {
apiName,
args,
identifier,
isArgumentsStreaming,
isLoading,
partialArgs,
result,
toolCallId,
} = props;
const readArgs = mapCommandToReadArgs(args?.command);
const partialReadArgs = mapCommandToReadArgs(partialArgs?.command);
if (readArgs || partialReadArgs) {
return (
<SharedReadInspector
apiName={apiName}
args={readArgs || partialReadArgs || {}}
identifier={identifier}
isArgumentsStreaming={isArgumentsStreaming}
isLoading={isLoading}
partialArgs={partialReadArgs}
result={result}
toolCallId={toolCallId}
/>
);
}
const grepArgs = mapCommandToGrepArgs(args?.command);
const partialGrepArgs = mapCommandToGrepArgs(partialArgs?.command);
if (grepArgs || partialGrepArgs) {
return (
<SharedGrepInspector
apiName={apiName}
args={grepArgs || partialGrepArgs || {}}
identifier={identifier}
isArgumentsStreaming={isArgumentsStreaming}
isLoading={isLoading}
partialArgs={partialGrepArgs}
result={result}
toolCallId={toolCallId}
/>
);
}
return <RunCommandInspector {...props} translationKey={COMMAND_EXECUTION_KEY} />;
});
CommandExecutionInspector.displayName = 'CodexCommandExecutionInspector';
export default CommandExecutionInspector;
@@ -5,24 +5,11 @@ import { inspectorTextStyles, shinyTextStyles } from '@lobechat/shared-tool-ui/s
import type { BuiltinInspectorProps } from '@lobechat/types';
import { createStaticStyles, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { type CodexFileChangeArgs, type CodexFileChangeState, getFileChangeStats } from './utils';
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
overflow: hidden;
display: inline-flex;
flex-shrink: 1;
align-items: center;
min-width: 0;
margin-inline-start: 6px;
padding-block: 2px;
padding-inline: 10px;
border-radius: 999px;
background: ${cssVar.colorFillTertiary};
`,
count: css`
margin-inline-start: 4px;
font-size: 12px;
@@ -31,25 +18,36 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
lineAdded: css`
margin-inline-start: 6px;
font-size: 12px;
font-weight: 600;
color: ${cssVar.colorSuccess};
`,
lineDeleted: css`
margin-inline-start: 4px;
font-size: 12px;
font-weight: 600;
color: ${cssVar.colorError};
`,
summary: css`
margin-inline-end: 6px;
`,
}));
const FileChangeInspector = memo<BuiltinInspectorProps<CodexFileChangeArgs, CodexFileChangeState>>(
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
const { t } = useTranslation('plugin');
const stats = getFileChangeStats(args || partialArgs, pluginState);
const hasLineStats = stats.linesAdded > 0 || stats.linesDeleted > 0;
const summary =
stats.total > 0
? t('builtins.codex.fileChange.editedFiles', {
count: stats.total,
defaultValue: stats.total === 1 ? 'Edited {{count}} file' : 'Edited {{count}} files',
})
: isArgumentsStreaming || isLoading
? t('builtins.codex.fileChange.editing', { defaultValue: 'Editing files' })
: t('builtins.codex.fileChange.noChanges', { defaultValue: 'No file changes' });
if (isArgumentsStreaming && !stats.firstPath) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>File changes</div>
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>{summary}</div>
);
}
@@ -60,11 +58,13 @@ const FileChangeInspector = memo<BuiltinInspectorProps<CodexFileChangeArgs, Code
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>File changes:</span>
{stats.firstPath && (
<span className={styles.chip}>
{stats.firstPath ? (
<>
<span className={styles.summary}>{summary}:</span>
<FilePathDisplay filePath={stats.firstPath} />
</span>
</>
) : (
<span>{summary}</span>
)}
{stats.total > 1 && <span className={styles.count}>+{stats.total - 1}</span>}
{hasLineStats && (
@@ -1,11 +1,11 @@
'use client';
import { FilePathDisplay, ToolResultCard } from '@lobechat/shared-tool-ui/components';
import { FilePathDisplay } from '@lobechat/shared-tool-ui/components';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Flexbox, Icon, Text } from '@lobehub/ui';
import { Flexbox, Text } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { Files, FileText } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import {
type CodexFileChangeArgs,
@@ -22,34 +22,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
font-size: 13px;
color: ${cssVar.colorTextTertiary};
`,
header: css`
flex-wrap: wrap;
gap: 6px;
align-items: center;
min-width: 0;
`,
headerChip: css`
overflow: hidden;
display: inline-flex;
flex: 0 1 auto;
align-items: center;
min-width: 0;
max-width: 100%;
padding-block: 4px;
padding-inline: 10px;
border-radius: 999px;
background: ${cssVar.colorFillTertiary};
`,
headerCount: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
`,
headerLabel: css`
font-size: 12px;
color: ${cssVar.colorTextSecondary};
`,
kindAdded: css`
background: ${cssVar.colorSuccess};
`,
@@ -69,11 +41,9 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
background: ${cssVar.colorWarning};
`,
lineAdded: css`
font-weight: 600;
color: ${cssVar.colorSuccess};
`,
lineDeleted: css`
font-weight: 600;
color: ${cssVar.colorError};
`,
lineStats: css`
@@ -85,33 +55,10 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
font-size: 12px;
`,
list: css`
gap: 0;
`,
panel: css`
overflow: hidden;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: 12px;
background: ${cssVar.colorFillQuaternary};
`,
panelHeader: css`
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 12px;
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
background: ${cssVar.colorBgContainer};
`,
panelMeta: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
`,
panelTitle: css`
display: flex;
gap: 8px;
align-items: center;
gap: 2px;
min-width: 0;
padding-block: 2px;
padding-inline: 4px;
`,
rowMain: css`
display: flex;
@@ -128,13 +75,12 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
min-width: 0;
`,
row: css`
gap: 10px;
gap: 8px;
align-items: center;
padding: 12px;
& + & {
border-block-start: 1px solid ${cssVar.colorBorderSecondary};
}
min-height: 26px;
padding-block: 3px;
padding-inline: 0;
`,
unknownPath: css`
font-size: 13px;
@@ -175,75 +121,45 @@ LineStats.displayName = 'CodexFileChangeLineStats';
const FileChangeRender = memo<BuiltinRenderProps<CodexFileChangeArgs, CodexFileChangeState>>(
({ args, pluginState }) => {
const { t } = useTranslation('plugin');
const stats = getFileChangeStats(args, pluginState);
const data = getFileChangeData(args, pluginState);
const summary = stats.total === 1 ? '1 file change' : `${stats.total} file changes`;
const detail = [
stats.byKind.added > 0 ? `${stats.byKind.added} added` : null,
stats.byKind.modified > 0 ? `${stats.byKind.modified} modified` : null,
stats.byKind.deleted > 0 ? `${stats.byKind.deleted} deleted` : null,
stats.byKind.renamed > 0 ? `${stats.byKind.renamed} renamed` : null,
]
.filter(Boolean)
.join(', ');
if (stats.total === 0) {
return (
<Text className={styles.emptyState}>
{t('builtins.codex.fileChange.noChanges', { defaultValue: 'No file changes' })}
</Text>
);
}
return (
<ToolResultCard
wrapHeader
icon={FileText}
header={
<Flexbox horizontal align={'center'} className={styles.header} wrap={'wrap'}>
<Text className={styles.headerLabel}>File changes:</Text>
{stats.firstPath && (
<span className={styles.headerChip}>
<FilePathDisplay filePath={stats.firstPath} />
</span>
)}
{stats.total > 1 && <Text className={styles.headerCount}>+{stats.total - 1}</Text>}
<LineStats linesAdded={stats.linesAdded} linesDeleted={stats.linesDeleted} />
</Flexbox>
}
>
{stats.total > 0 ? (
<div className={styles.panel}>
<Flexbox horizontal align={'center'} className={styles.panelHeader} wrap={'wrap'}>
<div className={styles.panelTitle}>
<Icon icon={Files} size={16} />
<Text strong>{summary}</Text>
</div>
{detail && <Text className={styles.panelMeta}>{detail}</Text>}
<LineStats linesAdded={stats.linesAdded} linesDeleted={stats.linesDeleted} />
</Flexbox>
<Flexbox className={styles.list}>
{data.changes.map((change, index) => {
const kind = getFileChangeKind(change.kind);
const path = change.path || '';
<Flexbox className={styles.list}>
{data.changes.map((change, index) => {
const kind = getFileChangeKind(change.kind);
const path = change.path || '';
return (
<Flexbox horizontal className={styles.row} key={`${path}-${index}`}>
<span className={cx(styles.kindDot, getKindClassName(kind))} />
<div className={styles.rowMain}>
<div className={styles.path}>
{path ? (
<FilePathDisplay filePath={path} />
) : (
<Text className={styles.unknownPath}>Unknown file</Text>
)}
</div>
<LineStats
linesAdded={change.linesAdded}
linesDeleted={change.linesDeleted}
/>
</div>
</Flexbox>
);
})}
return (
<Flexbox horizontal className={styles.row} key={`${path}-${index}`}>
<span className={cx(styles.kindDot, getKindClassName(kind))} />
<div className={styles.rowMain}>
<div className={styles.path}>
{path ? (
<FilePathDisplay filePath={path} />
) : (
<Text className={styles.unknownPath}>
{t('builtins.codex.fileChange.unknownFile', {
defaultValue: 'Unknown file',
})}
</Text>
)}
</div>
<LineStats linesAdded={change.linesAdded} linesDeleted={change.linesDeleted} />
</div>
</Flexbox>
</div>
) : (
<Text className={styles.emptyState}>No file changes</Text>
)}
</ToolResultCard>
);
})}
</Flexbox>
);
},
);
@@ -0,0 +1,79 @@
'use client';
import { LINEAR_TOOL_NAMES, LinearInspector } from '@lobechat/shared-tool-ui/inspectors';
import {
highlightTextStyles,
inspectorTextStyles,
shinyTextStyles,
} from '@lobechat/shared-tool-ui/styles';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import type { ComponentType } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { CodexMcpToolArgs, CodexMcpToolState } from './mcpToolUtils';
import {
getCodexLinearMcpApiName,
getMcpInputRecord,
getMcpServer,
getMcpToolName,
} from './mcpToolUtils';
const LINEAR_TOOL_NAME_SET = new Set<string>(LINEAR_TOOL_NAMES);
const SharedLinearInspector = LinearInspector as ComponentType<
BuiltinInspectorProps<Record<string, unknown>>
>;
const McpToolInspector = memo<BuiltinInspectorProps<CodexMcpToolArgs, CodexMcpToolState>>(
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
const { t } = useTranslation('plugin');
const label = t('builtins.codex.apiName.mcp_tool_call', {
defaultValue: 'Call MCP tool',
});
const server = getMcpServer(args, pluginState) || getMcpServer(partialArgs);
const tool = getMcpToolName(args, pluginState) || getMcpToolName(partialArgs);
const linearApiName = getCodexLinearMcpApiName(tool);
if (LINEAR_TOOL_NAME_SET.has(linearApiName)) {
return (
<SharedLinearInspector
apiName={linearApiName}
args={getMcpInputRecord(args, pluginState) || {}}
identifier={'codex'}
isArgumentsStreaming={isArgumentsStreaming}
isLoading={isLoading}
partialArgs={getMcpInputRecord(partialArgs) || {}}
pluginState={pluginState}
/>
);
}
const target = [server, tool].filter(Boolean).join(' > ');
if (isArgumentsStreaming && !target) {
return <div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>{label}</div>;
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{label}</span>
{target && (
<>
<span>: </span>
<span className={highlightTextStyles.primary}>{target}</span>
</>
)}
</div>
);
},
);
McpToolInspector.displayName = 'CodexMcpToolInspector';
export default McpToolInspector;
@@ -0,0 +1,94 @@
'use client';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { CodexMcpToolArgs, CodexMcpToolState } from './mcpToolUtils';
import {
formatMcpInput,
formatMcpOutput,
getMcpErrorText,
getMcpInput,
getMcpResultText,
getMcpToolName,
} from './mcpToolUtils';
const styles = createStaticStyles(({ css, cssVar }) => ({
sectionLabel: css`
margin-block-end: 4px;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
`,
}));
const McpToolRender = memo<BuiltinRenderProps<CodexMcpToolArgs, CodexMcpToolState, string>>(
({ args, content, pluginState }) => {
const { t } = useTranslation('plugin');
const toolName = getMcpToolName(args, pluginState);
const input = formatMcpInput(getMcpInput(args, pluginState), toolName);
const output = formatMcpOutput(getMcpResultText(content, pluginState, args), toolName);
const error = getMcpErrorText(pluginState, args);
if (!input && !output && !error) return null;
return (
<Flexbox gap={12}>
{input && (
<div>
<Text className={styles.sectionLabel}>
{t('builtins.codex.mcpTool.input', { defaultValue: 'Input' })}
</Text>
<Highlighter
wrap
language={input.language}
showLanguage={input.language !== 'text'}
style={{ maxHeight: 220, overflow: 'auto', paddingInline: 8 }}
variant={'outlined'}
>
{input.text}
</Highlighter>
</div>
)}
{output && (
<div>
<Text className={styles.sectionLabel}>
{t('builtins.codex.mcpTool.result', { defaultValue: 'Result' })}
</Text>
<Highlighter
wrap
language={output.language}
showLanguage={output.language !== 'text'}
style={{ maxHeight: 360, overflow: 'auto', paddingInline: 8 }}
variant={'filled'}
>
{output.text}
</Highlighter>
</div>
)}
{error && (
<div>
<Text className={styles.sectionLabel} style={{ color: cssVar.colorError }}>
{t('builtins.codex.mcpTool.error', { defaultValue: 'Error' })}
</Text>
<Highlighter
wrap
language={'text'}
showLanguage={false}
style={{ maxHeight: 220, overflow: 'auto', paddingInline: 8 }}
variant={'filled'}
>
{error}
</Highlighter>
</div>
)}
</Flexbox>
);
},
);
McpToolRender.displayName = 'CodexMcpToolRender';
export default McpToolRender;
@@ -0,0 +1,46 @@
'use client';
import {
highlightTextStyles,
inspectorTextStyles,
shinyTextStyles,
} from '@lobechat/shared-tool-ui/styles';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { type CodexWebSearchArgs, getWebSearchQuery } from './webSearchUtils';
const WebSearchInspector = memo<BuiltinInspectorProps<CodexWebSearchArgs>>(
({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const label = t('builtins.codex.apiName.web_search', { defaultValue: 'Search the web' });
const query = getWebSearchQuery(args) || getWebSearchQuery(partialArgs);
if (isArgumentsStreaming && !query) {
return <div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>{label}</div>;
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{label}</span>
{query && (
<>
<span>: </span>
<span className={highlightTextStyles.primary}>{query}</span>
</>
)}
</div>
);
},
);
WebSearchInspector.displayName = 'CodexWebSearchInspector';
export default WebSearchInspector;
@@ -0,0 +1,148 @@
'use client';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Flexbox, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import {
type CodexWebSearchArgs,
getWebSearchOutput,
getWebSearchQuery,
getWebSearchResults,
} from './webSearchUtils';
const styles = createStaticStyles(({ css, cssVar }) => ({
output: css`
overflow: auto;
max-height: 280px;
margin: 0;
padding-block: 2px;
padding-inline: 4px;
font-family: ${cssVar.fontFamily};
font-size: 13px;
line-height: 1.55;
color: ${cssVar.colorTextSecondary};
white-space: pre-wrap;
`,
query: css`
overflow: hidden;
min-width: 0;
color: ${cssVar.colorText};
text-overflow: ellipsis;
white-space: nowrap;
`,
queryLabel: css`
flex-shrink: 0;
color: ${cssVar.colorTextTertiary};
`,
queryRow: css`
gap: 6px;
align-items: center;
padding-block: 2px;
padding-inline: 4px;
font-size: 12px;
`,
resultItem: css`
min-width: 0;
padding-block: 5px;
padding-inline: 4px;
border-block-end: 1px solid ${cssVar.colorSplit};
&:last-child {
border-block-end: 0;
}
`,
resultList: css`
gap: 0;
min-width: 0;
`,
root: css`
gap: 4px;
min-width: 0;
padding-block: 2px;
`,
snippet: css`
font-size: 12px;
line-height: 1.45;
color: ${cssVar.colorTextSecondary};
`,
title: css`
overflow: hidden;
font-size: 13px;
font-weight: 500;
line-height: 1.45;
color: ${cssVar.colorText};
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
color: ${cssVar.colorLink};
}
`,
url: css`
overflow: hidden;
font-size: 12px;
color: ${cssVar.colorTextTertiary};
text-overflow: ellipsis;
white-space: nowrap;
`,
}));
const WebSearchRender = memo<BuiltinRenderProps<CodexWebSearchArgs>>(({ args, content }) => {
const { t } = useTranslation('plugin');
const query = getWebSearchQuery(args);
const results = getWebSearchResults(args, content);
const output = results.length > 0 ? '' : getWebSearchOutput(content);
if (!query && results.length === 0 && !output) return null;
return (
<Flexbox className={styles.root}>
{query && (
<Flexbox horizontal className={styles.queryRow}>
<span className={styles.queryLabel}>
{t('builtins.codex.webSearch.query', { defaultValue: 'Query' })}
</span>
<span className={styles.query}>{query}</span>
</Flexbox>
)}
{results.length > 0 && (
<Flexbox className={styles.resultList}>
{results.map((result, index) => {
const key = result.url || `${result.title}-${index}`;
const title = <span className={styles.title}>{result.title}</span>;
return (
<Flexbox className={styles.resultItem} gap={3} key={key}>
{result.url ? (
<a href={result.url} rel={'noreferrer'} target={'_blank'}>
{title}
</a>
) : (
title
)}
{result.url && <Text className={styles.url}>{result.url}</Text>}
{result.snippet && <Text className={styles.snippet}>{result.snippet}</Text>}
</Flexbox>
);
})}
</Flexbox>
)}
{output && <pre className={styles.output}>{output}</pre>}
</Flexbox>
);
});
WebSearchRender.displayName = 'CodexWebSearchRender';
export default WebSearchRender;
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import {
getCodexGrepCommandDisplay,
getCodexReadFileCommandDisplay,
} from './commandExecutionUtils';
describe('getCodexReadFileCommandDisplay', () => {
it('parses sed range reads', () => {
expect(
getCodexReadFileCommandDisplay(
"sed -n '1,260p' src/features/Conversation/Messages/AssistantGroup/Tool/index.tsx",
),
).toEqual({
endLine: 260,
filePath: 'src/features/Conversation/Messages/AssistantGroup/Tool/index.tsx',
startLine: 1,
});
});
it('parses double quoted and bare ranges', () => {
expect(getCodexReadFileCommandDisplay('sed -n "260,620p" packages/foo.ts')).toEqual({
endLine: 620,
filePath: 'packages/foo.ts',
startLine: 260,
});
expect(getCodexReadFileCommandDisplay('sed -n 42p packages/foo.ts')).toEqual({
filePath: 'packages/foo.ts',
startLine: 42,
});
});
it('unwraps simple shell wrappers', () => {
expect(
getCodexReadFileCommandDisplay("/bin/zsh -lc 'sed -n '\\''1,260p'\\'' packages/foo.ts'"),
).toEqual({
endLine: 260,
filePath: 'packages/foo.ts',
startLine: 1,
});
});
it('supports a quoted file path token', () => {
expect(getCodexReadFileCommandDisplay("sed -n '1,3p' 'src/foo bar.ts'")).toEqual({
endLine: 3,
filePath: 'src/foo bar.ts',
startLine: 1,
});
});
it('parses cat single file reads', () => {
expect(getCodexReadFileCommandDisplay('cat .agents/skills/react/SKILL.md')).toEqual({
filePath: '.agents/skills/react/SKILL.md',
});
expect(getCodexReadFileCommandDisplay("cat -- 'src/foo bar.ts'")).toEqual({
filePath: 'src/foo bar.ts',
});
});
it('ignores commands with extra shell behavior', () => {
expect(getCodexReadFileCommandDisplay("sed -n '1,260p' src/foo.ts | head")).toBeUndefined();
expect(getCodexReadFileCommandDisplay("sed -n '1,260p' src/foo.ts > /tmp/out")).toBeUndefined();
expect(getCodexReadFileCommandDisplay("sed -n '1,260p;2,3p' src/foo.ts")).toBeUndefined();
expect(getCodexReadFileCommandDisplay('cat src/foo.ts src/bar.ts')).toBeUndefined();
expect(getCodexReadFileCommandDisplay('cat src/foo.ts | head')).toBeUndefined();
});
});
describe('getCodexGrepCommandDisplay', () => {
it('parses simple rg content searches', () => {
expect(getCodexGrepCommandDisplay('rg "createReadLocalFileInspector" packages src')).toEqual({
pattern: 'createReadLocalFileInspector',
});
});
it('parses rg --files pipelines', () => {
expect(
getCodexGrepCommandDisplay(
'rg --files packages src | rg "codex|AssistantGroup/Tool|toolDisplayNames"',
),
).toEqual({
pattern: 'codex|AssistantGroup/Tool|toolDisplayNames',
});
});
it('parses rg regexp options', () => {
expect(getCodexGrepCommandDisplay('rg -n -e "foo|bar" src')).toEqual({
pattern: 'foo|bar',
});
expect(getCodexGrepCommandDisplay('rg --regexp=foo src')).toEqual({
pattern: 'foo',
});
});
it('ignores unsafe or unsupported rg commands', () => {
expect(getCodexGrepCommandDisplay('rg "foo" src > /tmp/out')).toBeUndefined();
expect(getCodexGrepCommandDisplay('rg --files src | sort | rg "foo"')).toBeUndefined();
expect(getCodexGrepCommandDisplay('rg --files src')).toBeUndefined();
});
});
@@ -0,0 +1,281 @@
const SHELL_WRAPPER_PATTERN =
/^(?:\/usr\/bin\/env\s+)?(?:\/\S+\/)?(?:bash|sh|zsh)\s+(?:-lc|-c|-l\s+-c)\s+(\S[\s\S]*)$/;
const CAT_OPTION_PATTERN = /^-[a-z]+$/i;
const SED_RANGE_PATTERN = /^(\d+)(?:,(\d+))?p$/;
const hasShellControlOperator = (value: string) =>
/\|\||&&|[|;<>`]/.test(value) || value.includes('$(');
const hasUnsafeShellControlOperator = (value: string) =>
/\|\||&&|[;<>`]/.test(value) || value.includes('$(');
const stripOuterShellQuotes = (value: string) => {
const trimmed = value.trim();
if (trimmed.length < 2) return trimmed;
const quote = trimmed[0];
if ((quote !== '"' && quote !== "'") || trimmed.at(-1) !== quote) return trimmed;
const body = trimmed.slice(1, -1);
if (quote === "'") return body.replaceAll("'\\''", "'");
return body
.replaceAll('\\"', '"')
.replaceAll('\\`', '`')
.replaceAll('\\$', '$')
.replaceAll('\\\\', '\\');
};
const stripShellWrapper = (command?: string) => {
const trimmed = command?.trim() || '';
if (!trimmed) return '';
const match = trimmed.match(SHELL_WRAPPER_PATTERN);
if (!match) return trimmed;
return stripOuterShellQuotes(match[1]) || trimmed;
};
const pushToken = (tokens: string[], token: string) => {
if (token) tokens.push(token);
};
const tokenizeShellLike = (command: string): string[] | undefined => {
const tokens: string[] = [];
let current = '';
let quote: '"' | "'" | undefined;
let escaping = false;
for (const char of command) {
if (escaping) {
current += char;
escaping = false;
continue;
}
if (char === '\\' && quote !== "'") {
escaping = true;
continue;
}
if (quote) {
if (char === quote) {
quote = undefined;
} else {
current += char;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '|') {
pushToken(tokens, current);
current = '';
tokens.push('|');
continue;
}
if (/\s/.test(char)) {
pushToken(tokens, current);
current = '';
continue;
}
current += char;
}
if (quote || escaping) return;
pushToken(tokens, current);
return tokens;
};
const parseLineRange = (range: string) => {
const match = range.match(SED_RANGE_PATTERN);
if (!match) return;
return {
endLine: match[2] ? Number(match[2]) : undefined,
startLine: Number(match[1]),
};
};
const getSingleFileToken = (token?: string) => {
if (!token || token === '-' || hasShellControlOperator(token)) return;
return token;
};
export interface CodexReadFileCommandDisplay {
endLine?: number;
filePath: string;
startLine?: number;
}
const parseSedReadCommand = (tokens: string[]): CodexReadFileCommandDisplay | undefined => {
if (tokens[0] !== 'sed') return;
const printOption = tokens[1];
if (printOption !== '-n' && printOption !== '--quiet' && printOption !== '--silent') return;
const range = parseLineRange(tokens[2] || '');
if (!range) return;
const targetIndex = tokens[3] === '--' ? 4 : 3;
if (tokens.length !== targetIndex + 1) return;
const filePath = getSingleFileToken(tokens[targetIndex]);
if (!filePath) return;
return { ...range, filePath };
};
const parseCatReadCommand = (tokens: string[]): CodexReadFileCommandDisplay | undefined => {
if (tokens[0] !== 'cat') return;
let targetIndex = 1;
while (CAT_OPTION_PATTERN.test(tokens[targetIndex] || '')) targetIndex += 1;
if (tokens[targetIndex] === '--') targetIndex += 1;
if (tokens.length !== targetIndex + 1) return;
const filePath = getSingleFileToken(tokens[targetIndex]);
if (!filePath) return;
return { filePath };
};
export const getCodexReadFileCommandDisplay = (
command?: string,
): CodexReadFileCommandDisplay | undefined => {
const displayCommand = stripShellWrapper(command);
if (!displayCommand || hasShellControlOperator(displayCommand)) return;
const tokens = tokenizeShellLike(displayCommand);
if (!tokens) return;
return parseSedReadCommand(tokens) || parseCatReadCommand(tokens);
};
const RG_OPTIONS_WITH_VALUE = new Set([
'-A',
'-B',
'-C',
'-g',
'-m',
'-t',
'-T',
'--after-context',
'--before-context',
'--colors',
'--context',
'--context-separator',
'--engine',
'--field-context-separator',
'--field-match-separator',
'--glob',
'--iglob',
'--json-seq',
'--max-columns',
'--max-count',
'--max-depth',
'--max-filesize',
'--mmap',
'--path-separator',
'--pre',
'--replace',
'--sort',
'--sortr',
'--type',
'--type-add',
'--type-clear',
'--type-not',
]);
const RG_PATTERN_OPTIONS = new Set(['-e', '--regexp']);
const splitRgPipeline = (tokens: string[]) => {
const pipeIndexes = tokens.reduce<number[]>((indexes, token, index) => {
if (token === '|') indexes.push(index);
return indexes;
}, []);
if (pipeIndexes.length === 0) return [tokens];
if (pipeIndexes.length > 1) return;
const pipeIndex = pipeIndexes[0];
const first = tokens.slice(0, pipeIndex);
const second = tokens.slice(pipeIndex + 1);
if (first[0] !== 'rg' || second[0] !== 'rg' || !first.includes('--files')) return;
return [second];
};
const getAttachedRgPattern = (token: string) => {
if (token.startsWith('--regexp=')) return token.slice('--regexp='.length);
if (token.startsWith('-e') && token.length > 2) return token.slice(2);
};
const isRgOptionWithAttachedValue = (token: string) =>
['-A', '-B', '-C', '-g', '-m', '-t', '-T'].some(
(option) => token.startsWith(option) && token.length > option.length,
) || /^--[^=]+=/.test(token);
const getRgPatternFromTokens = (tokens: string[]) => {
if (tokens[0] !== 'rg') return;
for (let index = 1; index < tokens.length; index++) {
const token = tokens[index];
if (!token) continue;
const attachedPattern = getAttachedRgPattern(token);
if (attachedPattern) return attachedPattern;
if (RG_PATTERN_OPTIONS.has(token)) {
return tokens[index + 1];
}
if (token === '--') {
return tokens[index + 1];
}
if (RG_OPTIONS_WITH_VALUE.has(token)) {
index += 1;
continue;
}
if (token.startsWith('-')) {
if (isRgOptionWithAttachedValue(token)) continue;
continue;
}
return token;
}
};
export interface CodexGrepCommandDisplay {
pattern: string;
}
export const getCodexGrepCommandDisplay = (
command?: string,
): CodexGrepCommandDisplay | undefined => {
const displayCommand = stripShellWrapper(command);
if (!displayCommand || hasUnsafeShellControlOperator(displayCommand)) return;
const tokens = tokenizeShellLike(displayCommand);
if (!tokens) return;
if (!tokens.includes('|') && tokens.includes('--files')) return;
const rgCommands = splitRgPipeline(tokens);
if (!rgCommands) return;
const pattern = getRgPatternFromTokens(rgCommands.at(-1) || []);
if (!pattern) return;
return { pattern };
};
@@ -0,0 +1,9 @@
import type { RenderDisplayControl } from '@lobechat/types';
export const CodexRenderDisplayControls: Record<string, RenderDisplayControl> = {
command_execution: 'collapsed',
file_change: 'expand',
mcp_tool_call: 'expand',
todo_list: 'expand',
web_search: 'expand',
};
+13 -9
View File
@@ -1,25 +1,29 @@
import {
type BuiltinInspector,
type BuiltinRender,
type RenderDisplayControl,
} from '@lobechat/types';
import { type BuiltinInspector, type BuiltinRender } from '@lobechat/types';
import CommandExecutionInspector from './CommandExecutionInspector';
import { CodexRenderDisplayControls } from './displayControls';
import FileChangeInspector from './FileChangeInspector';
import FileChangeRender from './FileChangeRender';
import McpToolInspector from './McpToolInspector';
import McpToolRender from './McpToolRender';
import TodoListInspector from './TodoListInspector';
import TodoListRender from './TodoListRender';
import WebSearchInspector from './WebSearchInspector';
import WebSearchRender from './WebSearchRender';
export const CodexInspectors: Record<string, BuiltinInspector> = {
command_execution: CommandExecutionInspector as BuiltinInspector,
file_change: FileChangeInspector as BuiltinInspector,
mcp_tool_call: McpToolInspector as BuiltinInspector,
todo_list: TodoListInspector as BuiltinInspector,
web_search: WebSearchInspector as BuiltinInspector,
};
export const CodexRenders: Record<string, BuiltinRender> = {
file_change: FileChangeRender as BuiltinRender,
mcp_tool_call: McpToolRender as BuiltinRender,
todo_list: TodoListRender as BuiltinRender,
web_search: WebSearchRender as BuiltinRender,
};
export const CodexRenderDisplayControls: Record<string, RenderDisplayControl> = {
file_change: 'expand',
todo_list: 'expand',
};
export { CodexRenderDisplayControls };
@@ -0,0 +1,217 @@
'use client';
export interface CodexMcpToolArgs extends Record<string, unknown> {
arguments?: unknown;
error?: unknown;
result?: unknown;
server?: unknown;
tool?: unknown;
}
export interface CodexMcpToolState extends Record<string, unknown> {
arguments?: unknown;
error?: unknown;
result?: unknown;
server?: unknown;
status?: unknown;
tool?: unknown;
}
export interface FormattedMcpValue {
language: string;
text: string;
}
const LINEAR_CODEX_PREFIX = 'linear_';
const LINEAR_CODEX_SERVER_PREFIX = 'server_';
const SERVER_KEYS = ['server', 'serverName', 'server_name', 'connector', 'connector_id'];
const TOOL_KEYS = ['tool', 'toolName', 'tool_name', 'name'];
const INPUT_KEYS = ['arguments', 'args', 'input', 'params', 'parameters'];
const COMPLETED_MCP_TOOL_CALL_PATTERN = /^Completed mcp_tool_call\.?$/iu;
export const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const normalizeString = (value: unknown) => (typeof value === 'string' ? value.trim() : '');
const getStringByKeys = (record: Record<string, unknown> | undefined, keys: string[]) => {
if (!record) return '';
for (const key of keys) {
const value = normalizeString(record[key]);
if (value) return value;
}
return '';
};
export const getMcpServer = (args?: CodexMcpToolArgs, state?: CodexMcpToolState) =>
getStringByKeys(args, SERVER_KEYS) || getStringByKeys(state, SERVER_KEYS);
export const getMcpToolName = (args?: CodexMcpToolArgs, state?: CodexMcpToolState) =>
getStringByKeys(args, TOOL_KEYS) || getStringByKeys(state, TOOL_KEYS);
export const getMcpInput = (args?: CodexMcpToolArgs, state?: CodexMcpToolState) => {
const records = [args, state].filter(isRecord);
for (const record of records) {
for (const key of INPUT_KEYS) {
if (record[key] !== undefined && record[key] !== null) return record[key];
}
}
};
const tryParseJson = (value: string): unknown => {
try {
return JSON.parse(value);
} catch {
return undefined;
}
};
export const getMcpInputRecord = (
args?: CodexMcpToolArgs,
state?: CodexMcpToolState,
): Record<string, unknown> | undefined => {
const input = getMcpInput(args, state);
if (isRecord(input)) return input;
if (typeof input === 'string') {
const parsed = tryParseJson(input);
if (isRecord(parsed)) return parsed;
}
};
export const getCodexLinearMcpApiName = (toolName: string) => {
if (!toolName) return '';
let apiName = toolName.trim();
if (apiName.startsWith(LINEAR_CODEX_PREFIX)) {
apiName = apiName.slice(LINEAR_CODEX_PREFIX.length);
}
if (apiName.startsWith(LINEAR_CODEX_SERVER_PREFIX)) {
apiName = apiName.slice(LINEAR_CODEX_SERVER_PREFIX.length);
}
return apiName;
};
const stringifyValue = (value: unknown): string => {
if (typeof value === 'string') return value.trim();
if (value === undefined || value === null) return '';
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const getTextFromContentItem = (item: unknown): string => {
if (typeof item === 'string') return item;
if (!isRecord(item)) return stringifyValue(item);
const text = normalizeString(item.text) || normalizeString(item.content);
if (text) return text;
return stringifyValue(item);
};
const unwrapMcpResultEnvelope = (value: unknown): unknown => {
if (!isRecord(value)) return value;
if ('Ok' in value) return value.Ok;
if ('Err' in value) return value.Err;
if ('ok' in value) return value.ok;
if ('error' in value && Object.keys(value).length === 1) return value.error;
return value;
};
export const getMcpResultText = (
content?: string,
state?: CodexMcpToolState,
args?: CodexMcpToolArgs,
) => {
const result = unwrapMcpResultEnvelope(state?.result ?? args?.result);
if (Array.isArray(result)) {
return result.map(getTextFromContentItem).filter(Boolean).join('\n\n');
}
if (isRecord(result)) {
if (Array.isArray(result.content)) {
return result.content.map(getTextFromContentItem).filter(Boolean).join('\n\n');
}
const text = normalizeString(result.text) || normalizeString(result.output);
if (text) return text;
}
const resultText = stringifyValue(result);
if (resultText) return resultText;
const output = content?.trim() || '';
if (COMPLETED_MCP_TOOL_CALL_PATTERN.test(output)) return '';
return output;
};
export const getMcpErrorText = (state?: CodexMcpToolState, args?: CodexMcpToolArgs) => {
const error = state?.error ?? args?.error;
if (!error) return '';
if (isRecord(error)) {
const message = normalizeString(error.message) || normalizeString(error.error);
if (message) return message;
}
return stringifyValue(error);
};
export const formatMcpInput = (
input: unknown,
toolName?: string,
): FormattedMcpValue | undefined => {
if (input === undefined || input === null) return;
const parsed = typeof input === 'string' ? tryParseJson(input) : undefined;
const value = parsed ?? input;
if (isRecord(value)) {
const code = normalizeString(value.code);
if (code && Object.keys(value).length === 1) {
return {
language: toolName === 'js' || toolName === 'javascript' ? 'javascript' : 'text',
text: code,
};
}
}
return {
language: typeof value === 'string' ? 'text' : 'json',
text: stringifyValue(value),
};
};
export const formatMcpOutput = (
value: string,
toolName?: string,
): FormattedMcpValue | undefined => {
const text = value.trim();
if (!text) return;
const parsed = tryParseJson(text);
if (parsed !== undefined) {
return {
language: 'json',
text: stringifyValue(parsed),
};
}
return {
language: toolName === 'js' || toolName === 'javascript' ? 'javascript' : 'text',
text,
};
};
@@ -0,0 +1,198 @@
'use client';
export interface CodexWebSearchArgs extends Record<string, unknown> {
query?: unknown;
results?: unknown;
search_query?: unknown;
searchQuery?: unknown;
}
export interface CodexWebSearchResult {
snippet?: string;
title: string;
url?: string;
}
const QUERY_KEYS = ['query', 'search_query', 'searchQuery', 'q', 'keyword', 'keywords', 'term'];
const NESTED_ARG_KEYS = ['args', 'arguments', 'input', 'params', 'request', 'payload', 'data'];
const RESULT_KEYS = ['results', 'search_results', 'searchResults', 'items', 'sources', 'citations'];
const TITLE_KEYS = ['title', 'name', 'pageTitle', 'source'];
const URL_KEYS = ['url', 'link', 'href', 'sourceUrl'];
const SNIPPET_KEYS = ['snippet', 'summary', 'description', 'text', 'content'];
const MAX_RESULTS = 8;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const normalizeString = (value: unknown) => (typeof value === 'string' ? value.trim() : '');
const stripTrailingUrlPunctuation = (value: string) => value.replace(/[),.;\]]+$/u, '');
const stripNumberPrefix = (value: string) => {
const trimmed = value.trim();
const marker = trimmed.match(/^\d+[).]\s+/u);
return marker ? trimmed.slice(marker[0].length).trim() : trimmed;
};
const stripLeadingSeparator = (value: string) => value.replace(/^[-:]\s+/u, '').trim();
const stripTrailingSeparator = (value: string) => value.replace(/\s+[-:]$/u, '').trim();
const getStringFromRecord = (record: Record<string, unknown>, keys: string[]) => {
for (const key of keys) {
const value = normalizeString(record[key]);
if (value) return value;
}
return '';
};
const getQueryFromValue = (value: unknown): string => {
const direct = normalizeString(value);
if (direct) return direct;
if (Array.isArray(value)) {
for (const item of value) {
const query = getQueryFromValue(item);
if (query) return query;
}
}
if (isRecord(value)) {
return getStringFromRecord(value, QUERY_KEYS);
}
return '';
};
const getNestedRecords = (args?: unknown) => {
if (!isRecord(args)) return [];
const records: Record<string, unknown>[] = [args];
for (const key of NESTED_ARG_KEYS) {
const nested = args[key];
if (isRecord(nested)) records.push(nested);
}
return records;
};
export const getWebSearchQuery = (args?: unknown) => {
for (const record of getNestedRecords(args)) {
for (const key of QUERY_KEYS) {
const query = getQueryFromValue(record[key]);
if (query) return query;
}
}
return '';
};
const getResultFromRecord = (record: Record<string, unknown>): CodexWebSearchResult | undefined => {
const title = getStringFromRecord(record, TITLE_KEYS);
const url = getStringFromRecord(record, URL_KEYS);
const snippet = getStringFromRecord(record, SNIPPET_KEYS);
if (!title && !url && !snippet) return;
return {
snippet: snippet || undefined,
title: title || url || snippet,
url: url || undefined,
};
};
const getResultsFromValue = (value: unknown): CodexWebSearchResult[] => {
if (Array.isArray(value)) {
return value
.map((item) => (isRecord(item) ? getResultFromRecord(item) : undefined))
.filter((item): item is CodexWebSearchResult => !!item)
.slice(0, MAX_RESULTS);
}
if (isRecord(value)) {
for (const key of RESULT_KEYS) {
const results = getResultsFromValue(value[key]);
if (results.length > 0) return results;
}
const result = getResultFromRecord(value);
return result ? [result] : [];
}
return [];
};
const parseMarkdownResultLine = (line: string): CodexWebSearchResult | undefined => {
const normalized = stripNumberPrefix(line);
if (!normalized.startsWith('[')) return;
const titleEnd = normalized.indexOf('](');
if (titleEnd <= 1) return;
const urlStart = titleEnd + 2;
const urlEnd = normalized.indexOf(')', urlStart);
if (urlEnd <= urlStart) return;
const title = normalized.slice(1, titleEnd).trim();
const url = stripTrailingUrlPunctuation(normalized.slice(urlStart, urlEnd).trim());
if (!title || !url) return;
const snippet = stripLeadingSeparator(normalized.slice(urlEnd + 1));
return {
snippet: snippet || undefined,
title,
url,
};
};
const parseTextResultLine = (line: string): CodexWebSearchResult | undefined => {
const normalized = stripNumberPrefix(line);
const urlMatch = normalized.match(/https?:\/\/\S+/u);
if (!urlMatch?.[0]) return;
const urlStart = urlMatch.index ?? 0;
const urlEnd = urlStart + urlMatch[0].length;
const url = stripTrailingUrlPunctuation(urlMatch[0]);
const beforeUrl = stripTrailingSeparator(normalized.slice(0, urlStart));
const afterUrl = stripLeadingSeparator(normalized.slice(urlEnd));
const title = beforeUrl || afterUrl;
if (!title || !url) return;
return {
title,
url,
};
};
const getResultsFromContent = (content?: string): CodexWebSearchResult[] => {
if (!content) return [];
return content
.split('\n')
.map((line) => parseMarkdownResultLine(line) || parseTextResultLine(line))
.filter((item): item is CodexWebSearchResult => !!item)
.slice(0, MAX_RESULTS);
};
export const getWebSearchResults = (args?: unknown, content?: string) => {
for (const record of getNestedRecords(args)) {
for (const key of RESULT_KEYS) {
const results = getResultsFromValue(record[key]);
if (results.length > 0) return results;
}
}
return getResultsFromContent(content);
};
export const getWebSearchOutput = (content?: string) => {
const output = content?.trim() || '';
if (/^Completed web_search\.?$/iu.test(output)) return '';
return output;
};
@@ -0,0 +1,16 @@
import { describe, expect, it, vi } from 'vitest';
import { CodexRenderDisplayControls } from './codex/displayControls';
import { getBuiltinRenderDisplayControl } from './displayControls';
vi.mock('@lobechat/builtin-tool-claude-code/client', () => ({
ClaudeCodeIdentifier: 'claude-code',
ClaudeCodeRenderDisplayControls: {},
}));
describe('CodexRenderDisplayControls', () => {
it('collapses Codex command output by default', () => {
expect(CodexRenderDisplayControls.command_execution).toBe('collapsed');
expect(getBuiltinRenderDisplayControl('codex', 'command_execution')).toBe('collapsed');
});
});
@@ -4,7 +4,7 @@ import {
} from '@lobechat/builtin-tool-claude-code/client';
import { type RenderDisplayControl } from '@lobechat/types';
import { CodexRenderDisplayControls } from './codex';
import { CodexRenderDisplayControls } from './codex/displayControls';
// Kept separate from `./renders` so consumers that only need display-control
// fallbacks (e.g. the tool store selector) don't pull in every builtin tool's
@@ -1,14 +1,11 @@
'use client';
import { ToolResultCard } from '@lobechat/shared-tool-ui/components';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { GitBranch } from 'lucide-react';
import { memo, useMemo } from 'react';
import {
getGhSubcommand,
getGithubOutput,
type GithubRunCommandArgs,
type GithubRunCommandState,
@@ -18,6 +15,7 @@ import {
const styles = createStaticStyles(({ css, cssVar }) => ({
exitCode: css`
margin-inline-start: 8px;
font-family: ${cssVar.fontFamilyCode};
font-size: 12px;
`,
@@ -27,30 +25,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
exitCodeSuccess: css`
color: ${cssVar.colorSuccess};
`,
ghPrefix: css`
flex-shrink: 0;
font-family: ${cssVar.fontFamilyCode};
font-size: 12px;
color: ${cssVar.colorTextDescription};
`,
headerCommand: css`
overflow: hidden;
flex: 1 1 auto;
min-width: 0;
font-family: ${cssVar.fontFamilyCode};
font-size: 13px;
color: ${cssVar.colorText};
text-overflow: ellipsis;
white-space: nowrap;
`,
headerRow: css`
flex-wrap: wrap;
gap: 6px;
align-items: center;
min-width: 0;
`,
sectionLabel: css`
margin-block-end: 4px;
font-size: 12px;
@@ -62,10 +36,7 @@ const GithubRunCommandRender = memo<
BuiltinRenderProps<GithubRunCommandArgs, GithubRunCommandState>
>(({ args, content, pluginState }) => {
const rawCommand = args?.command || '';
const description = args?.description || '';
const normalized = normalizeGhCommand(rawCommand);
const subcommand = getGhSubcommand(rawCommand);
const headerLabel = description || subcommand || normalized || 'command';
const output = getGithubOutput(pluginState, content);
const stderr = pluginState?.stderr || '';
@@ -83,72 +54,63 @@ const GithubRunCommandRender = memo<
if (!normalized && !output && !stderr) return null;
return (
<ToolResultCard
wrapHeader
icon={GitBranch}
header={
<Flexbox horizontal align={'center'} className={styles.headerRow} wrap={'wrap'}>
<span className={styles.ghPrefix}>gh</span>
<span className={styles.headerCommand}>{headerLabel}</span>
{success !== undefined && (
<span
className={`${styles.exitCode} ${
success ? styles.exitCodeSuccess : styles.exitCodeError
}`}
>
exit {exitCode ?? (success ? 0 : 1)}
</span>
)}
</Flexbox>
}
>
<Flexbox gap={12}>
{normalized && (
<div>
<Text className={styles.sectionLabel}>Command</Text>
<Highlighter
wrap
language={'sh'}
showLanguage={false}
style={{ maxHeight: 160, overflow: 'auto', paddingInline: 8 }}
variant={'outlined'}
>
{`gh ${normalized}`}
</Highlighter>
</div>
)}
{outputBody && (
<div>
<Text className={styles.sectionLabel}>Output</Text>
<Highlighter
wrap
language={outputLanguage}
showLanguage={outputLanguage === 'json'}
style={{ maxHeight: 360, overflow: 'auto', paddingInline: 8 }}
variant={'filled'}
>
{outputBody}
</Highlighter>
</div>
)}
{stderr && (
<div>
<Text className={styles.sectionLabel} style={{ color: cssVar.colorError }}>
Stderr
</Text>
<Highlighter
wrap
language={'text'}
showLanguage={false}
style={{ maxHeight: 200, overflow: 'auto', paddingInline: 8 }}
variant={'filled'}
>
{stderr}
</Highlighter>
</div>
)}
</Flexbox>
</ToolResultCard>
<Flexbox gap={12}>
{normalized && (
<div>
<Text className={styles.sectionLabel}>
Command
{success !== undefined && (
<span
className={`${styles.exitCode} ${
success ? styles.exitCodeSuccess : styles.exitCodeError
}`}
>
exit {exitCode ?? (success ? 0 : 1)}
</span>
)}
</Text>
<Highlighter
wrap
language={'sh'}
showLanguage={false}
style={{ maxHeight: 160, overflow: 'auto', paddingInline: 8 }}
variant={'outlined'}
>
{`gh ${normalized}`}
</Highlighter>
</div>
)}
{outputBody && (
<div>
<Text className={styles.sectionLabel}>Output</Text>
<Highlighter
wrap
language={outputLanguage}
showLanguage={outputLanguage === 'json'}
style={{ maxHeight: 360, overflow: 'auto', paddingInline: 8 }}
variant={'filled'}
>
{outputBody}
</Highlighter>
</div>
)}
{stderr && (
<div>
<Text className={styles.sectionLabel} style={{ color: cssVar.colorError }}>
Stderr
</Text>
<Highlighter
wrap
language={'text'}
showLanguage={false}
style={{ maxHeight: 200, overflow: 'auto', paddingInline: 8 }}
variant={'filled'}
>
{stderr}
</Highlighter>
</div>
)}
</Flexbox>
);
});
+1 -5
View File
@@ -64,7 +64,6 @@ import {
WebOnboardingInspectors,
WebOnboardingManifest,
} from '@lobechat/builtin-tool-web-onboarding/client';
import { createRunCommandInspector } from '@lobechat/shared-tool-ui/inspectors';
import type { BuiltinInspector } from '@lobechat/types';
import { CodexInspectors } from './codex';
@@ -116,10 +115,7 @@ const BuiltinToolInspectors: Record<string, Record<string, BuiltinInspector>> =
[TaskManifest.identifier]: TaskInspectors as Record<string, BuiltinInspector>,
[WebBrowsingManifest.identifier]: WebBrowsingInspectors as Record<string, BuiltinInspector>,
[WebOnboardingManifest.identifier]: WebOnboardingInspectors as Record<string, BuiltinInspector>,
codex: {
...CodexInspectors,
command_execution: createRunCommandInspector('Run') as BuiltinInspector,
},
codex: CodexInspectors,
[GithubIdentifier]: GithubInspectors,
[LinearIdentifier]: LinearInspectors,
[TwitterIdentifier]: TwitterInspectors,
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});
@@ -455,6 +455,80 @@ describe('CodexAdapter', () => {
});
});
it('maps mcp_tool_call items into compact args and MCP result content', () => {
const adapter = new CodexAdapter();
const started = adapter.adapt({
item: {
arguments: { code: '1 + 1' },
id: 'item_5',
server: 'node_repl',
status: 'in_progress',
tool: 'js',
type: 'mcp_tool_call',
},
type: 'item.started',
});
const completed = adapter.adapt({
item: {
arguments: { code: '1 + 1' },
error: null,
id: 'item_5',
result: {
content: [{ text: '2', type: 'text' }],
isError: false,
},
server: 'node_repl',
status: 'completed',
tool: 'js',
type: 'mcp_tool_call',
},
type: 'item.completed',
});
expect(started[0]).toMatchObject({
data: {
chunkType: 'tools_calling',
toolsCalling: [
{
apiName: 'mcp_tool_call',
arguments: JSON.stringify({
arguments: { code: '1 + 1' },
server: 'node_repl',
tool: 'js',
}),
id: 'item_5',
identifier: 'codex',
},
],
},
type: 'stream_chunk',
});
expect(completed[0]).toMatchObject({
data: {
content: '2',
isError: false,
pluginState: {
arguments: { code: '1 + 1' },
error: null,
result: {
content: [{ text: '2', type: 'text' }],
isError: false,
},
server: 'node_repl',
status: 'completed',
tool: 'js',
},
toolCallId: 'item_5',
},
type: 'tool_result',
});
expect(completed[1]).toMatchObject({
data: { isSuccess: true, toolCallId: 'item_5' },
type: 'tool_end',
});
});
it('uses failure copy for unsuccessful non-command tool completions', () => {
const adapter = new CodexAdapter();
@@ -12,6 +12,7 @@ const CODEX_IDENTIFIER = 'codex';
const CODEX_COLLAB_TOOL_CALL_API = 'collab_tool_call';
const CODEX_COMMAND_API = 'command_execution';
const CODEX_FILE_CHANGE_API = 'file_change';
const CODEX_MCP_TOOL_CALL_API = 'mcp_tool_call';
const CODEX_TODO_LIST_API = 'todo_list';
interface CodexBaseItem {
@@ -48,6 +49,14 @@ interface CodexFileChangeItem extends CodexBaseItem {
linesDeleted?: number;
}
interface CodexMcpToolCallItem extends CodexBaseItem {
arguments?: unknown;
error?: unknown;
result?: unknown;
server?: string;
tool?: string;
}
interface CodexCollabAgentState {
message?: string | null;
status?: string;
@@ -66,6 +75,7 @@ type CodexToolItem =
| CodexCollabToolCallItem
| CodexCommandExecutionItem
| CodexFileChangeItem
| CodexMcpToolCallItem
| CodexTodoListItem;
const isCommandExecutionItem = (item: CodexToolItem): item is CodexCommandExecutionItem =>
@@ -77,6 +87,9 @@ const isCollabToolCallItem = (item: CodexToolItem): item is CodexCollabToolCallI
const isFileChangeItem = (item: CodexToolItem): item is CodexFileChangeItem =>
item.type === CODEX_FILE_CHANGE_API;
const isMcpToolCallItem = (item: CodexToolItem): item is CodexMcpToolCallItem =>
item.type === CODEX_MCP_TOOL_CALL_API;
const isTodoListItem = (item: CodexToolItem): item is CodexTodoListItem =>
item.type === CODEX_TODO_LIST_API;
@@ -162,12 +175,116 @@ const synthesizeFileChangePluginState = (item: CodexFileChangeItem) => {
};
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const stringifyUnknown = (value: unknown): string => {
if (typeof value === 'string') return value;
if (value === undefined || value === null) return '';
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const getRecordString = (record: Record<string, unknown>, key: string): string | undefined => {
const value = record[key];
return typeof value === 'string' && value.trim() ? value : undefined;
};
const unwrapMcpResultEnvelope = (value: unknown): unknown => {
if (!isRecord(value)) return value;
if ('Ok' in value) return value.Ok;
if ('Err' in value) return value.Err;
if ('ok' in value) return value.ok;
return value;
};
const getMcpContentItemText = (item: unknown): string => {
if (typeof item === 'string') return item;
if (!isRecord(item)) return stringifyUnknown(item);
const text = getRecordString(item, 'text') || getRecordString(item, 'content');
if (text) return text;
return stringifyUnknown(item);
};
const getMcpResultContent = (item: CodexMcpToolCallItem): string => {
const result = unwrapMcpResultEnvelope(item.result);
if (Array.isArray(result)) {
return result.map(getMcpContentItemText).filter(Boolean).join('\n\n');
}
if (isRecord(result)) {
if (Array.isArray(result.content)) {
return result.content.map(getMcpContentItemText).filter(Boolean).join('\n\n');
}
const text = getRecordString(result, 'text') || getRecordString(result, 'output');
if (text) return text;
}
return stringifyUnknown(result);
};
const getMcpErrorContent = (item: CodexMcpToolCallItem): string => {
const error = item.error || unwrapMcpResultEnvelope(item.result);
if (isRecord(error)) {
return (
getRecordString(error, 'message') ||
getRecordString(error, 'error') ||
stringifyUnknown(error)
);
}
return stringifyUnknown(error);
};
const hasMcpResultError = (item: CodexMcpToolCallItem): boolean => {
if (item.error) return true;
const result = item.result;
if (!isRecord(result)) return false;
if ('Err' in result) return true;
const ok = unwrapMcpResultEnvelope(result);
return isRecord(ok) && ok.isError === true;
};
const synthesizeMcpToolPluginState = (item: CodexMcpToolCallItem) => ({
arguments: item.arguments,
error: item.error,
result: item.result,
server: item.server,
status: item.status,
tool: item.tool,
});
const pluralize = (count: number, singular: string, plural = `${singular}s`) =>
count === 1 ? singular : plural;
const toMcpToolPayloadArguments = (item: CodexMcpToolCallItem) => ({
arguments: item.arguments,
server: item.server,
tool: item.tool,
});
const toToolPayload = (item: CodexToolItem): ToolCallPayload => ({
apiName: item.type || CODEX_COMMAND_API,
arguments: JSON.stringify(isCommandExecutionItem(item) ? { command: item.command || '' } : item),
arguments: JSON.stringify(
isCommandExecutionItem(item)
? { command: item.command || '' }
: isMcpToolCallItem(item)
? toMcpToolPayloadArguments(item)
: item,
),
id: item.id,
identifier: CODEX_IDENTIFIER,
type: 'default',
@@ -253,6 +370,9 @@ const getFailureVerb = (item: CodexToolItem): 'cancelled' | 'failed' =>
const getToolFailureContent = (item: CodexToolItem): string => {
if (isTodoListItem(item)) return `Todo list update ${getFailureVerb(item)}.`;
if (isFileChangeItem(item)) return `File changes ${getFailureVerb(item)}.`;
if (isMcpToolCallItem(item)) {
return getMcpErrorContent(item) || `MCP tool ${getFailureVerb(item)}.`;
}
if (isCollabToolCallItem(item)) return `${item.tool || 'Collaboration'} ${getFailureVerb(item)}.`;
return `${item.type} ${getFailureVerb(item)}.`;
@@ -267,6 +387,7 @@ const getToolContent = (item: CodexToolItem, isSuccess: boolean): string => {
if (isTodoListItem(item)) return summarizeTodoList(item);
if (isFileChangeItem(item)) return summarizeFileChange(item);
if (isMcpToolCallItem(item)) return getMcpResultContent(item);
if (isCollabToolCallItem(item)) return summarizeCollabToolCall(item);
return summarizeFallbackTool(item);
@@ -278,6 +399,8 @@ const isSuccessfulToolCompletion = (item: CodexToolItem): boolean => {
return item.status === 'completed' && (exitCode === undefined || exitCode === 0);
}
if (isMcpToolCallItem(item) && hasMcpResultError(item)) return false;
return item.status !== 'cancelled' && item.status !== 'error' && item.status !== 'failed';
};
@@ -308,7 +431,9 @@ const getToolResultData = (item: CodexToolItem): ToolResultData => {
? synthesizeTodoListPluginState(item)
: isSuccess && isFileChangeItem(item)
? synthesizeFileChangePluginState(item)
: undefined;
: isMcpToolCallItem(item)
? synthesizeMcpToolPluginState(item)
: undefined;
return {
content: output,
+1
View File
@@ -1003,6 +1003,7 @@ export default {
'workflow.toolDisplayName.addPreferenceMemory': 'Saved memory',
'workflow.toolDisplayName.calculate': 'Calculated',
'workflow.toolDisplayName.callAgent': 'Called an agent',
'workflow.toolDisplayName.callMcpTool': 'Called MCP tool',
'workflow.toolDisplayName.callSubAgent': 'Call SubAgent',
'workflow.toolDisplayName.clearTodos': 'Cleared todos',
'workflow.toolDisplayName.copyDocument': 'Copied a document',
+18
View File
@@ -82,6 +82,24 @@ export default {
'builtins.lobe-claude-code.todoWrite.allDone': 'All tasks completed',
'builtins.lobe-claude-code.todoWrite.currentStep': 'Current step',
'builtins.lobe-claude-code.todoWrite.todos': 'Todos',
'builtins.codex.apiName.command_execution': 'Run command',
'builtins.codex.apiName.file_change': 'Edit files',
'builtins.codex.apiName.mcp_tool_call': 'Call MCP tool',
'builtins.codex.apiName.todo_list': 'Update tasks',
'builtins.codex.apiName.web_search': 'Search the web',
'builtins.codex.commandExecution.grep': 'Search',
'builtins.codex.commandExecution.noResults': 'No results',
'builtins.codex.commandExecution.readFile': 'Read file',
'builtins.codex.fileChange.editedFiles_one': 'Edited {{count}} file',
'builtins.codex.fileChange.editedFiles_other': 'Edited {{count}} files',
'builtins.codex.fileChange.editing': 'Editing files',
'builtins.codex.fileChange.noChanges': 'No file changes',
'builtins.codex.fileChange.unknownFile': 'Unknown file',
'builtins.codex.mcpTool.error': 'Error',
'builtins.codex.mcpTool.input': 'Input',
'builtins.codex.mcpTool.result': 'Result',
'builtins.codex.mcpTool.unknownTool': 'MCP tool',
'builtins.codex.webSearch.query': 'Query',
'builtins.lobe-cloud-sandbox.apiName.editFile': 'Edit file',
'builtins.lobe-cloud-sandbox.apiName.executeCode': 'Execute code',
'builtins.lobe-cloud-sandbox.apiName.exportFile': 'Export file',
@@ -1,49 +0,0 @@
'use client';
import { Flexbox, Icon } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { type LucideIcon } from 'lucide-react';
import { memo, type ReactNode } from 'react';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
padding-block: 4px;
`,
header: css`
padding-inline: 4px;
color: ${cssVar.colorTextSecondary};
`,
previewBox: css`
overflow: hidden;
padding: 8px;
border-radius: 8px;
background: ${cssVar.colorFillTertiary};
`,
}));
interface ToolResultCardProps {
children?: ReactNode;
header: ReactNode;
icon: LucideIcon;
wrapHeader?: boolean;
}
export const ToolResultCard = memo<ToolResultCardProps>(
({ icon, header, children, wrapHeader }) => (
<Flexbox className={styles.container} gap={8}>
<Flexbox
horizontal
align={'center'}
className={styles.header}
gap={8}
wrap={wrapHeader ? 'wrap' : undefined}
>
<Icon icon={icon} size={'small'} />
{header}
</Flexbox>
{children && <Flexbox className={styles.previewBox}>{children}</Flexbox>}
</Flexbox>
),
);
ToolResultCard.displayName = 'ToolResultCard';
@@ -1,3 +1,2 @@
export { AnimatedNumber } from './AnimatedNumber';
export { FilePathDisplay } from './FilePathDisplay';
export { ToolResultCard } from './ToolResultCard';
+9 -1
View File
@@ -13,7 +13,15 @@ interface FileListProps {
const FileIcon = memo<FileListProps>(({ fileName, size, variant = 'raw', isDirectory }) => {
if (isDirectory)
return <FileTypeIcon color={'gold'} size={size} type={'folder'} variant={'color'} />;
return (
<MaterialFileTypeIcon
fallbackUnknownType={false}
filename={fileName}
size={size}
type={'folder'}
variant={variant}
/>
);
if (Object.keys(mimeTypeMap).some((key) => fileName?.toLowerCase().endsWith(`.${key}`))) {
const ext = fileName.split('.').pop()?.toLowerCase() as string;
@@ -132,6 +132,7 @@ vi.mock('@/features/ExplorerTree', () => {
return {
ExplorerTree,
FOLDER_ICON_CSS: '',
HIDE_POINTER_FOCUS_RING_CSS: '',
getExplorerTreeStyleVars: () => ({}),
};
});
@@ -12,7 +12,12 @@ import type {
ExplorerTreeHandle,
ExplorerTreeNode,
} from '@/features/ExplorerTree';
import { ExplorerTree, FOLDER_ICON_CSS, getExplorerTreeStyleVars } from '@/features/ExplorerTree';
import {
ExplorerTree,
FOLDER_ICON_CSS,
getExplorerTreeStyleVars,
HIDE_POINTER_FOCUS_RING_CSS,
} from '@/features/ExplorerTree';
import { useChatStore } from '@/store/chat';
import DocumentExplorerToolbar from './DocumentExplorerToolbar';
@@ -24,6 +29,7 @@ import { canDropDocument } from './utils/canDrop';
const SKILL_INDEX_FILENAME = 'SKILL.md';
const FILE_TREE_HOST_TAG = 'file-tree-container';
const RENAME_INPUT_SELECTOR = 'input[data-item-rename-input]';
const DOCUMENT_TREE_UNSAFE_CSS = `${FOLDER_ICON_CSS}\n${HIDE_POINTER_FOCUS_RING_CSS}`;
// pierre/trees auto-selects the full value when the rename input mounts. For
// files with extensions (e.g. `Untitled document.md`), narrow the selection to
@@ -45,18 +51,14 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
--trees-bg-override: transparent;
--trees-border-color-override: transparent;
--trees-selected-bg-override: ${cssVar.colorFillSecondary};
--trees-selected-fg-override: ${cssVar.colorText};
--trees-bg-muted-override: ${cssVar.colorFillTertiary};
--trees-fg-override: ${cssVar.colorText};
--trees-fg-override: ${cssVar.colorTextSecondary};
--trees-fg-muted-override: ${cssVar.colorTextSecondary};
--trees-accent-override: ${cssVar.colorPrimary};
--trees-padding-inline-override: 0px;
--trees-font-size-override: 12px;
--trees-border-radius-override: 6px;
/* Drop the doubled outline pierre/trees draws via ::before on a
* focused+selected row — the filled background from
* --trees-selected-bg-override is already a clear selection signal. */
--trees-selected-focused-border-color-override: transparent;
`,
}));
@@ -274,7 +276,7 @@ const DocumentExplorerTree = memo<Props>(({ agentId, data, mutate, style }) => {
nodes={nodes}
ref={treeRef}
style={{ height: '100%' }}
unsafeCSS={FOLDER_ICON_CSS}
unsafeCSS={DOCUMENT_TREE_UNSAFE_CSS}
header={
<DocumentExplorerToolbar
onCreateDocument={() => handleCreateDocument(null)}
@@ -0,0 +1,83 @@
/**
* @vitest-environment happy-dom
*/
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useChatStore } from '@/store/chat';
import type { MarkdownElementProps } from '../type';
import Render from './Render';
interface LocalFileLinkProperties {
linkHref?: string;
linkLabel?: string;
}
const createRenderProps = (
properties: LocalFileLinkProperties,
): MarkdownElementProps<LocalFileLinkProperties> => ({
children: null,
id: 'local-file-link',
node: {
properties,
},
tagName: 'lobeLocalFileLink',
type: 'element',
});
vi.mock('@lobechat/const', async (importOriginal) => ({
...((await importOriginal()) as Record<string, unknown>),
isDesktop: true,
}));
vi.mock('@/components/FileIcon', () => ({
default: ({ fileName, size }: { fileName: string; size?: number }) => (
<span data-file-name={fileName} data-size={size} data-testid="file-icon" />
),
}));
describe('LocalFileLink Render', () => {
afterEach(() => {
useChatStore.setState(useChatStore.getInitialState());
});
it('opens local file links in the right-side local file portal', () => {
useChatStore.setState({
activeAgentId: 'agent-1',
activeTopicId: 'topic-1',
topicDataMap: {
'agent_agent-1': {
items: [
{
id: 'topic-1',
metadata: { workingDirectory: '/Users/me/project' },
},
],
total: 1,
},
} as any,
});
render(
<Render
{...createRenderProps({
linkHref: '/Users/me/project/src/Group.tsx:265',
linkLabel: 'Group.tsx',
})}
/>,
);
fireEvent.click(screen.getByRole('link', { name: 'Group.tsx' }));
expect(screen.getByTestId('file-icon')).toHaveAttribute('data-file-name', 'Group.tsx');
expect(screen.getByTestId('file-icon')).toHaveAttribute('data-size', '16');
expect(useChatStore.getState().openLocalFiles).toEqual([
{
filePath: '/Users/me/project/src/Group.tsx',
workingDirectory: '/Users/me/project',
},
]);
expect(useChatStore.getState().showPortal).toBe(true);
});
});
@@ -0,0 +1,72 @@
'use client';
import { isDesktop } from '@lobechat/const';
import { A } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo, type MouseEvent, useCallback } from 'react';
import FileIcon from '@/components/FileIcon';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import { type MarkdownElementProps } from '../type';
import { parseLocalFileHref } from './parse';
interface LocalFileLinkProperties {
linkHref?: string;
linkLabel?: string;
}
const styles = createStaticStyles(({ css }) => ({
icon: css`
display: inline-flex;
flex-shrink: 0;
align-items: center;
`,
link: css`
display: inline-flex;
gap: 4px;
align-items: center;
vertical-align: -0.16em;
`,
}));
const getFileName = (filePath: string) => filePath.split(/[\\/]/).at(-1) || filePath;
const Render = memo<MarkdownElementProps<LocalFileLinkProperties>>(({ node }) => {
const { linkHref, linkLabel } = node?.properties || {};
const openLocalFile = useChatStore((s) => s.openLocalFile);
const workingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
const parsed = isDesktop ? parseLocalFileHref(linkHref, { workingDirectory }) : null;
const label = linkLabel || parsed?.filePath || linkHref || '';
const iconFileName = parsed ? getFileName(parsed.filePath) : label;
const handleClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
if (!parsed) return;
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
event.preventDefault();
openLocalFile({
filePath: parsed.filePath,
workingDirectory: parsed.workingDirectory,
});
},
[openLocalFile, parsed],
);
return (
<A className={styles.link} href={linkHref} onClick={handleClick}>
<span aria-hidden className={styles.icon}>
<FileIcon fileName={iconFileName} size={16} variant={'raw'} />
</span>
<span>{label}</span>
</A>
);
});
Render.displayName = 'LocalFileLinkRender';
export default Render;
@@ -0,0 +1,15 @@
import { type FC } from 'react';
import { type MarkdownElement, type MarkdownElementProps } from '../type';
import { LOBE_LOCAL_FILE_LINK_TAG } from './parse';
import { rehypeLocalFileLink } from './rehypePlugin';
import Render from './Render';
const LocalFileLinkElement: MarkdownElement = {
Component: Render as FC<MarkdownElementProps>,
rehypePlugin: rehypeLocalFileLink,
scope: 'all',
tag: LOBE_LOCAL_FILE_LINK_TAG,
};
export default LocalFileLinkElement;
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { parseLocalFileHref } from './parse';
describe('parseLocalFileHref', () => {
it('parses a macOS absolute path with a line suffix', () => {
expect(
parseLocalFileHref('/Users/arvinxx/project/src/Group.tsx:265', {
workingDirectory: '/Users/arvinxx/project',
}),
).toEqual({
filePath: '/Users/arvinxx/project/src/Group.tsx',
line: 265,
workingDirectory: '/Users/arvinxx/project',
});
});
it('parses file URLs and strips line and column suffixes', () => {
expect(parseLocalFileHref('file:///Users/me/My%20File.tsx:10:2')).toEqual({
column: 2,
filePath: '/Users/me/My File.tsx',
line: 10,
workingDirectory: '/Users/me',
});
});
it('uses the file directory as working directory when no active cwd matches', () => {
expect(parseLocalFileHref('/tmp/report.md')).toEqual({
filePath: '/tmp/report.md',
workingDirectory: '/tmp',
});
});
it('ignores normal app routes and external URLs', () => {
expect(parseLocalFileHref('/settings/profile')).toBeNull();
expect(parseLocalFileHref('https://example.com/file.ts')).toBeNull();
expect(parseLocalFileHref('#user-content-fn-1')).toBeNull();
});
});
@@ -0,0 +1,153 @@
export const LOBE_LOCAL_FILE_LINK_TAG = 'lobeLocalFileLink';
export interface ParsedLocalFileHref {
column?: number;
filePath: string;
line?: number;
workingDirectory: string;
}
interface ParseLocalFileHrefOptions {
workingDirectory?: string;
}
const WINDOWS_ABSOLUTE_PATH_REGEX = /^(?:[a-z]:[\\/]|\\\\)/i;
const URL_PROTOCOL_REGEX = /^[a-z][a-z\d+.-]*:/i;
const LINE_SUFFIX_REGEX = /:(\d+)(?::(\d+))?$/;
const KNOWN_LOCAL_PATH_PREFIXES = [
'/Applications/',
'/Users/',
'/Volumes/',
'/home/',
'/mnt/',
'/opt/',
'/private/',
'/tmp/',
'/var/',
'/workspace/',
] as const;
const safeDecodeURIComponent = (value: string) => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const normalizeForCompare = (filePath: string) => {
const normalized = filePath.replaceAll('\\', '/');
if (normalized === '/') return normalized;
return normalized.replace(/\/+$/, '');
};
const isWindowsAbsolutePath = (filePath: string) => WINDOWS_ABSOLUTE_PATH_REGEX.test(filePath);
const isPosixAbsolutePath = (filePath: string) => filePath.startsWith('/');
const isAbsoluteLocalPath = (filePath: string) =>
isPosixAbsolutePath(filePath) || isWindowsAbsolutePath(filePath);
const isWithinDirectory = (filePath: string, directory: string) => {
const normalizedFilePath = normalizeForCompare(filePath);
const normalizedDirectory = normalizeForCompare(directory);
return (
normalizedFilePath === normalizedDirectory ||
normalizedFilePath.startsWith(`${normalizedDirectory}/`)
);
};
const hasKnownLocalRoot = (filePath: string) => {
if (isWindowsAbsolutePath(filePath)) return true;
const normalized = normalizeForCompare(filePath);
return KNOWN_LOCAL_PATH_PREFIXES.some((prefix) => {
const root = prefix.slice(0, -1);
return normalized === root || normalized.startsWith(prefix);
});
};
const dirname = (filePath: string) => {
const normalized = filePath.replace(/[\\/]+$/, '');
const slashIndex = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf('\\'));
if (slashIndex <= 0) {
if (normalized.startsWith('/')) return '/';
return normalized;
}
return normalized.slice(0, slashIndex);
};
const extractLineSuffix = (candidate: string) => {
const match = candidate.match(LINE_SUFFIX_REGEX);
if (!match) return { filePath: candidate };
const filePath = candidate.slice(0, -match[0].length);
if (!filePath) return { filePath: candidate };
const line = Number.parseInt(match[1]!, 10);
const column = match[2] ? Number.parseInt(match[2], 10) : undefined;
return {
column: column && column > 0 ? column : undefined,
filePath,
line: line > 0 ? line : undefined,
};
};
const hrefToPathCandidate = (href: string) => {
const isFileUrl = href.toLowerCase().startsWith('file:');
if (isFileUrl) {
try {
const url = new URL(href);
if (url.protocol !== 'file:') return null;
const pathname = safeDecodeURIComponent(url.pathname);
return /^\/[a-z]:/i.test(pathname) ? pathname.slice(1) : pathname;
} catch {
return null;
}
}
if (URL_PROTOCOL_REGEX.test(href) && !isWindowsAbsolutePath(href)) return null;
return safeDecodeURIComponent(href);
};
export const parseLocalFileHref = (
href?: string,
{ workingDirectory }: ParseLocalFileHrefOptions = {},
): ParsedLocalFileHref | null => {
const rawHref = href?.trim();
if (!rawHref) return null;
const candidate = hrefToPathCandidate(rawHref);
if (!candidate) return null;
const { filePath, line, column } = extractLineSuffix(candidate);
if (!isAbsoluteLocalPath(filePath)) return null;
const matchedWorkingDirectory =
workingDirectory && isWithinDirectory(filePath, workingDirectory)
? workingDirectory
: undefined;
if (
!matchedWorkingDirectory &&
!rawHref.toLowerCase().startsWith('file:') &&
!hasKnownLocalRoot(filePath)
) {
return null;
}
return {
column,
filePath,
line,
workingDirectory: matchedWorkingDirectory || dirname(filePath),
};
};
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest';
import { LOBE_LOCAL_FILE_LINK_TAG } from './parse';
import { rehypeLocalFileLink } from './rehypePlugin';
vi.mock('@lobechat/const', async (importOriginal) => ({
...((await importOriginal()) as Record<string, unknown>),
isDesktop: true,
}));
const createAnchor = (href: string, text: string) => ({
children: [{ type: 'text', value: text }],
properties: { href },
tagName: 'a',
type: 'element',
});
describe('rehypeLocalFileLink', () => {
it('rewrites absolute local file links into local file link nodes', () => {
const anchor = createAnchor('/Users/me/project/src/Group.tsx:265', 'Group.tsx');
const tree = { children: [anchor], type: 'root' };
rehypeLocalFileLink()(tree);
expect(anchor).toEqual({
children: [],
properties: {
linkHref: '/Users/me/project/src/Group.tsx:265',
linkLabel: 'Group.tsx',
},
tagName: LOBE_LOCAL_FILE_LINK_TAG,
type: 'element',
});
});
it('keeps regular app routes untouched', () => {
const anchor = createAnchor('/settings/profile', 'settings');
const tree = { children: [anchor], type: 'root' };
rehypeLocalFileLink()(tree);
expect(anchor.tagName).toBe('a');
expect(anchor.properties).toEqual({ href: '/settings/profile' });
});
});
@@ -0,0 +1,35 @@
import { isDesktop } from '@lobechat/const';
import { SKIP, visit } from 'unist-util-visit';
import { LOBE_LOCAL_FILE_LINK_TAG, parseLocalFileHref } from './parse';
const getNodeText = (node: any): string => {
if (!node) return '';
if (node.type === 'text') return String(node.value ?? '');
if (Array.isArray(node.children)) return node.children.map(getNodeText).join('');
return '';
};
export const rehypeLocalFileLink = () => (tree: any) => {
if (!isDesktop) return;
visit(tree, 'element', (node: any) => {
if (node.tagName !== 'a') return;
const href = node.properties?.href as string | undefined;
const parsed = parseLocalFileHref(href);
if (!parsed) return;
const text = getNodeText(node).trim();
const label = text || parsed.filePath.split(/[\\/]/).at(-1) || parsed.filePath;
node.tagName = LOBE_LOCAL_FILE_LINK_TAG;
node.children = [];
node.properties = {
linkHref: href,
linkLabel: label,
};
return SKIP;
});
};
@@ -4,6 +4,7 @@ import LobeAgents from './LobeAgents';
import LobeArtifact from './LobeArtifact';
import LobeThinking from './LobeThinking';
import LocalFile from './LocalFile';
import LocalFileLink from './LocalFileLink';
import Mention from './Mention';
import Skill from './Skill';
import Task from './Task';
@@ -26,5 +27,6 @@ export const markdownElements: MarkdownElement[] = [
UserFeedback,
ImageSearchRef,
LobeAgents,
LocalFileLink,
Link,
];
@@ -140,7 +140,7 @@ describe('Group', () => {
mockIsGenerating = false;
});
it('keeps long structured mixed content visible and renders the single tool inline', () => {
it('keeps long structured mixed content visible after the single inline tool', () => {
const longContent =
'后宫番 + 实际项目中的状态管理问题,这个组合挺有意思的!\n\n对于实际项目中的状态管理,你目前遇到的具体问题是什么?比如:\n- 不知道什么时候该用 useState,什么时候该用 Context\n- 组件间状态传递变得混乱\n- 性能问题(不必要的重渲染)';
@@ -164,17 +164,6 @@ describe('Group', () => {
expect(sequence).toEqual(['answer-segment', 'answer-segment']);
expect(parseAnswerSegments()).toEqual([
{
content: longContent,
contentOverride: longContent,
disableMarkdownStreaming: false,
domId: 'block-1__answer',
hasError: false,
hasToolsOverride: false,
id: 'block-1',
isFirstBlock: false,
toolCount: 0,
},
{
content: '',
contentOverride: '',
@@ -186,9 +175,71 @@ describe('Group', () => {
isFirstBlock: false,
toolCount: 1,
},
{
content: longContent,
contentOverride: longContent,
disableMarkdownStreaming: false,
domId: 'block-1__answer',
hasError: false,
hasToolsOverride: false,
id: 'block-1',
isFirstBlock: false,
toolCount: 0,
},
]);
});
it('keeps a final-looking mixed block after a folded multi-tool workflow', () => {
const finalSummary =
'我已经完成改动和验证,准备汇总。\n\n- targeted ESLint 通过\n- targeted Prettier check 通过\n- git diff --check 通过';
const { container } = render(
<Group
id="assistant-1"
messageIndex={0}
blocks={[
blk({
content: finalSummary,
id: 'block-1',
tools: [
{ apiName: 'command_execution', id: 'tool-1' } as any,
{ apiName: 'command_execution', id: 'tool-2' } as any,
{ apiName: 'command_execution', id: 'tool-3' } as any,
],
}),
]}
/>,
);
const sequence = Array.from(container.querySelectorAll('[data-testid]')).map((node) =>
node.getAttribute('data-testid'),
);
expect(sequence).toEqual(['workflow-segment', 'answer-segment']);
expect(parseWorkflowSegment()).toEqual([
{
content: '',
contentOverride: '',
disableMarkdownStreaming: false,
domId: 'block-1__workflow',
hasError: false,
hasToolsOverride: true,
toolCount: 3,
},
]);
expect(parseAnswerSegment()).toEqual({
content: finalSummary,
contentOverride: finalSummary,
disableMarkdownStreaming: false,
domId: 'block-1__answer',
hasError: false,
hasToolsOverride: false,
id: 'block-1',
isFirstBlock: false,
toolCount: 0,
});
});
it('keeps a short mixed status block inline when there is only one tool call', () => {
render(
<Group
@@ -265,6 +265,13 @@ const appendWorkflowRangeBlock = (
return;
}
appendWorkflowBlock(
segments,
createWorkflowRenderBlock(block, {
content: '',
imageList: undefined,
}),
);
appendAnswerBlock(
segments,
createAnswerRenderBlock(block, {
@@ -273,13 +280,6 @@ const appendWorkflowRangeBlock = (
tools: undefined,
}),
);
appendWorkflowBlock(
segments,
createWorkflowRenderBlock(block, {
content: '',
imageList: undefined,
}),
);
};
const appendPostToolBlocks = (
@@ -163,6 +163,13 @@ export const TOOL_API_DISPLAY_NAMES: Record<string, string> = {
// Cloud sandbox
executeCode: 'workflow.toolDisplayName.executeCode',
// Codex
command_execution: 'workflow.toolDisplayName.runCommand',
file_change: 'workflow.toolDisplayName.editLocalFile',
mcp_tool_call: 'workflow.toolDisplayName.callMcpTool',
todo_list: 'workflow.toolDisplayName.updateTodos',
web_search: 'workflow.toolDisplayName.search',
// Lobe Agent — Plan & Todos
createPlan: 'workflow.toolDisplayName.createPlan',
createTodos: 'workflow.toolDisplayName.createTodos',
@@ -5,7 +5,9 @@ import { type AssistantContentBlock } from '@/types/index';
import { POST_TOOL_FINAL_ANSWER_SCORE_THRESHOLD } from './constants';
import {
getPostToolAnswerSplitIndex,
getToolDisplayName,
getWorkflowStreamingHeadlineState,
getWorkflowSummaryText,
scoreBlockContentAsAnswerLike,
scorePostToolBlockAsFinalAnswer,
shapeProseForWorkflowHeadline,
@@ -14,6 +16,40 @@ import {
const blk = (p: Partial<AssistantContentBlock> & { id: string }): AssistantContentBlock =>
({ content: '', ...p }) as AssistantContentBlock;
describe('tool display names', () => {
it('uses friendly labels for Codex tool api names', () => {
expect(getToolDisplayName('command_execution')).toBe('Ran a command');
expect(getToolDisplayName('file_change')).toBe('Edited a file');
expect(getToolDisplayName('mcp_tool_call')).toBe('Called MCP tool');
expect(getToolDisplayName('todo_list')).toBe('Updated todos');
expect(getToolDisplayName('web_search')).toBe('Searched the web');
});
it('uses friendly Codex labels in workflow summaries', () => {
const summary = getWorkflowSummaryText([
blk({
id: '0',
tools: [
{ apiName: 'command_execution', id: 'tool-1', result: { content: 'ok' } } as any,
{ apiName: 'command_execution', id: 'tool-2', result: { content: 'ok' } } as any,
{ apiName: 'file_change', id: 'tool-3', result: { content: 'ok' } } as any,
{ apiName: 'mcp_tool_call', id: 'tool-4', result: { content: 'ok' } } as any,
{ apiName: 'web_search', id: 'tool-5', result: { content: 'ok' } } as any,
],
}),
]);
expect(summary).toContain('Ran a command (2)');
expect(summary).toContain('Edited a file');
expect(summary).toContain('Called MCP tool');
expect(summary).toContain('Searched the web');
expect(summary).not.toContain('Command_execution');
expect(summary).not.toContain('File_change');
expect(summary).not.toContain('Mcp_tool_call');
expect(summary).not.toContain('Web_search');
});
});
describe('shapeProseForWorkflowHeadline', () => {
it('does not split on dot inside Node.js in CJK prose', () => {
const s =
@@ -17,10 +17,18 @@ export default defineFixtures({
description: 'Preview Codex file change summaries.',
name: 'file_change',
},
{
description: 'Preview Codex MCP tool rendering.',
name: 'mcp_tool_call',
},
{
description: 'Preview Codex todo list rendering.',
name: 'todo_list',
},
{
description: 'Preview Codex web search rendering.',
name: 'web_search',
},
],
fixtures: {
command_execution: single({
@@ -83,6 +91,28 @@ export default defineFixtures({
linesDeleted: 0,
},
}),
mcp_tool_call: single({
args: {
arguments: {
code: "const result = await import('./package.json', { with: { type: 'json' } });\nresult.default.name;",
},
server: 'node_repl',
tool: 'js',
},
content: '@lobehub/desktop',
pluginState: {
arguments: {
code: "const result = await import('./package.json', { with: { type: 'json' } });\nresult.default.name;",
},
result: {
content: [{ text: '@lobehub/desktop', type: 'text' }],
isError: false,
},
server: 'node_repl',
status: 'completed',
tool: 'js',
},
}),
todo_list: single({
args: {
items: [
@@ -93,5 +123,24 @@ export default defineFixtures({
},
content: 'Todo list updated (1/3 completed).',
}),
web_search: single({
args: {
query: 'Codex tool render examples',
results: [
{
snippet: 'A compact preview of Codex builtin tool output in the chat timeline.',
title: 'Codex tool render examples',
url: 'https://example.com/codex-render',
},
{
snippet: 'How LobeHub maps builtin tool inspectors, renders, and display controls.',
title: 'LobeHub builtin tool render registry',
url: 'https://example.com/lobehub-tools',
},
],
},
content:
'Search results\n\n1. Codex tool render examples - https://example.com/codex-render\n2. LobeHub builtin tool render registry - https://example.com/lobehub-tools',
}),
},
});
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { getExplorerTreeIconCSS } from './folderIconStyle';
describe('getExplorerTreeIconCSS', () => {
it('maps ico files to the image icon', () => {
const css = getExplorerTreeIconCSS('https://example.com/icons');
const icoImageRule = new RegExp(
String.raw`\[data-item-type="file"\]\[data-item-path\$="\.ico" i\]` +
String.raw`[\S\s]*?background-image: url\("https:\/\/example\.com\/icons\/image\.svg"\)`,
);
expect(css).toMatch(icoImageRule);
});
});
+226 -14
View File
@@ -1,21 +1,209 @@
import { genCdnUrl } from '@lobehub/ui';
import type { CSSProperties } from 'react';
const folderClosedSvg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'/></svg>`;
const folderOpenSvg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.69.9H18a2 2 0 0 1 2 2v2'/></svg>`;
// Pierre's unsafeCSS is captured at FileTree construction with no public
// setter, so we can't rebuild this string in response to tree changes. Drive
// the file-icon offset through a CSS custom property the wrapper sets — custom
// properties cascade through shadow DOM, so toggling it on the host reflows
// the offset live (see `getExplorerTreeStyleVars`).
const FILE_ICON_OFFSET_VAR = '--explorer-file-icon-offset';
const FOLDER_ICON_SIZE = '18px';
const FILE_ICON_SIZE = '16px';
// Chevron column width + row gap at default density (16 + 6). We standardised
// consumers on default density, so this matches `--trees-icon-width` +
// `--trees-item-row-gap` exactly.
const RESERVED_FILE_ICON_OFFSET = '22px';
export const FOLDER_ICON_CSS = `
const MATERIAL_FILE_ICON_ASSETS_URL = genCdnUrl({
path: 'assets',
pkg: '@lobehub/assets-fileicon',
version: '1.0.0',
});
const MATERIAL_FOLDER_ICON_RULES = [
{ iconName: 'folder-github', names: ['.github', 'github', '_github', '__github__'] },
{
iconName: 'folder-vscode',
names: ['.vscode', 'vscode', '.vscode-test', 'vscode-test'],
},
{ iconName: 'folder-docs', names: ['docs', 'doc', 'documents', 'documentation'] },
{ iconName: 'folder-src', names: ['src', 'source', 'sources', 'code'] },
{ iconName: 'folder-node', names: ['node_modules'] },
{ iconName: 'folder-app', names: ['app', 'apps'] },
{ iconName: 'folder-packages', names: ['package', 'packages', 'pkg', 'pkgs'] },
{ iconName: 'folder-public', names: ['public', 'web'] },
{ iconName: 'folder-scripts', names: ['script', 'scripts'] },
{ iconName: 'folder-i18n', names: ['i18n', 'locale', 'locales'] },
{ iconName: 'folder-temp', names: ['tmp', 'temp'] },
{ iconName: 'folder-next', names: ['.next', 'next'] },
{ iconName: 'folder-husky', names: ['.husky', 'husky'] },
{ iconName: 'folder-git', names: ['.git', 'git', '.githooks', 'githooks'] },
{ iconName: 'folder-container', names: ['.devcontainer', 'devcontainer', 'container'] },
{ iconName: 'folder-command', names: ['command', 'commands', 'cmd', 'cli'] },
{ iconName: 'folder-test', names: ['test', 'tests', '__test__', '__tests__'] },
{ iconName: 'folder-mock', names: ['mock', 'mocks', '__mock__', '__mocks__'] },
{ iconName: 'folder-resource', names: ['asset', 'assets', 'resource', 'resources'] },
{ iconName: 'folder-components', names: ['component', 'components'] },
{ iconName: 'folder-routes', names: ['route', 'routes'] },
{ iconName: 'folder-server', names: ['server', 'backend'] },
{ iconName: 'folder-client', names: ['client', 'frontend'] },
{ iconName: 'folder-desktop', names: ['desktop'] },
{ iconName: 'folder-mobile', names: ['mobile'] },
{ iconName: 'folder-views', names: ['page', 'pages', 'view', 'views'] },
{ iconName: 'folder-config', names: ['.config', 'config', 'configs'] },
{ iconName: 'folder-environment', names: ['.env', '.envs', 'env', 'envs'] },
{ iconName: 'folder-database', names: ['db', 'database'] },
{ iconName: 'folder-class', names: ['model', 'models'] },
{ iconName: 'folder-controller', names: ['service', 'services'] },
{ iconName: 'folder-hook', names: ['hook', 'hooks'] },
{ iconName: 'folder-typescript', names: ['type', 'types', 'typings'] },
{ iconName: 'folder-utils', names: ['util', 'utils', 'utilities'] },
{ iconName: 'folder-plugin', names: ['plugin', 'plugins'] },
{ iconName: 'folder-shared', names: ['common', 'shared'] },
{ iconName: 'folder-css', names: ['style', 'styles'] },
{ iconName: 'folder-images', names: ['image', 'images', 'icon', 'icons'] },
{ iconName: 'folder-markdown', names: ['md', 'markdown'] },
{ iconName: 'folder-json', names: ['json'] },
{ iconName: 'folder-javascript', names: ['js', 'javascript'] },
{ iconName: 'folder-console', names: ['console', 'shell'] },
] satisfies { iconName: string; names: string[] }[];
const MATERIAL_FILE_EXTENSION_RULES = [
{ extensions: ['tsx'], iconName: 'react_ts' },
{ extensions: ['jsx'], iconName: 'react' },
{ extensions: ['ts', 'mts', 'cts'], iconName: 'typescript' },
{ extensions: ['js', 'mjs', 'cjs'], iconName: 'javascript' },
{ extensions: ['json', 'jsonc', 'json5', 'jsonl'], iconName: 'json' },
{ extensions: ['md'], iconName: 'markdown' },
{ extensions: ['mdx'], iconName: 'mdx' },
{ extensions: ['yml', 'yaml'], iconName: 'yaml' },
{ extensions: ['sh', 'bash', 'zsh', 'fish'], iconName: 'console' },
{ extensions: ['env'], iconName: 'tune' },
{ extensions: ['svg'], iconName: 'svg' },
{ extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'ico'], iconName: 'image' },
{ extensions: ['css'], iconName: 'css' },
{ extensions: ['scss', 'sass'], iconName: 'sass' },
{ extensions: ['less'], iconName: 'less' },
{ extensions: ['lock'], iconName: 'lock' },
{ extensions: ['log'], iconName: 'log' },
{ extensions: ['txt'], iconName: 'document' },
] satisfies { extensions: string[]; iconName: string }[];
const MATERIAL_FILE_NAME_RULES = [
{ iconName: 'codeowners', names: ['CODEOWNERS', 'OWNERS'] },
{ iconName: 'git', names: ['.gitignore', '.gitmodules', '.gitattributes', '.gitkeep'] },
{ iconName: 'npm', names: ['.npmrc', '.npmignore'] },
{ iconName: 'nodejs', names: ['.nvmrc', '.node-version', 'package.json', 'package-lock.json'] },
{ iconName: 'docker', names: ['.dockerignore', 'Dockerfile'] },
{ iconName: 'bun', names: ['bun.lockb', 'bunfig.toml'] },
{ iconName: 'markdown', names: ['AGENTS.md', 'CLAUDE.md', 'PULL_REQUEST_TEMPLATE.md'] },
] satisfies { iconName: string; names: string[] }[];
const MATERIAL_FILE_PREFIX_RULES = [
{ iconName: 'tune', prefixes: ['.env.'] },
{ iconName: 'eslint', prefixes: ['.eslintrc', 'eslint.config.'] },
{ iconName: 'stylelint', prefixes: ['.stylelintrc', 'stylelint.config.'] },
{ iconName: 'prettier', prefixes: ['.prettierrc', 'prettier.config.'] },
{ iconName: 'commitlint', prefixes: ['.commitlintrc', 'commitlint.config.'] },
{ iconName: 'next', prefixes: ['next.config.'] },
{ iconName: 'vite', prefixes: ['vite.config.'] },
] satisfies { iconName: string; prefixes: string[] }[];
const cssString = (value: string) => value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
const cssUrl = (url: string) => `url("${cssString(url)}")`;
const iconUrl = (iconsUrl: string, iconName: string, open = false) =>
`${iconsUrl}/${iconName}${open ? '-open' : ''}.svg`;
const iconBackground = (iconsUrl: string, iconName: string, open = false) =>
cssUrl(iconUrl(iconsUrl, iconName, open));
const getFolderRowSelectors = (names: string[]) =>
names
.flatMap((name) => {
const escaped = cssString(name);
return [
`[data-item-type="folder"][data-item-path="${escaped}/" i]`,
`[data-item-type="folder"][data-item-path$="/${escaped}/" i]`,
`[data-item-type="folder"][data-item-path="${escaped}" i]`,
`[data-item-type="folder"][data-item-path$="/${escaped}" i]`,
];
})
.join(',\n');
const getFileNameSelectors = (names: string[]) =>
names
.flatMap((name) => {
const escaped = cssString(name);
return [
`[data-item-type="file"][data-item-path="${escaped}" i]`,
`[data-item-type="file"][data-item-path$="/${escaped}" i]`,
];
})
.join(',\n');
const getFileExtensionSelectors = (extensions: string[]) =>
extensions
.map((extension) => {
const escaped = cssString(extension);
return `[data-item-type="file"][data-item-path$=".${escaped}" i]`;
})
.join(',\n');
const getFilePrefixSelectors = (prefixes: string[]) =>
prefixes
.flatMap((prefix) => {
const escaped = cssString(prefix);
return [
`[data-item-type="file"][data-item-path^="${escaped}" i]`,
`[data-item-type="file"][data-item-path*="/${escaped}" i]`,
];
})
.join(',\n');
const getFileIconSectionRules = (selectors: string, iconsUrl: string, iconName: string) => `
${selectors
.split(',\n')
.map((selector) => `${selector} > [data-item-section="icon"]`)
.join(',\n')} {
background-image: ${iconBackground(iconsUrl, iconName)};
}
`;
const getFolderIconRules = (iconsUrl: string) =>
MATERIAL_FOLDER_ICON_RULES.map(({ iconName, names }) => {
const selectors = getFolderRowSelectors(names);
return `
${selectors
.split(',\n')
.map((selector) => `${selector} [data-item-section="content"]::before`)
.join(',\n')} {
background-image: ${iconBackground(iconsUrl, iconName)};
}
${selectors
.split(',\n')
.map((selector) => `${selector}[aria-expanded="true"] [data-item-section="content"]::before`)
.join(',\n')} {
background-image: ${iconBackground(iconsUrl, iconName, true)};
}
`;
}).join('\n');
const getFileIconRules = (iconsUrl: string) => `
${MATERIAL_FILE_EXTENSION_RULES.map(({ extensions, iconName }) =>
getFileIconSectionRules(getFileExtensionSelectors(extensions), iconsUrl, iconName),
).join('\n')}
${MATERIAL_FILE_NAME_RULES.map(({ iconName, names }) =>
getFileIconSectionRules(getFileNameSelectors(names), iconsUrl, iconName),
).join('\n')}
${MATERIAL_FILE_PREFIX_RULES.map(({ iconName, prefixes }) =>
getFileIconSectionRules(getFilePrefixSelectors(prefixes), iconsUrl, iconName),
).join('\n')}
`;
export const getExplorerTreeIconCSS = (iconsUrl = MATERIAL_FILE_ICON_ASSETS_URL) => `
[data-item-type="folder"] [data-item-section="content"] {
display: flex;
align-items: center;
@@ -23,20 +211,44 @@ export const FOLDER_ICON_CSS = `
[data-item-type="folder"] [data-item-section="content"]::before {
content: '';
flex: 0 0 auto;
width: 14px;
height: 14px;
margin-inline-end: 6px;
background-color: currentColor;
-webkit-mask: url("data:image/svg+xml;utf8,${folderClosedSvg}") no-repeat center / contain;
mask: url("data:image/svg+xml;utf8,${folderClosedSvg}") no-repeat center / contain;
opacity: 0.85;
width: ${FOLDER_ICON_SIZE};
height: ${FOLDER_ICON_SIZE};
margin-inline-end: 4px;
background-image: ${iconBackground(iconsUrl, 'folder')};
background-position: center;
background-repeat: no-repeat;
background-size: ${FOLDER_ICON_SIZE} ${FOLDER_ICON_SIZE};
}
[data-item-type="folder"][aria-expanded="true"] [data-item-section="content"]::before {
-webkit-mask-image: url("data:image/svg+xml;utf8,${folderOpenSvg}");
mask-image: url("data:image/svg+xml;utf8,${folderOpenSvg}");
background-image: ${iconBackground(iconsUrl, 'folder', true)};
}
[data-item-type="file"] [data-item-section="icon"] {
[data-item-type="file"] > [data-item-section="icon"] {
margin-inline-start: var(${FILE_ICON_OFFSET_VAR}, 0px);
background-image: ${iconBackground(iconsUrl, 'file')};
background-position: center;
background-repeat: no-repeat;
background-size: ${FILE_ICON_SIZE} ${FILE_ICON_SIZE};
}
[data-item-type="file"] > [data-item-section="icon"] > svg {
visibility: hidden;
}
${getFolderIconRules(iconsUrl)}
${getFileIconRules(iconsUrl)}
`;
export const FOLDER_ICON_CSS = getExplorerTreeIconCSS();
// pierre/trees marks the clicked row as model-focused, which otherwise paints
// a pointer-only ring.
// Keep the native :focus-visible ring for keyboard navigation.
export const HIDE_POINTER_FOCUS_RING_CSS = `
[data-type='item'][data-item-focused='true']:not(:focus-visible)::before {
outline: none;
}
[data-type='item'][data-item-focused='true']:not(:focus-visible)
[data-item-flattened-subitems] {
--truncate-marker-block-inset: 0px;
}
`;
+5 -1
View File
@@ -1,4 +1,8 @@
export { FOLDER_ICON_CSS, getExplorerTreeStyleVars } from './folderIconStyle';
export {
FOLDER_ICON_CSS,
getExplorerTreeStyleVars,
HIDE_POINTER_FOCUS_RING_CSS,
} from './folderIconStyle';
export type {
ExplorerTreeCanDropCtx,
ExplorerTreeHandle,
+18 -2
View File
@@ -4,10 +4,11 @@ import { ContextMenuTrigger, type GenericItemType, Icon } from '@lobehub/ui';
import { confirmModal, ScrollArea } from '@lobehub/ui/base-ui';
import { SkillsIcon } from '@lobehub/ui/icons';
import { createStaticStyles } from 'antd-style';
import { FileIcon, XIcon } from 'lucide-react';
import { XIcon } from 'lucide-react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import FileIcon from '@/components/FileIcon';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
@@ -19,6 +20,15 @@ const resolveSkillName = (filePath: string): string | null => {
};
const styles = createStaticStyles(({ css, cssVar }) => ({
tabIcon: css`
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
`,
tabClose: css`
cursor: pointer;
@@ -242,7 +252,13 @@ const TabStrip = memo(() => {
}
}}
>
<Icon icon={skillName ? SkillsIcon : FileIcon} size={12} />
{skillName ? (
<Icon className={styles.tabIcon} icon={SkillsIcon} size={12} />
) : (
<span className={styles.tabIcon}>
<FileIcon fileName={filename} size={14} variant={'raw'} />
</span>
)}
<span className={styles.tabLabel}>{label}</span>
<button
aria-label={`Close ${filename}`}
@@ -46,7 +46,8 @@ vi.mock('@/features/ExplorerTree', () => {
MockExplorerTree.displayName = 'MockExplorerTree';
return {
ExplorerTree: MockExplorerTree,
FOLDER_ICON_CSS: '',
FOLDER_ICON_CSS: 'folder-css',
HIDE_POINTER_FOCUS_RING_CSS: 'hide-pointer-focus-ring-css',
getExplorerTreeStyleVars: () => ({}),
};
});
@@ -165,6 +166,7 @@ describe('Files — reveal request integration', () => {
{ path: 'src/foo/bar.ts', status: 'modified' },
{ path: 'deleted.ts', status: 'deleted' },
]);
expect(explorerTreeProps.current?.unsafeCSS).toBe('folder-css\nhide-pointer-focus-ring-css');
const nodes = explorerTreeProps.current?.nodes as { id: string }[];
const dirtyNode = nodes.find((node) => node.id === 'src/foo/bar.ts');
@@ -11,7 +11,12 @@ import { useTranslation } from 'react-i18next';
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
import type { ExplorerTreeNode } from '@/features/ExplorerTree';
import { ExplorerTree, FOLDER_ICON_CSS, getExplorerTreeStyleVars } from '@/features/ExplorerTree';
import {
ExplorerTree,
FOLDER_ICON_CSS,
getExplorerTreeStyleVars,
HIDE_POINTER_FOCUS_RING_CSS,
} from '@/features/ExplorerTree';
import type { ExplorerTreeHandle } from '@/features/ExplorerTree/types';
import { localFileService } from '@/services/electron/localFileService';
import { useChatStore } from '@/store/chat';
@@ -36,19 +41,15 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
--trees-bg-override: transparent;
--trees-border-color-override: transparent;
--trees-selected-bg-override: ${cssVar.colorFillSecondary};
--trees-selected-fg-override: ${cssVar.colorText};
--trees-bg-muted-override: ${cssVar.colorFillTertiary};
--trees-fg-override: ${cssVar.colorText};
--trees-fg-override: ${cssVar.colorTextSecondary};
--trees-fg-muted-override: ${cssVar.colorTextSecondary};
--trees-accent-override: ${cssVar.colorPrimary};
--trees-padding-inline-override: 0px;
--trees-font-size-override: 12px;
--trees-border-radius-override: 6px;
/* Drop the doubled outline pierre/trees draws via ::before on a
* focused+selected row — the filled background from
* --trees-selected-bg-override is already a clear selection signal. */
--trees-selected-focused-border-color-override: transparent;
flex: 1;
min-height: 0;
`,
@@ -72,6 +73,8 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
const stripTrailingSlash = (value: string) => (value.endsWith('/') ? value.slice(0, -1) : value);
const FILE_TREE_UNSAFE_CSS = `${FOLDER_ICON_CSS}\n${HIDE_POINTER_FOCUS_RING_CSS}`;
const getParentRelativePath = (relativePath: string): string | null => {
const cleaned = stripTrailingSlash(relativePath);
const idx = cleaned.lastIndexOf('/');
@@ -289,7 +292,7 @@ const Files = memo<FilesProps>(({ deviceId, workingDirectory }) => {
nodes={nodes}
ref={treeRef}
style={{ height: '100%' }}
unsafeCSS={FOLDER_ICON_CSS}
unsafeCSS={FILE_TREE_UNSAFE_CSS}
onExpandedChange={setExpandedIds}
onNodeClick={handleNodeClick}
/>