feat(web): show tool execution timing (#1140)

This commit is contained in:
Ananovo
2026-07-26 15:08:40 +08:00
committed by GitHub
parent 44390af35c
commit d90bde0b88
8 changed files with 367 additions and 38 deletions
+78 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { formatSubagentModelLabel, getSubagentModel, shouldShowInlineToolCardBody, shouldUseCompactTerminalToolCard } from '@/components/ToolCard/ToolCard'
import { formatSubagentModelLabel, getSubagentModel, getToolTimingDetails, shouldShowInlineToolCardBody, shouldUseCompactTerminalToolCard } from '@/components/ToolCard/ToolCard'
import type { AgentTextBlock, ChatBlock, ToolCallBlock } from '@/chat/types'
function makeAgentTextBlock(overrides: Partial<AgentTextBlock> = {}): AgentTextBlock {
@@ -64,6 +64,83 @@ describe('ToolCard terminal display mode helpers', () => {
})
})
describe('getToolTimingDetails', () => {
it('shows start, finish, and duration for completed tools', () => {
const tool = makeToolCallChild({
tool: {
...makeToolCallChild().tool,
createdAt: 900,
startedAt: 1_000,
completedAt: 4_000,
execStartedAt: 1_500,
execCompletedAt: 3_500,
},
}).tool
expect(getToolTimingDetails(tool, 10_000)).toEqual({
startedAt: 1_500,
completedAt: 3_500,
durationMs: 2_000,
})
})
it('shows a live duration and omits finish while a tool is running', () => {
const tool = makeToolCallChild({
tool: {
...makeToolCallChild().tool,
state: 'running',
createdAt: 900,
startedAt: 1_000,
completedAt: null,
execStartedAt: 1_200,
execCompletedAt: null,
},
}).tool
expect(getToolTimingDetails(tool, 4_000)).toEqual({
startedAt: 1_000,
completedAt: null,
durationMs: 3_000,
})
})
it('keeps start, finish, and duration on the hub clock when exec timing is incomplete', () => {
const tool = makeToolCallChild({
tool: {
...makeToolCallChild().tool,
createdAt: 900,
startedAt: 1_000,
completedAt: 4_000,
execStartedAt: 1_500,
execCompletedAt: null,
},
}).tool
expect(getToolTimingDetails(tool, 10_000)).toEqual({
startedAt: 1_000,
completedAt: 4_000,
durationMs: 3_000,
})
})
it('does not present a pending tool as started', () => {
const tool = makeToolCallChild({
tool: {
...makeToolCallChild().tool,
state: 'pending',
startedAt: null,
completedAt: null,
},
}).tool
expect(getToolTimingDetails(tool, 4_000)).toEqual({
startedAt: null,
completedAt: null,
durationMs: null,
})
})
})
describe('formatSubagentModelLabel', () => {
it('uses getClaudeModelLabel for a preset alias', () => {
expect(formatSubagentModelLabel('opus')).toBe('Opus')
+100 -21
View File
@@ -1,4 +1,4 @@
import type { ChatBlock, ToolCallBlock } from '@/chat/types'
import type { ChatBlock, ChatToolCall, ToolCallBlock } from '@/chat/types'
import type { ApiClient } from '@/api/client'
import type { SessionMetadataSummary } from '@/types/api'
import { memo, useEffect, useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
@@ -17,7 +17,7 @@ import { getToolFullViewComponent, getToolViewComponent } from '@/components/Too
import { getToolResultViewComponent } from '@/components/ToolCard/views/_results'
import { formatTaskChildLabel, TaskStateIcon } from '@/components/ToolCard/helpers'
import { toolDurationMs } from '@/components/ToolCard/toolDuration'
import { formatDuration } from '@/chat/presentation'
import { formatDuration, formatMessageTimestampTitle } from '@/chat/presentation'
import type { TerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode'
import { usePointerFocusRing } from '@/hooks/usePointerFocusRing'
import { getInputStringAny, truncate } from '@/lib/toolInputUtils'
@@ -45,25 +45,112 @@ export function shouldShowInlineToolCardBody(
return !presentationMinimal
}
function ElapsedView(props: { from: number; active: boolean }) {
export function getToolTimingDetails(tool: ChatToolCall, now: number): {
startedAt: number | null
completedAt: number | null
durationMs: number | null
} {
if (tool.state === 'pending') {
return { startedAt: null, completedAt: null, durationMs: null }
}
const active = tool.state === 'running'
const hasExecPair = tool.execStartedAt != null && tool.execCompletedAt != null
const startedAt = active || !hasExecPair
? (tool.startedAt ?? tool.createdAt)
: tool.execStartedAt
const completedAt = active
? null
: (hasExecPair ? tool.execCompletedAt : tool.completedAt)
const liveDurationMs = active && startedAt != null ? Math.max(0, now - startedAt) : null
return {
startedAt,
completedAt,
durationMs: toolDurationMs(tool) ?? liveDurationMs,
}
}
export function formatCompactToolTimestamp(value: number): string {
return new Date(value).toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
})
}
export function ToolTimingSummary(props: {
startedAt: number | null
completedAt: number | null
durationMs: number | null
typography?: 'detail' | 'group'
}) {
const { t } = useTranslation()
const items = [
props.startedAt != null ? { label: t('tool.startedAt'), value: formatCompactToolTimestamp(props.startedAt) } : null,
props.completedAt != null ? { label: t('tool.completedAt'), value: formatCompactToolTimestamp(props.completedAt) } : null,
props.durationMs != null ? { label: t('tool.duration'), value: formatDuration(props.durationMs) } : null,
].filter((item): item is { label: string; value: string } => item !== null)
if (items.length === 0) return null
return (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs text-[var(--app-hint)]">
{items.map((item) => (
<span key={item.label} className="inline-flex items-baseline gap-1.5 whitespace-nowrap">
<span className={props.typography === 'group' ? undefined : 'font-medium'}>{item.label}</span>
<span className={props.typography === 'group' ? undefined : 'font-mono'}>{item.value}</span>
</span>
))}
</div>
)
}
function ToolCardTimingSummary(props: { tool: ChatToolCall }) {
const active = props.tool.state === 'running'
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!props.active) return
if (!active) return
setNow(Date.now())
const id = setInterval(() => setNow(Date.now()), ELAPSED_INTERVAL_MS)
return () => clearInterval(id)
}, [props.active, props.from])
}, [active, props.tool.startedAt, props.tool.createdAt])
if (!props.active) return null
return <ToolTimingSummary {...getToolTimingDetails(props.tool, now)} />
}
const elapsed = Math.max(0, now - props.from) / 1000
if (!Number.isFinite(elapsed)) return null
function ToolTimingDetails(props: { block: ToolCallBlock }) {
const { t } = useTranslation()
const tool = props.block.tool
const active = tool.state === 'running'
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!active) return
setNow(Date.now())
const id = setInterval(() => setNow(Date.now()), ELAPSED_INTERVAL_MS)
return () => clearInterval(id)
}, [active, tool.startedAt, tool.createdAt])
const { startedAt, completedAt, durationMs } = getToolTimingDetails(tool, now)
const rows = [
startedAt != null ? [t('tool.startedAt'), formatMessageTimestampTitle(new Date(startedAt))] : null,
!active && completedAt != null ? [t('tool.completedAt'), formatMessageTimestampTitle(new Date(completedAt))] : null,
durationMs != null ? [t('tool.duration'), formatDuration(durationMs)] : null,
].filter((row): row is string[] => row !== null)
if (rows.length === 0) return null
return (
<span className="font-mono text-xs text-[var(--app-hint)]">
{elapsed.toFixed(1)}s
</span>
<div className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
{rows.map(([label, value]) => (
<div key={label} className="contents">
<span className="font-medium text-[var(--app-hint)]">{label}</span>
<span className="font-mono text-[var(--app-hint)]">{value}</span>
</div>
))}
</div>
)
}
@@ -280,16 +367,9 @@ export function ToolDetailDialogContent(props: {
const isQuestionToolWithAnswers = isQuestionTool
&& permission?.answers
&& Object.keys(permission.answers).length > 0
const durationMs = toolDurationMs(props.block.tool)
return (
<div className="mt-3 flex max-h-[75vh] flex-col gap-4 overflow-auto">
{durationMs != null ? (
<div className="flex items-center gap-2 text-xs">
<span className="font-medium text-[var(--app-hint)]">{t('tool.duration')}</span>
<span className="font-mono text-[var(--app-hint)]">{formatDuration(durationMs)}</span>
</div>
) : null}
<ToolTimingDetails block={props.block} />
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">
{isQuestionToolWithAnswers ? t('tool.questionsAnswers') : t('tool.input')}
@@ -337,7 +417,6 @@ function ToolCardInner(props: ToolCardProps) {
const subtitle = presentation.subtitle ?? props.block.tool.description
const taskSummary = renderTaskSummary(props.block, props.metadata, t)
const subagentModel = isSubagentToolName(toolName) ? getSubagentModel(props.block.children) : null
const runningFrom = props.block.tool.startedAt ?? props.block.tool.createdAt
const isCodexAgentCard = toolName === 'CodexAgent'
const useCompactTerminalCard = shouldUseCompactTerminalToolCard(toolName, props.terminalToolDisplayMode)
const showInline = shouldShowInlineToolCardBody(toolName, presentation.minimal, props.terminalToolDisplayMode)
@@ -390,6 +469,7 @@ function ToolCardInner(props: ToolCardProps) {
{truncate(subtitle, 160)}
</CardDescription>
) : null}
<ToolCardTimingSummary tool={props.block.tool} />
</div>
<div className={cn(
@@ -404,7 +484,6 @@ function ToolCardInner(props: ToolCardProps) {
{subagentModel}
</span>
) : null}
<ElapsedView from={runningFrom} active={props.block.tool.state === 'running'} />
<span className={stateColor}>
<ToolStatusIcon state={props.block.tool.state} />
</span>
@@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ToolCallBlock } from '@/chat/types'
import type { ToolGroupBlock } from '@/chat/toolGroups'
import { HappyChatProvider } from '@/components/AssistantChat/context'
import { ToolGroupCard } from '@/components/ToolCard/ToolGroupCard'
import { getToolGroupTiming, ToolGroupCard } from '@/components/ToolCard/ToolGroupCard'
import { I18nProvider } from '@/lib/i18n-context'
function makeToolBlock(id: string, name: string, input: unknown = {}): ToolCallBlock {
@@ -102,13 +102,72 @@ describe('ToolGroupCard', () => {
expect(screen.getByRole('button', { name: /inspect a\.ts/i })).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByText('Run 1 · Read 1')).toBeInTheDocument()
expect(screen.getByText('2 actions')).toBeInTheDocument()
expect(screen.queryByText('2 actions')).not.toBeInTheDocument()
expect(screen.getByText('Run 1 · Read 1')).toHaveClass('text-xs', 'font-normal', 'text-[var(--app-hint)]')
expect(screen.queryByText('src/a.ts')).not.toBeInTheDocument()
expect(screen.queryByText('bun test')).not.toBeInTheDocument()
expect(view.container.innerHTML).toContain('bg-[var(--app-tool-group-bg)]')
})
it('derives completed group wall-clock timing from the earliest start and latest finish', () => {
const first = makeToolBlock('read-1', 'Read')
first.tool.startedAt = 1_000
first.tool.completedAt = 2_000
const second = makeToolBlock('bash-1', 'Bash')
second.tool.startedAt = 1_500
second.tool.completedAt = 4_000
expect(getToolGroupTiming([first, second], 10_000)).toEqual({
startedAt: 1_000,
completedAt: 4_000,
durationMs: 3_000,
running: false,
})
})
it('shows group start, live duration, and a spinner while collapsed and running', () => {
const startedAt = Date.now() - 5_000
const completed = makeToolBlock('read-1', 'Read')
completed.tool.startedAt = startedAt
completed.tool.completedAt = startedAt + 1_000
const running = makeToolBlock('bash-1', 'Bash')
running.tool.state = 'running'
running.tool.startedAt = startedAt + 1_000
running.tool.completedAt = null
const group = makeGroup({
tools: [completed, running],
summary: {
...makeGroup().summary,
runningCount: 1,
},
})
const view = renderCard(group)
expect(screen.getByText('Started')).toBeInTheDocument()
expect(screen.getByText('Duration')).toBeInTheDocument()
expect(screen.queryByText('Finished')).not.toBeInTheDocument()
expect(within(view.container).getByLabelText('Running')).toBeInTheDocument()
})
it('shows final timing in the collapsed header after every tool finishes', () => {
const startedAt = Date.now() - 4_000
const first = makeToolBlock('read-1', 'Read')
first.tool.startedAt = startedAt
first.tool.completedAt = startedAt + 1_000
const second = makeToolBlock('bash-1', 'Bash')
second.tool.startedAt = startedAt + 1_000
second.tool.completedAt = startedAt + 4_000
renderCard(makeGroup({ tools: [first, second] }))
expect(screen.getByText('Started')).toBeInTheDocument()
expect(screen.getByText('Finished')).toBeInTheDocument()
expect(screen.getByText('Duration')).toBeInTheDocument()
expect(screen.getByText('4.0s')).toBeInTheDocument()
})
it('expands to show compact rows and opens a detail dialog per row', async () => {
const view = renderCard(makeGroup())
const groupToggle = within(view.container).getByRole('button', { name: /inspect a\.ts/i })
@@ -117,7 +176,8 @@ describe('ToolGroupCard', () => {
fireEvent.click(groupToggle)
expect(groupToggle).toHaveAttribute('aria-expanded', 'true')
expect(view.container.querySelector('svg[data-state="open"]')).toBeInTheDocument()
expect(screen.getByText('2 actions')).toBeInTheDocument()
expect(screen.getByText('Run 1 · Read 1')).toBeInTheDocument()
expect(screen.queryByText('2 actions')).not.toBeInTheDocument()
expect(screen.getByText('src/a.ts')).toBeInTheDocument()
expect(screen.getByText('Terminal')).toBeInTheDocument()
expect(screen.getByText('bun test')).toBeInTheDocument()
@@ -177,7 +237,7 @@ describe('ToolGroupCard', () => {
}
}))
expect(within(view.container).getByRole('button', { name: /^explored$/i })).toHaveAttribute('aria-expanded', 'true')
expect(within(view.container).getByRole('button', { name: /^explored\b/i })).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Read')).toBeInTheDocument()
expect(screen.getByText('package.json')).toBeInTheDocument()
expect(screen.getByText('Search')).toBeInTheDocument()
+61 -9
View File
@@ -4,13 +4,41 @@ import type { ToolCallBlock } from '@/chat/types'
import { getCodexCommandActions, type CodexCommandAction } from '@/chat/codexCommandPresentation'
import type { SessionMetadataSummary } from '@/types/api'
import { useHappyChatContext } from '@/components/AssistantChat/context'
import { ToolDetailDialogContent, ToolStatusIcon, toolStatusColorClass } from '@/components/ToolCard/ToolCard'
import { getToolTimingDetails, ToolDetailDialogContent, ToolStatusIcon, ToolTimingSummary, toolStatusColorClass } from '@/components/ToolCard/ToolCard'
import { getToolPresentation } from '@/components/ToolCard/knownTools'
import { formatGroupedHeaderSubtitle, formatGroupedHeaderTitle, safeGroupedLabelValue } from '@/components/ToolCard/groupedPresentation'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
import { useTranslation } from '@/lib/use-translation'
import { formatDuration } from '@/chat/presentation'
const TIMING_INTERVAL_MS = 1000
export function getToolGroupTiming(tools: ToolCallBlock[], now: number): {
startedAt: number | null
completedAt: number | null
durationMs: number | null
running: boolean
} {
const startedValues = tools
.filter((tool) => tool.tool.state !== 'pending')
.map((tool) => tool.tool.startedAt ?? tool.tool.createdAt)
.filter((value): value is number => Number.isFinite(value))
const startedAt = startedValues.length > 0 ? Math.min(...startedValues) : null
const running = tools.some((tool) => tool.tool.state === 'running')
const allFinished = tools.length > 0 && tools.every((tool) => tool.tool.state === 'completed' || tool.tool.state === 'error')
const completedValues = allFinished
? tools.map((tool) => tool.tool.completedAt).filter((value): value is number => value != null && Number.isFinite(value))
: []
const completedAt = allFinished && completedValues.length === tools.length ? Math.max(...completedValues) : null
const durationEnd = running ? now : completedAt
const durationMs = startedAt != null && durationEnd != null && durationEnd >= startedAt
? durationEnd - startedAt
: null
return { startedAt, completedAt, durationMs, running }
}
function DetailsIcon(props: { open: boolean }) {
return (
@@ -169,8 +197,17 @@ export function ToolGroupCard(props: {
const [isHydratingHistory, setIsHydratingHistory] = useState(false)
const [historyExhausted, setHistoryExhausted] = useState(false)
const [retryNonce, setRetryNonce] = useState(0)
const [now, setNow] = useState(() => Date.now())
const hydrationRunRef = useRef(0)
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const groupTiming = getToolGroupTiming(props.block.tools, now)
useEffect(() => {
if (!groupTiming.running) return
setNow(Date.now())
const id = setInterval(() => setNow(Date.now()), TIMING_INTERVAL_MS)
return () => clearInterval(id)
}, [groupTiming.running, groupTiming.startedAt])
function clearRetryTimer() {
if (retryTimerRef.current === null) {
@@ -281,6 +318,9 @@ export function ToolGroupCard(props: {
const subtitle = props.block.presentationMode === 'codex-exploration'
? null
: formatGroupedHeaderSubtitle(props.block, t) ?? formatActionSummary(props.block, t)
const summaryBadgeText = props.block.presentationMode === 'codex-exploration'
? null
: subtitle ?? t('toolGroup.toolCount', { n: props.block.tools.length })
const fileCount = props.block.summary.fileTargets.length
return (
@@ -302,18 +342,24 @@ export function ToolGroupCard(props: {
{primaryTitle}
</CardTitle>
</div>
{subtitle ? (
<CardDescription className="truncate whitespace-nowrap font-mono text-xs text-[var(--app-tool-card-subtitle)]">
{subtitle}
</CardDescription>
) : null}
<ToolTimingSummary
startedAt={groupTiming.startedAt}
completedAt={groupTiming.completedAt}
durationMs={groupTiming.durationMs}
typography="group"
/>
</div>
<div className="flex shrink-0 items-center gap-2 self-center text-[var(--app-hint)]">
{props.block.presentationMode !== 'codex-exploration' ? (
{groupTiming.running ? (
<span className={toolStatusColorClass('running')} aria-label={t('toolGroup.rowStatus.running')}>
<ToolStatusIcon state="running" />
</span>
) : null}
{summaryBadgeText ? (
<SummaryBadge
className="bg-[var(--app-subtle-bg)] text-[var(--app-hint)]"
text={t('toolGroup.toolCount', { n: props.block.tools.length })}
className="bg-[var(--app-subtle-bg)] text-xs font-normal text-[var(--app-hint)]"
text={summaryBadgeText}
/>
) : null}
{props.block.summary.runningCount > 0 ? (
@@ -351,6 +397,7 @@ export function ToolGroupCard(props: {
{props.block.presentationMode === 'codex-exploration' ? (
<CodexExplorationRows tools={props.block.tools} onSelect={setSelectedToolId} />
) : props.block.tools.map((tool) => {
const timing = getToolTimingDetails(tool.tool, now)
return (
<button
key={tool.id}
@@ -363,6 +410,11 @@ export function ToolGroupCard(props: {
</span>
<RowLabel block={tool} metadata={props.metadata} />
<div className="flex shrink-0 items-center gap-2">
{timing.durationMs != null ? (
<span className="font-mono text-xs text-[var(--app-hint)]">
{formatDuration(timing.durationMs)}
</span>
) : null}
<RowStatusBadge block={tool} />
</div>
</button>
@@ -33,7 +33,7 @@ function makeBlock(tool: Partial<ChatToolCall>): ToolCallBlock {
}
}
describe('ToolDetailDialogContent — duration row', () => {
describe('ToolDetailDialogContent — execution timing', () => {
it('shows a Duration row for a completed tool', () => {
renderWithI18n(<ToolDetailDialogContent block={makeBlock({ state: 'completed', startedAt: 1000, completedAt: 3500 })} metadata={null} />)
expect(screen.getByText('Duration')).toBeTruthy()
@@ -46,9 +46,11 @@ describe('ToolDetailDialogContent — duration row', () => {
expect(screen.getByText('0.8s')).toBeTruthy()
})
it('does not show a Duration row while running (no completedAt)', () => {
it('shows start and live duration but no finish while running', () => {
renderWithI18n(<ToolDetailDialogContent block={makeBlock({ state: 'running', startedAt: 1000, completedAt: null })} metadata={null} />)
expect(screen.queryByText('Duration')).toBeNull()
expect(screen.getByText('Started')).toBeTruthy()
expect(screen.getByText('Duration')).toBeTruthy()
expect(screen.queryByText('Finished')).toBeNull()
})
it('does not show a Duration row on clock skew (completedAt precedes startedAt)', () => {
@@ -121,4 +123,15 @@ describe('ToolDetailDialogContent — duration row', () => {
expect(screen.getByText('Duration')).toBeTruthy()
expect(screen.getByText('2.5s')).toBeTruthy()
})
it('shows start and finish timestamps for a completed tool', () => {
renderWithI18n(<ToolDetailDialogContent block={makeBlock({
state: 'completed',
startedAt: 1_700_000_000_000,
completedAt: 1_700_000_002_500,
})} metadata={null} />)
expect(screen.getByText('Started')).toBeTruthy()
expect(screen.getByText('Finished')).toBeTruthy()
expect(screen.getByText('Duration')).toBeTruthy()
})
})
@@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { ToolTimingSummary } from '@/components/ToolCard/ToolCard'
import { I18nProvider } from '@/lib/i18n-context'
describe('ToolTimingSummary', () => {
it('renders aligned label/value pairs for completed timing', () => {
render(
<I18nProvider>
<ToolTimingSummary startedAt={1_700_000_000_000} completedAt={1_700_000_002_500} durationMs={2_500} />
</I18nProvider>
)
expect(screen.getByText('Started')).toBeInTheDocument()
expect(screen.getByText('Finished')).toBeInTheDocument()
expect(screen.getByText('Duration')).toBeInTheDocument()
expect(screen.getByText('2.5s')).toBeInTheDocument()
})
it('omits finish while timing is still live', () => {
render(
<I18nProvider>
<ToolTimingSummary startedAt={1_700_000_000_000} completedAt={null} durationMs={2_500} />
</I18nProvider>
)
expect(screen.getByText('Started')).toBeInTheDocument()
expect(screen.queryByText('Finished')).not.toBeInTheDocument()
expect(screen.getByText('Duration')).toBeInTheDocument()
})
it('supports the compact UI typography used by grouped cards', () => {
render(
<I18nProvider>
<ToolTimingSummary startedAt={1_700_000_000_000} completedAt={null} durationMs={2_500} typography="group" />
</I18nProvider>
)
const summary = screen.getByText('Started').parentElement?.parentElement
expect(summary).toHaveClass('text-xs', 'items-baseline')
expect(summary).not.toHaveClass('items-center', 'font-sans', 'font-normal', 'leading-5')
expect(summary).not.toHaveClass('font-mono')
})
})
+2
View File
@@ -416,6 +416,8 @@ export default {
'tool.trace.callsSuffix': 'calls',
'tool.result': 'Result',
'tool.duration': 'Duration',
'tool.startedAt': 'Started',
'tool.completedAt': 'Finished',
'tool.semanticTitle.readFile': 'Read file',
'tool.semanticTitle.runShell': 'Run shell',
'tool.semanticTitle.search': 'Search',
+2
View File
@@ -420,6 +420,8 @@ export default {
'tool.trace.callsSuffix': '次调用',
'tool.result': '结果',
'tool.duration': '耗时',
'tool.startedAt': '开始',
'tool.completedAt': '结束',
'tool.semanticTitle.readFile': '读取文件',
'tool.semanticTitle.runShell': '运行命令',
'tool.semanticTitle.search': '搜索',