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 packages/kilo-vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,11 @@
"none"
],
"description": "Sound to play on errors"
},
"kilo-code.new.showTaskTimeline": {
"type": "boolean",
"default": true,
"description": "Show the task timeline graph in the chat header"
}
}
}
Expand Down
13 changes: 13 additions & 0 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "requestNotificationSettings":
this.sendNotificationSettings()
break
case "requestTimelineSetting":
this.sendTimelineSetting()
break
case "requestNotifications":
this.fetchAndSendNotifications().catch((e) =>
console.error("[Kilo New] fetchAndSendNotifications failed:", e),
Expand Down Expand Up @@ -1136,6 +1139,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.seedSessionStatusMap(),
])
this.sendNotificationSettings()
this.sendTimelineSetting()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Timeline setting changes are not broadcast after initialization

showTaskTimeline is only sent here and when the webview explicitly requests it. handleUpdateSetting() and the reset-all-settings flow never call sendTimelineSetting(), so other open webviews — and the current webview after a reset — can keep showing a stale expanded/collapsed state even though the persisted VS Code setting changed.


// Start polling worktree diff stats for the sidebar badge
this.startStatsPolling()
Expand Down Expand Up @@ -2041,6 +2045,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
}

private sendTimelineSetting(): void {
const config = vscode.workspace.getConfiguration("kilo-code.new")
this.postMessage({
type: "timelineSettingLoaded",
visible: config.get<boolean>("showTaskTimeline", true),
})
}

/** Returns the number of sessions currently in "busy" state. */
private getBusySessionCount(): number {
return getBusySessionCount(this.sessionStatusMap)
Expand Down Expand Up @@ -2545,6 +2557,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.sendAutocompleteSettings()
this.sendBrowserSettings()
this.sendNotificationSettings()
this.sendTimelineSetting()

// Re-send globalState items to the webview
this.postMessage({ type: "variantsLoaded", variants: {} })
Expand Down
108 changes: 108 additions & 0 deletions packages/kilo-vscode/tests/unit/timeline-colors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, it, expect } from "vitest"
import { color, palette, label } from "../../webview-ui/src/utils/timeline/colors"
import type {
Part,
ToolPart,
TextPart,
ReasoningPart,
StepStartPart,
StepFinishPart,
} from "../../webview-ui/src/types/messages"

function mkText(text = "hello"): TextPart {
return { id: "t1", type: "text", text }
}

function mkReasoning(text = "thinking..."): ReasoningPart {
return { id: "r1", type: "reasoning", text }
}

function mkTool(name: string, status: "pending" | "running" | "completed" | "error" = "completed"): ToolPart {
const base = { id: "tool1", type: "tool" as const, tool: name }
if (status === "pending") return { ...base, state: { status: "pending", input: {} } }
if (status === "running") return { ...base, state: { status: "running", input: {} } }
if (status === "error") return { ...base, state: { status: "error", input: {}, error: "fail" } }
return { ...base, state: { status: "completed", input: {}, output: "ok", title: name } }
}

function mkStepStart(): StepStartPart {
return { id: "ss1", type: "step-start" }
}

function mkStepFinish(): StepFinishPart {
return { id: "sf1", type: "step-finish", reason: "done" }
}

describe("timeline colors", () => {
it("classifies text parts as text color", () => {
expect(color(mkText())).toBe(palette.text)
})

it("classifies reasoning parts as reasoning color", () => {
expect(color(mkReasoning())).toBe(palette.reasoning)
})

it("classifies read tools as read color", () => {
expect(color(mkTool("read"))).toBe(palette.read)
expect(color(mkTool("glob"))).toBe(palette.read)
expect(color(mkTool("grep"))).toBe(palette.read)
expect(color(mkTool("ls"))).toBe(palette.read)
expect(color(mkTool("diagnostics"))).toBe(palette.read)
expect(color(mkTool("warpgrep"))).toBe(palette.read)
})

it("classifies write tools as write color", () => {
expect(color(mkTool("edit"))).toBe(palette.write)
expect(color(mkTool("write"))).toBe(palette.write)
expect(color(mkTool("patch"))).toBe(palette.write)
expect(color(mkTool("multiedit"))).toBe(palette.write)
expect(color(mkTool("apply_patch"))).toBe(palette.write)
})

it("classifies generic tools as tool color", () => {
expect(color(mkTool("bash"))).toBe(palette.tool)
expect(color(mkTool("task"))).toBe(palette.tool)
expect(color(mkTool("browser"))).toBe(palette.tool)
})

it("classifies errored tools as error color", () => {
expect(color(mkTool("bash", "error"))).toBe(palette.error)
expect(color(mkTool("read", "error"))).toBe(palette.error)
})

it("classifies step-start as step color", () => {
expect(color(mkStepStart())).toBe(palette.step)
})

it("classifies step-finish as success color", () => {
expect(color(mkStepFinish())).toBe(palette.success)
})

it("returns fallback for unknown part types", () => {
const weird = { id: "w1", type: "snapshot" } as unknown as Part
expect(color(weird)).toBe(palette.fallback)
})
})

describe("timeline labels", () => {
it("returns 'Text' for text parts", () => {
expect(label(mkText())).toBe("Text")
})

it("returns 'Reasoning' for reasoning parts", () => {
expect(label(mkReasoning())).toBe("Reasoning")
})

it("returns tool name for tool parts", () => {
expect(label(mkTool("bash"))).toBe("bash")
expect(label(mkTool("read"))).toBe("read")
})

it("returns 'Step start' for step-start", () => {
expect(label(mkStepStart())).toBe("Step start")
})

it("returns 'Step finish' for step-finish", () => {
expect(label(mkStepFinish())).toBe("Step finish")
})
})
92 changes: 92 additions & 0 deletions packages/kilo-vscode/tests/unit/timeline-sizes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest"
import { sizes, MAX_HEIGHT } from "../../webview-ui/src/utils/timeline/sizes"
import type { Part, TextPart, ToolPart, StepFinishPart } from "../../webview-ui/src/types/messages"

