Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/cli-sidebar-balance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---

Show personal credits, team credits, and Kilo Pass in the CLI sidebar, and refresh the balance immediately after switching teams.
12 changes: 12 additions & 0 deletions packages/opencode/src/kilocode/balance-refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Lets actions that change the active account (e.g. /teams org switch) tell the sidebar balance
// to re-fetch immediately, instead of waiting for its poll. Same-process pub/sub, no event-bus plumbing.
const subscribers = new Set<() => void>()

export function onBalanceRefresh(fn: () => void): () => void {
subscribers.add(fn)
return () => subscribers.delete(fn)
}

export function refreshBalance(): void {
for (const fn of subscribers) fn()
}
14 changes: 13 additions & 1 deletion packages/opencode/src/kilocode/kilo-commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { DialogClawSetup } from "./components/dialog-claw-setup.js"
import { DialogClawUpgrade } from "./components/dialog-claw-upgrade.js"
import { DialogIndexing } from "./components/dialog-indexing.js"
import { indexingEnabled } from "./indexing-feature"
import { refreshBalance } from "./balance-refresh"

// These types are OpenCode-internal and imported at runtime
type UseSDK = any
Expand Down Expand Up @@ -220,14 +221,25 @@ export function registerKiloCommands(useSDK: () => UseSDK) {
onSelect={async (orgId) => {
try {
// Switch to team immediately using server endpoint
await sdk.client.kilo.organization.set({
const result = await sdk.client.kilo.organization.set({
organizationId: orgId,
})
if (result.error) {
toast.show({
message: "Failed to switch team",
variant: "error",
})
dialog.clear()
return
}

// Refresh provider state to reload models with new organization context
await sdk.client.instance.dispose()
await sync.bootstrap()

// Update the sidebar balance immediately for the newly selected account
refreshBalance()
Comment thread
johnnyeric marked this conversation as resolved.

// Show success toast
const teamName = orgId
? profile.organizations!.find((o: Organization) => o.id === orgId)?.name
Expand Down
180 changes: 179 additions & 1 deletion packages/opencode/src/kilocode/plugins/sidebar-footer.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,73 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@kilocode/plugin/tui"
import { createMemo, Show } from "solid-js"
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import type { KiloPassState } from "@kilocode/kilo-gateway"
import type { Message } from "@kilocode/sdk/v2"
import { onBalanceRefresh } from "../balance-refresh"

const id = "internal:kilo-sidebar-footer"
const TEAM_POLL_MS = 5 * 60_000
const BILLED_REFRESH_MS = 10_000
const REFRESH_TIMEOUT_MS = 30_000
const log = Log.create({ service: "sidebar-footer" })

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

type State = {
balance?: number
scope: ReturnType<typeof scope>
pass: KiloPassState | null
}

export function format(value: number) {
return usd.format(value)
}

export function scope(org: string | null | undefined, list?: readonly { id: string; name: string }[]) {
if (!org) {
return { kind: "Personal" as const }
}
return {
kind: "Team" as const,
name: list?.find((item) => item.id === org)?.name,
}
}

export function creditLabel(value: ReturnType<typeof scope>) {
if (value.kind === "Personal") return "Personal credits"
return value.name ? `${value.name} team` : "Team credits"
}

const short = (value: number) => "$" + Math.round(value)

// Pass credits are part of the personal balance, so we show this period's usage against the base allotment.
export function passLine(pass: KiloPassState) {
return `${short(pass.currentPeriodUsageUsd)} / ${short(pass.currentPeriodBaseCreditsUsd)}`
}

export function resetLabel(iso?: string | null) {
if (!iso) return undefined
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return undefined
return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" }).format(date)
}

// A billed turn: a completed Kilo assistant message with a non-zero cost.
export function billable(info: Message) {
if (info.role !== "assistant") return false
return info.providerID === "kilo" && info.time.completed !== undefined && info.cost > 0
}

function View(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current
const [state, setState] = createSignal<State>()
let seq = 0
let inflight: AbortController | undefined
let teamPoll: ReturnType<typeof setInterval> | undefined
const has = createMemo(() =>
props.api.state.provider.some(
(item) =>
Expand All @@ -15,6 +77,17 @@ function View(props: { api: TuiPluginApi }) {
)
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
const show = createMemo(() => !has() && !done())
const wallet = createMemo(() => {
const data = state()
if (!data) return undefined
if (data.balance !== undefined) return data
if (data.scope.kind === "Personal" && data.pass) return data
return undefined
})
const tone = createMemo(() => {
const value = state()?.balance
return value !== undefined && value <= 2 ? theme().error : theme().textMuted
})
const path = createMemo(() => {
const dir = props.api.state.path.directory || process.cwd()
const out = dir.replace(Global.Path.home, "~")
Expand All @@ -25,9 +98,114 @@ function View(props: { api: TuiPluginApi }) {
name: list.at(-1) ?? "",
}
})
const refresh = () => {
const id = ++seq
// Cancel any prior request and time this one out — the client path has no fetch timeout,
// so a stalled Gateway call would otherwise leak the in-flight request and its closure.
inflight?.abort()
const controller = new AbortController()
inflight = controller
const timeout = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS)
void props.api.client.kilo
.profile(undefined, { signal: controller.signal })
.then((res) => {
if (id !== seq) return
if (res.error || !res.data) {
setState(undefined)
return
}
// Show the wallet whenever authenticated — an empty/zero balance is real data, not "still loading".
const next: State = {
balance: res.data.balance?.balance,
scope: scope(res.data.currentOrgId, res.data.profile.organizations),
pass: res.data.kiloPass ?? null,
}
setState(next)
// Team balances move with other members' usage, so poll while on a team; personal updates on spend.
clearInterval(teamPoll)
teamPoll = next.scope.kind === "Team" ? setInterval(refresh, TEAM_POLL_MS) : undefined
})
.catch((err) => {
if (id !== seq) return
setState(undefined)
log.debug("balance refresh failed", { err })
})
.finally(() => {
clearTimeout(timeout)
if (inflight === controller) inflight = undefined
})
}

onMount(() => {
refresh()
// Switching org via /teams fires this, so the balance updates immediately.
onCleanup(onBalanceRefresh(refresh))
// Refresh shortly after a billed turn so the balance reflects new spend.
let billed: ReturnType<typeof setTimeout> | undefined
onCleanup(
props.api.event.on("message.updated", (event) => {
if (!billable(event.properties.info)) return
clearTimeout(billed)
billed = setTimeout(refresh, BILLED_REFRESH_MS)
}),
)
onCleanup(() => {
clearTimeout(billed)
clearInterval(teamPoll)
inflight?.abort()
})
})

return (
<box gap={1}>
<Show when={wallet()}>
{(data) => (
<box gap={0}>
<text fg={theme().text}>
<b>Balance</b>
</text>
{(() => {
const balance = data().balance
if (balance === undefined) return null
return (
<box flexDirection="row" justifyContent="space-between">
<box flexDirection="row" gap={1}>
<text fg={tone()}>•</text>
<text fg={theme().text}>
<b>{creditLabel(data().scope)}</b>
</text>
</box>
<text fg={tone()}>{format(balance)}</text>
</box>
)
})()}
<Show when={data().scope.kind === "Personal" ? data().pass : null}>
{(pass) => (
<box gap={0}>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<text fg={theme().textMuted}>{" └ Kilo Pass"}</text>
<text fg={theme().textMuted}>{passLine(pass())}</text>
</box>
<Show when={pass().currentPeriodBonusCreditsUsd > 0}>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<text fg={theme().textMuted}>{" Bonus"}</text>
<text fg={theme().textMuted}>{"+" + format(pass().currentPeriodBonusCreditsUsd)}</text>
</box>
</Show>
<Show when={resetLabel(pass().nextBillingAt)}>
{(date) => (
<box flexDirection="row" justifyContent="space-between" gap={1}>
<text fg={theme().textMuted}>{" Renews"}</text>
<text fg={theme().textMuted}>{date()}</text>
</box>
)}
</Show>
</box>
)}
</Show>
</box>
)}
</Show>
<Show when={show()}>
<box
backgroundColor={theme().backgroundElement}
Expand Down
55 changes: 55 additions & 0 deletions packages/opencode/test/kilocode/sidebar-footer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test"
import type { KiloPassState } from "@kilocode/kilo-gateway"
import type { Message } from "@kilocode/sdk/v2"

import { billable, creditLabel, format, passLine, resetLabel, scope } from "../../src/kilocode/plugins/sidebar-footer"

const kiloPass = {
currentPeriodBaseCreditsUsd: 199,
currentPeriodUsageUsd: 73.27,
currentPeriodBonusCreditsUsd: 99.5,
nextBillingAt: "2026-07-01T00:00:00.000Z",
} satisfies KiloPassState

const message = {
id: "msg_1",
sessionID: "ses_1",
role: "assistant",
time: { created: 1, completed: 2 },
parentID: "msg_0",
modelID: "kilo-auto/balanced",
providerID: "kilo",
mode: "build",
agent: "build",
path: { cwd: "/tmp", root: "/tmp" },
cost: 0.1,
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
} satisfies Message

describe("Kilo sidebar footer", () => {
test("formats money", () => {
expect(format(12.345)).toBe("$12.35")
expect(format(0)).toBe("$0.00")
})

test("labels balance scope", () => {
expect(scope(null)).toEqual({ kind: "Personal" })
expect(scope("org_1", [{ id: "org_1", name: "Acme" }])).toEqual({ kind: "Team", name: "Acme" })
expect(creditLabel(scope(null))).toBe("Personal credits")
expect(creditLabel(scope("org_1", [{ id: "org_1", name: "Acme" }]))).toBe("Acme team")
})

test("shows pass period usage and reset date", () => {
expect(passLine(kiloPass)).toBe("$73 / $199")
expect(resetLabel(kiloPass.nextBillingAt)).toBe("Jul 1")
expect(resetLabel(null)).toBeUndefined()
expect(resetLabel("not-a-date")).toBeUndefined()
})

test("refreshes only after completed billed Kilo turns", () => {
expect(billable(message)).toBeTrue()
expect(billable({ ...message, providerID: "anthropic" })).toBeFalse()
expect(billable({ ...message, cost: 0 })).toBeFalse()
expect(billable({ ...message, time: { created: 1 } })).toBeFalse()
})
})
Loading