Skip to content

Commit 6034aba

Browse files
committed
Local test merge of PR anomalyco#4709: feat: show live token usage during streaming
2 parents 184dc30 + 0f7d18a commit 6034aba

7 files changed

Lines changed: 276 additions & 59 deletions

File tree

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { Prompt, type PromptRef } from "@tui/component/prompt"
2828
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk"
2929
import { useLocal } from "@tui/context/local"
3030
import { Locale } from "@/util/locale"
31+
import { Token } from "@/util/token"
3132
import type { Tool } from "@/tool/tool"
3233
import type { ReadTool } from "@/tool/read"
3334
import type { WriteTool } from "@/tool/write"
@@ -80,6 +81,7 @@ const context = createContext<{
8081
conceal: () => boolean
8182
showThinking: () => boolean
8283
showTimestamps: () => boolean
84+
showTokens: () => boolean
8385
}>()
8486

8587
function use() {
@@ -106,11 +108,20 @@ export function Session() {
106108
return messages().findLast((x) => x.role === "assistant")
107109
})
108110

111+
const local = useLocal()
112+
113+
const contextLimit = createMemo(() => {
114+
const c = local.model.current()
115+
const provider = sync.data.provider.find((p) => p.id === c.providerID)
116+
return provider?.models[c.modelID]?.limit.context ?? 200000
117+
})
118+
109119
const dimensions = useTerminalDimensions()
110120
const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto"))
111121
const [conceal, setConceal] = createSignal(true)
112122
const [showThinking, setShowThinking] = createSignal(true)
113123
const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show")
124+
const [showTokens, setShowTokens] = createSignal(kv.get("tokens", "hide") === "show")
114125

115126
const wide = createMemo(() => dimensions().width > 120)
116127
const sidebarVisible = createMemo(() => sidebar() === "show" || (sidebar() === "auto" && wide()))
@@ -204,8 +215,6 @@ export function Session() {
204215
}, 50)
205216
}
206217

