Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
70edb69
feat(util,app): add sub-agent cost breakdown with recursive session w…
JudicaelPoumay Feb 14, 2026
abf0f72
Merge remote-tracking branch 'origin/dev' into feat/cost-breakdown
JudicaelPoumay Feb 14, 2026
7b8be97
fix: resolve i18n duplicates, enterprise type errors, and session rac…
JudicaelPoumay Feb 14, 2026
40fb64a
feat(ui): add reactive session syncing and polish cost display
JudicaelPoumay Feb 14, 2026
6d55c87
fix: address reviewer feedback — localize cost breakdown, refine type…
JudicaelPoumay Feb 14, 2026
bc26330
refactor: remove cost indicators from UI and i18n
JudicaelPoumay Feb 15, 2026
1d23c0d
Revert "refactor: remove cost indicators from UI and i18n"
JudicaelPoumay Feb 15, 2026
2b048c9
refactor: simplify cost display by removing agent cost and loaded sta…
JudicaelPoumay Feb 15, 2026
65b34e5
clean up
JudicaelPoumay Feb 15, 2026
bb8c736
fix(types): update custom-elements.d.ts reference
JudicaelPoumay Feb 15, 2026
4e8e78f
fix(tui): use correct props variable in sidebar
JudicaelPoumay Feb 15, 2026
7de99d5
Merge remote-tracking branch 'origin/dev' into feat/cost-breakdown
JudicaelPoumay Feb 15, 2026
bff8a10
chore: restore custom-elements.d.ts as symlinks to match dev
JudicaelPoumay Feb 15, 2026
27ce800
refactor: prune unnecessary changes from cost breakdown PR
JudicaelPoumay Feb 15, 2026
a2c39ab
refactor: prune noisy changes and inline redundant variables in cost …
JudicaelPoumay Feb 15, 2026
1a9101e
chore: trigger CI rerun
JudicaelPoumay Feb 15, 2026
b074e12
fix(enterprise): fix type error in custom-elements.d.ts to unblock CI
JudicaelPoumay Feb 15, 2026
33bcb81
fix(app): fix type error in custom-elements.d.ts to unblock CI
JudicaelPoumay Feb 15, 2026
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
22 changes: 18 additions & 4 deletions packages/app/src/components/session-context-usage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Match, Show, Switch, createMemo } from "solid-js"
import { Match, Show, Switch, createMemo, createEffect } from "solid-js"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button"
Expand Down Expand Up @@ -44,7 +44,15 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}),
)

