From 13ded03cce5257b7bd05155f87ee6f5a4dec55e1 Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Sun, 5 Apr 2026 08:32:57 +0530 Subject: [PATCH 1/4] token speed added --- .../src/cli/cmd/tui/routes/session/index.tsx | 5 +++ .../cli/cmd/tui/routes/session/sidebar.tsx | 35 +++++++++++++++++++ packages/opencode/src/util/locale.ts | 5 +++ 3 files changed, 45 insertions(+) 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 afeed6a22f03..e1844a33f40a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1336,6 +1336,8 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las return props.message.time.completed - user.time.created }) + const tps = createMemo(() => Locale.tokensPerSec(props.message.tokens.output, duration())) + const keybind = useKeybind() return ( @@ -1396,6 +1398,9 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las · {Locale.duration(duration())} + + · {tps()} + · interrupted diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 3c87cfe47233..752a0daf3bac 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -4,6 +4,7 @@ import { useTheme } from "../../context/theme" import { useTuiConfig } from "../../context/tui-config" import { Installation } from "@/installation" import { TuiPluginRuntime } from "../../plugin" +import { Locale } from "@/util/locale" import { getScrollAcceleration } from "../../util/scroll" @@ -14,6 +15,31 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const session = createMemo(() => sync.session.get(props.sessionID)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) + const metrics = createMemo(() => { + const msgs = sync.data.message[props.sessionID] ?? [] + let totalOutput = 0 + let totalTime = 0 + + for (const msg of msgs) { + if (msg.role !== "assistant") continue + if (!msg.time.completed) continue + const out = msg.tokens.output + if (out <= 0) continue + const user = msgs.find((x) => x.role === "user" && x.id === msg.parentID) + if (!user) continue + const dur = msg.time.completed - user.time.created + if (dur <= 0) continue + totalOutput += out + totalTime += dur + } + + if (totalOutput <= 0 || totalTime <= 0) return + return { + tps: Locale.tokensPerSec(totalOutput, totalTime), + tokens: totalOutput, + } + }) + return ( + + {(m) => ( + + {m().tps} + · + {Locale.number(m().tokens)} tokens + + )} + diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index 653da09a0b7d..770e64b51855 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -78,4 +78,9 @@ export namespace Locale { const template = count === 1 ? singular : plural return template.replace("{}", count.toString()) } + + export function tokensPerSec(tokens: number, ms: number): string | undefined { + if (ms <= 0 || tokens <= 0) return + return `${(tokens / (ms / 1000)).toFixed(1)} tok/s` + } } From c1e1c759c905b0170e84342c167a88f841ee2a4b Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Sun, 5 Apr 2026 08:50:32 +0530 Subject: [PATCH 2/4] UI done --- .../cli/cmd/tui/component/prompt/index.tsx | 8 ++++- .../tui/feature-plugins/sidebar/context.tsx | 9 +++++ .../cli/cmd/tui/routes/session/sidebar.tsx | 35 ------------------- packages/opencode/src/util/locale.ts | 2 +- 4 files changed, 17 insertions(+), 37 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 382bd2806ec7..07f86d1f0fff 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -150,7 +150,13 @@ export function Prompt(props: PromptProps) { const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0) + const user = msg.find((item) => item.role === "user" && item.id === last.parentID) + const tps = + user && last.time.completed + ? Locale.tokensPerSec(last.tokens.output, last.time.completed - user.time.created) + : undefined return { + tps, context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), cost: cost > 0 ? money.format(cost) : undefined, } @@ -1224,7 +1230,7 @@ export function Prompt(props: PromptProps) { {(item) => ( - {[item().context, item().cost].filter(Boolean).join(" · ")} + {[item().tps, item().context, item().cost].filter(Boolean).join(" · ")} )} diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx index 9ffe77979150..afc31b108a99 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx @@ -1,6 +1,7 @@ import type { AssistantMessage } from "@opencode-ai/sdk/v2" import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" import { createMemo } from "solid-js" +import { Locale } from "@/util/locale" const id = "internal:sidebar-context" @@ -20,15 +21,22 @@ function View(props: { api: TuiPluginApi; session_id: string }) { return { tokens: 0, percent: null, + tps: undefined, } } const tokens = last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID] + const user = msg().find((item) => item.role === "user" && item.id === last.parentID) + const tps = + user && last.time.completed + ? Locale.tokensPerSec(last.tokens.output, last.time.completed - user.time.created) + : undefined return { tokens, percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null, + tps, } }) @@ -37,6 +45,7 @@ function View(props: { api: TuiPluginApi; session_id: string }) { Context + {state().tps ? {state().tps} : null} {state().tokens.toLocaleString()} tokens {state().percent ?? 0}% used {money.format(cost())} spent diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 752a0daf3bac..3c87cfe47233 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -4,7 +4,6 @@ import { useTheme } from "../../context/theme" import { useTuiConfig } from "../../context/tui-config" import { Installation } from "@/installation" import { TuiPluginRuntime } from "../../plugin" -import { Locale } from "@/util/locale" import { getScrollAcceleration } from "../../util/scroll" @@ -15,31 +14,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const session = createMemo(() => sync.session.get(props.sessionID)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) - const metrics = createMemo(() => { - const msgs = sync.data.message[props.sessionID] ?? [] - let totalOutput = 0 - let totalTime = 0 - - for (const msg of msgs) { - if (msg.role !== "assistant") continue - if (!msg.time.completed) continue - const out = msg.tokens.output - if (out <= 0) continue - const user = msgs.find((x) => x.role === "user" && x.id === msg.parentID) - if (!user) continue - const dur = msg.time.completed - user.time.created - if (dur <= 0) continue - totalOutput += out - totalTime += dur - } - - if (totalOutput <= 0 || totalTime <= 0) return - return { - tps: Locale.tokensPerSec(totalOutput, totalTime), - tokens: totalOutput, - } - }) - return ( - - {(m) => ( - - {m().tps} - · - {Locale.number(m().tokens)} tokens - - )} - diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index 770e64b51855..be6391b12664 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -81,6 +81,6 @@ export namespace Locale { export function tokensPerSec(tokens: number, ms: number): string | undefined { if (ms <= 0 || tokens <= 0) return - return `${(tokens / (ms / 1000)).toFixed(1)} tok/s` + return `${(tokens / (ms / 1000)).toFixed(1)} TPS` } } From e874004c6e35d54deb5670d2def24a2dd5af5f64 Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Sun, 5 Apr 2026 09:21:37 +0530 Subject: [PATCH 3/4] TPS streaming fixed --- .../opencode/src/cli/cmd/tui/component/prompt/index.tsx | 6 ++---- .../src/cli/cmd/tui/feature-plugins/sidebar/context.tsx | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 07f86d1f0fff..1da42a413c97 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -151,10 +151,8 @@ export function Prompt(props: PromptProps) { const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0) const user = msg.find((item) => item.role === "user" && item.id === last.parentID) - const tps = - user && last.time.completed - ? Locale.tokensPerSec(last.tokens.output, last.time.completed - user.time.created) - : undefined + const end = last.time.completed ?? Date.now() + const tps = user ? Locale.tokensPerSec(last.tokens.output, end - user.time.created) : undefined return { tps, context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx index afc31b108a99..d4c765ed4b74 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx @@ -29,10 +29,8 @@ function View(props: { api: TuiPluginApi; session_id: string }) { last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID] const user = msg().find((item) => item.role === "user" && item.id === last.parentID) - const tps = - user && last.time.completed - ? Locale.tokensPerSec(last.tokens.output, last.time.completed - user.time.created) - : undefined + const end = last.time.completed ?? Date.now() + const tps = user ? Locale.tokensPerSec(last.tokens.output, end - user.time.created) : undefined return { tokens, percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null, From 861c2f50c7fd661f99772b3ceb7db05d54690507 Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Sun, 5 Apr 2026 09:42:39 +0530 Subject: [PATCH 4/4] feat:tps done works for multiple models --- .../cli/cmd/tui/component/prompt/index.tsx | 57 ++++++++++++++++++- .../tui/feature-plugins/sidebar/context.tsx | 53 ++++++++++++++++- packages/opencode/src/cli/cmd/tui/util/tps.ts | 48 ++++++++++++++++ 3 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/tps.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 1da42a413c97..17ca688e60b5 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -35,6 +35,7 @@ import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" +import * as Tps from "../../util/tps" export type PromptProps = { sessionID?: string @@ -137,11 +138,61 @@ export function Prompt(props: PromptProps) { return messages.findLast((m) => m.role === "user") }) + const messages = createMemo(() => { + if (!props.sessionID) return [] + return sync.data.message[props.sessionID] ?? [] + }) + + const active = createMemo(() => { + return messages().findLast((item): item is AssistantMessage => item.role === "assistant" && !item.time.completed) + }) + + const [samples, setSamples] = createSignal([]) + const [tick, setTick] = createSignal(Date.now()) + + createEffect( + on( + () => active()?.id, + () => setSamples([]), + { defer: true }, + ), + ) + + onMount(() => { + const off = sdk.event.on("message.part.delta", (evt) => { + if (evt.properties.sessionID !== props.sessionID) return + if (evt.properties.field !== "text") return + if (evt.properties.messageID !== active()?.id) return + setSamples((list) => Tps.append(list, { delta: evt.properties.delta })) + }) + onCleanup(off) + }) + + createEffect(() => { + if (status().type === "idle") return + const timer = setInterval(() => setTick(Date.now()), 250) + onCleanup(() => clearInterval(timer)) + }) + + const liveTps = createMemo(() => { + tick() + if (status().type === "idle") return + return Tps.live(samples()) + }) + const usage = createMemo(() => { if (!props.sessionID) return - const msg = sync.data.message[props.sessionID] ?? [] + const msg = messages() const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) - if (!last) return + if (!last) { + const tps = liveTps() + if (!tps) return + return { + tps, + context: undefined, + cost: undefined, + } + } const tokens = last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write @@ -152,7 +203,7 @@ export function Prompt(props: PromptProps) { const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0) const user = msg.find((item) => item.role === "user" && item.id === last.parentID) const end = last.time.completed ?? Date.now() - const tps = user ? Locale.tokensPerSec(last.tokens.output, end - user.time.created) : undefined + const tps = liveTps() ?? (user ? Locale.tokensPerSec(last.tokens.output, end - user.time.created) : undefined) return { tps, context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx index d4c765ed4b74..8aa9b04f0acc 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx @@ -1,7 +1,8 @@ import type { AssistantMessage } from "@opencode-ai/sdk/v2" import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" -import { createMemo } from "solid-js" +import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" import { Locale } from "@/util/locale" +import * as Tps from "../../util/tps" const id = "internal:sidebar-context" @@ -13,11 +14,49 @@ const money = new Intl.NumberFormat("en-US", { function View(props: { api: TuiPluginApi; session_id: string }) { const theme = () => props.api.theme.current const msg = createMemo(() => props.api.state.session.messages(props.session_id)) + const status = createMemo(() => props.api.state.session.status(props.session_id)?.type ?? "idle") const cost = createMemo(() => msg().reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)) + const active = createMemo(() => { + return msg().findLast((item): item is AssistantMessage => item.role === "assistant" && !item.time.completed) + }) + + const [samples, setSamples] = createSignal([]) + const [tick, setTick] = createSignal(Date.now()) + + createEffect( + on( + () => active()?.id, + () => setSamples([]), + { defer: true }, + ), + ) + + onMount(() => { + const off = props.api.event.on("message.part.delta", (evt) => { + if (evt.properties.sessionID !== props.session_id) return + if (evt.properties.field !== "text") return + if (evt.properties.messageID !== active()?.id) return + setSamples((list) => Tps.append(list, { delta: evt.properties.delta })) + }) + onCleanup(off) + }) + + createEffect(() => { + if (status() === "idle") return + const timer = setInterval(() => setTick(Date.now()), 250) + onCleanup(() => clearInterval(timer)) + }) + + const liveTps = createMemo(() => { + tick() + if (status() === "idle") return + return Tps.live(samples()) + }) + const state = createMemo(() => { const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) - if (!last) { + if (!last && !liveTps()) { return { tokens: 0, percent: null, @@ -25,12 +64,20 @@ function View(props: { api: TuiPluginApi; session_id: string }) { } } + if (!last) { + return { + tokens: 0, + percent: null, + tps: liveTps(), + } + } + const tokens = last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID] const user = msg().find((item) => item.role === "user" && item.id === last.parentID) const end = last.time.completed ?? Date.now() - const tps = user ? Locale.tokensPerSec(last.tokens.output, end - user.time.created) : undefined + const tps = liveTps() ?? (user ? Locale.tokensPerSec(last.tokens.output, end - user.time.created) : undefined) return { tokens, percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null, diff --git a/packages/opencode/src/cli/cmd/tui/util/tps.ts b/packages/opencode/src/cli/cmd/tui/util/tps.ts new file mode 100644 index 000000000000..c93b07d10e04 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/tps.ts @@ -0,0 +1,48 @@ +import { Locale } from "@/util/locale" + +export type Sample = { + at: number + tokens: number +} + +const win = 15_000 +const gap = 1_250 +const stale = 1_500 +const single = 1_000 +const enc = new TextEncoder() + +export function estimate(delta: string) { + return Math.max(1, Math.ceil(enc.encode(delta).length / 4)) +} + +export function append(list: Sample[], input: { at?: number; delta: string }) { + const at = input.at ?? Date.now() + const next = list.filter((item) => at - item.at <= win) + next.push({ at, tokens: estimate(input.delta) }) + return next +} + +function active(list: Sample[], at: number) { + if (list.length === 0) return 0 + if (list.length === 1) { + const tail = Math.max(0, at - list[0].at) + return Math.min(Math.max(tail, 250), single) + } + + const span = list.reduce((sum, item, idx) => { + if (idx === 0) return sum + return sum + Math.min(Math.max(0, item.at - list[idx - 1].at), gap) + }, 0) + const tail = Math.min(Math.max(0, at - list[list.length - 1].at), gap) + return Math.max(span + tail, single) +} + +export function live(list: Sample[], at: number = Date.now()) { + const next = list.filter((item) => at - item.at <= win) + if (next.length === 0) return + const last = next.at(-1) + if (!last) return + if (at - last.at > stale) return + const tokens = next.reduce((sum, item) => sum + item.tokens, 0) + return Locale.tokensPerSec(tokens, active(next, at)) +}