Skip to content

Commit b0c1a0a

Browse files
committed
feat: show timestamp and duration on tool execution blocks
Display clock time and running duration on every tool execution block in both web UI and TUI. Interrupted tools show 'Interrupted' in red instead of a misleading duration. Closes #22144
1 parent 74b14a2 commit b0c1a0a

File tree

8 files changed

+477
-6
lines changed

8 files changed

+477
-6
lines changed

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
For,
88
Match,
99
on,
10+
onCleanup,
1011
onMount,
1112
Show,
1213
Switch,
@@ -1682,6 +1683,9 @@ function InlineTool(props: {
16821683
error()?.includes("user dismissed"),
16831684
)
16841685

1686+
const timing = createTiming(() => props.part)
1687+
const interrupted = createMemo(() => isToolInterrupted(props.part))
1688+
16851689
return (
16861690
<box
16871691
marginTop={margin()}
@@ -1717,13 +1721,21 @@ function InlineTool(props: {
17171721
>
17181722
<Switch>
17191723
<Match when={props.spinner}>
1720-
<Spinner color={fg()} children={props.children} />
1724+
<Spinner color={fg()}>
1725+
{props.children}
1726+
<Show when={timing()}>
1727+
<span style={{ fg: interrupted() ? theme.error : theme.textMuted }}> · {timing()}</span>
1728+
</Show>
1729+
</Spinner>
17211730
</Match>
17221731
<Match when={true}>
17231732
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
17241733
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
17251734
<span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
17261735
</Show>
1736+
<Show when={timing()}>
1737+
<span style={{ fg: interrupted() ? theme.error : theme.textMuted }}> · {timing()}</span>
1738+
</Show>
17271739
</text>
17281740
</Match>
17291741
</Switch>
@@ -1734,6 +1746,65 @@ function InlineTool(props: {
17341746
)
17351747
}
17361748

1749+
function createTiming(part: () => ToolPart | undefined) {
1750+
const [now, setNow] = createSignal(Date.now())
1751+
const [displayMs, setDisplayMs] = createSignal(0)
1752+
const running = createMemo(() => part()?.state.status === "running")
1753+
1754+
createEffect(
1755+
on(
1756+
() => part()?.id,
1757+
() => {
1758+
setDisplayMs(0)
1759+
},
1760+
{ defer: true },
1761+
),
1762+
)
1763+
1764+
createEffect(() => {
1765+
if (!running()) return
1766+
setNow(Date.now())
1767+
const interval = setInterval(() => setNow(Date.now()), 1000)
1768+
onCleanup(() => clearInterval(interval))
1769+
})
1770+
1771+
createEffect(() => {
1772+
const p = part()
1773+
if (!p) return
1774+
const start = toolStartTime(p)
1775+
if (typeof start !== "number") return
1776+
const end = toolEndTime(p)
1777+
const finish = typeof end === "number" ? end : running() ? now() : undefined
1778+
if (typeof finish !== "number") return
1779+
setDisplayMs((prev) => Math.max(prev, finish - start))
1780+
})
1781+
1782+
return createMemo(() => {
1783+
const p = part()
1784+
if (!p) return ""
1785+
const start = toolStartTime(p)
1786+
if (typeof start !== "number") return ""
1787+
1788+
if (isToolInterrupted(p)) {
1789+
return formatToolClock(start) + " · Interrupted"
1790+
}
1791+
1792+
const parts = [formatToolClock(start)]
1793+
if (displayMs() > 0) {
1794+
parts.push(formatToolRuntime(displayMs()))
1795+
return parts.join(" · ")
1796+
}
1797+
1798+
const end = toolEndTime(p)
1799+
if (typeof end === "number") {
1800+
parts.push(formatToolRuntime(Math.max(1, end - start)))
1801+
return parts.join(" · ")
1802+
}
1803+
1804+
return parts.join(" · ")
1805+
})
1806+
}
1807+
17371808
function BlockTool(props: {
17381809
title: string
17391810
children: JSX.Element
@@ -1745,6 +1816,9 @@ function BlockTool(props: {
17451816
const renderer = useRenderer()
17461817
const [hover, setHover] = createSignal(false)
17471818
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
1819+
const timing = createTiming(() => props.part)
1820+
const interrupted = createMemo(() => (props.part ? isToolInterrupted(props.part) : false))
1821+
17481822
return (
17491823
<box
17501824
border={["left"]}
@@ -1768,10 +1842,18 @@ function BlockTool(props: {
17681842
fallback={
17691843
<text paddingLeft={3} fg={theme.textMuted}>
17701844
{props.title}
1845+
<Show when={timing()}>
1846+
<span style={{ fg: interrupted() ? theme.error : theme.textMuted }}> · {timing()}</span>
1847+
</Show>
17711848
</text>
17721849
}
17731850
>
1774-
<Spinner color={theme.textMuted}>{props.title.replace(/^# /, "")}</Spinner>
1851+
<Spinner color={theme.textMuted}>
1852+
{props.title.replace(/^# /, "")}
1853+
<Show when={timing()}>
1854+
<span style={{ fg: interrupted() ? theme.error : theme.textMuted }}> · {timing()}</span>
1855+
</Show>
1856+
</Spinner>
17751857
</Show>
17761858
{props.children}
17771859
<Show when={error()}>
@@ -1781,6 +1863,49 @@ function BlockTool(props: {
17811863
)
17821864
}
17831865

1866+
function toolStartTime(part: ToolPart): number | undefined {
1867+
switch (part.state.status) {
1868+
case "running":
1869+
case "completed":
1870+
case "error":
1871+
return part.state.time.start
1872+
default:
1873+
return undefined
1874+
}
1875+
}
1876+
1877+
function toolEndTime(part: ToolPart): number | undefined {
1878+
switch (part.state.status) {
1879+
case "completed":
1880+
case "error":
1881+
return part.state.time.end
1882+
default:
1883+
return undefined
1884+
}
1885+
}
1886+
1887+
function isToolInterrupted(part: ToolPart): boolean {
1888+
if (part.state.status !== "error") return false
1889+
return part.state.metadata?.interrupted === true
1890+
}
1891+
1892+
function formatToolClock(input: number) {
1893+
return new Intl.DateTimeFormat(undefined, {
1894+
hour: "2-digit",
1895+
minute: "2-digit",
1896+
}).format(input)
1897+
}
1898+
1899+
function formatToolRuntime(input: number) {
1900+
const total = input <= 0 ? 0 : Math.max(1, Math.round(input / 1000))
1901+
if (total < 60) return `${total}s`
1902+
const hours = Math.floor(total / 3600)
1903+
const minutes = Math.floor((total % 3600) / 60)
1904+
const seconds = total % 60
1905+
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`
1906+
return `${minutes}m ${seconds}s`
1907+
}
1908+
17841909
function Bash(props: ToolProps<typeof BashTool>) {
17851910
const { theme } = useTheme()
17861911
const sync = useSync()

packages/opencode/src/session/prompt.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,14 +371,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
371371
metadata: (val) =>
372372
input.processor.updateToolCall(options.toolCallId, (match) => {
373373
if (!["running", "pending"].includes(match.state.status)) return match
374+
const start = match.state.status === "running" ? match.state.time.start : Date.now()
374375
return {
375376
...match,
376377
state: {
377378
title: val.title,
378379
metadata: val.metadata,
379380
status: "running",
380381
input: args,
381-
time: { start: Date.now() },
382+
time: { start },
382383
},
383384
}
384385
}),
@@ -632,7 +633,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
632633
status: "error",
633634
error: "Cancelled",
634635
time: { start: part.state.time.start, end: Date.now() },
635-
metadata: part.state.metadata,
636+
metadata: { ...part.state.metadata, interrupted: true },
636637
input: part.state.input,
637638
},
638639
} satisfies MessageV2.ToolPart)

packages/ui/src/components/basic-tool.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,19 @@
158158
}
159159
}
160160

161+
[data-slot="basic-tool-tool-meta"] {
162+
flex-shrink: 0;
163+
white-space: nowrap;
164+
font-family: var(--font-family-sans);
165+
font-variant-numeric: tabular-nums;
166+
font-size: 14px;
167+
font-style: normal;
168+
font-weight: var(--font-weight-regular);
169+
line-height: var(--line-height-large);
170+
letter-spacing: var(--letter-spacing-normal);
171+
color: var(--text-weak);
172+
}
173+
161174
[data-slot="basic-tool-tool-arg"] {
162175
flex-shrink: 1;
163176
min-width: 0;

packages/ui/src/components/basic-tool.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { TextShimmer } from "./text-shimmer"
99
export type TriggerTitle = {
1010
title: string
1111
titleClass?: string
12+
meta?: string
13+
metaClass?: string
1214
subtitle?: string
1315
subtitleClass?: string
1416
args?: string[]
@@ -145,6 +147,16 @@ export function BasicTool(props: BasicToolProps) {
145147
>
146148
<TextShimmer text={title().title} active={pending()} />
147149
</span>
150+
<Show when={title().meta}>
151+
<span
152+
data-slot="basic-tool-tool-meta"
153+
classList={{
154+
[title().metaClass ?? ""]: !!title().metaClass,
155+
}}
156+
>
157+
{title().meta}
158+
</span>
159+
</Show>
148160
<Show when={!pending()}>
149161
<Show when={title().subtitle}>
150162
<span
@@ -265,6 +277,7 @@ export function GenericTool(props: {
265277
status?: string
266278
hideDetails?: boolean
267279
input?: Record<string, unknown>
280+
metaText?: string
268281
}) {
269282
const i18n = useI18n()
270283

@@ -274,6 +287,7 @@ export function GenericTool(props: {
274287
status={props.status}
275288
trigger={{
276289
title: i18n.t("ui.basicTool.called", { tool: props.tool }),
290+
meta: props.metaText,
277291
subtitle: label(props.input),
278292
args: args(props.input),
279293
}}

0 commit comments

Comments
 (0)