Skip to content
Closed
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
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 81 additions & 2 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,28 @@ import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import { batch, onCleanup, onMount } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
import type { Workspace } from "@opencode-ai/sdk/v2"

export type TokensLiveData = {
sessionID: string
messageID: string
modelID: string
providerID: string
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
cost: number
contextUsed: number
contextLimit: number
cacheHitPct: number | null
phase: "streaming" | "final"
timestamp: number
}

export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
Expand Down Expand Up @@ -75,6 +92,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
vcs: VcsInfo | undefined
path: Path
workspaceList: Workspace[]
tokensLive: Record<string, TokensLiveData | undefined>
}>({
provider_next: {
all: [],
Expand Down Expand Up @@ -103,9 +121,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
vcs: undefined,
path: { state: "", config: "", worktree: "", directory: "" },
workspaceList: [],
tokensLive: {},
})

const sdk = useSDK()
const tokensLiveTimeouts = new Map<string, ReturnType<typeof setTimeout>>()

async function syncWorkspaces() {
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
Expand All @@ -115,6 +135,32 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({

sdk.event.listen((e) => {
const event = e.details

// not yet in SDK's generated Event union — handle before typed switch
if ((event as any).type === "message.tokens.live") {
const payload = (event as any).properties as TokensLiveData
Log.Default.info("[sync] tokensLive update", {
phase: payload.phase,
sessionID: payload.sessionID,
out: payload.outputTokens,
})
setStore("tokensLive", payload.sessionID, payload)

const existing = tokensLiveTimeouts.get(payload.sessionID)
if (existing) clearTimeout(existing)

if (payload.phase === "final") {
tokensLiveTimeouts.set(
payload.sessionID,
setTimeout(() => {
tokensLiveTimeouts.delete(payload.sessionID)
setStore("tokensLive", payload.sessionID, undefined)
}, 500),
)
}
return
}

switch (event.type) {
case "server.instance.disposed":
bootstrap()
Expand Down Expand Up @@ -203,7 +249,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break

case "session.deleted": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
const deletedID = event.properties.info.id
const result = Binary.search(store.session, deletedID, (s) => s.id)
if (result.found) {
setStore(
"session",
Expand All @@ -212,6 +259,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}),
)
}
const pendingTimeout = tokensLiveTimeouts.get(deletedID)
if (pendingTimeout) {
clearTimeout(pendingTimeout)
tokensLiveTimeouts.delete(deletedID)
}
setStore("tokensLive", deletedID, undefined)
break
}
case "session.error": {
const errorSessionID = event.properties.sessionID
if (errorSessionID) {
const pendingTimeout = tokensLiveTimeouts.get(errorSessionID)
if (pendingTimeout) {
clearTimeout(pendingTimeout)
tokensLiveTimeouts.delete(errorSessionID)
}
setStore("tokensLive", errorSessionID, undefined)
}
break
}
case "session.updated": {
Expand Down Expand Up @@ -441,6 +506,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
bootstrap()
})

onCleanup(() => {
for (const timeout of tokensLiveTimeouts.values()) {
clearTimeout(timeout)
}
tokensLiveTimeouts.clear()
})

const fullSyncedSessions = new Set<string>()
const result = {
data: store,
Expand Down Expand Up @@ -468,6 +540,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return last.time.completed ? "idle" : "working"
},
async sync(sessionID: string) {
const pending = tokensLiveTimeouts.get(sessionID)
if (pending) {
clearTimeout(pending)
tokensLiveTimeouts.delete(sessionID)
}
setStore("tokensLive", sessionID, undefined)

if (fullSyncedSessions.has(sessionID)) return
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
Expand Down
188 changes: 183 additions & 5 deletions packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo } from "solid-js"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
import { createMemo, For, Show } from "solid-js"
import { useSync, type TokensLiveData } from "@tui/context/sync"
import { formatTokenNumber, formatCost } from "@/session/live-token-math"
import { computeCacheHitRate } from "@/session/context-window"
import { computeUsageCost, getModelPricing } from "@/session/pricing"

const id = "internal:sidebar-context"

Expand All @@ -9,17 +13,108 @@ const money = new Intl.NumberFormat("en-US", {
currency: "USD",
})

function getBarColor(percent: number, theme: TuiThemeCurrent) {
if (percent < 60) return theme.success
if (percent < 80) return theme.warning
return theme.error
}

type ModelUsage = {
model: string
input: number
output: number
cacheRead: number
cacheWrite: number
cost: number
}

function View(props: { api: TuiPluginApi; session_id: string }) {
const sync = useSync()
const theme = () => props.api.theme.current
const msg = createMemo(() => props.api.state.session.messages(props.session_id))
const cost = createMemo(() => msg().reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0))

// Recursively collect sessionID + all descendant session IDs
const descendantSessionIDs = createMemo(() => {
const allSessions = sync.data.session ?? []
const byParent = new Map<string, string[]>()
for (const s of allSessions) {
const pid = (s as any).parentID
if (!pid) continue
const list = byParent.get(pid) ?? []
list.push(s.id)
byParent.set(pid, list)
}
const ids = new Set<string>([props.session_id])
const stack = [props.session_id]
while (stack.length > 0) {
const current = stack.pop()!
const kids = byParent.get(current) ?? []
for (const k of kids) {
if (!ids.has(k)) { ids.add(k); stack.push(k) }
}
}
return [...ids]
})

// All messages across the session tree, flattened
const allMessages = createMemo(() => {
const result: AssistantMessage[] = []
for (const id of descendantSessionIDs()) {
const msgs = sync.data.message?.[id] ?? []
for (const m of msgs) {
if (m.role === "assistant") result.push(m as AssistantMessage)
}
}
return result
})

// Cost: compute via our pricing (not am.cost which is often 0)
const cost = createMemo(() => {
let total = 0
for (const am of allMessages()) {
const pricing = getModelPricing(am.modelID)
total += computeUsageCost({
input: am.tokens.input,
output: am.tokens.output,
cacheRead: am.tokens.cache.read,
cacheWrite: am.tokens.cache.write,
}, pricing)
}
return total
})

const live = createMemo(() => sync.data.tokensLive[props.session_id] as TokensLiveData | undefined)

const state = createMemo(() => {
const liveData = live()

if (liveData) {
const tokens = liveData.inputTokens + liveData.cacheReadTokens + liveData.cacheWriteTokens
+ (liveData.phase === "final" ? liveData.outputTokens : 0)
const percent = liveData.contextLimit > 0
? Math.round((tokens / liveData.contextLimit) * 100)
: null
return {
tokens,
percent,
input: liveData.inputTokens,
output: liveData.phase === "final" ? liveData.outputTokens : null,
cacheRead: liveData.cacheReadTokens,
cacheWrite: liveData.cacheWriteTokens,
streaming: liveData.phase === "streaming",
}
}

const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
if (!last) {
return {
tokens: 0,
percent: null,
input: 0,
output: 0 as number | null,
cacheRead: 0,
cacheWrite: 0,
streaming: false,
}
}

Expand All @@ -28,18 +123,101 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
return {
tokens,
percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null,
percent: model?.limit?.context ? Math.round((tokens / model.limit.context) * 100) : null,
input: last.tokens.input,
output: last.tokens.output as number | null,
cacheRead: last.tokens.cache.read,
cacheWrite: last.tokens.cache.write,
streaming: false,
}
})

const cacheHit = createMemo(() => {
const s = state()
return computeCacheHitRate({ input: s.input, cacheRead: s.cacheRead, cacheWrite: s.cacheWrite })
})

const progressBar = createMemo(() => {
const percent = state().percent
if (percent === null) return null
const clamped = Math.min(100, Math.max(0, percent))
const filled = Math.round((clamped / 100) * 20)
return "█".repeat(filled) + "░".repeat(20 - filled)
})

const modelBreakdown = createMemo(() => {
const byModel = new Map<string, ModelUsage>()
for (const am of allMessages()) {
const pricing = getModelPricing(am.modelID)
const itemCost = computeUsageCost({
input: am.tokens.input,
output: am.tokens.output,
cacheRead: am.tokens.cache.read,
cacheWrite: am.tokens.cache.write,
}, pricing)
const existing = byModel.get(am.modelID)
if (existing) {
existing.input += am.tokens.input
existing.output += am.tokens.output
existing.cacheRead += am.tokens.cache.read
existing.cacheWrite += am.tokens.cache.write
existing.cost += itemCost
} else {
byModel.set(am.modelID, {
model: am.modelID,
input: am.tokens.input,
output: am.tokens.output,
cacheRead: am.tokens.cache.read,
cacheWrite: am.tokens.cache.write,
cost: itemCost,
})
}
}
if (byModel.size < 2) return []
return [...byModel.values()].sort((a, b) => b.cost - a.cost)
})

return (
<box>
<text fg={theme().text}>
<b>Context</b>
</text>
<text fg={theme().textMuted}>{state().tokens.toLocaleString()} tokens</text>
<text fg={theme().textMuted}>{state().percent ?? 0}% used</text>
<Show when={progressBar()}>
{(bar) => (
<text fg={getBarColor(state().percent!, theme())}>
{bar()} {state().percent}% used
</text>
)}
</Show>
<Show when={!progressBar()}>
<text fg={theme().textMuted}>{state().percent ?? 0}% used</text>
</Show>
<Show when={state().streaming}>
<text fg={theme().textMuted}>◌ generating…</text>
</Show>
<Show when={!state().streaming && state().output !== null}>
<text fg={theme().textMuted}>{formatTokenNumber(state().output!)} output</text>
</Show>
<Show when={cacheHit() !== null}>
<text fg={theme().textMuted}>cache hit: {cacheHit()}%</text>
</Show>
<text fg={theme().textMuted}>{money.format(cost())} spent</text>

<Show when={modelBreakdown().length > 0}>
<text fg={theme().text}>Usage by model</text>
<For each={modelBreakdown()}>
{(m) => (
<box>
<text fg={theme().text}> {m.model}:</text>
<text fg={theme().textMuted}>
{" "}
{formatTokenNumber(m.input)} input, {formatTokenNumber(m.output)} output, {formatTokenNumber(m.cacheRead)} cache read, {formatTokenNumber(m.cacheWrite)} cache write ({formatCost(m.cost)})
</text>
</box>
)}
</For>
</Show>
</box>
)
}
Expand Down
Loading
Loading