207-
const local = useLocal()
208-
209218
function moveChild(direction: number) {
210219
const parentID = session()?.parentID ?? session()?.id
211220
let children = sync.data.session
@@ -428,6 +437,19 @@ export function Session() {
428437
dialog.clear()
429438
},
430439
},
440+
{
441+
title: "Toggle tokens",
442+
value: "session.toggle.tokens",
443+
category: "Session",
444+
onSelect: (dialog) => {
445+
setShowTokens((prev) => {
446+
const next = !prev
447+
kv.set("tokens", next ? "show" : "hide")
448+
return next
449+
})
450+
dialog.clear()
451+
},
452+
},
431453
{
432454
title: "Page up",
433455
value: "session.page.up",
@@ -729,6 +751,7 @@ export function Session() {
729751
conceal,
730752
showThinking,
731753
showTimestamps,
754+
showTokens,
732755
}}
733756
>
734757
<box flexDirection="row" paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={2}>
@@ -864,6 +887,7 @@ export function Session() {
864887
last={lastAssistant()?.id === message.id}
865888
message={message as AssistantMessage}
866889
parts={sync.data.part[message.id] ?? []}
890+
contextLimit={contextLimit()}
867891
/>
868892
</Match>
869893
</Switch>
@@ -917,6 +941,13 @@ function UserMessage(props: {
917941
const queued = createMemo(() => props.pending && props.message.id > props.pending)
918942
const color = createMemo(() => (queued() ? theme.accent : theme.secondary))
919943

944+
const individualTokens = createMemo(() => {
945+
return props.parts.reduce((sum, part) => {
946+
if (part.type === "text") return sum + Token.estimate(part.text)
947+
return sum
948+
}, 0)
949+
})
950+
920951
const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
921952

922953
return (
@@ -977,6 +1008,9 @@ function UserMessage(props: {
9771008
>
9781009
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
9791010
</Show>
1011+
<Show when={ctx.showTokens() && !queued() && individualTokens() > 0}>
1012+
<span style={{ fg: theme.textMuted }}> ⬝~{individualTokens().toLocaleString()} tok</span>
1013+
</Show>
9801014
</text>
9811015
</box>
9821016
</box>
@@ -994,7 +1028,8 @@ function UserMessage(props: {
9941028
)
9951029
}
9961030

997-
function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
1031+
function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean; contextLimit: number }) {
1032+
const ctx = use()
9981033
const local = useLocal()
9991034
const { theme } = useTheme()
10001035
const sync = useSync()
@@ -1004,12 +1039,71 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
10041039
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
10051040
})
10061041

1042+
// Find the parent user message (reused by duration and token calculations)
1043+
const user = createMemo(() => messages().find((x) => x.role === "user" && x.id === props.message.parentID))
1044+
10071045
const duration = createMemo(() => {
10081046
if (!final()) return 0
10091047
if (!props.message.time.completed) return 0
1010-
const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID)
1011-
if (!user || !user.time) return 0
1012-
return props.message.time.completed - user.time.created
1048+
const u = user()
1049+
if (!u || !u.time) return 0
1050+
return props.message.time.completed - u.time.created
1051+
})
1052+
1053+
// OUT tokens (sent TO API) - includes user text + tool results from previous assistant
1054+
const outEstimate = createMemo(() => props.message.sentEstimate)
1055+
1056+
// IN tokens (from API TO computer)
1057+
const inTokens = createMemo(() => props.message.tokens.output)
1058+
const inEstimate = createMemo(() => props.message.outputEstimate)
1059+
1060+
// Reasoning tokens (must be defined BEFORE inDisplay)
1061+
const reasoningTokens = createMemo(() => props.message.tokens.reasoning)
1062+
const reasoningEstimate = createMemo(() => props.message.reasoningEstimate)
1063+
1064+
const outDisplay = createMemo(() => {
1065+
const estimate = outEstimate()
1066+
if (estimate !== undefined) return "~" + estimate.toLocaleString()
1067+
const tokens = props.message.tokens.input
1068+
if (tokens > 0) return tokens.toLocaleString()
1069+
return "0"
1070+
})
1071+
1072+
const inDisplay = createMemo(() => {
1073+
const estimate = inEstimate()
1074+
if (estimate !== undefined) return "~" + estimate.toLocaleString()
1075+
const tokens = inTokens()
1076+
if (tokens > 0) return tokens.toLocaleString()
1077+
// Show ~0 during streaming when we have reasoning but no output yet
1078+
if (reasoningEstimate() !== undefined || reasoningTokens() > 0) return "~0"
1079+
return undefined
1080+
})
1081+
1082+
const tokensDisplay = createMemo(() => {
1083+
const inVal = inDisplay()
1084+
if (!inVal) return undefined
1085+
return `${inVal}↓/${outDisplay()}↑`
1086+
})
1087+
1088+
const reasoningDisplay = createMemo(() => {
1089+
const estimate = reasoningEstimate()
1090+
if (estimate !== undefined) return "~" + estimate.toLocaleString()
1091+
const tokens = reasoningTokens()
1092+
if (tokens > 0) return tokens.toLocaleString()
1093+
return undefined
1094+
})
1095+
1096+
const contextEstimate = createMemo(() => props.message.contextEstimate)
1097+
1098+
const cumulativeTokens = createMemo(() => {
1099+
const estimate = contextEstimate()
1100+
if (estimate !== undefined) return estimate
1101+
return props.message.tokens.input + props.message.tokens.cache.read + props.message.tokens.cache.write
1102+
})
1103+
1104+
const percentage = createMemo(() => {
1105+
if (!props.contextLimit) return 0
1106+
return Math.round((cumulativeTokens() / props.contextLimit) * 100)
10131107
})
10141108

10151109
return (
@@ -1053,6 +1147,22 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
10531147
<Show when={duration()}>
10541148
<span style={{ fg: theme.textMuted }}>{Locale.duration(duration())}</span>
10551149
</Show>
1150+
<Show when={ctx.showTokens() && (tokensDisplay() || reasoningDisplay())}>
1151+
<span style={{ fg: theme.textMuted }}>
1152+
{" "}
1153+
{tokensDisplay()} tok
1154+
<Show when={reasoningDisplay()}>
1155+
{" · "}
1156+
{reasoningDisplay()} think
1157+
</Show>
1158+
<Show
1159+
when={cumulativeTokens() > 0 || inEstimate() !== undefined || reasoningEstimate() !== undefined}
1160+
>
1161+
{" · "}
1162+
{cumulativeTokens().toLocaleString()} context ({percentage()}%)
1163+
</Show>
1164+
</span>
1165+
</Show>
10561166
</text>
10571167
</box>
10581168
</Match>

packages/opencode/src/session/compaction.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ export namespace SessionCompaction {
9898
}) {
9999
const model = await Provider.getModel(input.model.providerID, input.model.modelID)
100100
const system = [...SystemPrompt.compaction(model.providerID)]
101+
const lastFinished = input.messages.find((m) => m.info.role === "assistant" && m.info.finish)?.info as
102+
| MessageV2.Assistant
103+
| undefined
101104
const msg = (await Session.updateMessage({
102105
id: Identifier.ascending("message"),
103106
role: "assistant",
@@ -121,6 +124,10 @@ export namespace SessionCompaction {
121124
time: {
122125
created: Date.now(),
123126
},
127+
outputEstimate: lastFinished?.outputEstimate,
128+
reasoningEstimate: lastFinished?.reasoningEstimate,
129+
contextEstimate: lastFinished?.contextEstimate,
130+
sentEstimate: lastFinished?.sentEstimate,
124131
})) as MessageV2.Assistant
125132
const processor = SessionProcessor.create({
126133
assistantMessage: msg,

packages/opencode/src/session/message-v2.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,8 @@ export namespace MessageV2 {
301301
}),
302302
system: z.string().optional(),
303303
tools: z.record(z.string(), z.boolean()).optional(),
304+
sentEstimate: z.number().optional(),
305+
contextEstimate: z.number().optional(),
304306
}).meta({
305307
ref: "UserMessage",
306308
})
@@ -360,6 +362,10 @@ export namespace MessageV2 {
360362
write: z.number(),
361363
}),
362364
}),
365+
outputEstimate: z.number().optional(),
366+
reasoningEstimate: z.number().optional(),
367+
contextEstimate: z.number().optional(),
368+
sentEstimate: z.number().optional(),
363369
finish: z.string().optional(),
364370
}).meta({
365371
ref: "AssistantMessage",

packages/opencode/src/session/processor.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { SessionSummary } from "./summary"
1111
import { Bus } from "@/bus"
1212
import { SessionRetry } from "./retry"
1313
import { SessionStatus } from "./status"
14+
import { Token } from "@/util/token"
1415

1516
export namespace SessionProcessor {
1617
const DOOM_LOOP_THRESHOLD = 3
@@ -40,6 +41,9 @@ export namespace SessionProcessor {
4041
},
4142
async process(fn: () => StreamTextResult<Record<string, AITool>, never>) {
4243
log.info("process")
44+
// Initialize from existing estimates (convert tokens to characters) to accumulate across multiple process() calls
45+
let reasoningTotal = Token.toCharCount(input.assistantMessage.reasoningEstimate ?? 0)
46+
let textTotal = Token.toCharCount(input.assistantMessage.outputEstimate ?? 0)
4347
while (true) {
4448
try {
4549
let currentText: MessageV2.TextPart | undefined
@@ -75,7 +79,15 @@ export namespace SessionProcessor {
7579
const part = reasoningMap[value.id]
7680
part.text += value.text
7781
if (value.providerMetadata) part.metadata = value.providerMetadata
78-
if (part.text) await Session.updatePart({ part, delta: value.text })
82+
if (part.text) {
83+
const active = Object.values(reasoningMap).reduce((sum, p) => sum + p.text.length, 0)
84+
const estimate = Token.toTokenEstimate(Math.max(0, reasoningTotal + active))
85+
if (input.assistantMessage.reasoningEstimate !== estimate) {
86+
input.assistantMessage.reasoningEstimate = estimate
87+
await Session.updateMessage(input.assistantMessage)
88+
}
89+
await Session.updatePart({ part, delta: value.text })
90+
}
7991
}
8092
break
8193

@@ -89,6 +101,7 @@ export namespace SessionProcessor {
89101
end: Date.now(),
90102
}
91103
if (value.providerMetadata) part.metadata = value.providerMetadata
104+
reasoningTotal += part.text.length
92105
await Session.updatePart(part)
93106
delete reasoningMap[value.id]
94107
}
@@ -248,6 +261,8 @@ export namespace SessionProcessor {
248261
input.assistantMessage.finish = value.finishReason
249262
input.assistantMessage.cost += usage.cost
250263
input.assistantMessage.tokens = usage.tokens
264+
input.assistantMessage.contextEstimate =
265+
usage.tokens.input + usage.tokens.cache.read + usage.tokens.cache.write
251266
await Session.updatePart({
252267
id: Identifier.ascending("part"),
253268
reason: value.finishReason,
@@ -297,11 +312,17 @@ export namespace SessionProcessor {
297312
if (currentText) {
298313
currentText.text += value.text
299314
if (value.providerMetadata) currentText.metadata = value.providerMetadata
300-
if (currentText.text)
315+
if (currentText.text) {
316+
const estimate = Token.toTokenEstimate(Math.max(0, textTotal + currentText.text.length))
317+
if (input.assistantMessage.outputEstimate !== estimate) {
318+
input.assistantMessage.outputEstimate = estimate
319+
await Session.updateMessage(input.assistantMessage)
320+
}
301321
await Session.updatePart({
302322
part: currentText,
303323
delta: value.text,
304324
})
325+
}
305326
}
306327
break
307328

@@ -313,6 +334,7 @@ export namespace SessionProcessor {
313334
end: Date.now(),
314335
}
315336
if (value.providerMetadata) currentText.metadata = value.providerMetadata
337+
textTotal += currentText.text.length
316338
await Session.updatePart(currentText)
317339
}
318340
currentText = undefined

0 commit comments

Comments
 (0)