From dd6e8f093e305a1b3fd1e5645ab51b5bb2715d3f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 20 Feb 2026 17:39:46 -0500 Subject: [PATCH 1/3] feat(tui): show tokens per second in assistant message footer --- .../src/cli/cmd/tui/routes/session/index.tsx | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 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 f5a7f6f6ca49..72728463423e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1101,6 +1101,7 @@ export function Session() { { - if (!final()) return 0 - if (!props.message.time.completed) return 0 - const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID) - if (!user || !user.time) return 0 - return props.message.time.completed - user.time.created + const stats = createMemo(() => { + if (!final() || !props.message.time.completed) return null + + const list = messages() + let tokens = props.message.tokens?.output || 0 + + for (let i = props.index - 1; i >= 0; i--) { + const msg = list[i] + if (msg.id === props.message.parentID && msg.role === "user") { + if (!msg.time?.created) return null + return { + duration: props.message.time.completed - msg.time.created, + tokens, + } + } + if (msg.role === "assistant") { + tokens += msg.tokens?.output || 0 + } + } + + return null }) return ( @@ -1334,8 +1350,14 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las {" "} {Locale.titlecase(props.message.mode)} · {props.message.modelID} - - · {Locale.duration(duration())} + + {(s) => ( + + {" "} + · {Locale.duration(s().duration)} + 0}> · {(s().tokens / (s().duration / 1000)).toFixed(1)} tok/s + + )} · interrupted From daac4e559975a164df8bf3222c1ce75af9146c00 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 21 Feb 2026 13:21:22 -0500 Subject: [PATCH 2/3] tui: show streaming tokens-per-second in real-time during model responses Previously, users only saw tok/s metrics after a message completed. Now the TUI displays live streaming speed that updates throughout the response, giving immediate feedback on model performance. --- .../src/cli/cmd/tui/routes/session/index.tsx | 24 ++++++++++++------- packages/opencode/src/session/message-v2.ts | 2 ++ packages/opencode/src/session/processor.ts | 6 +++++ packages/sdk/js/src/v2/gen/types.gen.ts | 2 ++ 4 files changed, 26 insertions(+), 8 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 72728463423e..b7ff2ff3e449 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1281,22 +1281,30 @@ function AssistantMessage(props: { index: number; message: AssistantMessage; par }) const stats = createMemo(() => { - if (!final() || !props.message.time.completed) return null + // if (!final() || !props.message.time.completed) return null const list = messages() - let tokens = props.message.tokens?.output || 0 + const stats = { + duration: 0, + tps: [] as number[], + } - for (let i = props.index - 1; i >= 0; i--) { + for (let i = props.index; i >= 0; i--) { const msg = list[i] + if (msg.id === props.message.parentID && msg.role === "user") { - if (!msg.time?.created) return null + stats.duration = (props.message.time.completed ?? Date.now()) - msg.time.created return { - duration: props.message.time.completed - msg.time.created, - tokens, + duration: (props.message.time.completed ?? Date.now()) - msg.time.created, + tps: stats.tps.reduce((sum, x) => sum + x, 0) / stats.tps.length, } } if (msg.role === "assistant") { - tokens += msg.tokens?.output || 0 + if (msg.tokens.output && msg.time.started && msg.time.streamed) { + const duration = msg.time.streamed - msg.time.started + const tps = msg.tokens.output / (duration / 1000) + stats.tps.push(tps) + } } } @@ -1355,7 +1363,7 @@ function AssistantMessage(props: { index: number; message: AssistantMessage; par {" "} · {Locale.duration(s().duration)} - 0}> · {(s().tokens / (s().duration / 1000)).toFixed(1)} tok/s + 0}> · {s().tps.toFixed(0)} tok/s )} diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 178751a2227a..dc7acdc7b316 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -392,6 +392,8 @@ export namespace MessageV2 { role: z.literal("assistant"), time: z.object({ created: z.number(), + started: z.number().optional(), + streamed: z.number().optional(), completed: z.number().optional(), }), error: z diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index e7532d20073b..4dfaef0fe65b 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -57,6 +57,8 @@ export namespace SessionProcessor { switch (value.type) { case "start": SessionStatus.set(input.sessionID, { type: "busy" }) + input.assistantMessage.time.started = Date.now() + await Session.updateMessage(input.assistantMessage) break case "reasoning-start": @@ -334,12 +336,16 @@ export namespace SessionProcessor { await Session.updatePart(currentText) } currentText = undefined + input.assistantMessage.time.streamed = Date.now() + await Session.updateMessage(input.assistantMessage) break case "finish": break default: + type: value.type, + }) log.info("unhandled", { ...value, }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4050ef15738c..b7e8ddc57fa6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -207,6 +207,8 @@ export type AssistantMessage = { role: "assistant" time: { created: number + started?: number + streamed?: number completed?: number } error?: From 9d7cc2c6dbe1367b1b6e33395508c541f7f8cd84 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 21 Feb 2026 14:13:05 -0500 Subject: [PATCH 3/3] fix --- packages/opencode/src/session/processor.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 4dfaef0fe65b..3d1bf6aeb4aa 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -344,8 +344,6 @@ export namespace SessionProcessor { break default: - type: value.type, - }) log.info("unhandled", { ...value, })