feat(ui): show aggregated total tokens and cost for parent sessions including subagents (#415)

## Summary

- Adds three new chips (Total In, Total Out, Total Cost) to the
`ContextUsagePanel` that aggregate usage across the parent session and
all child sessions (subagents and forks)
- Child session messages are proactively loaded to ensure historical
data appears without manually navigating to each child
- Chips respect the existing `showUsageMetrics` preference toggle and
are hidden when the session has no children
- Reactive computation is scoped to the current instance to avoid
unnecessary re-runs

## Changes

| File | Change |
|------|--------|
| `packages/ui/src/components/session/context-usage-panel.tsx` | Adds
`threadTotals` memo, proactive `loadMessages` effect, and three total
chips |
| `packages/ui/src/lib/i18n/messages/*/settings.ts` (7 locales) | Adds
`totalInput`, `totalOutput`, `totalCost` i18n keys |

## Screenshots
When viewing a parent session with subagents, the panel shows:
```
[Input] [Output] [Cost] | [Total In] [Total Out] [Total Cost]
```

## Notes
- This PR was primarily AI-generated and has gone through several
iterative review and refinement steps
- Built and type-checked against `upstream/dev`
- Needs review and approval from the CodeNomad team

Closes #401

---------

Co-authored-by: Pascal André <pascalandr@gmail.com>
This commit is contained in:
Claas Pancratius 2026-05-09 21:47:56 +02:00 committed by GitHub
parent d344ef3132
commit c9dff33662
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 259 additions and 9 deletions

View file

@ -235,7 +235,7 @@ const SessionList: Component<SessionListProps> = (props) => {
})
try {
await loadMessages(props.instanceId, sessionId, true)
await loadMessages(props.instanceId, sessionId, { force: true })
} catch (error) {
log.error(`Failed to reload session ${sessionId}:`, error)
showToastNotification({ message: t("sessionList.reload.error"), variant: "error" })

View file

@ -1,7 +1,12 @@
import { createMemo, type Component } from "solid-js"
import { getSessionInfo } from "../../stores/sessions"
import { createEffect, createMemo, Show, untrack, type Component } from "solid-js"
import { getChildSessions, getSessionFamily, getSessionInfo, loadMessages, sessionInfoByInstance } from "../../stores/sessions"
import { formatTokenTotal } from "../../lib/formatters"
import { computeThreadTotals } from "../../lib/thread-totals"
import { useI18n } from "../../lib/i18n"
import { getLogger } from "../../lib/logger"
import { useConfig } from "../../stores/preferences"
const log = getLogger("session")
interface ContextUsagePanelProps {
instanceId: string
@ -14,6 +19,8 @@ const chipLabelClass = "uppercase text-[10px] tracking-wide text-muted"
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
const { t } = useI18n()
const { preferences } = useConfig()
const showUsage = createMemo(() => preferences().showUsageMetrics ?? true)
const info = createMemo(
() =>
getSessionInfo(props.instanceId, props.sessionId) ?? {
@ -29,6 +36,43 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
},
)
const children = createMemo(() => getChildSessions(props.instanceId, props.sessionId))
const hasChildren = createMemo(() => children().length > 0)
const instanceInfoMap = createMemo(() =>
sessionInfoByInstance().get(props.instanceId),
)
createEffect(() => {
if (!showUsage()) return
const instanceId = props.instanceId
const childSessions = children()
untrack(() => {
for (const child of childSessions) {
loadMessages(instanceId, child.id, { skipDiff: true }).catch((error) =>
log.error("Failed to load child session messages", {
instanceId,
sessionId: child.id,
error,
}),
)
}
})
})
const threadTotals = createMemo(() => {
if (!hasChildren()) return { cost: 0, inputTokens: 0, outputTokens: 0, reasoningTokens: 0 }
const map = instanceInfoMap()
const family = getSessionFamily(props.instanceId, props.sessionId)
return computeThreadTotals(family, map)
})
const totalCostDisplay = createMemo(() => `$${threadTotals().cost.toFixed(2)}`)
const totalInputTokens = createMemo(() => threadTotals().inputTokens)
const totalOutputTokens = createMemo(() => threadTotals().outputTokens)
const totalReasoningTokens = createMemo(() => threadTotals().reasoningTokens)
const inputTokens = createMemo(() => info().inputTokens ?? 0)
const outputTokens = createMemo(() => info().outputTokens ?? 0)
const costValue = createMemo(() => {
@ -53,6 +97,24 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
<span class={chipLabelClass}>{t("contextUsagePanel.labels.cost")}</span>
<span class="font-semibold text-primary">{costDisplay()}</span>
</div>
<Show when={hasChildren() && showUsage()}>
<div class={chipClass}>
<span class={chipLabelClass}>{t("contextUsagePanel.labels.totalInput")}</span>
<span class="font-semibold text-primary">{formatTokenTotal(totalInputTokens())}</span>
</div>
<div class={chipClass}>
<span class={chipLabelClass}>{t("contextUsagePanel.labels.totalOutput")}</span>
<span class="font-semibold text-primary">{formatTokenTotal(totalOutputTokens())}</span>
</div>
<div class={chipClass}>
<span class={chipLabelClass}>{t("contextUsagePanel.labels.totalCost")}</span>
<span class="font-semibold text-primary">{totalCostDisplay()}</span>
</div>
<div class={chipClass}>
<span class={chipLabelClass}>{t("contextUsagePanel.labels.totalReasoning")}</span>
<span class="font-semibold text-primary">{formatTokenTotal(totalReasoningTokens())}</span>
</div>
</Show>
</div>
</div>
)

View file

@ -52,6 +52,10 @@ export const settingsMessages = {
"contextUsagePanel.labels.input": "Input",
"contextUsagePanel.labels.output": "Output",
"contextUsagePanel.labels.cost": "Cost",
"contextUsagePanel.labels.totalInput": "Total In",
"contextUsagePanel.labels.totalOutput": "Total Out",
"contextUsagePanel.labels.totalCost": "Total Cost",
"contextUsagePanel.labels.totalReasoning": "Total Reasoning",
"contextUsagePanel.labels.used": "Used",
"contextUsagePanel.labels.available": "Avail",
"contextUsagePanel.unavailable": "--",

View file

@ -52,6 +52,10 @@ export const settingsMessages = {
"contextUsagePanel.labels.input": "Entrada",
"contextUsagePanel.labels.output": "Salida",
"contextUsagePanel.labels.cost": "Costo",
"contextUsagePanel.labels.totalInput": "Entrada total",
"contextUsagePanel.labels.totalOutput": "Salida total",
"contextUsagePanel.labels.totalCost": "Costo total",
"contextUsagePanel.labels.totalReasoning": "Razonamiento total",
"contextUsagePanel.labels.used": "Usado",
"contextUsagePanel.labels.available": "Disp.",
"contextUsagePanel.unavailable": "--",

View file

@ -52,6 +52,10 @@ export const settingsMessages = {
"contextUsagePanel.labels.input": "Entrée",
"contextUsagePanel.labels.output": "Sortie",
"contextUsagePanel.labels.cost": "Coût",
"contextUsagePanel.labels.totalInput": "Entrée totale",
"contextUsagePanel.labels.totalOutput": "Sortie totale",
"contextUsagePanel.labels.totalCost": "Coût total",
"contextUsagePanel.labels.totalReasoning": "Raisonnement total",
"contextUsagePanel.labels.used": "Utilisé",
"contextUsagePanel.labels.available": "Dispo",
"contextUsagePanel.unavailable": "--",

View file

@ -52,6 +52,10 @@ export const settingsMessages = {
"contextUsagePanel.labels.input": "קלט",
"contextUsagePanel.labels.output": "פלט",
"contextUsagePanel.labels.cost": "עלות",
"contextUsagePanel.labels.totalInput": "קלט כולל",
"contextUsagePanel.labels.totalOutput": "פלט כולל",
"contextUsagePanel.labels.totalCost": "עלות כוללת",
"contextUsagePanel.labels.totalReasoning": "חשיבה כוללת",
"contextUsagePanel.labels.used": "בשימוש",
"contextUsagePanel.labels.available": "זמין",
"contextUsagePanel.unavailable": "--",

View file

@ -52,6 +52,10 @@ export const settingsMessages = {
"contextUsagePanel.labels.input": "入力",
"contextUsagePanel.labels.output": "出力",
"contextUsagePanel.labels.cost": "コスト",
"contextUsagePanel.labels.totalInput": "合計入力",
"contextUsagePanel.labels.totalOutput": "合計出力",
"contextUsagePanel.labels.totalCost": "合計コスト",
"contextUsagePanel.labels.totalReasoning": "合計推論",
"contextUsagePanel.labels.used": "使用",
"contextUsagePanel.labels.available": "残り",
"contextUsagePanel.unavailable": "--",

View file

@ -52,6 +52,10 @@ export const settingsMessages = {
"contextUsagePanel.labels.input": "Ввод",
"contextUsagePanel.labels.output": "Вывод",
"contextUsagePanel.labels.cost": "Стоимость",
"contextUsagePanel.labels.totalInput": "Всего ввод",
"contextUsagePanel.labels.totalOutput": "Всего вывод",
"contextUsagePanel.labels.totalCost": "Общая стоимость",
"contextUsagePanel.labels.totalReasoning": "Всего рассуждений",
"contextUsagePanel.labels.used": "Использовано",
"contextUsagePanel.labels.available": "Доступно",
"contextUsagePanel.unavailable": "--",

View file

@ -52,6 +52,10 @@ export const settingsMessages = {
"contextUsagePanel.labels.input": "输入",
"contextUsagePanel.labels.output": "输出",
"contextUsagePanel.labels.cost": "费用",
"contextUsagePanel.labels.totalInput": "总输入",
"contextUsagePanel.labels.totalOutput": "总输出",
"contextUsagePanel.labels.totalCost": "总费用",
"contextUsagePanel.labels.totalReasoning": "总推理",
"contextUsagePanel.labels.used": "已用",
"contextUsagePanel.labels.available": "可用",
"contextUsagePanel.unavailable": "--",

View file

@ -0,0 +1,124 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import type { SessionInfo } from "../stores/sessions.js"
import { computeThreadTotals } from "./thread-totals.js"
function makeInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
cost: 0,
contextWindow: 0,
isSubscriptionModel: false,
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: 0,
contextAvailableTokens: null,
...overrides,
}
}
describe("computeThreadTotals", () => {
it("sums cost, input, output, and reasoning tokens for normal sessions", () => {
const family = [{ id: "s1" }, { id: "s2" }]
const infoMap = new Map<string, SessionInfo>([
["s1", makeInfo({ cost: 0.15, inputTokens: 500, outputTokens: 200, reasoningTokens: 100 })],
["s2", makeInfo({ cost: 0.35, inputTokens: 1000, outputTokens: 400, reasoningTokens: 200 })],
])
const totals = computeThreadTotals(family, infoMap)
assert.equal(totals.cost, 0.5)
assert.equal(totals.inputTokens, 1500)
assert.equal(totals.outputTokens, 600)
assert.equal(totals.reasoningTokens, 300)
})
it("includes tokens but not cost for subscription model sessions", () => {
const family = [{ id: "s1" }]
const infoMap = new Map<string, SessionInfo>([
[
"s1",
makeInfo({
cost: 1.0,
inputTokens: 100,
outputTokens: 50,
reasoningTokens: 25,
isSubscriptionModel: true,
}),
],
])
const totals = computeThreadTotals(family, infoMap)
assert.equal(totals.cost, 0)
assert.equal(totals.inputTokens, 100)
assert.equal(totals.outputTokens, 50)
assert.equal(totals.reasoningTokens, 25)
})
it("mixes subscription and normal sessions correctly", () => {
const family = [{ id: "normal" }, { id: "sub" }]
const infoMap = new Map<string, SessionInfo>([
[
"normal",
makeInfo({ cost: 0.1, inputTokens: 200, outputTokens: 100, reasoningTokens: 50 }),
],
[
"sub",
makeInfo({
cost: 0.5,
inputTokens: 300,
outputTokens: 150,
reasoningTokens: 75,
isSubscriptionModel: true,
}),
],
])
const totals = computeThreadTotals(family, infoMap)
assert.equal(totals.cost, 0.1)
assert.equal(totals.inputTokens, 500)
assert.equal(totals.outputTokens, 250)
assert.equal(totals.reasoningTokens, 125)
})
it("returns zeros for an empty family", () => {
const totals = computeThreadTotals([], undefined)
assert.equal(totals.cost, 0)
assert.equal(totals.inputTokens, 0)
assert.equal(totals.outputTokens, 0)
assert.equal(totals.reasoningTokens, 0)
})
it("returns zeros when no session info is available", () => {
const family = [{ id: "missing" }]
const totals = computeThreadTotals(family, undefined)
assert.equal(totals.cost, 0)
assert.equal(totals.inputTokens, 0)
assert.equal(totals.outputTokens, 0)
assert.equal(totals.reasoningTokens, 0)
})
it("treats missing info as zeros", () => {
const family = [{ id: "present" }, { id: "missing" }]
const infoMap = new Map<string, SessionInfo>([
[
"present",
makeInfo({ cost: 0.1, inputTokens: 100, outputTokens: 50, reasoningTokens: 10 }),
],
])
const totals = computeThreadTotals(family, infoMap)
assert.equal(totals.cost, 0.1)
assert.equal(totals.inputTokens, 100)
assert.equal(totals.outputTokens, 50)
assert.equal(totals.reasoningTokens, 10)
})
})

View file

@ -0,0 +1,28 @@
import type { SessionInfo } from "../stores/sessions"
export interface ThreadTotals {
cost: number
inputTokens: number
outputTokens: number
reasoningTokens: number
}
export function computeThreadTotals(
family: { id: string }[],
infoMap: Map<string, SessionInfo> | undefined,
): ThreadTotals {
let cost = 0
let inputTokens = 0
let outputTokens = 0
let reasoningTokens = 0
for (const session of family) {
const sessionInfo = infoMap?.get(session.id)
inputTokens += sessionInfo?.inputTokens ?? 0
outputTokens += sessionInfo?.outputTokens ?? 0
reasoningTokens += sessionInfo?.reasoningTokens ?? 0
if (!sessionInfo?.isSubscriptionModel) {
cost += sessionInfo?.cost ?? 0
}
}
return { cost, inputTokens, outputTokens, reasoningTokens }
}

View file

@ -595,7 +595,14 @@ async function fetchProviders(instanceId: string): Promise<void> {
}
}
async function loadMessages(instanceId: string, sessionId: string, force = false): Promise<void> {
async function loadMessages(
instanceId: string,
sessionId: string,
options?: { force?: boolean; skipDiff?: boolean },
): Promise<void> {
const force = options?.force ?? false
const skipDiff = options?.skipDiff ?? false
if (force) {
setMessagesLoaded((prev) => {
const next = new Map(prev)
@ -631,10 +638,11 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
throw new Error("Session not found")
}
// Fetch session-level diffs in the background once the session is opened.
void loadSessionDiff(instanceId, sessionId).catch((error) => {
log.warn("Failed to load session diff", { instanceId, sessionId, error })
})
if (!skipDiff) {
void loadSessionDiff(instanceId, sessionId).catch((error) => {
log.warn("Failed to load session diff", { instanceId, sessionId, error })
})
}
setLoading((prev) => {
const next = { ...prev }

View file

@ -614,7 +614,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
ensureSessionStatus(instanceId, sessionID, "working", (event as any)?.directory)
}
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload session after compaction", error))
loadMessages(instanceId, sessionID, { force: true }).catch((error) => log.error("Failed to reload session after compaction", error))
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionID)