From c27724fafc34baa5367642fffe3efd8b7eee2f3e Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Wed, 24 Dec 2025 14:02:28 +0100 Subject: [PATCH 1/5] feat: Added experimental TPS for model responses [FEATURE]: Adding Experimental Calculation and Display of Tokens per second Fixes #6096 --- .../src/cli/cmd/tui/routes/session/index.tsx | 64 +++++++++++++++++++ packages/opencode/src/flag/flag.ts | 1 + packages/opencode/src/session/processor.ts | 2 +- 3 files changed, 66 insertions(+), 1 deletion(-) 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 c685d8c66cc..dbd45ed905d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -69,6 +69,7 @@ import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { Filesystem } from "@/util/filesystem" import { DialogSubagent } from "./dialog-subagent.tsx" +import { Flag } from "@/flag/flag.ts" addDefaultParsers(parsers.parsers) @@ -1254,6 +1255,10 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las const sync = useSync() const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? []) + function getParts(messageID: string) { + return sync.data.part[messageID] ?? [] + } + const final = createMemo(() => { return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish) }) @@ -1266,6 +1271,62 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las return props.message.time.completed - user.time.created }) + const TPS = createMemo(() => { + if (!final()) return 0 + if (!props.message.time.completed) return 0 + if (!Flag.OPENCODE_EXPERIMENTAL_TPS) return 0 + + const allParts = getParts(props.message.id) + + const INVALID_REASONING_TEXTS = ["[REDACTED]", "", null, undefined] as const + + // Filter for actual streaming parts (reasoning + text), exclude tool/step markers + const streamingParts = allParts.filter((part): part is TextPart | ReasoningPart => { + // Only text and reasoning parts have streaming time data + if (part.type !== "text" && part.type !== "reasoning") return false + + // Skip parts without valid timestamps + if (!part.time?.start || !part.time?.end) return false + + // Include text parts with content + if (part.type === "text" && (part.text?.trim().length ?? 0) > 0) return true + + // Include reasoning parts with valid (non-empty) text + if (part.type === "reasoning" && !INVALID_REASONING_TEXTS.includes(part.text as any)) { + return true + } + + return false + }) + + if (streamingParts.length === 0) return 0 + + // Sum individual part durations (excludes tool execution time between parts) + let totalStreamingTimeMs = 0 + let hasValidReasoning = false + + for (const part of streamingParts) { + totalStreamingTimeMs += part.time!.end! - part.time!.start! + if (part.type === "reasoning") { + hasValidReasoning = true + } + } + + if (totalStreamingTimeMs === 0) return 0 + + // Only count reasoning tokens if valid reasoning exists + const totalTokens = + (hasValidReasoning ? props.message.tokens.reasoning : 0) + props.message.tokens.output + + if (totalTokens === 0) return 0 + + // Calculate tokens per second + const totalStreamingTimeSec = totalStreamingTimeMs / 1000 + const tokensPerSecond = totalTokens / totalStreamingTimeSec + + return Number(tokensPerSecond.toFixed(2)) + }) + return ( <> @@ -1307,6 +1368,9 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las · {Locale.duration(duration())} + + · {TPS()} tps + diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 805da33cc7a..0e1a9b8c60a 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -31,6 +31,7 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT") export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY") export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL") + export const OPENCODE_EXPERIMENTAL_TPS = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_TPS") function truthy(key: string) { const value = process.env[key]?.toLowerCase() diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 78871630c65..dde39cd9fc8 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -321,7 +321,7 @@ export namespace SessionProcessor { ) currentText.text = textOutput.text currentText.time = { - start: Date.now(), + start: currentText.time?.start ?? Date.now(), // No need to set start time here, it's already set in the text-start event end: Date.now(), } if (value.providerMetadata) currentText.metadata = value.providerMetadata From 240df9b1df0c4a0f7b1b48b6ac8f703de5c09d27 Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Wed, 24 Dec 2025 14:45:02 +0100 Subject: [PATCH 2/5] refactor: enhance token calculation for multiple assistant messages connected by 1 parent message --- .../src/cli/cmd/tui/routes/session/index.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 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 dbd45ed905d..6a79dad5cf6 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1276,7 +1276,9 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las if (!props.message.time.completed) return 0 if (!Flag.OPENCODE_EXPERIMENTAL_TPS) return 0 - const allParts = getParts(props.message.id) + const assistantMessages : AssistantMessage[] = messages().filter((msg) => msg.role === "assistant" && msg.id !== props.message.id) as AssistantMessage[] + + const allParts = assistantMessages.flatMap((msg) => getParts(msg.id)) const INVALID_REASONING_TEXTS = ["[REDACTED]", "", null, undefined] as const @@ -1314,9 +1316,16 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las if (totalStreamingTimeMs === 0) return 0 - // Only count reasoning tokens if valid reasoning exists - const totalTokens = - (hasValidReasoning ? props.message.tokens.reasoning : 0) + props.message.tokens.output + const totals = assistantMessages.reduce( + (acc, m) => { + acc.output += m.tokens.output + if (hasValidReasoning) acc.reasoning += m.tokens.reasoning // Only count reasoning tokens if valid reasoning parts exists + return acc + }, + { output: 0, reasoning: 0 }, + ) + + const totalTokens = totals.reasoning + totals.output if (totalTokens === 0) return 0 From 1a22b6607e775b33069bc522c8b37ecd4e5c7237 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sat, 27 Dec 2025 13:42:50 -0500 Subject: [PATCH 3/5] feat: replace TPS display environment variable with config setting - Add display_message_tps to tui config schema - Update TPS checks to use config instead of OPENCODE_EXPERIMENTAL_TPS flag - Remove unused OPENCODE_EXPERIMENTAL_TPS flag from flag.ts Users can now enable TPS display via opencode.json: { "tui": { "display_message_tps": true } } --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 5 ++--- packages/opencode/src/config/config.ts | 4 ++++ packages/opencode/src/flag/flag.ts | 1 - 3 files changed, 6 insertions(+), 4 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 6a79dad5cf6..477e2705818 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -69,7 +69,6 @@ import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { Filesystem } from "@/util/filesystem" import { DialogSubagent } from "./dialog-subagent.tsx" -import { Flag } from "@/flag/flag.ts" addDefaultParsers(parsers.parsers) @@ -1274,7 +1273,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las const TPS = createMemo(() => { if (!final()) return 0 if (!props.message.time.completed) return 0 - if (!Flag.OPENCODE_EXPERIMENTAL_TPS) return 0 + if (!sync.data.config.tui?.display_message_tps) return 0 const assistantMessages : AssistantMessage[] = messages().filter((msg) => msg.role === "assistant" && msg.id !== props.message.id) as AssistantMessage[] @@ -1377,7 +1376,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las · {Locale.duration(duration())} - + · {TPS()} tps diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ba9d1973025..3476f6b44cd 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -585,6 +585,10 @@ export namespace Config { .enum(["auto", "stacked"]) .optional() .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), + display_message_tps: z + .boolean() + .optional() + .describe("Display tokens per second in assistant message footer"), }) export const Layout = z.enum(["auto", "stretch"]).meta({ diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 0e1a9b8c60a..805da33cc7a 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -31,7 +31,6 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT") export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY") export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL") - export const OPENCODE_EXPERIMENTAL_TPS = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_TPS") function truthy(key: string) { const value = process.env[key]?.toLowerCase() From 4765b46d4c9408bcd8723d8e3634951b6608ae95 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sat, 27 Dec 2025 13:50:49 -0500 Subject: [PATCH 4/5] feat: replace TPS display environment variable with config setting - Add display_message_tps to tui config schema - Update TPS checks to use config instead of OPENCODE_EXPERIMENTAL_TPS flag - Remove unused OPENCODE_EXPERIMENTAL_TPS flag from flag.ts - Add type assertions to bypass TypeScript caching issue Users can now enable TPS display via opencode.json: { "tui": { "display_message_tps": true } } --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 4 ++-- packages/opencode/src/config/config.ts | 2 ++ 2 files changed, 4 insertions(+), 2 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 477e2705818..057b0cc5fc2 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1273,7 +1273,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las const TPS = createMemo(() => { if (!final()) return 0 if (!props.message.time.completed) return 0 - if (!sync.data.config.tui?.display_message_tps) return 0 + if (!(sync.data.config.tui as any)?.display_message_tps) return 0 const assistantMessages : AssistantMessage[] = messages().filter((msg) => msg.role === "assistant" && msg.id !== props.message.id) as AssistantMessage[] @@ -1376,7 +1376,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las · {Locale.duration(duration())} - + · {TPS()} tps diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3476f6b44cd..98d108b706c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -591,6 +591,8 @@ export namespace Config { .describe("Display tokens per second in assistant message footer"), }) + export type TUI = z.infer + export const Layout = z.enum(["auto", "stretch"]).meta({ ref: "LayoutConfig", }) From f0e8686770f61cd777bc0307265a0cc5ecdb8833 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Thu, 8 Jan 2026 01:54:58 -0500 Subject: [PATCH 5/5] Fix TPS calculation to use current message instead of other messages --- .../src/cli/cmd/tui/routes/session/index.tsx | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 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 4013cca0afe..079273b813e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1200,10 +1200,9 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las if (!final()) return 0 if (!props.message.time.completed) return 0 if (!(sync.data.config.tui as any)?.display_message_tps) return 0 - - const assistantMessages : AssistantMessage[] = messages().filter((msg) => msg.role === "assistant" && msg.id !== props.message.id) as AssistantMessage[] - const allParts = assistantMessages.flatMap((msg) => getParts(msg.id)) + // Get parts for the current message only + const allParts = getParts(props.message.id) const INVALID_REASONING_TEXTS = ["[REDACTED]", "", null, undefined] as const @@ -1240,17 +1239,11 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las } if (totalStreamingTimeMs === 0) return 0 - - const totals = assistantMessages.reduce( - (acc, m) => { - acc.output += m.tokens.output - if (hasValidReasoning) acc.reasoning += m.tokens.reasoning // Only count reasoning tokens if valid reasoning parts exists - return acc - }, - { output: 0, reasoning: 0 }, - ) - const totalTokens = totals.reasoning + totals.output + // Use token counts from the current message + const outputTokens = props.message.tokens.output + const reasoningTokens = hasValidReasoning ? props.message.tokens.reasoning : 0 + const totalTokens = outputTokens + reasoningTokens if (totalTokens === 0) return 0