From 331aabf461cbdc4a989ab4ac297e0a028eea9846 Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Mon, 20 Jul 2026 12:36:47 +0200 Subject: [PATCH] feat(tui): show assistant turn completion time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appends the wall-clock completion time after the duration in the TUI turn-summary line, the scrollback turn-summary commit, and session replay. Uses the existing Locale.time() helper which respects the user's locale settings. Closes #35348 Revives #32771 (auto-closed by the needs:compliance bot, not rejected on merits — original implementation and tests are preserved here). Co-authored-by: joeyparis Co-authored-by: Sisyphus --- packages/opencode/src/cli/cmd/run/footer.ts | 1 + .../opencode/src/cli/cmd/run/runtime.queue.ts | 4 +++- .../src/cli/cmd/run/scrollback.surface.ts | 2 +- .../src/cli/cmd/run/scrollback.writer.tsx | 4 ++-- .../opencode/src/cli/cmd/run/turn-summary.ts | 5 ++++- packages/opencode/src/cli/cmd/run/types.ts | 2 ++ .../test/cli/run/scrollback.surface.test.ts | 17 ++++++++++++++++ .../test/cli/run/session-replay.test.ts | 9 ++++++--- .../test/cli/run/turn-summary.test.ts | 20 +++++++++++++++++++ packages/tui/src/routes/session/index.tsx | 6 ++++++ .../inline-tool-wrap-snapshot.test.tsx.snap | 2 +- .../tui/inline-tool-wrap-snapshot.test.tsx | 2 +- 12 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 packages/opencode/test/cli/run/turn-summary.test.ts diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index 0d9da6f297c5..c15a64de73b7 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -397,6 +397,7 @@ export class RunFooter implements FooterApi { agent: this.options.agentLabel, model: current ? modelInfo(this.providers(), current).model : this.state().model, duration: next.duration, + time: next.time, }), ) .catch((error) => { diff --git a/packages/opencode/src/cli/cmd/run/runtime.queue.ts b/packages/opencode/src/cli/cmd/run/runtime.queue.ts index d29e03672a20..edc588068f6a 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.queue.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.queue.ts @@ -229,11 +229,13 @@ export async function runPromptQueue(input: QueueInput): Promise { } if (sent.mode !== "shell") { - const duration = Locale.duration(Math.max(0, Date.now() - start)) + const end = Date.now() + const duration = Locale.duration(Math.max(0, end - start)) emit( { type: "turn.duration", duration, + time: Locale.time(end), }, { duration, diff --git a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts b/packages/opencode/src/cli/cmd/run/scrollback.surface.ts index f8516a054cc2..6d42a11bde34 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts +++ b/packages/opencode/src/cli/cmd/run/scrollback.surface.ts @@ -420,7 +420,7 @@ export class RunScrollbackStream { this.markRendered(await this.finishActive(trailingNewline)) } - public async writeTurnSummary(input: { agent: string; model: string; duration: string }): Promise { + public async writeTurnSummary(input: { agent: string; model: string; duration: string; time?: string }): Promise { await this.append(turnSummaryCommit(input)) } diff --git a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx index 43176ded79bb..d4796f5d7538 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx +++ b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx @@ -333,7 +333,7 @@ export function spacerWriter(): ScrollbackWriter { }) } -export function turnSummaryWriter(input: { agent: string; model: string; duration: string; theme: RunTheme }) { +export function turnSummaryWriter(input: { agent: string; model: string; duration: string; time?: string; theme: RunTheme }) { return createScrollbackWriter( () => ( @@ -342,7 +342,7 @@ export function turnSummaryWriter(input: { agent: string; model: string; duratio {input.agent} {" "} - · {input.model} · {input.duration} + · {input.model} · {input.duration}{input.time ? ` · ${input.time}` : ""} diff --git a/packages/opencode/src/cli/cmd/run/turn-summary.ts b/packages/opencode/src/cli/cmd/run/turn-summary.ts index 95284dfa0c26..32faacebc383 100644 --- a/packages/opencode/src/cli/cmd/run/turn-summary.ts +++ b/packages/opencode/src/cli/cmd/run/turn-summary.ts @@ -6,17 +6,19 @@ export function turnSummaryCommit(input: { agent: string model: string duration: string + time?: string messageID?: string }): StreamCommit { return { kind: "system", - text: `▣ ${input.agent} · ${input.model} · ${input.duration}`, + text: `▣ ${input.agent} · ${input.model} · ${input.duration}${input.time ? ` · ${input.time}` : ""}`, phase: "final", source: "system", summary: { agent: input.agent, model: input.model, duration: input.duration, + time: input.time, }, messageID: input.messageID, } @@ -42,6 +44,7 @@ export function messageTurnSummaryCommit( agent: Locale.titlecase(info.agent), model: model ?? info.modelID, duration: Locale.duration(completed - info.time.created), + time: Locale.time(completed), messageID: info.id, }) } diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index a914922e4870..2db251a57cdc 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -101,6 +101,7 @@ export type TurnSummary = { agent: string model: string duration: string + time?: string } export type ScrollbackOptions = { @@ -266,6 +267,7 @@ export type FooterEvent = | { type: "turn.duration" duration: string + time: string } | { type: "stream.patch" diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index 52ff5a354d58..4d45095781ae 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -128,6 +128,23 @@ test("turn summary starts at the left edge", async () => { } }) +test("turn summary appends completion time after duration", async () => { + const out = await setup() + + try { + await out.scrollback.writeTurnSummary({ agent: "Build", model: "Little Frank", duration: "2.2s", time: "3:41 PM" }) + + const commits = claim(out.renderer) + try { + expect(renderRows(commits.at(-1)!)[0]).toBe("▣ Build · Little Frank · 2.2s · 3:41 PM") + } finally { + destroy(commits) + } + } finally { + out.scrollback.destroy() + } +}) + test("theme swaps restyle active reasoning without resetting the stream", async () => { const previousSyntax = SyntaxStyle.fromStyles({ default: { fg: "#123456" } }) const nextSyntax = SyntaxStyle.fromStyles({ default: { fg: "#abcdef" } }) diff --git a/packages/opencode/test/cli/run/session-replay.test.ts b/packages/opencode/test/cli/run/session-replay.test.ts index e3356d18313c..a49d864b45db 100644 --- a/packages/opencode/test/cli/run/session-replay.test.ts +++ b/packages/opencode/test/cli/run/session-replay.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { replayLocalRows, replaySession } from "@/cli/cmd/run/session-replay" import type { SessionMessages } from "@/cli/cmd/run/session.shared" import type { RunProvider } from "@/cli/cmd/run/types" +import * as Locale from "@/util/locale" function userMessage(id: string, text: string): SessionMessages[number] { return { @@ -279,7 +280,7 @@ describe("run session replay", () => { }), expect.objectContaining({ kind: "system", - text: "▣ Build · gpt-5 · 2.8s", + text: `▣ Build · gpt-5 · 2.8s · ${Locale.time(3000)}`, phase: "final", source: "system", messageID: "msg-1", @@ -287,6 +288,7 @@ describe("run session replay", () => { agent: "Build", model: "gpt-5", duration: "2.8s", + time: Locale.time(3000), }, }), ]) @@ -314,11 +316,12 @@ describe("run session replay", () => { expect(out.commits.at(-1)).toEqual( expect.objectContaining({ kind: "system", - text: "▣ Build · Little Frank · 2.8s", + text: `▣ Build · Little Frank · 2.8s · ${Locale.time(3000)}`, summary: { agent: "Build", model: "Little Frank", duration: "2.8s", + time: Locale.time(3000), }, }), ) @@ -346,7 +349,7 @@ describe("run session replay", () => { expect(out.commits.filter((commit) => commit.summary)).toEqual([ expect.objectContaining({ kind: "system", - text: "▣ Build · gpt-5 · 2.0s", + text: `▣ Build · gpt-5 · 2.0s · ${Locale.time(3000)}`, messageID: "msg-step-2", }), ]) diff --git a/packages/opencode/test/cli/run/turn-summary.test.ts b/packages/opencode/test/cli/run/turn-summary.test.ts new file mode 100644 index 000000000000..a06afd246c5e --- /dev/null +++ b/packages/opencode/test/cli/run/turn-summary.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "bun:test" +import { turnSummaryCommit } from "@/cli/cmd/run/turn-summary" + +test("turn summary commit appends completion time after duration", () => { + const commit = turnSummaryCommit({ agent: "Sisyphus - Ultraworker", model: "GPT-5.5", duration: "6.2s", time: "3:41 PM" }) + + expect(commit.text).toBe("▣ Sisyphus - Ultraworker · GPT-5.5 · 6.2s · 3:41 PM") + expect(commit.summary).toEqual({ + agent: "Sisyphus - Ultraworker", + model: "GPT-5.5", + duration: "6.2s", + time: "3:41 PM", + }) +}) + +test("turn summary commit keeps existing text without completion time", () => { + expect(turnSummaryCommit({ agent: "Sisyphus - Ultraworker", model: "GPT-5.5", duration: "6.2s" }).text).toBe( + "▣ Sisyphus - Ultraworker · GPT-5.5 · 6.2s", + ) +}) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6d77b0ea58fd..cb29cd8a0e92 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1471,6 +1471,11 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las if (!user || !user.time) return 0 return props.message.time.completed - user.time.created }) + const completedTime = createMemo(() => { + if (!duration()) return "" + if (!props.message.time.completed) return "" + return Locale.time(props.message.time.completed) + }) const childShortcut = useCommandShortcut("session.child.first") const backgroundShortcut = useCommandShortcut("session.background") @@ -1549,6 +1554,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las · {model()} · {Locale.duration(duration())} + · {completedTime()} · interrupted diff --git a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap index 46c48ef325f5..dbb21db7949b 100644 --- a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +++ b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap @@ -76,7 +76,7 @@ exports[`TUI inline tool wrapping separates a task row from a preceding inline d `; exports[`TUI inline tool wrapping separates an inline row from the previous assistant summary 1`] = ` -" ▣ Build · Little Frank · 53.1s +" ▣ Build · Little Frank · 53.1s · 3:41 PM ✓ Build Task — Review changes ↳ 48 toolcalls · 1m 40s" diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index 8ba730906ae0..fb2be3ac7e35 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -144,7 +144,7 @@ function AssistantSummaryBeforeInlineFixture() { return ( alwaysSeparate.add(el)} paddingLeft={3}> - ▣ Build · Little Frank · 53.1s + ▣ Build · Little Frank · 53.1s · 3:41 PM {"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}