Skip to content

Commit ea714f8

Browse files
committed
feat(tui): tick a stopwatch on running tool calls and space prompt footers
A running tool call showed no time at all, so a long plugin call such as vibeterm_prompt_session gave no sense of how long it had been going. With footer.time and footer.duration on tool calls, a running call now shows when it started and a stopwatch in whole seconds (16:25:29 · 37s, 2m 5s); once it finishes it shows its end time and duration as before, now with tenths of a second up to 11 minutes (2m 5.3s, then 11m 0s). Running Task calls, drawn as a spinner, get the footer below it. Every running call reads one shared 1s clock (util/ticker.ts). It runs only while a computation holds it and stops on the first tick after the last one lets go, so an idle TUI sets no timer. A prompt's time and message ID now sit one blank line below its text, unless the text already ends with a blank line or file chips sit between them (userFooterGap). AI-Tool: opencode 1.18.34-vt-163-907b3bc518 AI-Model: anthropic/claude-opus-5-5 AI-Variant: high AI-Platform: linux AI-Harness: Vibeterm a823eaf AI-Session-ID: ses_ee68aab01ffei41gwUGJ5ESj6W
1 parent c69bc5b commit ea714f8

8 files changed

Lines changed: 182 additions & 14 deletions

File tree

‎packages/tui/src/config/index.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ export const Footer = Schema.Struct({
193193
),
194194
time: footerElement(
195195
[
196-
"When an entry happened: the time of day if today, the date and time otherwise.",
196+
"When an entry happened: the time of day if today, the date and time otherwise. A running tool call shows when it started.",
197197
"Applies to: user (prompt sent), assistant (turn finished, in its footer), text (text block finished, below it),",
198198
"reasoning (thinking block finished), tool (every tool call, plugin and MCP tools included, when it finished),",
199199
"error (turn failed, in its error box),",
@@ -205,7 +205,7 @@ export const Footer = Schema.Struct({
205205
duration: footerElement(
206206
[
207207
"How long an entry took. Applies to: assistant (the turn's own message), reasoning (the thinking block),",
208-
"tool (every tool call).",
208+
"tool (every tool call: while it runs, a stopwatch in whole seconds; once finished, tenths of a second up to 11 minutes).",
209209
"Default: reasoning; turns follow /turn-durations (turn_timing.duration); tool calls when the legacy",
210210
"timestamps list includes 'tool'.",
211211
].join(" "),

‎packages/tui/src/routes/session/index.tsx‎

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ import { usePathFormatter } from "../../context/path-format"
102102
import { LocationProvider } from "../../context/location"
103103
import { TuiLayout } from "../../layout"
104104
import { cellText, idUnder, markOwnIds } from "../../ui/id-click"
105+
import { secondTicker } from "../../util/ticker"
105106
import { onClick } from "../../ui/click"
106107
import { DialogModel } from "../../component/dialog-model"
107108

@@ -1740,7 +1741,11 @@ function UserMessage(props: {
17401741
</box>
17411742
</Show>
17421743
<Show when={metadataVisible()}>
1743-
<text ref={markOwnIds} fg={theme.textMuted}>
1744+
<text
1745+
ref={markOwnIds}
1746+
marginTop={userFooterGap(text(), files().length, time() || messageID()) ? 1 : 0}
1747+
fg={theme.textMuted}
1748+
>
17441749
<Show
17451750
when={queued()}
17461751
fallback={
@@ -2356,6 +2361,13 @@ export function InlineToolRow(props: {
23562361
<Switch>
23572362
<Match when={props.spinner}>
23582363
<Spinner color={props.color} children={props.children} />
2364+
<Show when={props.timestamp}>
2365+
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
2366+
<text ref={markOwnIds} fg={props.timestampColor}>
2367+
{props.timestamp}
2368+
</text>
2369+
</box>
2370+
</Show>
23592371
</Match>
23602372
<Match when={true}>
23612373
<Show
@@ -2751,11 +2763,13 @@ function toolFooter(ctx: ReturnType<typeof use>, part: ToolPart) {
27512763
const messageID = TuiConfig.toolFooterShowsMessageID((type) =>
27522764
type === "tool" ? shows("message_id") : ctx.footer("message_id", "assistant", part.messageID),
27532765
)
2766+
const duration = shows("duration")
27542767
return {
27552768
time: shows("time"),
2756-
duration: shows("duration"),
2769+
duration,
27572770
messageID: messageID ? part.messageID : undefined,
27582771
format: ctx.tui.datetime_format,
2772+
now: duration && part.state.status === "running" ? secondTicker.watch() : undefined,
27592773
}
27602774
}
27612775

@@ -2764,20 +2778,42 @@ function expandHint(overflow: boolean, expanded: boolean) {
27642778
return expanded ? "Click to collapse" : "Click to expand"
27652779
}
27662780

2781+
// A running call shows when it started and a whole-second stopwatch up to `now`; a finished one
2782+
// when it ended and how long it ran.
27672783
export function formatToolTimestamp(
27682784
state: ToolPart["state"],
2769-
show: { time: boolean; duration: boolean; messageID?: string; format?: Locale.DateTimeFormat } = {
2785+
show: {
2786+
time: boolean
2787+
duration: boolean
2788+
messageID?: string
2789+
format?: Locale.DateTimeFormat
2790+
now?: number
2791+
} = {
27702792
time: true,
27712793
duration: true,
27722794
},
27732795
) {
2774-
if (state.status !== "completed" && state.status !== "error") return
2775-
const details = [
2776-
show.time && Locale.todayTimeOrDateFirst(state.time.end, show.format),
2777-
show.duration && Locale.duration(Math.max(0, state.time.end - state.time.start)),
2778-
show.messageID,
2779-
].flatMap((detail) => (detail ? [detail] : []))
2780-
return details.length ? details.join(" · ") : undefined
2796+
if (state.status === "pending") return
2797+
const running = state.status === "running"
2798+
const details = running
2799+
? [
2800+
show.time && Locale.todayTimeOrDateFirst(state.time.start, show.format),
2801+
show.duration && show.now !== undefined && Locale.stopwatch(show.now - state.time.start),
2802+
show.messageID,
2803+
]
2804+
: [
2805+
show.time && Locale.todayTimeOrDateFirst(state.time.end, show.format),
2806+
show.duration && Locale.duration(Math.max(0, state.time.end - state.time.start), true),
2807+
show.messageID,
2808+
]
2809+
const shown = details.flatMap((detail) => (detail ? [detail] : []))
2810+
return shown.length ? shown.join(" · ") : undefined
2811+
}
2812+
2813+
// A prompt's footer (time, message ID) sits one blank line below its text, unless the file chips
2814+
// already pad it or the text itself ends in a blank line.
2815+
export function userFooterGap(text: string, files: number, footer: boolean) {
2816+
return footer && files === 0 && !/\n[ \t]*$/.test(text)
27812817
}
27822818

27832819
export function formatSubagentToolcalls(count: number) {

‎packages/tui/src/util/locale.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,18 @@ export function number(num: number): string {
7979
return num.toString()
8080
}
8181

82-
export function duration(input: number) {
82+
// `tenths` keeps the tenth of a second past the first minute, up to 11 minutes: `2m 5.3s`.
83+
export function duration(input: number, tenths = false) {
8384
if (input < 1000) {
8485
return `${input}ms`
8586
}
8687
if (input < 60000) {
8788
return `${(input / 1000).toFixed(1)}s`
8889
}
90+
if (tenths && input < 660000) {
91+
const minutes = Math.floor(input / 60000)
92+
return `${minutes}m ${(Math.floor((input % 60000) / 100) / 10).toFixed(1)}s`
93+
}
8994
if (input < 3600000) {
9095
const minutes = Math.floor(input / 60000)
9196
const seconds = Math.floor((input % 60000) / 1000)
@@ -101,6 +106,12 @@ export function duration(input: number) {
101106
return `${days}d ${hours}h`
102107
}
103108

109+
// Elapsed time of something still running, in whole seconds: `0s`, `37s`, `2m 5s`.
110+
export function stopwatch(input: number) {
111+
if (input < 60000) return `${Math.floor(Math.max(0, input) / 1000)}s`
112+
return duration(input)
113+
}
114+
104115
export function truncate(str: string, len: number): string {
105116
if (str.length <= len) return str
106117
return str.slice(0, len - 1) + "…"

‎packages/tui/src/util/ticker.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { createSignal, getOwner, onCleanup } from "solid-js"
2+
3+
// One clock shared by everything that ticks at the same interval. It runs only while something
4+
// holds it: the interval stops itself on the first tick after the last holder lets go, so a
5+
// holder released and re-taken in the same update (a memo re-running) does not restart it.
6+
export function createTicker(interval: number) {
7+
const [now, setNow] = createSignal(Date.now())
8+
let holders = 0
9+
let timer: ReturnType<typeof setInterval> | undefined
10+
11+
const hold = () => {
12+
holders++
13+
if (!timer) {
14+
setNow(Date.now())
15+
timer = setInterval(() => {
16+
if (holders > 0) return setNow(Date.now())
17+
clearInterval(timer)
18+
timer = undefined
19+
}, interval)
20+
}
21+
let held = true
22+
return () => {
23+
if (!held) return
24+
held = false
25+
holders--
26+
}
27+
}
28+
29+
return {
30+
now,
31+
hold,
32+
running: () => timer !== undefined,
33+
// The current time, held until the calling computation re-runs or is disposed.
34+
watch() {
35+
if (getOwner()) onCleanup(hold())
36+
return now()
37+
},
38+
}
39+
}
40+
41+
export const secondTicker = createTicker(1000)

‎packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
formatSubagentTitle,
99
formatSubagentToolcalls,
1010
formatToolTimestamp,
11+
userFooterGap,
1112
InlineToolRow,
1213
parseApplyPatchFiles,
1314
parseDiagnostics,
@@ -268,14 +269,37 @@ describe("TUI inline tool wrapping", () => {
268269
expect(lines).toContain("1:07 PM · 1.2s")
269270
})
270271

272+
test("formats a running tool call's start time and whole-second stopwatch", () => {
273+
const start = new Date()
274+
start.setHours(16, 25, 29, 0)
275+
const state = { status: "running" as const, input: {}, time: { start: start.getTime() } }
276+
const time = Locale.todayTimeOrDateFirst(start.getTime())
277+
expect(formatToolTimestamp(state, { time: true, duration: true, now: start.getTime() + 37_600 })).toBe(
278+
`${time} · 37s`,
279+
)
280+
expect(formatToolTimestamp(state, { time: true, duration: true, now: start.getTime() + 125_000 })).toBe(
281+
`${time} · 2m 5s`,
282+
)
283+
expect(formatToolTimestamp(state, { time: false, duration: false })).toBeUndefined()
284+
expect(formatToolTimestamp({ status: "pending", input: {}, raw: "" })).toBeUndefined()
285+
})
286+
287+
test("a prompt's footer sits one blank line below its text", () => {
288+
expect(userFooterGap("hello", 0, true)).toBe(true)
289+
expect(userFooterGap("hello\n", 0, true)).toBe(false)
290+
expect(userFooterGap("hello\n ", 0, true)).toBe(false)
291+
expect(userFooterGap("hello", 1, true)).toBe(false)
292+
expect(userFooterGap("hello", 0, false)).toBe(false)
293+
})
294+
271295
test("formats a finished tool call's end time and duration", () => {
272296
const end = new Date()
273297
end.setHours(13, 7, 0, 0)
274298
const start = end.getTime() - 1234
275299
const input = {}
276300
const time = Locale.todayTimeOrDateFirst(end.getTime())
277301
expect(formatToolTimestamp({ status: "pending", input, raw: "" })).toBeUndefined()
278-
expect(formatToolTimestamp({ status: "running", input, time: { start } })).toBeUndefined()
302+
expect(formatToolTimestamp({ status: "running", input, time: { start } })).toBe(Locale.todayTimeOrDateFirst(start))
279303
expect(
280304
formatToolTimestamp({
281305
status: "completed",

‎packages/tui/test/util/locale.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,19 @@ test("an unset datetime_format part keeps the locale output", () => {
4141
)
4242
expect(Locale.date(yesterday, { time: "HH" })).toBe(new Date(yesterday).toLocaleDateString())
4343
})
44+
45+
test("a stopwatch shows whole seconds while running", () => {
46+
expect(Locale.stopwatch(0)).toBe("0s")
47+
expect(Locale.stopwatch(999)).toBe("0s")
48+
expect(Locale.stopwatch(37_900)).toBe("37s")
49+
expect(Locale.stopwatch(125_400)).toBe("2m 5s")
50+
expect(Locale.stopwatch(-50)).toBe("0s")
51+
})
52+
53+
test("a finished duration keeps one decimal up to 11 minutes", () => {
54+
expect(Locale.duration(4_830, true)).toBe("4.8s")
55+
expect(Locale.duration(125_370, true)).toBe("2m 5.3s")
56+
expect(Locale.duration(659_990, true)).toBe("10m 59.9s")
57+
expect(Locale.duration(660_000, true)).toBe("11m 0s")
58+
expect(Locale.duration(125_370)).toBe("2m 5s")
59+
})
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { expect, test } from "bun:test"
2+
import { createMemo, createRoot, createSignal } from "solid-js"
3+
import { createTicker } from "../../src/util/ticker"
4+
5+
test("the ticker runs only while held and stops on the first tick after release", async () => {
6+
const ticker = createTicker(10)
7+
expect(ticker.running()).toBe(false)
8+
const release = ticker.hold()
9+
expect(ticker.running()).toBe(true)
10+
const start = ticker.now()
11+
await Bun.sleep(40)
12+
expect(ticker.now()).toBeGreaterThan(start)
13+
release()
14+
release()
15+
expect(ticker.running()).toBe(true)
16+
await Bun.sleep(30)
17+
expect(ticker.running()).toBe(false)
18+
})
19+
20+
test("one ticker serves every watcher and stops once none is left", async () => {
21+
const ticker = createTicker(10)
22+
const [running, setRunning] = createSignal(true)
23+
const dispose = createRoot((dispose) => {
24+
createMemo(() => (running() ? ticker.watch() : undefined))
25+
createMemo(() => ticker.watch())
26+
return dispose
27+
})
28+
await Bun.sleep(30)
29+
expect(ticker.running()).toBe(true)
30+
setRunning(false)
31+
await Bun.sleep(30)
32+
expect(ticker.running()).toBe(true)
33+
dispose()
34+
await Bun.sleep(30)
35+
expect(ticker.running()).toBe(false)
36+
})

‎packages/web/src/content/docs/tui.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,10 @@ A type a key does not apply to has no effect, so `"all"` and `"important"` selec
506506

507507
Each message ID appears once. A tool call belongs to its assistant message, so the tool call shows the message's ID only when the message's own footer does not: with `"message_id": "tool"`, or when `"important"` leaves that step's footer out. With `"all"`, the ID is in the assistant footer and the tool calls show only their time and duration.
508508

509+
While a tool call runs, its footer shows when it started and a stopwatch that ticks every second in whole seconds: `16:25:29 · 37s`, `16:25:29 · 2m 5s`. Once it finishes, the footer shows when it finished and how long it ran, with tenths of a second up to 11 minutes: `4.8s`, `2m 5.3s`, then `11m 0s`. One clock drives every running call, and it stops when none is running.
510+
511+
A prompt's footer sits one blank line below the prompt's text, unless the text already ends with a blank line or file chips sit between them.
512+
509513
```json title="tui.json"
510514
{
511515
"footer": {

0 commit comments

Comments
 (0)