Skip to content
Open
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
42 changes: 41 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
session_diff: {
[sessionID: string]: Snapshot.FileDiff[]
}
session_cost: {
[sessionID: string]: { self: number; subagents: number; subagent_count: number }
}
todo: {
[sessionID: string]: Todo[]
}
Expand Down Expand Up @@ -96,6 +99,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
session: [],
session_status: {},
session_diff: {},
session_cost: {},
todo: {},
message: {},
part: {},
Expand All @@ -112,8 +116,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const kv = useKV()

const fullSyncedSessions = new Set<string>()
const inflightCostRefresh = new Set<string>()
let syncedWorkspace = project.workspace.current()

async function refreshCost(sessionID: string) {
if (inflightCostRefresh.has(sessionID)) return
inflightCostRefresh.add(sessionID)
try {
const response = await sdk.client.session.cost({ sessionID })
if (response.data) setStore("session_cost", sessionID, response.data)
} catch {
// Ignore transient errors; sidebar will keep last known value.
} finally {
inflightCostRefresh.delete(sessionID)
}
}

function sessionListQuery(): { scope?: "project"; path?: string } {
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" }
Expand Down Expand Up @@ -251,6 +269,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}

case "message.updated": {
// When an assistant message completes, refresh any cached cost
// rollups. This catches subagent completions for ancestor sidebars
// without tracking parent_id chains client-side.
if (
event.properties.info.role === "assistant" &&
event.properties.info.time?.completed &&
(event.properties.info.cost ?? 0) > 0
) {
for (const id of Object.keys(store.session_cost)) {
void refreshCost(id)
}
}
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
Expand Down Expand Up @@ -512,13 +542,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (last.role === "user") return "working"
return last.time.completed ? "idle" : "working"
},
cost(sessionID: string) {
// Returns the last fetched value (or undefined). The caller is
// expected to have triggered `syncCost` for this session.
return store.session_cost[sessionID]
},
syncCost(sessionID: string) {
return refreshCost(sessionID)
},
async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return
const [session, messages, todo, diff] = await Promise.all([
const [session, messages, todo, diff, cost] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
sdk.client.session.cost({ sessionID }),
])
setStore(
produce((draft) => {
Expand All @@ -531,6 +570,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
draft.part[message.info.id] = message.parts
}
draft.session_diff[sessionID] = diff.data ?? []
if (cost.data) draft.session_cost[sessionID] = cost.data
}),
)
fullSyncedSessions.add(sessionID)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo } from "solid-js"
import { createEffect, createMemo } from "solid-js"

const id = "internal:sidebar-context"

Expand All @@ -12,7 +12,24 @@ const money = new Intl.NumberFormat("en-US", {
function View(props: { api: TuiPluginApi; session_id: string }) {
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))

// Fetch the cost rollup whenever the session changes; the rollup is
// refreshed automatically when descendant sessions complete an assistant turn.
createEffect(() => {
const id = props.session_id
if (!id) return
props.api.state.session.refreshCost(id)
})

// Prefer the server-side rollup (which includes the parent's own cost). Fall
// back to summing local messages so we still render before the first fetch
// resolves.
const cost = createMemo(() => {
const rollup = props.api.state.session.cost(props.session_id)
if (rollup) return { self: rollup.self, subagents: rollup.subagents, subagent_count: rollup.subagent_count }
const self = msg().reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)
return { self, subagents: 0, subagent_count: 0 }
})

const state = createMemo(() => {
const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
Expand All @@ -32,14 +49,22 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
}
})

