feat(vscode): add task header with cost, context usage, and compact button

Add a sticky TaskHeader component above the chat messages showing session
title, accumulated cost, context token usage, and a button to trigger
context compaction (summarization).

Wire cost and token data through the message pipeline (SSE message.updated
events and initial message load) so the webview can compute session totals.

Closes #166
This commit is contained in:
Igor Šćekić
2026-02-11 14:01:15 +01:00
parent ce50833ddf
commit ae89304207
9 changed files with 244 additions and 2 deletions
+35 -2
View File
@@ -182,6 +182,9 @@ export class KiloProvider implements vscode.WebviewViewProvider {
case "requestProviders":
await this.fetchAndSendProviders()
break
case "compact":
await this.handleCompact(message.sessionID, message.providerID, message.modelID)
break
}
})
}
@@ -332,13 +335,15 @@ export class KiloProvider implements vscode.WebviewViewProvider {
const workspaceDir = this.getWorkspaceDirectory()
const messagesData = await this.httpClient.getMessages(sessionID, workspaceDir)
// Convert to webview format
// Convert to webview format, including cost/tokens for assistant messages
const messages = messagesData.map((m) => ({
id: m.info.id,
sessionID: m.info.sessionID,
role: m.info.role,
parts: m.parts,
createdAt: new Date(m.info.time.created).toISOString(),
cost: m.info.cost,
tokens: m.info.tokens,
}))
for (const message of messages) {
@@ -504,6 +509,32 @@ export class KiloProvider implements vscode.WebviewViewProvider {
}
}
/**
* Handle compact (context summarization) request from the webview.
*/
private async handleCompact(sessionID?: string, providerID?: string, modelID?: string): Promise<void> {
if (!this.httpClient) {
return
}
const target = sessionID || this.currentSession?.id
if (!target) {
console.error("[Kilo New] KiloProvider: No sessionID for compact")
return
}
try {
const workspaceDir = this.getWorkspaceDirectory()
await this.httpClient.summarize(target, providerID || "kilo", modelID || "kilo/auto", workspaceDir)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to compact session:", error)
this.postMessage({
type: "error",
message: error instanceof Error ? error.message : "Failed to compact session",
})
}
}
/**
* Handle permission response from the webview.
*/
@@ -672,7 +703,7 @@ export class KiloProvider implements vscode.WebviewViewProvider {
}
case "message.updated":
// Message info updated
// Message info updated — forward cost/tokens for assistant messages
this.postMessage({
type: "messageCreated",
message: {
@@ -680,6 +711,8 @@ export class KiloProvider implements vscode.WebviewViewProvider {
sessionID: event.properties.info.sessionID,
role: event.properties.info.role,
createdAt: new Date(event.properties.info.time.created).toISOString(),
cost: event.properties.info.cost,
tokens: event.properties.info.tokens,
},
})
break
@@ -192,6 +192,18 @@ export class HttpClient {
return true
}
/**
* Trigger context compaction (summarization) for a session.
*/
async summarize(sessionId: string, providerID: string, modelID: string, directory: string): Promise<boolean> {
return this.request<boolean>(
"POST",
`/session/${sessionId}/summarize`,
{ providerID, modelID, auto: false },
{ directory, allowEmpty: true },
)
}
// ============================================
// Permission Methods
// ============================================
@@ -6,6 +6,7 @@ export type {
SessionStatusInfo,
MessageInfo,
MessagePart,
TokenUsage,
ToolState,
PermissionRequest,
SSEEvent,
@@ -18,6 +18,14 @@ export type SessionStatusInfo =
| { type: "retry"; attempt: number; message: string; next: number }
| { type: "busy" }
// Token usage shape returned by the server on assistant messages
export interface TokenUsage {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
// Message types from MessageV2
export interface MessageInfo {
id: string
@@ -27,6 +35,9 @@ export interface MessageInfo {
created: number
completed?: number
}
// Present on assistant messages
cost?: number
tokens?: TokenUsage
}
// Part types - simplified for UI display
@@ -4,6 +4,7 @@
*/
import { Component } from "solid-js"
import { TaskHeader } from "./TaskHeader"
import { MessageList } from "./MessageList"
import { PromptInput } from "./PromptInput"
import { PermissionDialog } from "./PermissionDialog"
@@ -11,6 +12,7 @@ import { PermissionDialog } from "./PermissionDialog"
export const ChatView: Component = () => {
return (
<div class="chat-view">
<TaskHeader />
<div class="chat-messages">
<MessageList />
</div>
@@ -0,0 +1,75 @@
/**
* TaskHeader component
* Sticky header above the chat messages showing session title,
* cost, context usage, and a compact button.
*/
import { Component, Show, createMemo } from "solid-js"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useSession } from "../../context/session"
export const TaskHeader: Component = () => {
const session = useSession()
const title = createMemo(() => session.currentSession()?.title)
const hasMessages = createMemo(() => session.messages().length > 0)
const visible = createMemo(() => !!(title() && hasMessages()))
const busy = createMemo(() => session.status() === "busy")
const cost = createMemo(() => {
const total = session.totalCost()
if (total === 0) return undefined
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const context = createMemo(() => {
const usage = session.contextUsage()
if (!usage) return undefined
const tokens = usage.tokens.toLocaleString("en-US")
const pct = usage.percentage !== null ? `${usage.percentage}%` : undefined
return { tokens, pct }
})
return (
<Show when={visible()}>
<div class="task-header">
<div class="task-header-title" title={title()}>
{title()}
</div>
<div class="task-header-stats">
<Show when={cost()}>
{(c) => (
<Tooltip value="Session cost" placement="bottom">
<span class="task-header-stat">{c()}</span>
</Tooltip>
)}
</Show>
<Show when={context()}>
{(ctx) => (
<Tooltip
value={ctx().pct ? `${ctx().tokens} tokens (${ctx().pct} of context)` : `${ctx().tokens} tokens`}
placement="bottom"
>
<span class="task-header-stat">{ctx().pct ?? ctx().tokens}</span>
</Tooltip>
)}
</Show>
<Tooltip value="Compact session" placement="bottom">
<IconButton
icon="collapse"
size="small"
variant="ghost"
disabled={busy() || !hasMessages()}
onClick={() => session.compact()}
aria-label="Compact session"
/>
</Tooltip>
</div>
</div>
</Show>
)
}
@@ -29,6 +29,7 @@ import type {
PermissionRequest,
TodoItem,
ModelSelection,
ContextUsage,
ExtensionMessage,
} from "../types/messages"
@@ -69,9 +70,14 @@ interface SessionContextValue {
selected: Accessor<ModelSelection | null>
selectModel: (providerID: string, modelID: string) => void
// Cost and context usage for the current session
totalCost: Accessor<number>
contextUsage: Accessor<ContextUsage | undefined>
// Actions
sendMessage: (text: string, providerID?: string, modelID?: string) => void
abort: () => void
compact: () => void
respondToPermission: (permissionId: string, response: "once" | "always" | "reject") => void
createSession: () => void
loadSessions: () => void
@@ -335,6 +341,22 @@ export const SessionProvider: ParentComponent = (props) => {
})
}
function compact() {
const sessionID = currentSessionID()
if (!sessionID) {
console.warn("[Kilo New] Cannot compact: no current session")
return
}
const sel = selected()
vscode.postMessage({
type: "compact",
sessionID,
providerID: sel?.providerID,
modelID: sel?.modelID,
})
}
function respondToPermission(permissionId: string, response: "once" | "always" | "reject") {
// Resolve sessionID from the stored permission request
const permission = permissions().find((p) => p.id === permissionId)
@@ -405,6 +427,27 @@ export const SessionProvider: ParentComponent = (props) => {
Object.values(store.sessions).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()),
)
// Total cost across all assistant messages in the current session
const totalCost = createMemo(() => {
return messages().reduce((sum, m) => sum + (m.role === "assistant" ? (m.cost ?? 0) : 0), 0)
})
// Context usage from the last assistant message that has token data
const contextUsage = createMemo<ContextUsage | undefined>(() => {
const msgs = messages()
for (let i = msgs.length - 1; i >= 0; i--) {
const m = msgs[i]
if (m.role !== "assistant" || !m.tokens) continue
const total = m.tokens.input + m.tokens.output + m.tokens.reasoning + m.tokens.cache.read + m.tokens.cache.write
if (total === 0) continue
const sel = selected()
const model = sel ? provider.findModel(sel) : undefined
const percentage = model?.contextLength ? Math.round((total / model.contextLength) * 100) : null
return { tokens: total, percentage }
}
return undefined
})
const value: SessionContextValue = {
currentSessionID,
currentSession,
@@ -417,8 +460,11 @@ export const SessionProvider: ParentComponent = (props) => {
permissions,
selected,
selectModel,
totalCost,
contextUsage,
sendMessage,
abort,
compact,
respondToPermission,
createSession,
loadSessions,
@@ -20,6 +20,44 @@
min-height: 0;
}
/* ============================================
Task Header
============================================ */
.task-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--vscode-sideBar-background);
border-bottom: 1px solid var(--vscode-panel-border);
flex-shrink: 0;
min-height: 32px;
}
.task-header-title {
flex: 1;
font-size: 12px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--vscode-foreground);
}
.task-header-stats {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.task-header-stat {
font-size: 11px;
color: var(--vscode-descriptionForeground);
white-space: nowrap;
}
.chat-input {
flex-shrink: 0;
border-top: 1px solid var(--vscode-panel-border);
@@ -64,6 +64,20 @@ export interface PartDelta {
textDelta?: string
}
// Token usage for assistant messages
export interface TokenUsage {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
// Context usage derived from the last assistant message's tokens
export interface ContextUsage {
tokens: number
percentage: number | null
}
// Message structure (simplified for webview)
export interface Message {
id: string
@@ -72,6 +86,8 @@ export interface Message {
content?: string
parts?: Part[]
createdAt: string
cost?: number
tokens?: TokenUsage
}
// Session info (simplified for webview)
@@ -345,6 +361,13 @@ export interface RequestProvidersMessage {
type: "requestProviders"
}
export interface CompactRequest {
type: "compact"
sessionID: string
providerID?: string
modelID?: string
}
export type WebviewMessage =
| SendMessageRequest
| AbortRequest
@@ -359,6 +382,7 @@ export type WebviewMessage =
| CancelLoginRequest
| WebviewReadyRequest
| RequestProvidersMessage
| CompactRequest
// ============================================
// VS Code API type