const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all, sync.data))
createEffect(() => {
const missing = metrics().missing
if (missing.length > 0) {
for (const id of missing) {
sync.session.sync(id)
}
}
})
const context = createMemo(() => metrics().context)
const cost = createMemo(() => {
return usd().format(metrics().totalCost)
Expand Down Expand Up @@ -92,16 +100,22 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
<Show when={params.id}>
<Tooltip value={tooltipValue()} placement="top">
<Switch>
<Match when={variant() === "indicator"}>{circle()}</Match>
<Match when={variant() === "indicator"}>
<div class="flex items-center gap-1.5">
{circle()}
<span class="text-12-regular text-text-weak">{cost()}</span>
</div>
</Match>
<Match when={true}>
<Button
type="button"
variant="ghost"
class="size-6"
class="h-6 px-1 flex items-center gap-1.5"
onClick={openContext}
aria-label={language.t("context.usage.view")}
>
{circle()}
<span class="text-12-regular text-text-weak">{cost()}</span>
</Button>
</Match>
</Switch>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ const assistant = (
cost: number,
providerID = "openai",
modelID = "gpt-4.1",
sessionID = "s1",
) => {
return {
id,
sessionID,
role: "assistant",
providerID,
modelID,
Expand All @@ -28,9 +30,10 @@ const assistant = (
} as unknown as Message
}

const user = (id: string) => {
const user = (id: string, sessionID = "s1") => {
return {
id,
sessionID,
role: "user",
cost: 0,
time: { created: 1 },
Expand Down Expand Up @@ -98,4 +101,44 @@ describe("getSessionContextMetrics", () => {
expect(metrics.totalCost).toBe(0)
expect(metrics.context).toBeUndefined()
})

test("computes costs with store including sub-agents", () => {
const messages = [
assistant("a1", { input: 10, output: 10, reasoning: 0, read: 0, write: 0 }, 0.5, "openai", "gpt-4.1", "s1"),
]
const store = {
message: {
s1: [{ id: "a1", role: "assistant", cost: 0.5 }],
s2: [{ id: "a2", role: "assistant", cost: 1.0 }],
},
part: {
a1: [{ type: "tool", tool: "task", state: { metadata: { sessionId: "s2" } } }],
},
}

const metrics = getSessionContextMetrics(messages, [], store)

expect(metrics.totalCost).toBe(1.5)
expect(metrics.ownCost).toBe(0.5)
expect(metrics.missing).toEqual([])
})

test("identifies missing child sessions in store", () => {
const messages = [
assistant("a1", { input: 10, output: 10, reasoning: 0, read: 0, write: 0 }, 0.5, "openai", "gpt-4.1", "s1"),
]
const store = {
message: {
s1: [{ id: "a1", role: "assistant", cost: 0.5 }],
},
part: {
a1: [{ type: "tool", tool: "task", state: { metadata: { sessionId: "s2" } } }],
},
}

const metrics = getSessionContextMetrics(messages, [], store)

expect(metrics.totalCost).toBe(0.5)
expect(metrics.missing).toEqual(["s2"])
})
})
33 changes: 25 additions & 8 deletions packages/app/src/components/session/session-context-metrics.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { costs, type CostStore } from "@opencode-ai/util/cost"
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"

type Provider = {
Expand Down Expand Up @@ -31,6 +32,8 @@ type Context = {

type Metrics = {
totalCost: number
ownCost: number
missing: string[]
context: Context | undefined
}

Expand All @@ -42,23 +45,37 @@ const lastAssistantWithTokens = (messages: Message[]) => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role !== "assistant") continue
if (tokenTotal(msg) <= 0) continue
return msg
if (tokenTotal(msg as AssistantMessage) <= 0) continue
return msg as AssistantMessage
}
}

const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => {
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
const build = (messages: Message[] = [], providers: Provider[] = [], store?: CostStore): Metrics => {
const id = messages[0]?.sessionID
const result = store && id ? costs(id, store) : undefined

const message = lastAssistantWithTokens(messages)
if (!message) return { totalCost, context: undefined }
if (!message)
return {
totalCost: result
? result.total
: messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0),
ownCost: result ? result.own : 0,
missing: result ? result.missing : [],
context: undefined,
}

const provider = providers.find((item) => item.id === message.providerID)
const model = provider?.models[message.modelID]
const limit = model?.limit.context
const total = tokenTotal(message)

return {
totalCost,
totalCost: result
? result.total
: messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0),
ownCost: result ? result.own : 0,
missing: result ? result.missing : [],
context: {
message,
provider,
Expand All @@ -77,6 +94,6 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics =>
}
}

export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers)
export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = [], store?: CostStore) {
return build(messages, providers, store)
}
10 changes: 9 additions & 1 deletion packages/app/src/components/session/session-context-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,15 @@ export function SessionContextTab(props: SessionContextTabProps) {
}),
)

const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all, sync.data))
createEffect(() => {
const missing = metrics().missing
if (missing.length > 0) {
for (const id of missing) {
sync.session.sync(id)
}
}
})
const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))

Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/custom-elements.d.ts
2 changes: 1 addition & 1 deletion packages/enterprise/src/custom-elements.d.ts
8 changes: 3 additions & 5 deletions packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type Accessor, createMemo, createSignal, Match, Show, Switch } from "solid-js"
import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { costs } from "@opencode-ai/util/cost"
import { pipe, sumBy } from "remeda"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
Expand Down Expand Up @@ -36,14 +37,11 @@ export function Header() {
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])

const cost = createMemo(() => {
const total = pipe(
messages(),
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
)
const result = costs(route.sessionID, sync.data)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
}).format(result.total)
})