const costLine = createMemo(() => {
const c = cost()
if (c.subagent_count > 0) {
return `${money.format(c.self)} (${money.format(c.subagents)} subagents) spent`
}
return `${money.format(c.self)} spent`
})

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>
<text fg={theme().textMuted}>{money.format(cost())} spent</text>
<text fg={theme().textMuted}>{costLine()}</text>
</box>
)
}
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/cli/cmd/tui/plugin/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,12 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
question(sessionID) {
return sync.data.question[sessionID] ?? []
},
cost(sessionID) {
return sync.data.session_cost[sessionID]
},
refreshCost(sessionID) {
void sync.session.syncCost(sessionID)
},
},
part(messageID) {
return sync.data.part[messageID] ?? []
Expand Down
17 changes: 16 additions & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ const GO_UPSELL_LAST_SEEN_AT = "go_upsell_last_seen_at"
const GO_UPSELL_DONT_SHOW = "go_upsell_dont_show"
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs

const taskMoney = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })

const context = createContext<{
width: number
sessionID: string
Expand Down Expand Up @@ -1989,6 +1991,17 @@ function Task(props: ToolProps<typeof TaskTool>) {
return assistant - first
})

// Total cost of this subagent task (includes its own descendant subagents).
// Falls back to summing the locally-cached child messages until the
// server-side rollup arrives.
const totalCost = createMemo(() => {
const id = props.metadata.sessionId
if (!id) return 0
const rollup = sync.data.session_cost[id]
if (rollup) return rollup.self + rollup.subagents
return messages().reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)
})

