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 087742a979d7..c7b48d21ad8c 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" import { CONSOLE_MANAGED_ICON, consoleManagedProviderLabel } from "@tui/util/provider-origin" export type PromptProps = { @@ -147,11 +148,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 @@ -160,7 +211,11 @@ 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 end = last.time.completed ?? Date.now() + 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), cost: cost > 0 ? money.format(cost) : undefined, } @@ -1235,7 +1290,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..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,6 +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" @@ -12,23 +14,74 @@ 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 && !liveTps()) { + return { + tokens: 0, + percent: null, + tps: undefined, + } + } + 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 = 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, + tps, } }) @@ -37,6 +90,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/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/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)) +} diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index 653da09a0b7d..be6391b12664 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)} TPS` + } }