From 0eaa75ec0f713df9577edfbd6b2a95e31b72e803 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 10 Jul 2026 19:50:50 -0400 Subject: [PATCH] fix(tui): total root session family cost --- packages/tui/src/component/prompt/index.tsx | 2 +- packages/tui/src/context/data.tsx | 9 +++++++ .../src/feature-plugins/sidebar/context.tsx | 2 +- .../src/routes/session/subagent-footer.tsx | 2 +- packages/tui/test/cli/tui/data.test.tsx | 27 ++++++++++++++++--- 5 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index d6d09b0b4f0..ce4bcae3be7 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -290,7 +290,7 @@ export function Prompt(props: PromptProps) { .list(session.location) ?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id) const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined - const cost = session.cost + const cost = data.session.cost(props.sessionID) return { context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), cost: cost > 0 ? money.format(cost) : undefined, diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 59f15d74d63..b3cfb760b82 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -805,6 +805,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ family(sessionID: string) { return store.session.family[resolveRoot(sessionID)] ?? [] }, + cost(sessionID: string) { + const session = store.session.info[sessionID] + if (!session) return 0 + if (session.parentID) return session.cost + return (store.session.family[sessionID] ?? [sessionID]).reduce( + (total, id) => total + (store.session.info[id]?.cost ?? 0), + 0, + ) + }, status(sessionID: string) { return store.session.status[sessionID] ?? "idle" }, diff --git a/packages/tui/src/feature-plugins/sidebar/context.tsx b/packages/tui/src/feature-plugins/sidebar/context.tsx index ae41cec303a..9a6be91812d 100644 --- a/packages/tui/src/feature-plugins/sidebar/context.tsx +++ b/packages/tui/src/feature-plugins/sidebar/context.tsx @@ -16,7 +16,7 @@ function View(props: { api: TuiPluginApi; session_id: string }) { const theme = () => props.api.theme.current const msg = createMemo(() => data.session.message.list(props.session_id)) const session = createMemo(() => data.session.get(props.session_id)) - const cost = createMemo(() => session()?.cost ?? 0) + const cost = createMemo(() => data.session.cost(props.session_id)) const state = createMemo(() => { const last = lastAssistantWithUsage(msg(), session()?.revert?.messageID) diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx index f02ef3f20e1..dcd00e106f0 100644 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ b/packages/tui/src/routes/session/subagent-footer.tsx @@ -33,7 +33,7 @@ export function SubagentFooter() { .model.list(current.location) ?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id) const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined - const cost = current.cost + const cost = data.session.cost(route.sessionID) const money = new Intl.NumberFormat("en-US", { style: "currency", diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index fade50e7226..6f2d0475170 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -2148,12 +2148,12 @@ test("projects live instruction updates with their message ID", async () => { } }) -function sessionInfo(id: string, parentID: string | undefined) { +function sessionInfo(id: string, parentID: string | undefined, cost = 0) { return { id, parentID, projectID: "proj_test", - cost: 0, + cost, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 0, updated: 0 }, title: id, @@ -2164,10 +2164,11 @@ function sessionInfo(id: string, parentID: string | undefined) { // Mounts a DataProvider whose `/api/session/:id` responses are driven by the // given parent map (sessionID -> parentID). Roots omit the entry. Reused across // the family-index tests below. -async function mountData(parents: Record) { +async function mountData(parents: Record, costs: Record = {}) { const calls = createFetch((url) => { const match = url.pathname.match(/^\/api\/session\/([^/]+)$/) - if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]]) }) + if (match && match[1] !== "active") + return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) }) }) let data!: ReturnType let ready!: () => void @@ -2235,6 +2236,24 @@ test("indexes arbitrarily deep nesting under a single root", async () => { } }) +test("totals family cost for roots and keeps subagent cost scoped", async () => { + const { data, app } = await mountData( + { grandchild: "child", child: "root" }, + { root: 1, child: 2, grandchild: 3 }, + ) + try { + await data.session.refresh("grandchild") + await data.session.refresh("child") + await data.session.refresh("root") + + expect(data.session.cost("root")).toBe(6) + expect(data.session.cost("child")).toBe(2) + expect(data.session.cost("grandchild")).toBe(3) + } finally { + app.renderer.destroy() + } +}) + test("re-registering an existing session is idempotent", async () => { const { data, app } = await mountData({ grandchild: "child", child: "root" }) try {