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 5f47562d2e3..7c9441cf402 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1220,6 +1220,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) }) @@ -1232,6 +1236,64 @@ 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 (!(sync.data.config.tui as any)?.display_message_tps) return 0 + + // Get parts for the current message only + 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 + + // 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 + + // Calculate tokens per second + const totalStreamingTimeSec = totalStreamingTimeMs / 1000 + const tokensPerSecond = totalTokens / totalStreamingTimeSec + + return Number(tokensPerSecond.toFixed(2)) + }) + return ( <> @@ -1282,6 +1344,9 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las · {Locale.duration(duration())} + + · {TPS()} tps + · interrupted diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 020e626cba8..5ea2906d6ed 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -810,8 +810,14 @@ 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 type TUI = z.infer + export const Server = z .object({ port: z.number().int().positive().optional().describe("Port to listen on"), @@ -824,6 +830,8 @@ export namespace Config { ref: "ServerConfig", }) + export type Server = z.infer + export const Layout = z.enum(["auto", "stretch"]).meta({ ref: "LayoutConfig", }) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 27071056180..1b545575b61 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -316,7 +316,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