const content = createMemo(() => {
if (!props.input.description) return ""
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${props.input.description}`]
Expand All @@ -2003,7 +2016,9 @@ function Task(props: ToolProps<typeof TaskTool>) {
}

if (props.part.state.status === "completed") {
content.push(`└ ${tools().length} toolcalls · ${Locale.duration(duration())}`)
const cost = totalCost()
const costSuffix = cost > 0 ? ` · ${taskMoney.format(cost)}` : ""
content.push(`└ ${tools().length} toolcalls · ${Locale.duration(duration())}${costSuffix}`)
}

return content.join("\n")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export const SessionPaths = {
status: `${root}/status`,
get: `${root}/:sessionID`,
children: `${root}/:sessionID/children`,
cost: `${root}/:sessionID/cost`,
todo: `${root}/:sessionID/todo`,
diff: `${root}/:sessionID/diff`,
messages: `${root}/:sessionID/message`,
Expand Down Expand Up @@ -142,6 +143,18 @@ export const SessionApi = HttpApi.make("session")
description: "Retrieve all child sessions that were forked from the specified parent session.",
}),
),
HttpApiEndpoint.get("cost", SessionPaths.cost, {
params: { sessionID: SessionID },
success: described(Session.Cost, "Cost rollup"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.cost",
summary: "Get session cost rollup",
description:
"Get the cumulative cost of this session plus the rolled-up cost of all descendant subagent sessions.",
}),
),
HttpApiEndpoint.get("todo", SessionPaths.todo, {
params: { sessionID: SessionID },
success: described(Schema.Array(Todo.Info), "Todo list"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
return yield* session.children(ctx.params.sessionID)
})

const cost = Effect.fn("SessionHttpApi.cost")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* session.cost(ctx.params.sessionID)
})

const todo = Effect.fn("SessionHttpApi.todo")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* todoSvc.get(ctx.params.sessionID)
})
Expand Down Expand Up @@ -363,6 +367,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
.handle("status", status)
.handle("get", get)
.handle("children", children)
.handle("cost", cost)
.handle("todo", todo)
.handle("diff", diff)
.handle("messages", messages)
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/server/routes/instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
app.get(SessionPaths.status, (c) => handler(c.req.raw, context))
app.get(SessionPaths.get, (c) => handler(c.req.raw, context))
app.get(SessionPaths.children, (c) => handler(c.req.raw, context))
app.get(SessionPaths.cost, (c) => handler(c.req.raw, context))
app.get(SessionPaths.todo, (c) => handler(c.req.raw, context))
app.get(SessionPaths.diff, (c) => handler(c.req.raw, context))
app.get(SessionPaths.messages, (c) => handler(c.req.raw, context))
Expand Down
34 changes: 34 additions & 0 deletions packages/opencode/src/server/routes/instance/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,40 @@ export const SessionRoutes = lazy(() =>
})
},
)
.get(
"/:sessionID/cost",
describeRoute({
summary: "Get session cost rollup",
tags: ["Session"],
description:
"Get the cumulative cost of this session plus the rolled-up cost of all descendant subagent sessions.",
operationId: "session.cost",
responses: {
200: {
description: "Cost rollup",
content: {
"application/json": {
schema: resolver(Session.Cost.zod),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: Session.CostInput.zod,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
return jsonRequest("SessionRoutes.cost", c, function* () {
const session = yield* Session.Service
return yield* session.cost(sessionID)
})
},
)
.get(
"/:sessionID/todo",
describeRoute({
Expand Down
48 changes: 48 additions & 0 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,16 @@ export const ForkInput = Schema.Struct({
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const GetInput = SessionID
export const ChildrenInput = SessionID
export const CostInput = SessionID
export const Cost = Schema.Struct({
/** Cumulative cost of assistant messages on this session only. */
self: Schema.Finite,
/** Cumulative cost of assistant messages across all descendant sessions (subagents and their descendants). */
subagents: Schema.Finite,
/** Number of descendant sessions contributing to `subagents`. */
subagent_count: NonNegativeInt,
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Cost = Schema.Schema.Type<typeof Cost>
export const RemoveInput = SessionID
export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema.String }).pipe(
withStatics((s) => ({ zod: zod(s) })),
Expand Down Expand Up @@ -448,6 +458,7 @@ export interface Interface {
readonly diff: (sessionID: SessionID) => Effect.Effect<Snapshot.FileDiff[]>
readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect<MessageV2.WithParts[]>
readonly children: (parentID: SessionID) => Effect.Effect<Info[]>
readonly cost: (sessionID: SessionID) => Effect.Effect<Cost>
readonly remove: (sessionID: SessionID) => Effect.Effect<void>
readonly updateMessage: <T extends MessageV2.Info>(msg: T) => Effect.Effect<T>
readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<MessageID>
Expand Down Expand Up @@ -554,6 +565,42 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
return rows.map(fromRow)
})

const sumAssistantCost = (sessionID: SessionID) => {
let total = 0
for (const message of MessageV2.stream(sessionID)) {
if (message.info.role === "assistant") total += message.info.cost ?? 0
}
return total
}

const cost: Interface["cost"] = Effect.fn("Session.cost")(function* (sessionID: SessionID) {
// BFS through descendants. Avoids unbounded recursion stack and lets us
// collect every transitively-spawned subagent session.
const descendants: SessionID[] = []
const queue: SessionID[] = [sessionID]
const seen = new Set<SessionID>([sessionID])
while (queue.length > 0) {
const parents = queue.splice(0, queue.length)
const rows = yield* db((d) =>
d
.select({ id: SessionTable.id, parent_id: SessionTable.parent_id })
.from(SessionTable)
.where(inArray(SessionTable.parent_id, parents))
.all(),
)
for (const row of rows) {
if (seen.has(row.id)) continue
seen.add(row.id)
descendants.push(row.id)
queue.push(row.id)
}
}
const self = sumAssistantCost(sessionID)
let subagents = 0
for (const id of descendants) subagents += sumAssistantCost(id)
return { self, subagents, subagent_count: descendants.length }
})

const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) {
try {
const session = yield* get(sessionID)
Expand Down Expand Up @@ -794,6 +841,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
diff,
messages,
children,
cost,
remove,
updateMessage,
removeMessage,
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/test/fixture/tui-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,8 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
status: opts.state?.session?.status ?? (() => undefined),
permission: opts.state?.session?.permission ?? (() => []),
question: opts.state?.session?.question ?? (() => []),
cost: opts.state?.session?.cost ?? (() => undefined),
refreshCost: opts.state?.session?.refreshCost ?? (() => {}),
},
part: opts.state?.part ?? (() => []),
lsp: opts.state?.lsp ?? (() => []),
Expand Down
Loading
Loading