function mkText(text: string): TextPart {
return { id: `t-${text.length}`, type: "text", text }
}

function mkTool(name: string, input: Record<string, unknown> = {}, output = ""): ToolPart {
return {
id: `tool-${name}`,
type: "tool",
tool: name,
state: { status: "completed", input, output, title: name },
}
}

function mkStepFinish(input = 100, output = 50): StepFinishPart {
return {
id: "sf",
type: "step-finish",
reason: "done",
tokens: { input, output },
}
}

describe("timeline sizes", () => {
it("returns empty array for empty input", () => {
expect(sizes([])).toEqual([])
})

it("returns one entry per part", () => {
const parts: Part[] = [mkText("a"), mkText("bb"), mkText("ccc")]
const result = sizes(parts)
expect(result).toHaveLength(3)
})

it("all bars have uniform width", () => {
const parts: Part[] = [mkText("short"), mkText("a".repeat(500)), mkText("medium")]
const result = sizes(parts)
const w = result[0]!.width
for (const bar of result) {
expect(bar.width).toBe(w)
}
})

it("height stays within bounds", () => {
const parts: Part[] = [mkText("short"), mkText("a".repeat(500)), mkText("medium length text")]
const result = sizes(parts)
for (const bar of result) {
expect(bar.height).toBeGreaterThanOrEqual(8)
expect(bar.height).toBeLessThanOrEqual(MAX_HEIGHT)
}
})

it("larger content produces taller bars", () => {
const parts: Part[] = [mkText("x"), mkText("x".repeat(1000))]
const result = sizes(parts)
expect(result[1]!.height).toBeGreaterThan(result[0]!.height)
})

it("handles tool parts with input/output content", () => {
const parts: Part[] = [
mkTool("bash", { command: "ls" }, "file1\nfile2\nfile3"),
mkTool("read", { path: "README.md" }, "a".repeat(200)),
]
const result = sizes(parts)
expect(result).toHaveLength(2)
expect(result[0]!.content).toBeGreaterThan(0)
expect(result[1]!.content).toBeGreaterThan(result[0]!.content)
})

it("handles step-finish parts using token counts", () => {
const parts: Part[] = [mkStepFinish(1000, 500), mkStepFinish(100, 50)]
const result = sizes(parts)
expect(result).toHaveLength(2)
expect(result[0]!.content).toBeGreaterThan(result[1]!.content)
})

it("handles single-part input without crashing", () => {
const result = sizes([mkText("only one")])
expect(result).toHaveLength(1)
})

it("returns integer values for height", () => {
const parts: Part[] = [mkText("a"), mkText("bb"), mkText("ccc"), mkText("dddd")]
const result = sizes(parts)
for (const bar of result) {
expect(Number.isInteger(bar.height)).toBe(true)
}
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* ContextProgress — three-segment progress bar showing context window usage.
*
* Segments:
* 1. Used tokens (foreground color, turns red when >= 50%)
* 2. Reserved for output (medium gray)
* 3. Available (transparent / background)
*
* Token counts flanking the bar: used on left, total on right.
*/

import { Component, createMemo, Show } from "solid-js"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useSession } from "../../context/session"
import { useProvider } from "../../context/provider"

function fmt(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)
}

export const ContextProgress: Component = () => {
const session = useSession()
const provider = useProvider()

const data = createMemo(() => {
const usage = session.contextUsage()
if (!usage || usage.tokens === 0) return undefined

const sel = session.selected()
const model = sel ? provider.findModel(sel) : undefined
const limit = model?.limit?.context ?? model?.contextLength ?? 0
const output = model?.limit?.output ?? 0

if (limit === 0) return undefined

const used = Math.min(usage.tokens, limit)
const reserved = Math.min(output, limit - used)
const available = Math.max(0, limit - used - reserved)

const pctUsed = (used / limit) * 100
const pctReserved = (reserved / limit) * 100
const pctAvail = (available / limit) * 100

return { used, reserved, available, limit, pctUsed, pctReserved, pctAvail, output }
})

const tip = createMemo(() => {
const d = data()
if (!d) return ""
const lines = [`${fmt(d.used)} / ${fmt(d.limit)} tokens used`]
if (d.output > 0) lines.push(`${fmt(d.output)} reserved for output`)
if (d.available > 0) lines.push(`${fmt(d.available)} available`)
return lines.join("\n")
})

return (
<Show when={data()}>
{(d) => (
<div class="context-progress">
<span class="context-progress-count">{fmt(d().used)}</span>
<Tooltip value={tip()} placement="top">
<div class="context-progress-bar">
<div
class="context-progress-used"
classList={{ "context-progress-used--hot": d().pctUsed >= 50 }}
style={{ width: `${d().pctUsed}%` }}
/>
<div class="context-progress-reserved" style={{ width: `${d().pctReserved}%` }} />
<Show when={d().pctAvail > 0}>
<div class="context-progress-available" style={{ width: `${d().pctAvail}%` }} />
</Show>
</div>
</Tooltip>
<span class="context-progress-count">{fmt(d().limit)}</span>
</div>
)}
</Show>
)
}
Loading
Loading