+
+
+ )
+ }
+
return (
{children}
diff --git a/src/renderer/src/pages/home/Markdown/__tests__/CodeBlock.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/CodeBlock.test.tsx
index 9ada24b8298..3dfdfc3cb72 100644
--- a/src/renderer/src/pages/home/Markdown/__tests__/CodeBlock.test.tsx
+++ b/src/renderer/src/pages/home/Markdown/__tests__/CodeBlock.test.tsx
@@ -63,6 +63,11 @@ vi.mock('@renderer/components/CodeBlockView', () => ({
HtmlArtifactsCard: mocks.HtmlArtifactsCard
}))
+// Mock ClickableFilePath
+vi.mock('@renderer/pages/home/Messages/Tools/MessageAgentTools/ClickableFilePath', () => ({
+ ClickableFilePath: ({ path }: { path: string }) => {path}
+}))
+
describe('CodeBlock', () => {
const defaultProps = {
blockId: 'test-msg-block-id',
@@ -106,6 +111,34 @@ describe('CodeBlock', () => {
expect(codeElement.tagName).toBe('CODE')
expect(mocks.CodeBlockView).not.toHaveBeenCalled()
})
+
+ it('should render ClickableFilePath for absolute file paths', () => {
+ const pathProps = {
+ ...defaultProps,
+ className: undefined,
+ children: '/Users/foo/bar.tsx'
+ }
+ render( )
+
+ expect(screen.getByTestId('clickable-file-path')).toBeInTheDocument()
+ expect(screen.getByText('/Users/foo/bar.tsx')).toBeInTheDocument()
+ })
+
+ it.each(['/home/user/project/src/index.ts', '/tmp/test.log', '/var/log/app.log', '/etc/nginx/nginx.conf'])(
+ 'should detect %s as a file path',
+ (path) => {
+ render( )
+ expect(screen.getByTestId('clickable-file-path')).toBeInTheDocument()
+ }
+ )
+
+ it.each(['inline code', '/single-segment', '//comment style', 'not/absolute/path', '/path with spaces/file.ts'])(
+ 'should NOT detect %s as a file path',
+ (text) => {
+ render( )
+ expect(screen.queryByTestId('clickable-file-path')).not.toBeInTheDocument()
+ }
+ )
})
describe('save', () => {
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ClickableFilePath.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ClickableFilePath.tsx
new file mode 100644
index 00000000000..53a675fcdaf
--- /dev/null
+++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ClickableFilePath.tsx
@@ -0,0 +1,108 @@
+import { MoreOutlined } from '@ant-design/icons'
+import { useExternalApps } from '@renderer/hooks/useExternalApps'
+import { buildEditorUrl, getEditorIcon } from '@renderer/utils/editorUtils'
+import type { ExternalAppInfo } from '@shared/externalApp/types'
+import { Dropdown, type MenuProps, Tooltip } from 'antd'
+import { FolderOpen } from 'lucide-react'
+import { memo, useCallback, useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+
+interface ClickableFilePathProps {
+ path: string
+ displayName?: string
+}
+
+export const ClickableFilePath = memo(function ClickableFilePath({ path, displayName }: ClickableFilePathProps) {
+ const { t } = useTranslation()
+ const { data: externalApps } = useExternalApps()
+
+ const availableEditors = useMemo(
+ () => externalApps?.filter((app) => app.tags.includes('code-editor')) ?? [],
+ [externalApps]
+ )
+
+ const openInEditor = useCallback(
+ (app: ExternalAppInfo) => {
+ window.open(buildEditorUrl(app, path))
+ },
+ [path]
+ )
+
+ const handleOpen = useCallback(
+ (e: React.MouseEvent | React.KeyboardEvent) => {
+ e.stopPropagation()
+ window.api.file.openPath(path).catch(() => {
+ window.toast.error(t('chat.input.tools.open_file_error', { path }))
+ })
+ },
+ [path, t]
+ )
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ handleOpen(e)
+ }
+ },
+ [handleOpen]
+ )
+
+ const menuItems: MenuProps['items'] = useMemo(() => {
+ const items: MenuProps['items'] = [
+ {
+ key: 'reveal',
+ label: t('chat.input.tools.reveal_in_finder'),
+ icon: ,
+ onClick: ({ domEvent }) => {
+ domEvent.stopPropagation()
+ window.api.file.showInFolder(path).catch(() => {
+ window.toast.error(t('chat.input.tools.file_not_found', { path }))
+ })
+ }
+ }
+ ]
+
+ if (availableEditors.length > 0) {
+ items.push({ type: 'divider' })
+ for (const app of availableEditors) {
+ items.push({
+ key: app.id,
+ label: app.name,
+ icon: getEditorIcon(app),
+ onClick: ({ domEvent }) => {
+ domEvent.stopPropagation()
+ openInEditor(app)
+ }
+ })
+ }
+ }
+
+ return items
+ }, [path, t, availableEditors, openInEditor])
+
+ return (
+
+
+
+ {displayName ?? path}
+
+
+
+
+ e.stopPropagation()}
+ className="cursor-pointer rounded px-0.5 opacity-60 hover:bg-black/10 hover:opacity-100"
+ style={{ color: 'var(--color-link)', fontSize: '14px' }}
+ />
+
+
+
+ )
+})
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx
index c01aae2cd6f..d9167a4444e 100644
--- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx
+++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx
@@ -1,5 +1,6 @@
import type { CollapseProps } from 'antd'
+import { ClickableFilePath } from './ClickableFilePath'
import { ToolHeader } from './GenericTools'
import type { EditToolInput, EditToolOutput } from './types'
import { AgentToolsType } from './types'
@@ -39,7 +40,7 @@ export function EditTool({
label: (
: undefined}
variant="collapse-label"
showStatus={false}
/>
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx
index 6e4def8afd5..831b508be28 100644
--- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx
+++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx
@@ -2,6 +2,7 @@ import type { CollapseProps } from 'antd'
import { useTranslation } from 'react-i18next'
import { countLines, truncateOutput } from '../shared/truncateOutput'
+import { ClickableFilePath } from './ClickableFilePath'
import { ToolHeader, TruncatedIndicator } from './GenericTools'
import {
AgentToolsType,
@@ -34,7 +35,17 @@ export function GlobTool({
),
children: (
- {truncatedOutput}
+
+ {truncatedOutput?.split('\n').map((line, i) =>
+ line.startsWith('/') ? (
+
+
+
+ ) : (
+ {line}
+ )
+ )}
+
{isTruncated && }
)
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx
index cd4f907b5f9..c98c5c834cf 100644
--- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx
+++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx
@@ -2,6 +2,7 @@ import type { CollapseProps } from 'antd'
import { useTranslation } from 'react-i18next'
import { countLines, truncateOutput } from '../shared/truncateOutput'
+import { ClickableFilePath } from './ClickableFilePath'
import { ToolHeader, TruncatedIndicator } from './GenericTools'
import { AgentToolsType, type GrepToolInput, type GrepToolOutput } from './types'
@@ -35,7 +36,20 @@ export function GrepTool({
),
children: (
- {truncatedOutput}
+
+ {truncatedOutput?.split('\n').map((line, i) => {
+ const match = line.match(/^(\/[\w./@+-][^:]*[^:])(:.*)?$/)
+ if (match) {
+ return (
+
+
+ {match[2] ?? ''}
+
+ )
+ }
+ return {line}
+ })}
+
{isTruncated && }
)
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx
index dd9c0f18ab8..bf53bcab584 100644
--- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx
+++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx
@@ -1,5 +1,6 @@
import type { CollapseProps } from 'antd'
+import { ClickableFilePath } from './ClickableFilePath'
import { renderCodeBlock } from './EditTool'
import { ToolHeader } from './GenericTools'
import type { MultiEditToolInput, MultiEditToolOutput } from './types'
@@ -17,7 +18,7 @@ export function MultiEditTool({
label: (
: undefined}
variant="collapse-label"
showStatus={false}
/>
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx
index c3db3bded7a..87c6859ba9d 100644
--- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx
+++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx
@@ -3,6 +3,7 @@ import { Tag } from 'antd'
import ReactMarkdown from 'react-markdown'
import { truncateOutput } from '../shared/truncateOutput'
+import { ClickableFilePath } from './ClickableFilePath'
import { ToolHeader, TruncatedIndicator } from './GenericTools'
import type { NotebookEditToolInput, NotebookEditToolOutput } from './types'
import { AgentToolsType } from './types'
@@ -21,7 +22,7 @@ export function NotebookEditTool({
label: (
- {input?.notebook_path}
+ {input?.notebook_path ? : undefined}
),
children: (
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx
index b0d6d58455b..97328f584d8 100644
--- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx
+++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown'
import { truncateOutput } from '../shared/truncateOutput'
+import { ClickableFilePath } from './ClickableFilePath'
import { SkeletonValue, ToolHeader, TruncatedIndicator } from './GenericTools'
import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types'
import { AgentToolsType } from './types'
@@ -54,7 +55,12 @@ export function ReadTool({
label: (
}
+ params={
+ : undefined}
+ width="120px"
+ />
+ }
stats={
stats
? `${t('message.tools.units.line', { count: stats.lineCount })}, ${formatFileSize(stats.fileSize)}`
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx
index 406fc896f1b..a2fa5c86055 100644
--- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx
+++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx
@@ -3,6 +3,7 @@ import { getLanguageByFilePath } from '@renderer/utils/code-language'
import type { CollapseProps } from 'antd'
import { useMemo } from 'react'
+import { ClickableFilePath } from './ClickableFilePath'
import { SkeletonValue, ToolHeader } from './GenericTools'
import { AgentToolsType, type WriteToolInput, type WriteToolOutput } from './types'
@@ -19,7 +20,12 @@ export function WriteTool({
label: (
}
+ params={
+ : undefined}
+ width="200px"
+ />
+ }
variant="collapse-label"
showStatus={false}
/>
diff --git a/src/renderer/src/pages/home/Messages/Tools/__tests__/ClickableFilePath.test.tsx b/src/renderer/src/pages/home/Messages/Tools/__tests__/ClickableFilePath.test.tsx
new file mode 100644
index 00000000000..7ac9ecbcdc0
--- /dev/null
+++ b/src/renderer/src/pages/home/Messages/Tools/__tests__/ClickableFilePath.test.tsx
@@ -0,0 +1,97 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { ClickableFilePath } from '../MessageAgentTools/ClickableFilePath'
+
+const mockOpenPath = vi.fn().mockResolvedValue(undefined)
+const mockShowInFolder = vi.fn().mockResolvedValue(undefined)
+
+vi.stubGlobal('api', {
+ file: {
+ openPath: mockOpenPath,
+ showInFolder: mockShowInFolder,
+ read: vi.fn(),
+ writeWithId: vi.fn()
+ }
+})
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => {
+ const map: Record = {
+ 'chat.input.tools.open_file': 'Open File',
+ 'chat.input.tools.reveal_in_finder': 'Reveal in Finder'
+ }
+ return map[key] ?? key
+ }
+ })
+}))
+
+vi.mock('@renderer/hooks/useExternalApps', () => ({
+ useExternalApps: () => ({
+ data: [
+ { id: 'vscode', name: 'Visual Studio Code', protocol: 'vscode://', tags: ['code-editor'], path: '/app/vscode' },
+ { id: 'cursor', name: 'Cursor', protocol: 'cursor://', tags: ['code-editor'], path: '/app/cursor' }
+ ]
+ })
+}))
+
+vi.mock('@renderer/utils/editorUtils', () => ({
+ getEditorIcon: ({ id }: { id: string }) => ,
+ buildEditorUrl: (app: { protocol: string }, path: string) =>
+ `${app.protocol}file/${path.split('/').map(encodeURIComponent).join('/')}?windowId=_blank`
+}))
+
+describe('ClickableFilePath', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render the path as text', () => {
+ render( )
+ expect(screen.getByText('/Users/foo/bar.tsx')).toBeInTheDocument()
+ })
+
+ it('should render displayName when provided', () => {
+ render( )
+ expect(screen.getByText('bar.tsx')).toBeInTheDocument()
+ expect(screen.queryByText('/Users/foo/bar.tsx')).not.toBeInTheDocument()
+ })
+
+ it('should call openPath on click', () => {
+ render( )
+ fireEvent.click(screen.getByText('/Users/foo/bar.tsx'))
+ expect(mockOpenPath).toHaveBeenCalledWith('/Users/foo/bar.tsx')
+ })
+
+ it('should have clickable styling', () => {
+ render( )
+ const span = screen.getByText('/tmp/test.ts')
+ expect(span).toHaveClass('cursor-pointer')
+ expect(span).toHaveStyle({ color: 'var(--color-link)' })
+ })
+
+ it('should render ellipsis dropdown trigger', () => {
+ render( )
+ expect(document.querySelector('.anticon-more')).toBeInTheDocument()
+ })
+
+ it('should have role="link" and tabIndex for keyboard accessibility', () => {
+ render( )
+ const span = screen.getByText('/tmp/test.ts')
+ expect(span).toHaveAttribute('role', 'link')
+ expect(span).toHaveAttribute('tabindex', '0')
+ })
+
+ it('should call openPath on Enter key', () => {
+ render( )
+ fireEvent.keyDown(screen.getByText('/Users/foo/bar.tsx'), { key: 'Enter' })
+ expect(mockOpenPath).toHaveBeenCalledWith('/Users/foo/bar.tsx')
+ })
+
+ it('should call openPath on Space key', () => {
+ render( )
+ fireEvent.keyDown(screen.getByText('/Users/foo/bar.tsx'), { key: ' ' })
+ expect(mockOpenPath).toHaveBeenCalledWith('/Users/foo/bar.tsx')
+ })
+})
diff --git a/src/renderer/src/utils/editorUtils.tsx b/src/renderer/src/utils/editorUtils.tsx
new file mode 100644
index 00000000000..896e38d1409
--- /dev/null
+++ b/src/renderer/src/utils/editorUtils.tsx
@@ -0,0 +1,29 @@
+import { CursorIcon, VSCodeIcon, ZedIcon } from '@renderer/components/Icons/SVGIcon'
+import type { ExternalAppInfo } from '@shared/externalApp/types'
+
+export const getEditorIcon = (app: ExternalAppInfo, className = 'size-4') => {
+ switch (app.id) {
+ case 'vscode':
+ return
+ case 'cursor':
+ return
+ case 'zed':
+ return
+ }
+}
+
+/**
+ * Build the protocol URL to open a file/folder in an external editor.
+ * @see https://code.visualstudio.com/docs/configure/command-line#_opening-vs-code-with-urls
+ * @see https://github.com/microsoft/vscode/issues/141548#issuecomment-1102200617
+ * @see https://github.com/zed-industries/zed/issues/8482
+ */
+export function buildEditorUrl(app: ExternalAppInfo, filePath: string): string {
+ const encodedPath = filePath.split(/[/\\]/).map(encodeURIComponent).join('/')
+ if (app.id === 'zed') {
+ // Zed parses URLs by stripping "zed://file" prefix, so the format is
+ // zed://file/absolute/path (no extra "/" between "file" and path, no query params)
+ return `${app.protocol}file${encodedPath}`
+ }
+ return `${app.protocol}file/${encodedPath}?windowId=_blank`
+}