From d51fcf2b1fd0ed4a86fe18a9008f88dfd3ce7a08 Mon Sep 17 00:00:00 2001 From: Bala Krishna Date: Sat, 6 Jun 2026 12:36:50 +0530 Subject: [PATCH 1/3] feat(tui): show wall-clock + elapsed time on every tool call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each tool block (BlockTool + InlineTool) now renders `HH:MM:SS · 1.2s` in the title row. Running tools tick the elapsed counter at 1Hz and freeze on completion; tools running >=30s switch to theme.warning so slow calls stand out at a glance. - Add Locale.clockTime() for 24-hour HH:MM:SS formatting. - Add useElapsed(part) hook: returns {start, ms, running}; live-ticks only while status === "running" and cleans up the interval on transition to completed/error. - Gate visibility behind the existing showTimestamps toggle; default flipped from "hide" -> "show". Toggle relabeled "Show tool timing" and aliased to /timing in addition to /timestamps. - Pending parts (no time.start yet) render no timing row, so the UX stays clean during the brief pre-running window. Tests: 2 new Locale.clockTime cases; existing TUI suite (157) still passes; tsgo typecheck clean. --- .../src/cli/cmd/tui/routes/session/index.tsx | 66 +++++++++++++++++-- packages/opencode/src/util/locale.ts | 5 ++ packages/opencode/test/util/locale.test.ts | 15 +++++ 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d86e7b740..958baac76 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -7,6 +7,7 @@ import { For, Match, on, + onCleanup, onMount, Show, Switch, @@ -152,7 +153,7 @@ export function Session() { const [sidebarOpen, setSidebarOpen] = createSignal(false) const [conceal, setConceal] = createSignal(true) const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true) - const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide") + const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "show") const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true) @@ -615,12 +616,12 @@ export function Session() { }, }, { - title: showTimestamps() ? "Hide timestamps" : "Show timestamps", + title: showTimestamps() ? "Hide tool timing" : "Show tool timing", value: "session.toggle.timestamps", category: "Session", slash: { name: "timestamps", - aliases: ["toggle-timestamps"], + aliases: ["toggle-timestamps", "timing", "toggle-timing"], }, onSelect: (dialog) => { setTimestamps((prev) => (prev === "show" ? "hide" : "show")) @@ -1690,6 +1691,26 @@ function ToolTitle(props: { fallback: string; when: any; icon: string; children: ) } +// Elapsed time (ms) for a tool part. Returns undefined for "pending" (no start yet), +// ticks at 1Hz while "running", freezes to end-start on "completed"/"error". +const SLOW_TOOL_MS = 30_000 +function useElapsed(part: () => ToolPart | undefined) { + const [now, setNow] = createSignal(Date.now()) + createEffect(() => { + if (part()?.state.status !== "running") return + const id = setInterval(() => setNow(Date.now()), 1000) + onCleanup(() => clearInterval(id)) + }) + return createMemo<{ start: number; ms: number; running: boolean } | undefined>(() => { + const p = part() + if (!p) return undefined + const s = p.state + if (s.status === "pending") return undefined + if (s.status === "running") return { start: s.time.start, ms: now() - s.time.start, running: true } + return { start: s.time.start, ms: s.time.end - s.time.start, running: false } + }) +} + function InlineTool(props: { icon: string iconColor?: RGBA @@ -1729,6 +1750,14 @@ function InlineTool(props: { error()?.includes("user dismissed"), ) + const elapsed = useElapsed(() => props.part) + const timingVisible = createMemo(() => ctx.showTimestamps() && !!elapsed()) + const timingColor = createMemo(() => { + const e = elapsed() + if (e?.running && e.ms >= SLOW_TOOL_MS) return theme.warning + return theme.textMuted + }) + return ( ~ {props.pending}} when={props.complete}> {props.icon} {props.children} + + + {" · "} + {Locale.clockTime(elapsed()!.start)} · {elapsed()!.running ? "running " : ""} + {Locale.duration(elapsed()!.ms)} + + @@ -1788,10 +1824,18 @@ function BlockTool(props: { part?: ToolPart spinner?: boolean }) { + const ctx = use() const { theme } = useTheme() const renderer = useRenderer() const [hover, setHover] = createSignal(false) const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined)) + const elapsed = useElapsed(() => props.part) + const timingVisible = createMemo(() => ctx.showTimestamps() && !!elapsed()) + const timingColor = createMemo(() => { + const e = elapsed() + if (e?.running && e.ms >= SLOW_TOOL_MS) return theme.warning + return theme.textMuted + }) return ( - {props.title} + {props.title} + + + {" "} + {Locale.clockTime(elapsed()!.start)} · {elapsed()!.running ? "running " : ""} + {Locale.duration(elapsed()!.ms)} + + } > - {props.title.replace(/^# /, "")} + + {props.title.replace(/^# /, "") + + (timingVisible() + ? ` ${Locale.clockTime(elapsed()!.start)} · ${elapsed()!.running ? "running " : ""}${Locale.duration(elapsed()!.ms)}` + : "")} + {props.children} diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index 487b8b7fa..dbbd8d880 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -8,6 +8,11 @@ export namespace Locale { return date.toLocaleTimeString(undefined, { timeStyle: "short" }) } + export function clockTime(input: number): string { + const date = new Date(input) + return date.toLocaleTimeString(undefined, { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }) + } + export function datetime(input: number): string { const date = new Date(input) const localTime = time(input) diff --git a/packages/opencode/test/util/locale.test.ts b/packages/opencode/test/util/locale.test.ts index 502b85b6a..60c574381 100644 --- a/packages/opencode/test/util/locale.test.ts +++ b/packages/opencode/test/util/locale.test.ts @@ -51,6 +51,21 @@ describe("Locale.duration", () => { }) }) +describe("Locale.clockTime", () => { + test("renders HH:MM:SS in 24-hour form", () => { + // 2024-01-15T03:04:05 in the host's local time, since the helper formats local time. + const d = new Date(2024, 0, 15, 3, 4, 5).getTime() + const out = Locale.clockTime(d) + expect(out).toMatch(/^\d{2}:\d{2}:\d{2}$/) + expect(out).toBe("03:04:05") + }) + + test("zero-pads single digit components", () => { + const d = new Date(2024, 0, 15, 9, 0, 7).getTime() + expect(Locale.clockTime(d)).toBe("09:00:07") + }) +}) + describe("Locale.truncateMiddle", () => { test("returns original if short enough", () => { expect(Locale.truncateMiddle("hello", 35)).toBe("hello") From c6e37585b96e42547c6d62574cb818e6c86db2cd Mon Sep 17 00:00:00 2001 From: Bala Krishna Date: Sat, 6 Jun 2026 13:43:46 +0530 Subject: [PATCH 2/3] feat(tui): show total tool time in the session sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggregates duration across every ToolPart in the session — completed and error tools by (end - start), the currently running tool by (now - start), live-ticking once a second only while a tool is running. Renders under the existing Context block as '… in tools' (with a '(running)' suffix when active), hidden when zero. --- .../cli/cmd/tui/routes/session/sidebar.tsx | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index e97c1797b..d5a038758 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,10 +1,10 @@ import { useSync } from "@tui/context/sync" -import { createMemo, For, Show, Switch, Match } from "solid-js" +import { createEffect, createMemo, createSignal, For, onCleanup, Show, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" import { Locale } from "@/util/locale" import path from "path" -import type { AssistantMessage } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, ToolPart } from "@opencode-ai/sdk/v2" import { Global } from "@/global" import { Installation } from "@/installation" import { useKeybind } from "../../context/keybind" @@ -62,6 +62,39 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { } }) + // altimate_change start - total tool time + // Flatten every tool part in this session so we can sum execution time across + // completed/error tools and live-tick any tool that's still running. + const toolParts = createMemo(() => { + const out: ToolPart[] = [] + for (const message of messages()) { + const parts = sync.data.part[message.id] + if (!parts) continue + for (const part of parts) { + if (part.type === "tool") out.push(part as ToolPart) + } + } + return out + }) + const hasRunningTool = createMemo(() => toolParts().some((p) => p.state.status === "running")) + const [nowToolTime, setNowToolTime] = createSignal(Date.now()) + createEffect(() => { + if (!hasRunningTool()) return + const id = setInterval(() => setNowToolTime(Date.now()), 1000) + onCleanup(() => clearInterval(id)) + }) + const toolTime = createMemo(() => { + let ms = 0 + const tick = nowToolTime() + for (const part of toolParts()) { + const s = part.state + if (s.status === "running") ms += tick - s.time.start + else if (s.status === "completed" || s.status === "error") ms += s.time.end - s.time.start + } + return ms + }) + // altimate_change end + const directory = useDirectory() const kv = useKV() @@ -107,6 +140,13 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {context()?.tokens ?? 0} tokens {context()?.percentage ?? 0}% used {cost()} spent + {/* altimate_change start - total tool time */} + 0}> + + {Locale.duration(toolTime())} in tools{hasRunningTool() ? " (running)" : ""} + + + {/* altimate_change end */} {/* altimate_change start - trace section */} From 612a111ac0cf2f41b5c8a0d768165743ae15edc2 Mon Sep 17 00:00:00 2001 From: Bala Krishna Date: Sat, 6 Jun 2026 14:59:58 +0530 Subject: [PATCH 3/3] refactor(tui): extract shared timing helpers for tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls SLOW_TOOL_MS, the Elapsed type, and the 'HH:MM:SS · running …' suffix builder into routes/session/timing.ts. Collapses three copies of the timing format string across InlineTool and BlockTool down to a single formatElapsed() call, and drops a redundant 'as ToolPart' cast in the sidebar — the part.type === 'tool' discriminant already narrows the union. --- .../src/cli/cmd/tui/routes/session/index.tsx | 15 +++++---------- .../src/cli/cmd/tui/routes/session/sidebar.tsx | 2 +- .../src/cli/cmd/tui/routes/session/timing.ts | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 11 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/timing.ts diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 958baac76..02fe9be50 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -34,6 +34,7 @@ import { Prompt, type PromptRef } from "@tui/component/prompt" import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" +import { SLOW_TOOL_MS, formatElapsed, type Elapsed } from "./timing" import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" @@ -1693,7 +1694,6 @@ function ToolTitle(props: { fallback: string; when: any; icon: string; children: // Elapsed time (ms) for a tool part. Returns undefined for "pending" (no start yet), // ticks at 1Hz while "running", freezes to end-start on "completed"/"error". -const SLOW_TOOL_MS = 30_000 function useElapsed(part: () => ToolPart | undefined) { const [now, setNow] = createSignal(Date.now()) createEffect(() => { @@ -1701,7 +1701,7 @@ function useElapsed(part: () => ToolPart | undefined) { const id = setInterval(() => setNow(Date.now()), 1000) onCleanup(() => clearInterval(id)) }) - return createMemo<{ start: number; ms: number; running: boolean } | undefined>(() => { + return createMemo(() => { const p = part() if (!p) return undefined const s = p.state @@ -1803,8 +1803,7 @@ function InlineTool(props: { {" · "} - {Locale.clockTime(elapsed()!.start)} · {elapsed()!.running ? "running " : ""} - {Locale.duration(elapsed()!.ms)} + {formatElapsed(elapsed()!)} @@ -1862,18 +1861,14 @@ function BlockTool(props: { {" "} - {Locale.clockTime(elapsed()!.start)} · {elapsed()!.running ? "running " : ""} - {Locale.duration(elapsed()!.ms)} + {formatElapsed(elapsed()!)} } > - {props.title.replace(/^# /, "") + - (timingVisible() - ? ` ${Locale.clockTime(elapsed()!.start)} · ${elapsed()!.running ? "running " : ""}${Locale.duration(elapsed()!.ms)}` - : "")} + {props.title.replace(/^# /, "") + (timingVisible() ? ` ${formatElapsed(elapsed()!)}` : "")} {props.children} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index d5a038758..dc8db18e3 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -71,7 +71,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const parts = sync.data.part[message.id] if (!parts) continue for (const part of parts) { - if (part.type === "tool") out.push(part as ToolPart) + if (part.type === "tool") out.push(part) } } return out diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/timing.ts b/packages/opencode/src/cli/cmd/tui/routes/session/timing.ts new file mode 100644 index 000000000..091bbdc09 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/timing.ts @@ -0,0 +1,17 @@ +import { Locale } from "@/util/locale" + +// Threshold above which a still-running tool is rendered in the warning color. +export const SLOW_TOOL_MS = 30_000 + +export interface Elapsed { + start: number + ms: number + running: boolean +} + +// Single source of truth for the "HH:MM:SS · running 12.3s" suffix shown +// next to a tool call. Separator/prefix lives at the call site because it +// differs by surface (inline vs block). +export function formatElapsed(e: Elapsed): string { + return `${Locale.clockTime(e.start)} · ${e.running ? "running " : ""}${Locale.duration(e.ms)}` +}