Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 58 additions & 3 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<Tps.Sample[]>([])
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
Expand All @@ -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,
}
Expand Down Expand Up @@ -1235,7 +1290,7 @@ export function Prompt(props: PromptProps) {
<Match when={usage()}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
{[item().context, item().cost].filter(Boolean).join(" · ")}
{[item().tps, item().context, item().cost].filter(Boolean).join(" · ")}
</text>
)}
</Match>
Expand Down
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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<Tps.Sample[]>([])
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,
}
})

Expand All @@ -37,6 +90,7 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
<text fg={theme().text}>
<b>Context</b>
</text>
{state().tps ? <text fg={theme().textMuted}>{state().tps}</text> : null}
<text fg={theme().textMuted}>{state().tokens.toLocaleString()} tokens</text>
<text fg={theme().textMuted}>{state().percent ?? 0}% used</text>
<text fg={theme().textMuted}>{money.format(cost())} spent</text>
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -1396,6 +1398,9 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
<Show when={duration()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
</Show>
<Show when={tps()}>
<span style={{ fg: theme.textMuted }}> · {tps()}</span>
</Show>
<Show when={props.message.error?.name === "MessageAbortedError"}>
<span style={{ fg: theme.textMuted }}> · interrupted</span>
</Show>
Expand Down
48 changes: 48 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/tps.ts
Original file line number Diff line number Diff line change
@@ -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))
}
5 changes: 5 additions & 0 deletions packages/opencode/src/util/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
}
}
Loading