diff --git a/.changeset/vscode-model-usage.md b/.changeset/vscode-model-usage.md new file mode 100644 index 00000000000..f3347f01e13 --- /dev/null +++ b/.changeset/vscode-model-usage.md @@ -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. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 4fc32536ffe..8829becf64c 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -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 { + 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 — diff --git a/packages/kilo-vscode/tests/unit/model-usage.test.ts b/packages/kilo-vscode/tests/unit/model-usage.test.ts new file mode 100644 index 00000000000..4d1c84c981c --- /dev/null +++ b/packages/kilo-vscode/tests/unit/model-usage.test.ts @@ -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 + +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() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx index d4409d07473..96ac022f93f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx @@ -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 = (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 = (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 = (props) => {
- - {(tk) => ( -
- Tokens - 0}> - - - {fmtNum(tk().input)} - - - 0}> - - - {fmtNum(tk().output)} - - - 0}> - - - cache {fmtNum(tk().cached)} - - -
- )} -
+ {(tk) => } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskUsage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskUsage.tsx new file mode 100644 index 00000000000..fe793528824 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskUsage.tsx @@ -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 = (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 = () => ( + <> + Tokens + 0}> + + + {number(props.tokens.input)} + + + 0}> + + + {number(props.tokens.output)} + + + 0}> + + + cache {number(props.tokens.cached)} + + + + ) + + return ( + + + + } + > + + + + + + + + +
+ + {(group) => ( +
+

{group.providerName}

+ + {(model) => ( +
+
+ {modelUsageName(model, provider.providers())} +
+
+ {model.steps} {model.steps === 1 ? "step" : "steps"} · {cost(model.cost)} +
+
+ In {count(model.tokens.input)} · Out {count(model.tokens.output)} · Reason{" "} + {count(model.tokens.reasoning)} +
+
+ Cache R {count(model.tokens.cache.read)} · W {count(model.tokens.cache.write)} · Rate{" "} + {rate(model)} +
+
+ )} +
+
+ )} +
+
+
+ + + ) +} diff --git a/packages/kilo-vscode/webview-ui/src/context/model-usage.ts b/packages/kilo-vscode/webview-ui/src/context/model-usage.ts new file mode 100644 index 00000000000..b6427f5d4f6 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/model-usage.ts @@ -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() + 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) { + const groups = new Map() + 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) { + 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 ") +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 235b3ea1d37..2eb55703230 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -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 // session/agent scoped variant key -> variant name recentModels: ModelSelection[] favoriteModels: ModelSelection[] + modelUsage: Record } interface SessionContextValue { @@ -194,6 +197,8 @@ interface SessionContextValue { // Cost and context usage for the current session costBreakdown: Accessor> contextUsage: Accessor + modelUsage: Accessor + refreshModelUsage: () => void // Skills loaded from the CLI backend skills: Accessor @@ -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(() => { @@ -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 { 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(() => { + const id = currentSessionID() + return id ? store.modelUsage[id]?.data : undefined + }) + const contextUsage = createMemo(() => { 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, diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 4e9c67b0057..bb90113eb93 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -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: () => [], diff --git a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx index cd27ec5dc4b..6c17c513d7c 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx @@ -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) => () => ( + + +
+ +
+
+
+) + +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 // --------------------------------------------------------------------------- diff --git a/packages/kilo-vscode/webview-ui/src/styles/task-header.css b/packages/kilo-vscode/webview-ui/src/styles/task-header.css index d791b23bd16..70d3bf14b01 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/task-header.css +++ b/packages/kilo-vscode/webview-ui/src/styles/task-header.css @@ -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; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 94795f2101b..3467cff72ce 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -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 diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts b/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts index 23f9640def0..e0ba68abc1b 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts @@ -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) diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index a0325f45cad..a2645b5ef3d 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -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 diff --git a/packages/opencode/src/kilocode/session/model-usage.ts b/packages/opencode/src/kilocode/session/model-usage.ts index 47467be1d7a..109b0a36b2b 100644 --- a/packages/opencode/src/kilocode/session/model-usage.ts +++ b/packages/opencode/src/kilocode/session/model-usage.ts @@ -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(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 }) }) } diff --git a/packages/opencode/test/kilocode/cli/cmd/tui/model-usage.test.ts b/packages/opencode/test/kilocode/cli/cmd/tui/model-usage.test.ts index 9ad5ba0b363..82735eb14d2 100644 --- a/packages/opencode/test/kilocode/cli/cmd/tui/model-usage.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/tui/model-usage.test.ts @@ -22,6 +22,7 @@ const session = (id: string, parentID?: string) => }) satisfies Session const data = { + sessionIDs: ["ses_current"], totals: { steps: 0, cost: 0, diff --git a/packages/opencode/test/kilocode/session-model-usage.test.ts b/packages/opencode/test/kilocode/session-model-usage.test.ts index e47f9d9ac58..abcc8870e0b 100644 --- a/packages/opencode/test/kilocode/session-model-usage.test.ts +++ b/packages/opencode/test/kilocode/session-model-usage.test.ts @@ -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, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c02092f16c9..f6c13a7fd78 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -10927,6 +10927,7 @@ export type KilocodeSessionModelUsageResponses = { * Model usage for a session tree */ 200: { + sessionIDs: Array totals: { steps: number cost: number diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 22ffd36c45a..7006c67d729 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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" }