const context = createMemo(() => {
Expand Down
22 changes: 22 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 @@ -15,6 +15,7 @@ import { Dynamic } from "solid-js/web"
import path from "path"
import { useRoute, useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { costs } from "@opencode-ai/util/cost"
import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { selectedForeground, useTheme } from "@tui/context/theme"
Expand Down Expand Up @@ -191,6 +192,15 @@ export function Session() {
})
})

createEffect(() => {
const result = costs(route.sessionID, sync.data)
if (result.missing.length > 0) {
for (const id of result.missing) {
sync.session.sync(id)
}
}
})

const toast = useToast()
const sdk = useSDK()

Expand Down Expand Up @@ -1879,6 +1889,18 @@ function Task(props: ToolProps<typeof TaskTool>) {
<text style={{ fg: theme.textMuted }}>
{props.input.description} ({tools().length} toolcalls)
</text>
<Show when={props.metadata.sessionId}>
{(sessionId) => {
const result = createMemo(() => costs(sessionId(), sync.data))
const total = createMemo(() =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(result().total),
)
return <text style={{ fg: theme.textMuted }}>({total})</text>
}}
</Show>
<Show when={current()}>
{(item) => {
const title = item().state.status === "completed" ? (item().state as any).title : ""
Expand Down
5 changes: 3 additions & 2 deletions packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useSync } from "@tui/context/sync"
import { costs } from "@opencode-ai/util/cost"
import { createMemo, For, Show, Switch, Match } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../../context/theme"
Expand Down Expand Up @@ -41,11 +42,11 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
)

const cost = createMemo(() => {
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
const result = costs(props.sessionID, sync.data)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
}).format(result.total)
})

const context = createMemo(() => {
Expand Down
82 changes: 49 additions & 33 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,22 +581,30 @@ export namespace Session {
export const updateMessage = fn(MessageV2.Info, async (msg) => {
const time_created = msg.role === "user" ? msg.time.created : msg.time.created
const { id, sessionID, ...data } = msg
Database.use((db) => {
db.insert(MessageTable)
.values({
id,
session_id: sessionID,
time_created,
data,
})
.onConflictDoUpdate({ target: MessageTable.id, set: { data } })
.run()
Database.effect(() =>
Bus.publish(MessageV2.Event.Updated, {
info: msg,
}),
)
})
try {
Database.use((db) => {
db.insert(MessageTable)
.values({
id,
session_id: sessionID,
time_created,
data,
})
.onConflictDoUpdate({ target: MessageTable.id, set: { data } })
.run()
Database.effect(() =>
Bus.publish(MessageV2.Event.Updated, {
info: msg,
}),
)
})
} catch (e) {
if (e instanceof Error && e.message.includes("FOREIGN KEY constraint failed")) {
log.warn("ignoring message update for missing session", { sessionID, messageID: id })
return msg
}
throw e
}
return msg
})

Expand Down Expand Up @@ -646,23 +654,31 @@ export namespace Session {
export const updatePart = fn(UpdatePartInput, async (part) => {
const { id, messageID, sessionID, ...data } = part
const time = Date.now()
Database.use((db) => {
db.insert(PartTable)
.values({
id,
message_id: messageID,
session_id: sessionID,
time_created: time,
data,
})
.onConflictDoUpdate({ target: PartTable.id, set: { data } })
.run()
Database.effect(() =>
Bus.publish(MessageV2.Event.PartUpdated, {
part,
}),
)
})
try {
Database.use((db) => {
db.insert(PartTable)
.values({
id,
message_id: messageID,
session_id: sessionID,
time_created: time,
data,
})
.onConflictDoUpdate({ target: PartTable.id, set: { data } })
.run()
Database.effect(() =>
Bus.publish(MessageV2.Event.PartUpdated, {
part,
}),
)
})
} catch (e) {
if (e instanceof Error && e.message.includes("FOREIGN KEY constraint failed")) {
log.warn("ignoring part update for missing message/session", { sessionID, messageID, partID: id })
return part
}
throw e
}
return part
})

Expand Down
Loading
Loading