feat(vscode): show usage by routed model

This commit is contained in:
Josh Lambert
2026-06-26 20:27:56 -04:00
parent 02633eb8cb
commit 5080c78e62
18 changed files with 620 additions and 38 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@kilocode/cli": patch
"@kilocode/sdk": patch
"kilo-code": patch
---
Show full task token usage and a provider-grouped model breakdown in the VS Code session header.
+36 -1
View File
@@ -983,6 +983,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "loadSessions":
this.handleLoadSessions().catch((e) => console.error("[Kilo New] handleLoadSessions failed:", e))
break
case "requestSessionModelUsage":
void this.fetchAndSendSessionModelUsage(message.sessionID, message.requestID)
break
case "login": {
const attempt = ++this.loginAttempt
await handleLogin(this.authCtx, attempt, () => this.loginAttempt)
@@ -1662,6 +1665,29 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
.catch((e: unknown) => console.error("[Kilo New] KiloProvider: Failed to fetch session statuses:", e))
}
private fetchAndSendSessionModelUsage(sessionID: string, requestID: string): Promise<void> {
const directory = this.getWorkspaceDirectory(sessionID)
return this.connectionService
.getClientAsync(directory)
.then(async (client) => {
const load = () => client.kilocode.sessionModelUsage({ sessionID, directory }, { throwOnError: true })
const first = await load()
const added = first.data.sessionIDs.some((id) => {
if (this.trackedSessionIds.has(id)) return false
this.trackedSessionIds.add(id)
return true
})
return added ? load() : first
})
.then((response) => {
this.postMessage({ type: "sessionModelUsageLoaded", sessionID, requestID, data: response.data })
})
.catch((error: unknown) => {
console.warn("[Kilo New] KiloProvider: Failed to load session model usage:", error)
this.postMessage({ type: "sessionModelUsageLoaded", sessionID, requestID })
})
}
private async handleLoadMessages(
sessionID: string,
options: { mode?: MessageLoadMode; before?: string; limit?: number; preserveStream?: boolean } = {},
@@ -3412,7 +3438,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
: sessionPatchToWebview(event.properties.sessionID, event.properties.info),
}
case "session.deleted":
return null
return {
type: "sessionDeleted" as const,
sessionID: event.properties.sessionID,
}
}
}
@@ -3548,6 +3577,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.setCurrentSession(applySessionPatch(this.currentSession, event.properties.info))
this.contextSessionID = event.properties.sessionID
}
if (event.type === "session.deleted") {
const sid = event.properties.sessionID
this.trackedSessionIds.delete(sid)
this.sessionDirectories.delete(sid)
this.connectionService.pruneSession(sid)
}
// Auto-adopt child sessions as soon as the task tool part reveals their ID.
// This means the child's permission/question events are tracked immediately —
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test"
import type { Provider, SessionModelUsage } from "../../webview-ui/src/types/messages"
import {
groupModelUsage,
hasModelUsage,
isSameSessionTree,
modelUsageName,
tokenSummary,
} from "../../webview-ui/src/context/model-usage"
const tokens = { input: 10, output: 2, reasoning: 1, cache: { read: 20, write: 5 } }
const models = [
{ providerID: "kilo", modelID: "qwen/qwen3.7-plus-20260602", steps: 1, cost: 0.01, tokens },
{ providerID: "minimax", modelID: "minimax-m3", steps: 1, cost: 0.02, tokens },
]
const usage = {
sessionIDs: ["root", "child"],
totals: { steps: 2, cost: 0.03, tokens },
models,
} satisfies SessionModelUsage
const providers = {
kilo: {
id: "kilo",
name: "Kilo Gateway",
models: {
"qwen/qwen3.7-plus": { id: "qwen/qwen3.7-plus", name: "Qwen: Qwen3.7 Plus (20% off)" },
},
},
minimax: {
id: "minimax",
name: "MiniMax",
models: { "minimax-m3": { id: "minimax-m3", name: "MiniMax M3" } },
},
} satisfies Record<string, Provider>
describe("model usage", () => {
test("groups billing routes and resolves compact catalog names", () => {
expect(hasModelUsage(usage)).toBeTrue()
expect(
hasModelUsage({
sessionIDs: [],
totals: { steps: 0, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } },
models: [],
}),
).toBeFalse()
expect(tokenSummary(usage)).toEqual({ input: 10, output: 2, cached: 20 })
expect(groupModelUsage(models, providers).map((group) => group.providerName)).toEqual(["Kilo Gateway", "MiniMax"])
expect(modelUsageName(models[0], providers)).toBe("Qwen 3.7 Plus")
expect(modelUsageName(models[1], providers)).toBe("MiniMax M3")
expect(modelUsageName({ ...models[0], modelID: "moonshotai/kimi-k2.7-code-20260612" }, {})).toBe("kimi-k2.7-code")
})
test("matches sessions through their top-level tree", () => {
const sessions = new Map([
["root", {}],
["child", { parentID: "root" }],
["sibling", { parentID: "root" }],
["other", {}],
])
const get = (id: string) => sessions.get(id)
expect(isSameSessionTree("child", "sibling", get)).toBeTrue()
expect(isSameSessionTree("child", "new", get, "sibling")).toBeTrue()
expect(isSameSessionTree("child", "other", get)).toBeFalse()
})
})
@@ -19,6 +19,8 @@ import { useLanguage } from "../../context/language"
import { useVSCode } from "../../context/vscode"
import { TaskTimeline } from "./TaskTimeline"
import { ContextProgress } from "./ContextProgress"
import { TaskUsage } from "./TaskUsage"
import { hasModelUsage, tokenSummary } from "../../context/model-usage"
import { SessionRenameEditor } from "../shared/SessionRenameEditor"
import { target as todoTarget } from "../../context/todo-revert"
import type { Part, TodoItem, ExtensionMessage } from "../../types/messages"
@@ -68,7 +70,10 @@ export const TaskHeader: Component<TaskHeaderProps> = (props) => {
return { tokens, pct }
})
const tokens = createMemo(() => calcTokenUsage(session.visibleMessages()))
const tokens = createMemo(() => {
const usage = session.modelUsage()
return hasModelUsage(usage) ? tokenSummary(usage) : calcTokenUsage(session.visibleMessages())
})
const hasTimeline = createMemo(() => {
for (const m of session.visibleMessages()) {
@@ -78,12 +83,6 @@ export const TaskHeader: Component<TaskHeaderProps> = (props) => {
return false
})
const fmtNum = (n: number): string => {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
}
const vscode = useVSCode()
const [expanded, setExpanded] = createSignal(true)
@@ -233,31 +232,7 @@ export const TaskHeader: Component<TaskHeaderProps> = (props) => {
<div data-slot="task-header-graph-row">
<ContextProgress />
</div>
<Show when={tokens()}>
{(tk) => (
<div class="task-header-tokens">
<span class="task-header-tokens-label">Tokens</span>
<Show when={tk().input > 0}>
<span class="task-header-tokens-value">
<Icon name="arrow-up" size="small" />
{fmtNum(tk().input)}
</span>
</Show>
<Show when={tk().output > 0}>
<span class="task-header-tokens-value">
<Icon name="arrow-down-to-line" size="small" />
{fmtNum(tk().output)}
</span>
</Show>
<Show when={tk().cached > 0}>
<span class="task-header-tokens-value">
<Icon name="arrow-down-to-line" size="small" />
cache {fmtNum(tk().cached)}
</span>
</Show>
</div>
)}
</Show>
<Show when={tokens()}>{(tk) => <TaskUsage tokens={tk()} usage={session.modelUsage()} />}</Show>
</div>
</Show>
<Show when={hasTodos()}>
@@ -0,0 +1,121 @@
import type { Component } from "solid-js"
import { For, Show, createMemo } from "solid-js"
import { Collapsible } from "@kilocode/kilo-ui/collapsible"
import { Icon } from "@kilocode/kilo-ui/icon"
import { useLanguage } from "../../context/language"
import { useProvider } from "../../context/provider"
import type { SessionModelUsage } from "../../types/messages"
import { groupModelUsage, modelUsageName, type TokenSummary } from "../../context/model-usage"
interface TaskUsageProps {
tokens: TokenSummary
usage?: SessionModelUsage
defaultOpen?: boolean
}
export const TaskUsage: Component<TaskUsageProps> = (props) => {
const language = useLanguage()
const provider = useProvider()
const groups = createMemo(() => groupModelUsage(props.usage?.models ?? [], provider.providers()))
const money = createMemo(
() =>
new Intl.NumberFormat(language.locale(), {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 6,
}),
)
const number = (value: number) => {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
return String(value)
}
const count = (value: number) => value.toLocaleString(language.locale())
const cost = (input: number) => {
const value = Math.max(0, Number.isFinite(input) ? input : 0)
if (value > 0 && value < 0.000001) return "<$0.000001"
return money().format(value)
}
const rate = (model: SessionModelUsage["models"][number]) => {
const total = model.tokens.input + model.tokens.cache.read + model.tokens.cache.write
if (total === 0) return "-"
return `${((model.tokens.cache.read / total) * 100).toFixed(1)}%`
}
const Summary = () => (
<>
<span class="task-header-tokens-label">Tokens</span>
<Show when={props.tokens.input > 0}>
<span class="task-header-tokens-value">
<Icon name="arrow-up" size="small" />
{number(props.tokens.input)}
</span>
</Show>
<Show when={props.tokens.output > 0}>
<span class="task-header-tokens-value">
<Icon name="arrow-down-to-line" size="small" />
{number(props.tokens.output)}
</span>
</Show>
<Show when={props.tokens.cached > 0}>
<span class="task-header-tokens-value">
<Icon name="arrow-down-to-line" size="small" />
cache {number(props.tokens.cached)}
</span>
</Show>
</>
)
return (
<Show
when={props.usage?.models.length}
fallback={
<div class="task-header-tokens">
<Summary />
</div>
}
>
<Collapsible variant="ghost" class="task-header-usage tool-collapsible" defaultOpen={props.defaultOpen}>
<Collapsible.Trigger class="task-header-usage-trigger">
<span class="task-header-tokens">
<Summary />
</span>
<Collapsible.Arrow />
</Collapsible.Trigger>
<Collapsible.Content>
<div class="task-header-usage-detail">
<For each={groups()}>
{(group) => (
<section class="task-header-usage-provider">
<h4>{group.providerName}</h4>
<For each={group.models}>
{(model) => (
<div class="task-header-usage-model">
<div class="task-header-usage-model-name" title={`${model.providerID}/${model.modelID}`}>
{modelUsageName(model, provider.providers())}
</div>
<div class="task-header-usage-meta">
{model.steps} {model.steps === 1 ? "step" : "steps"} · {cost(model.cost)}
</div>
<div class="task-header-usage-meta">
In {count(model.tokens.input)} · Out {count(model.tokens.output)} · Reason{" "}
{count(model.tokens.reasoning)}
</div>
<div class="task-header-usage-meta">
Cache R {count(model.tokens.cache.read)} · W {count(model.tokens.cache.write)} · Rate{" "}
{rate(model)}
</div>
</div>
)}
</For>
</section>
)}
</For>
</div>
</Collapsible.Content>
</Collapsible>
</Show>
)
}
@@ -0,0 +1,72 @@
import type { Provider, SessionModelUsage } from "../types/messages"
const DATE_SUFFIX = /(?:-(?:20\d{6}|20\d{2}-\d{2}-\d{2}))(?:-v\d+(?::\d+)?)?$/i
export type TokenSummary = { input: number; output: number; cached: number }
export function isSameSessionTree(
current: string,
sessionID: string,
get: (id: string) => { parentID?: string | null } | undefined,
parentID?: string | null,
) {
const top = (start: string, first?: string | null): string => {
const seen = new Set<string>()
const visit = (id: string, parent?: string | null): string => {
if (seen.has(id)) return start
seen.add(id)
const next = parent ?? get(id)?.parentID
return next ? visit(next) : id
}
return visit(start, first)
}
return top(current) === top(sessionID, parentID)
}
export function hasModelUsage(usage: SessionModelUsage | undefined): usage is SessionModelUsage {
if (!usage) return false
const tokens = usage.totals.tokens
return (
usage.models.length > 0 ||
usage.totals.steps > 0 ||
usage.totals.cost > 0 ||
tokens.input > 0 ||
tokens.output > 0 ||
tokens.reasoning > 0 ||
tokens.cache.read > 0 ||
tokens.cache.write > 0
)
}
export function tokenSummary(usage: SessionModelUsage): TokenSummary {
return {
input: usage.totals.tokens.input,
output: usage.totals.tokens.output,
cached: usage.totals.tokens.cache.read,
}
}
export function groupModelUsage(models: SessionModelUsage["models"], providers: Record<string, Provider>) {
const groups = new Map<string, { providerID: string; providerName: string; models: SessionModelUsage["models"] }>()
for (const model of models) {
const group = groups.get(model.providerID) ?? {
providerID: model.providerID,
providerName: providers[model.providerID]?.name ?? model.providerID,
models: [],
}
group.models.push(model)
groups.set(model.providerID, group)
}
return [...groups.values()]
}
export function modelUsageName(model: SessionModelUsage["models"][number], providers: Record<string, Provider>) {
const provider = providers[model.providerID]
const id = model.modelID.replace(DATE_SUFFIX, "")
const name = provider?.models[model.modelID]?.name ?? provider?.models[id]?.name ?? id
return name
.replace(/^[^:]+:\s*/, "")
.replace(/^[^/]+\//, "")
.replace(/\s*\([^)]*%\s*off[^)]*\)\s*$/i, "")
.replace(/^qwen(?=\d)/i, "Qwen ")
}
@@ -25,6 +25,7 @@ import { useLanguage } from "./language"
import { showToast } from "@kilocode/kilo-ui/toast"
import type {
SessionInfo,
SessionModelUsage,
SessionUpdate,
Message,
Part,
@@ -74,6 +75,7 @@ import { KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "../../../src/shar
import { reviewMetadata, type ReviewMessageData } from "../../../src/shared/review-comments"
import { visibleMessages as filterVisibleMessages } from "./session-queue"
import { createAbortState } from "./abort-state"
import { isSameSessionTree } from "./model-usage"
const RECENT_LIMIT = 5
const MESSAGE_PAGE_LIMIT = 80
@@ -109,6 +111,7 @@ interface SessionStore {
variantSelections: Record<string, string> // session/agent scoped variant key -> variant name
recentModels: ModelSelection[]
favoriteModels: ModelSelection[]
modelUsage: Record<string, { requestID: string; data?: SessionModelUsage }>
}
interface SessionContextValue {
@@ -194,6 +197,8 @@ interface SessionContextValue {
// Cost and context usage for the current session
costBreakdown: Accessor<Array<{ label: string; cost: number }>>
contextUsage: Accessor<ContextUsage | undefined>
modelUsage: Accessor<SessionModelUsage | undefined>
refreshModelUsage: () => void
// Skills loaded from the CLI backend
skills: Accessor<SkillInfo[]>
@@ -500,7 +505,27 @@ export const SessionProvider: ParentComponent = (props) => {
variantSelections: {},
recentModels: [],
favoriteModels: [],
modelUsage: {},
})
const [modelUsageReady, setModelUsageReady] = createSignal(false)
let modelUsageQueued = false
function refreshModelUsage() {
const sessionID = currentSessionID()
if (!sessionID || sessionID.startsWith("cloud:")) return
const requestID = crypto.randomUUID()
setStore("modelUsage", sessionID, { requestID, data: store.modelUsage[sessionID]?.data })
vscode.postMessage({ type: "requestSessionModelUsage", sessionID, requestID })
}
function queueModelUsageRefresh() {
if (modelUsageQueued) return
modelUsageQueued = true
queueMicrotask(() => {
modelUsageQueued = false
refreshModelUsage()
})
}
// Per-session agent selection
const selectedAgentName = createMemo<string>(() => {
@@ -983,9 +1008,42 @@ export const SessionProvider: ParentComponent = (props) => {
return false
}
function handleModelUsageMessage(message: ExtensionMessage): boolean {
if (message.type !== "sessionModelUsageLoaded") return false
const state = store.modelUsage[message.sessionID]
if (state?.requestID === message.requestID) {
setStore("modelUsage", message.sessionID, { requestID: message.requestID, data: message.data })
}
return true
}
function refreshModelUsageForMessage(message: ExtensionMessage) {
if (message.type === "partUpdated") {
if (message.part.type === "step-finish" && modelUsageRelated(message.sessionID)) queueModelUsageRefresh()
return
}
if (message.type === "partsUpdated") {
if (message.updates.some((item) => item.part.type === "step-finish" && modelUsageRelated(item.sessionID))) {
queueModelUsageRefresh()
}
return
}
if (message.type === "partRemoved" || message.type === "messageRemoved" || message.type === "sessionDeleted") {
if (modelUsageRelated(message.sessionID, store.sessions[message.sessionID]?.parentID)) queueModelUsageRefresh()
return
}
if (message.type === "sessionCreated" && modelUsageRelated(message.session.id, message.session.parentID)) {
queueModelUsageRefresh()
return
}
if (message.type === "extensionDataReady") queueModelUsageRefresh()
}
function handleExtensionMessage(message: ExtensionMessage): void {
// Route suggestion messages (extracted to stay within complexity limit)
routeSuggestionMessage(message)
if (handleModelUsageMessage(message)) return
refreshModelUsageForMessage(message)
if (handleStreamMessage(message)) return
switch (message.type) {
case "sessionCreated":
@@ -1110,6 +1168,7 @@ export const SessionProvider: ParentComponent = (props) => {
// Handle messages from extension
onMount(() => {
const unsubscribe = vscode.onMessage(handleExtensionMessage)
setModelUsageReady(true)
onCleanup(unsubscribe)
})
@@ -1758,10 +1817,23 @@ export const SessionProvider: ParentComponent = (props) => {
return sessionIDs(rootID, (sid) => store.messages[sid] ?? [])
}
function modelUsageRelated(sessionID: string, parentID?: string | null): boolean {
const current = currentSessionID()
if (!current) return false
const ids = store.modelUsage[current]?.data?.sessionIDs
if (ids?.includes(sessionID) || (!!parentID && ids?.includes(parentID))) return true
return isSameSessionTree(current, sessionID, (id) => store.sessions[id], parentID)
}
function visibleFamily(rootID: string): Set<string> {
return sessionIDs(rootID, visible)
}
createEffect(() => {
if (!modelUsageReady() || !server.isConnected() || !currentSessionID()) return
untrack(refreshModelUsage)
})
/** Return permissions scoped to the given session's family (self + subagents). */
function scopedPermissions(sessionID: string | undefined): PermissionRequest[] {
if (!sessionID) return []
@@ -1872,6 +1944,14 @@ export const SessionProvider: ParentComponent = (props) => {
delete todos[sessionID]
}),
)
setStore(
"modelUsage",
produce((usage) => {
for (const [id, state] of Object.entries(usage)) {
if (id === sessionID || state.data?.sessionIDs.includes(sessionID)) delete usage[id]
}
}),
)
setPages(
produce((map) => {
delete map[sessionID]
@@ -2684,6 +2764,11 @@ export const SessionProvider: ParentComponent = (props) => {
return fallback
})
const modelUsage = createMemo<SessionModelUsage | undefined>(() => {
const id = currentSessionID()
return id ? store.modelUsage[id]?.data : undefined
})
const contextUsage = createMemo<ContextUsage | undefined>(() => {
const msgs = visibleMessages()
for (let i = msgs.length - 1; i >= 0; i--) {
@@ -2740,6 +2825,8 @@ export const SessionProvider: ParentComponent = (props) => {
clearModelOverride,
costBreakdown,
contextUsage,
modelUsage,
refreshModelUsage,
agents,
allAgents,
skills,
@@ -220,6 +220,8 @@ export function mockSessionValue(overrides?: {
clearModelOverride: noop,
costBreakdown: () => [],
contextUsage: () => undefined,
modelUsage: () => undefined,
refreshModelUsage: noop,
agents: () => [{ name: "code", description: "Code mode", mode: "primary" as const }],
allAgents: () => [{ name: "code", description: "Code mode", mode: "primary" as const }],
skills: () => [],
@@ -13,15 +13,25 @@ import { StoryProviders, defaultMockData, mockSessionValue } from "./StoryProvid
import { ChatView } from "../components/chat/ChatView"
import { ErrorDisplay } from "../components/chat/ErrorDisplay"
import { TaskHeader } from "../components/chat/TaskHeader"
import { TaskUsage } from "../components/chat/TaskUsage"
import { QuestionDock } from "../components/chat/QuestionDock"
import { SuggestBar } from "../components/chat/SuggestBar"
import { MessageList } from "../components/chat/MessageList"
import { VscodeUserMessage } from "../components/chat/VscodeUserMessage"
import { TurnOutcome } from "../components/shared/TurnOutcome"
import { SessionContext } from "../context/session"
import { ProviderContext } from "../context/provider"
import { ServerContext } from "../context/server"
import { WorktreeModeProvider } from "../context/worktree-mode"
import type { Message, Part, QuestionRequest, ReviewComment, SuggestionRequest, TodoItem } from "../types/messages"
import type {
Message,
Part,
QuestionRequest,
ReviewComment,
SessionModelUsage,
SuggestionRequest,
TodoItem,
} from "../types/messages"
import { formatReviewCommentsMarkdown } from "../utils/review-comment-markdown"
import { reviewMetadata } from "../../../src/shared/review-comments"
@@ -850,6 +860,84 @@ export const TaskHeaderWithTodosAllDone: Story = {
},
}
const usageTokens = { input: 25_900_000, output: 52_000, reasoning: 4_100, cache: { read: 10_500_000, write: 80_000 } }
const usageData = {
sessionIDs: [SESSION_ID, "story-subagent-001"],
totals: {
steps: 4,
cost: 0.097214,
tokens: { input: 25_908_400, output: 52_710, reasoning: 4_220, cache: { read: 10_514_000, write: 80_900 } },
},
models: [
{ providerID: "kilo", modelID: "qwen/qwen3.7-plus-20260602", steps: 3, cost: 0.067214, tokens: usageTokens },
{
providerID: "minimax",
modelID: "minimax-m3",
steps: 1,
cost: 0.03,
tokens: { input: 8_400, output: 710, reasoning: 120, cache: { read: 14_000, write: 900 } },
},
],
} satisfies SessionModelUsage
const usageProviders = {
kilo: {
id: "kilo",
name: "Kilo Gateway",
models: {
"qwen/qwen3.7-plus": { id: "qwen/qwen3.7-plus", name: "Qwen: Qwen3.7 Plus (20% off)" },
},
},
minimax: {
id: "minimax",
name: "MiniMax",
models: { "minimax-m3": { id: "minimax-m3", name: "MiniMax M3" } },
},
}
const usageProvider = {
providers: () => usageProviders,
connected: () => ["kilo", "minimax"],
defaults: () => ({}),
defaultSelection: () => ({ providerID: "kilo", modelID: "qwen/qwen3.7-plus" }),
models: () => [],
findModel: () => undefined,
authMethods: () => ({}),
authStates: () => ({}),
isModelValid: () => true,
}
const usageStory = (open: boolean) => () => (
<StoryProviders sessionID={SESSION_ID} status="idle" noPadding>
<ProviderContext.Provider value={usageProvider as any}>
<div style={{ "max-height": "560px", overflow: "auto" }}>
<TaskUsage
defaultOpen={open}
usage={usageData}
tokens={{
input: usageData.totals.tokens.input,
output: usageData.totals.tokens.output,
cached: usageData.totals.tokens.cache.read,
}}
/>
</div>
</ProviderContext.Provider>
</StoryProviders>
)
export const TaskUsageCollapsed: Story = {
name: "Task usage — collapsed",
render: usageStory(false),
}
export const TaskUsageExpanded: Story = {
name: "Task usage — provider and model breakdown",
render: usageStory(true),
}
export const TaskUsageExpanded200: Story = {
name: "Task usage — provider and model breakdown, narrow",
render: usageStory(true),
}
// ---------------------------------------------------------------------------
// Welcome screen with AccountSwitcher + KiloNotifications
// ---------------------------------------------------------------------------
@@ -254,11 +254,11 @@
.task-header-tokens {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
gap: 2px 6px;
padding: 0 8px;
font-size: var(--kilo-font-size-11);
color: var(--vscode-descriptionForeground);
white-space: nowrap;
}
.task-header-tokens-label {
@@ -271,6 +271,84 @@
gap: 2px;
}
.task-header-usage-trigger {
display: flex;
align-items: flex-start;
width: 100%;
min-height: 28px;
height: auto;
padding: 2px 0;
border-radius: 3px;
color: inherit;
}
.task-header-usage-trigger:hover {
background: var(--vscode-toolbar-hoverBackground);
}
.task-header-usage-trigger:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
}
.task-header-usage-trigger .task-header-tokens {
flex: 1;
min-width: 0;
padding-right: 0;
}
.task-header-usage-detail {
display: flex;
flex-direction: column;
gap: 8px;
max-height: min(320px, 45vh);
margin: 4px 8px 2px;
padding-left: 8px;
overflow-y: auto;
border-left: 1px solid var(--border-weak-base);
}
.task-header-usage-provider {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.task-header-usage-provider h4 {
margin: 0;
color: var(--vscode-foreground);
font-size: var(--kilo-font-size-11);
font-weight: 600;
}
.task-header-usage-model {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.task-header-usage-model + .task-header-usage-model {
padding-top: 4px;
border-top: 1px solid var(--border-weak-base);
}
.task-header-usage-model-name {
overflow: hidden;
color: var(--vscode-foreground);
font-size: var(--kilo-font-size-11);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-header-usage-meta {
color: var(--vscode-descriptionForeground);
font-size: var(--kilo-font-size-11);
font-variant-numeric: tabular-nums;
}
/* Expand toggle */
[data-slot="task-header-expand"] {
all: unset;
@@ -11,6 +11,7 @@ import type {
MessageLoadMode,
SessionCloseReason,
SessionInfo,
SessionModelUsage,
SessionUpdate,
} from "./sessions"
import type { PermissionRequest } from "./permissions"
@@ -194,6 +195,13 @@ export interface MessagesLoadedMessage {
since?: number
}
export interface SessionModelUsageLoadedMessage {
type: "sessionModelUsageLoaded"
sessionID: string
requestID: string
data?: SessionModelUsage
}
export interface MessageCreatedMessage {
type: "messageCreated"
message: Message
@@ -1027,6 +1035,7 @@ export type ExtensionMessage =
| SessionDeletedMessage
| MessageRemovedMessage
| MessagesLoadedMessage
| SessionModelUsageLoadedMessage
| MessageCreatedMessage
| SessionsLoadedMessage
| CloudSessionsLoadedMessage
@@ -1,5 +1,8 @@
import type { KilocodeSessionModelUsageResponse } from "@kilocode/sdk/v2"
import type { Part, TokenUsage } from "./parts"
export type SessionModelUsage = KilocodeSessionModelUsageResponse
export type SessionCloseReason = "completed" | "error" | "interrupted"
// Message structure (simplified for webview)
@@ -82,6 +82,12 @@ export interface LoadSessionsRequest {
type: "loadSessions"
}
export interface RequestSessionModelUsageMessage {
type: "requestSessionModelUsage"
sessionID: string
requestID: string
}
export interface RequestCloudSessionsMessage {
type: "requestCloudSessions"
cursor?: string
@@ -1145,6 +1151,7 @@ export type WebviewMessage =
| ClearSessionRequest
| LoadMessagesRequest
| LoadSessionsRequest
| RequestSessionModelUsageMessage
| RequestCloudSessionsMessage
| RequestGitRemoteUrlMessage
| LoginRequest
@@ -31,6 +31,7 @@ export namespace ModelUsage {
type Model = typeof Model.Type
export const Info = Schema.Struct({
sessionIDs: Schema.Array(SessionID),
totals: Usage,
models: Schema.Array(Model),
})
@@ -76,6 +77,23 @@ export namespace ModelUsage {
SELECT id, parent_id AS parentID
FROM ancestor`
const FAMILY_SQL = `
WITH RECURSIVE family(id) AS (
SELECT id
FROM session
WHERE id = ? AND project_id = ?
UNION
SELECT child.id
FROM session AS child
JOIN family AS parent ON child.parent_id = parent.id
WHERE child.project_id = ?
)
SELECT id
FROM family
ORDER BY id`
const USAGE_SQL = `
WITH RECURSIVE family(id) AS (
SELECT id
@@ -141,6 +159,10 @@ export namespace ModelUsage {
const ids = new Set(ancestors.map((item) => item.id))
const rootID = ancestors.find((item) => !item.parentID || !ids.has(item.parentID))?.id ?? sessionID
const familyArgs = [rootID, anchor.projectID, anchor.projectID] as const
const sessionIDs = db
.prepare<{ id: SessionID }, [string, string, string]>(FAMILY_SQL)
.all(...familyArgs)
.map((item) => item.id)
const rows = db.prepare<Row, [string, string, string]>(USAGE_SQL).all(...familyArgs)
const totals = empty()
const models = rows.map((row): Model => {
@@ -165,7 +187,7 @@ export namespace ModelUsage {
}
})
return { totals, models } satisfies Info
return { sessionIDs, totals, models } satisfies Info
})
})
}
@@ -22,6 +22,7 @@ const session = (id: string, parentID?: string) =>
}) satisfies Session
const data = {
sessionIDs: ["ses_current"],
totals: {
steps: 0,
cost: 0,
@@ -129,6 +129,7 @@ describe("session model usage", () => {
})
expect(yield* ModelUsage.get(child.id)).toEqual({
sessionIDs: [root.id, sibling.id, child.id].sort(),
totals: {
steps: 3,
cost: 1.125,
+1
View File
@@ -10927,6 +10927,7 @@ export type KilocodeSessionModelUsageResponses = {
* Model usage for a session tree
*/
200: {
sessionIDs: Array<string>
totals: {
steps: number
cost: number
+8 -1
View File
@@ -15092,6 +15092,13 @@
"schema": {
"type": "object",
"properties": {
"sessionIDs": {
"type": "array",
"items": {
"type": "string",
"pattern": "^ses"
}
},
"totals": {
"type": "object",
"properties": {
@@ -15198,7 +15205,7 @@
}
}
},
"required": ["totals", "models"],
"required": ["sessionIDs", "totals", "models"],
"additionalProperties": false,
"description": "Model usage for a session tree"
}