mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user