Merge branch 'main' into fix/question-reply-locks

This commit is contained in:
Joshua Lambert
2026-06-10 18:15:40 -04:00
committed by GitHub
29 changed files with 1566 additions and 609 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep large chat transcripts responsive by mounting only viewport-visible conversation rows.
+7
View File
@@ -0,0 +1,7 @@
---
"kilo-code": patch
"@kilocode/cli": patch
"@kilocode/kilo-gateway": patch
---
Show model free and prompt-training indicators only when their explicit catalog metadata is enabled.
+1
View File
@@ -74,6 +74,7 @@ export const Model = Schema.Struct({
recommendedIndex: Schema.optional(Schema.Finite),
prompt: Schema.optional(Schema.String),
isFree: Schema.optional(Schema.Boolean),
mayTrainOnYourPrompts: Schema.optional(Schema.Boolean),
ai_sdk_provider: Schema.optional(Schema.String),
// kilocode_change end
experimental: Schema.optional(
+2
View File
@@ -36,6 +36,7 @@ const openRouterModelSchema = z.object({
supported_parameters: z.array(z.string()).optional(),
preferredIndex: z.number().optional(),
isFree: z.boolean().optional(),
mayTrainOnYourPrompts: z.boolean().optional(),
terminalBench: z
.object({
overallScore: z.number(),
@@ -190,6 +191,7 @@ function transformToModelDevFormat(model: OpenRouterModel): any {
ai_sdk_provider: model.opencode?.ai_sdk_provider,
tool_call: supportsTools,
isFree: model.isFree,
mayTrainOnYourPrompts: model.mayTrainOnYourPrompts,
...(model.terminalBench && { terminalBench: model.terminalBench }),
...(inputPrice !== undefined &&
outputPrice !== undefined && {
@@ -15,6 +15,8 @@ const VALID_RESPONSE = JSON.stringify({
output_modalities: ["text"],
},
supported_parameters: ["tools", "temperature"],
isFree: false,
mayTrainOnYourPrompts: true,
},
],
})
@@ -143,7 +145,10 @@ test("returns models without error on success", async () => {
;(globalThis as any).fetch = orig
expect(result.error).toBeUndefined()
expect(Object.keys(result.models).length).toBeGreaterThan(0)
expect(result.models["test/model-a"]).toMatchObject({
isFree: false,
mayTrainOnYourPrompts: true,
})
})
test("preserves Terminal Bench metadata as a dedicated model field", async () => {
@@ -22,6 +22,15 @@ describe("model preview data collection line", () => {
expect(styles).toContain(".model-preview-data-line")
})
it("renders prompt training independently from the free badge", () => {
expect(selector).toMatch(
/<Show when=\{isFree\(model\)\}>[\s\S]*?<\/Show>\s*<Show when=\{isDataCollectedModel\(model\)\}>/,
)
expect(preview).toMatch(
/<Show when=\{model\(\)\.isFree\}>[\s\S]*?<\/Show>\s*<Show when=\{isDataCollectedModel\(model\(\)\)\}>/,
)
})
it("uses the book open check icon for all webview model data disclosures", () => {
expect(selector).toContain('Icon name="book-open-check"')
expect(selector).not.toContain('Icon name="warning"')
@@ -8,6 +8,7 @@ import {
PROVIDER_ORDER,
freeDataLabel,
isDataCollectedModel,
isFree,
} from "../../webview-ui/src/components/shared/model-selector-utils"
const labels = { select: "Select model", noProviders: "No providers", notSet: "Not set" }
@@ -102,11 +103,19 @@ describe("freeDataLabel", () => {
})
})
describe("isFree", () => {
it("uses only explicit free metadata", () => {
expect(isFree({ isFree: true })).toBe(true)
expect(isFree({ isFree: false })).toBe(false)
expect(isFree({})).toBe(false)
})
})
describe("isDataCollectedModel", () => {
it("only marks free Kilo Gateway models with the training disclosure", () => {
expect(isDataCollectedModel({ providerID: KILO_GATEWAY_ID, isFree: true })).toBe(true)
expect(isDataCollectedModel({ providerID: "openrouter", isFree: true })).toBe(false)
expect(isDataCollectedModel({ providerID: KILO_GATEWAY_ID, isFree: false })).toBe(false)
it("uses only explicit prompt training metadata", () => {
expect(isDataCollectedModel({ mayTrainOnYourPrompts: true })).toBe(true)
expect(isDataCollectedModel({ mayTrainOnYourPrompts: false })).toBe(false)
expect(isDataCollectedModel({})).toBe(false)
})
})
@@ -19,12 +19,20 @@ describe("TaskTimeline delegated tooltip contract", () => {
expect(src).not.toMatch(/<Tooltip\b/)
})
it("keeps bar labels and renders one delegated portal tooltip", () => {
expect(src).toMatch(/data-tip=\{bar\(\)\.tip\}/)
expect(src).toMatch(/role="img"/)
expect(src).toMatch(/aria-label=\{bar\(\)\.tip\}/)
expect(src).toMatch(/if \(!bar \|\| !ref\?\.contains\(bar\)\) return hideTip\(\)/)
it("delegates SVG hit testing to one portal tooltip", () => {
expect(src).toMatch(/hit\(layout\(\)\.items, e\.clientX - rect\.left \+ ref\.scrollLeft\)/)
expect(src).toMatch(/const bar = bars\(\)\[idx\]/)
expect(src).toMatch(/text: bar\.tip/)
expect(src).toMatch(/<Portal>/)
expect(src).toMatch(/class="task-timeline-tooltip"/)
})
it("keeps accessibility and bar overlays bounded", () => {
expect(src).toMatch(/data-timeline-count=\{bars\(\)\.length\}/)
expect(src).toMatch(/tabIndex=\{0\}/)
expect(src).toMatch(/aria-label=\{aria\(\)\}/)
expect(src).toMatch(/<For each=\{layout\(\)\.paths\}>/)
expect(src).not.toMatch(/<Index\b/)
expect(src).not.toMatch(/data-tip=/)
})
})
@@ -0,0 +1,46 @@
import { describe, expect, it } from "bun:test"
import { geometry, hit, navigate } from "../../webview-ui/src/utils/timeline/geometry"
describe("timeline geometry", () => {
const bars = [
{ bg: "blue", width: 3, height: 4 },
{ bg: "red", width: 5, height: 8 },
{ bg: "blue", width: 2, height: 6 },
]
it("preserves visual positions while grouping paths by color", () => {
const result = geometry(bars, 10)
expect(result.width).toBe(13)
expect(result.items.map((item) => [item.idx, item.x])).toEqual([
[0, 0],
[1, 4],
[2, 10],
])
expect(result.paths).toHaveLength(2)
expect(result.paths.map((path) => path.bg)).toEqual(["blue", "red"])
expect(result.paths[0]!.d).toContain("M0,10")
expect(result.paths[0]!.d).toContain("M10,10")
expect(result.paths[1]!.d).toContain("M4,10")
})
it("hit tests bars but not their gaps", () => {
const items = geometry(bars, 10).items
expect(hit(items, 0)).toBe(0)
expect(hit(items, 2.99)).toBe(0)
expect(hit(items, 3)).toBe(-1)
expect(hit(items, 4)).toBe(1)
expect(hit(items, 12)).toBe(-1)
})
it("navigates in visual order with bounded endpoints", () => {
expect(navigate(-1, 3, "ArrowRight")).toBe(0)
expect(navigate(1, 3, "ArrowLeft")).toBe(0)
expect(navigate(2, 3, "ArrowRight")).toBe(2)
expect(navigate(1, 3, "Home")).toBe(0)
expect(navigate(1, 3, "End")).toBe(2)
expect(navigate(1, 3, "Escape")).toBe(1)
expect(navigate(0, 0, "ArrowRight")).toBe(-1)
})
})
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it } from "bun:test"
import type { CacheSnapshot } from "virtua"
import {
getMeasurement,
getScroll,
layoutFingerprint,
resetTranscriptCaches,
resolveAnchor,
rowFingerprint,
setMeasurement,
setScroll,
} from "../../webview-ui/src/components/chat/transcript-cache"
const snapshot = (id: number) => ({ id }) as unknown as CacheSnapshot
const layout = layoutFingerprint({ width: 800, ratio: 2, font: "Kilo Sans", size: "13px", line: "20px" })
describe("transcript measurement cache", () => {
beforeEach(resetTranscriptCaches)
it("returns a cache only for the exact row and layout fingerprints", () => {
const keys = rowFingerprint(["a", "bc"])
const cache = snapshot(1)
setMeasurement("session", keys, layout, cache)
expect(getMeasurement("session", keys, layout)).toBe(cache)
expect(getMeasurement("session", rowFingerprint(["a", "bd"]), layout)).toBeUndefined()
expect(getMeasurement("session", keys, layout)).toBeUndefined()
})
it("invalidates a measurement when layout changes", () => {
const keys = rowFingerprint(["row"])
setMeasurement("session", keys, layout, snapshot(1))
const changed = layoutFingerprint({ width: 801, ratio: 2, font: "Kilo Sans", size: "13px", line: "20px" })
expect(getMeasurement("session", keys, changed)).toBeUndefined()
expect(getMeasurement("session", keys, layout)).toBeUndefined()
})
it("uses collision-safe row and layout fingerprints", () => {
expect(rowFingerprint(["a|b", "c"])).not.toBe(rowFingerprint(["a", "b|c"]))
expect(layoutFingerprint({ width: 80, ratio: 1, font: "a|b", size: "c", line: "d" })).not.toBe(
layoutFingerprint({ width: 80, ratio: 1, font: "a", size: "b|c", line: "d" }),
)
})
it("evicts the least recently used measurement after 16 sessions", () => {
const keys = rowFingerprint(["row"])
for (let i = 0; i < 16; i += 1) setMeasurement(`s${i}`, keys, layout, snapshot(i))
expect(getMeasurement("s0", keys, layout)).toBeDefined()
setMeasurement("s16", keys, layout, snapshot(16))
expect(getMeasurement("s1", keys, layout)).toBeUndefined()
expect(getMeasurement("s0", keys, layout)).toBeDefined()
})
})
describe("transcript scroll cache", () => {
beforeEach(resetTranscriptCaches)
it("stores bottom-follow and anchored positions independently from measurements", () => {
setMeasurement("a", rowFingerprint(["row"]), layout, snapshot(1))
setScroll("a", { type: "bottom" })
setScroll("b", { type: "anchor", key: "row-2", offset: 37 })
expect(getScroll("a")).toEqual({ type: "bottom" })
expect(getScroll("b")).toEqual({ type: "anchor", key: "row-2", offset: 37 })
})
it("resolves an anchor after prepended rows shift its index", () => {
const state = { type: "anchor", key: "stable", offset: 19 } as const
expect(resolveAnchor(state, ["stable", "tail"])).toEqual({ index: 0, offset: 19 })
expect(resolveAnchor(state, ["older-1", "older-2", "stable", "tail"])).toEqual({ index: 2, offset: 19 })
expect(resolveAnchor(state, ["other"])).toBeUndefined()
expect(resolveAnchor({ type: "bottom" }, ["stable"])).toBeUndefined()
})
it("evicts the least recently used scroll state after 50 sessions", () => {
for (let i = 0; i < 50; i += 1) setScroll(`s${i}`, { type: "bottom" })
expect(getScroll("s0")).toEqual({ type: "bottom" })
setScroll("s50", { type: "anchor", key: "row", offset: 4 })
expect(getScroll("s1")).toBeUndefined()
expect(getScroll("s0")).toEqual({ type: "bottom" })
expect(getScroll("s50")).toEqual({ type: "anchor", key: "row", offset: 4 })
})
})
@@ -0,0 +1,219 @@
import { describe, expect, it } from "bun:test"
import { messageTurns } from "../../webview-ui/src/context/session-queue"
import { partitionRows, transcriptRows } from "../../webview-ui/src/context/transcript-rows"
import type { Message, Part } from "../../webview-ui/src/types/messages"
const base = {
sessionID: "session",
createdAt: "2026-01-01T00:00:00.000Z",
time: { created: 1 },
}
const user = (id: string, opts: Partial<Message> = {}): Message => ({ ...base, id, role: "user", ...opts })
const assistant = (id: string, parentID: string, opts: Partial<Message> = {}): Message => ({
...base,
id,
parentID,
role: "assistant",
...opts,
})
const part = (id: string, messageID: string): Part => ({ id, messageID, type: "text", text: id })
const lookup = (values: Record<string, Part[]>) => (id: string) => values[id] ?? []
describe("transcriptRows", () => {
it("preserves turn order across user, bounded assistant, diff, and error rows", () => {
const u1 = user("u1", { summary: { diffs: [{ file: "a.ts" }] } })
const a1 = assistant("a1", "u1")
const a2 = assistant("a2", "u1", { error: { name: "ProviderError" } })
const u2 = user("u2")
const a3 = assistant("a3", "u2")
const parts = {
u1: [part("up1", "u1")],
a1: Array.from({ length: 10 }, (_, i) => part(`p${i}`, "a1")),
a2: [part("p10", "a2")],
a3: [part("p11", "a3")],
}
const rows = transcriptRows(messageTurns([u1, a1, a2, u2, a3]), lookup(parts))
expect(rows.map((row) => `${row.turn}:${row.type}`)).toEqual([
"u1:user",
"u1:assistant",
"u1:assistant",
"u1:assistant",
"u1:diff",
"u1:error",
"u2:user",
"u2:assistant",
])
expect(rows.filter((row) => row.type === "assistant").map((row) => row.parts.length)).toEqual([8, 2, 1, 1])
})
it("uses the configured bound and keeps an empty assistant renderable", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const a2 = assistant("a2", "u1")
const rows = transcriptRows(
messageTurns([u1, a1, a2]),
lookup({ a1: Array.from({ length: 7 }, (_, i) => part(`p${i}`, "a1")) }),
{ size: 3 },
)
expect(rows.filter((row) => row.type === "assistant").map((row) => row.parts.length)).toEqual([3, 3, 1, 0])
})
it("omits synthetic users for partial turns and carries row metadata", () => {
const a1 = assistant("a1", "u1")
const rows = transcriptRows(messageTurns([a1]), lookup({ a1: [part("p1", "a1")] }), {
queued: new Set(["u1"]),
live: new Set(["u1"]),
})
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ type: "assistant", turn: "u1", partial: true, queued: true, live: true })
})
it("places only the first visible non-abort error after diffs", () => {
const u1 = user("u1", { summary: { diffs: [{ file: "a.ts" }] } })
const a1 = assistant("a1", "u1", { error: { name: "MessageAbortedError" } })
const a2 = assistant("a2", "u1", { error: { name: "HiddenError" } })
const a3 = assistant("a3", "u1", { error: { name: "ShownError" } })
const rows = transcriptRows(messageTurns([u1, a1, a2, a3]), lookup({}), { hidden: (id) => id === "a2" })
expect(rows.slice(-2).map((row) => row.type)).toEqual(["diff", "error"])
expect(rows.at(-1)).toMatchObject({ type: "error", message: a3, error: a3.error })
})
it("keeps keys stable when older turns are prepended and parts are appended", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const parts = Array.from({ length: 8 }, (_, i) => part(`p${i}`, "a1"))
const current = transcriptRows(messageTurns([u1, a1]), lookup({ a1: parts }))
const older = user("u0")
const next = transcriptRows(messageTurns([older, u1, a1]), lookup({ a1: [...parts, part("p8", "a1")] }))
expect(next.find((row) => row.type === "user" && row.turn === "u1")?.key).toBe(current[0]?.key)
expect(next.find((row) => row.type === "assistant" && row.parts[0]?.id === "p0")?.key).toBe(current[1]?.key)
})
it("reuses unchanged rows across prepend and append updates", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const u2 = user("u2")
const p1 = part("p1", "a1")
const first = transcriptRows(messageTurns([u1, a1, u2]), lookup({ a1: [p1] }))
const u0 = user("u0")
const a2 = assistant("a2", "u2")
const second = transcriptRows(messageTurns([u0, u1, a1, u2, a2]), lookup({ a1: [p1] }), {}, first)
expect(second[1]).toBe(first[0])
expect(second[2]).toBe(first[1])
expect(second[3]).not.toBe(first[2])
expect(second[4]).not.toBe(first[2])
})
it("selects the last real assistant text part as the copy target", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const a2 = assistant("a2", "u1")
const synthetic: Part = { ...part("p2", "a2"), synthetic: true }
const blank: Part = { ...part("p3", "a2"), text: " " }
const rows = transcriptRows(messageTurns([u1, a1, a2]), lookup({ a1: [part("p1", "a1")], a2: [synthetic, blank] }))
expect(rows.filter((row) => row.type === "assistant").map((row) => row.copy)).toEqual(["p1", "p1"])
})
it("keeps compaction replies ordered under the compacted turn and respects revert turns", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const u2 = user("u2", {
parts: [{ id: "compact", messageID: "u2", type: "compaction", auto: false }],
})
const a2 = assistant("a2", "u1")
const u3 = user("u3")
const turns = messageTurns([u1, a1, u2, a2, u3], "u3")
const rows = transcriptRows(turns, (id) => (id === "u2" ? (u2.parts ?? []) : []))
expect(rows.map((row) => `${row.turn}:${row.message.id}`)).toEqual(["u1:u1", "u1:a1", "u2:u2", "u2:a2"])
})
it("replaces only rows whose data or metadata changed", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const p1 = part("p1", "a1")
const first = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [p1] }))
const changed = { ...p1, text: "changed" }
const second = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [changed] }), {}, first)
expect(second[0]).toBe(first[0])
expect(second[1]).not.toBe(first[1])
const live = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [changed] }), { live: new Set(["u1"]) }, second)
expect(live[0]).not.toBe(second[0])
expect(live[1]).not.toBe(second[1])
})
})
describe("partitionRows", () => {
it("pins only a bounded live suffix and virtualizes completed history", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const u2 = user("u2")
const a2 = assistant("a2", "u2")
const parts = Array.from({ length: 18 }, (_, i) => part(`p${i}`, "a2"))
const rows = transcriptRows(messageTurns([u1, a1, u2, a2]), lookup({ a1: [part("old", "a1")], a2: parts }), {
live: new Set(["u2"]),
})
const result = partitionRows(rows)
expect(result.keep).toEqual([result.virtual.length - 2, result.virtual.length - 1])
expect(result.keep.map((idx) => result.virtual[idx]).every((row) => row?.live && row.turn === "u2")).toBe(true)
expect(
result.keep
.flatMap((idx) => {
const row = result.virtual[idx]
return row?.type === "assistant" ? row.parts : []
})
.map((item) => item.id),
).toEqual(["p8", "p9", "p10", "p11", "p12", "p13", "p14", "p15", "p16", "p17"])
expect(result.virtual.some((row) => row.turn === "u1")).toBe(true)
expect(result.virtual.some((row) => row.turn === "u2" && row.type === "user")).toBe(true)
})
it("returns completed live metadata to virtual history after queue handoff", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const u2 = user("u2")
const first = transcriptRows(messageTurns([u1, a1, u2]), lookup({ a1: [part("p1", "a1")] }), {
live: new Set(["u1"]),
queued: new Set(["u2"]),
})
const active = partitionRows(first)
expect(active.keep).toEqual([0, 1])
expect(active.keep.map((idx) => active.virtual[idx]?.turn)).toEqual(["u1", "u1"])
expect(active.queued.map((row) => row.turn)).toEqual(["u2"])
const second = transcriptRows(messageTurns([u1, a1, u2]), lookup({ a1: [part("p1", "a1")] }), {
live: new Set(["u2"]),
})
const handed = partitionRows(second)
expect(handed.keep.map((idx) => handed.virtual[idx]?.turn)).toEqual(["u2"])
expect(handed.virtual.filter((row) => row.turn === "u1")).toHaveLength(2)
})
it("keeps queued rows in visual order inside virtual data", () => {
const u1 = user("u1")
const u2 = user("u2")
const rows = transcriptRows(messageTurns([u1, u2]), lookup({}), {
live: new Set(["u1"]),
queued: new Set(["u2"]),
})
const result = partitionRows(rows)
expect(result.virtual.map((row) => row.turn)).toEqual(["u1"])
expect(result.keep).toEqual([0])
expect(result.queued.map((row) => row.turn)).toEqual(["u2"])
expect(result.queued[0]).toMatchObject({ type: "user", queued: true })
})
})
@@ -110,6 +110,7 @@ function matchToolRequest<T extends { tool?: { callID: string; messageID: string
interface AssistantMessageProps {
message: SDKAssistantMessage
parts?: SDKPart[]
showAssistantCopyPartID?: string | null
feedback?: MessageFeedbackControls
}
@@ -177,7 +178,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
const open = createMemo(() => config().terminal_command_display !== "collapsed")
const parts = createMemo(() => {
const stored = data.store.part?.[props.message.id]
const stored = props.parts ?? data.store.part?.[props.message.id]
if (!stored) return []
return (stored as SDKPart[]).filter((part) => isRenderable(part))
})
@@ -20,23 +20,32 @@ import { useLanguage } from "../../context/language"
import { recentSessions } from "../../context/session-utils"
import { formatRelativeDate } from "../../utils/date"
import { FeedbackDialog } from "./FeedbackDialog"
import { VscodeSessionTurn } from "./VscodeSessionTurn"
import { TranscriptRowView } from "./TranscriptRow"
import { RevertBanner } from "./RevertBanner"
import { AccountSwitcher } from "../shared/AccountSwitcher"
import { KiloNotifications } from "./KiloNotifications"
import { WorkingIndicator } from "../shared/WorkingIndicator"
import { TurnOutcome } from "../shared/TurnOutcome"
import { QuestionDock } from "./QuestionDock"
import { Virtualizer } from "virtua/solid"
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
import { SuggestBar } from "./SuggestBar"
import {
getMeasurement,
getScroll,
layoutFingerprint,
resolveAnchor,
rowFingerprint,
setMeasurement,
setScroll,
} from "./transcript-cache"
import {
activeUserMessageID as getActiveUserMessageID,
messageTurns,
partitionTurns,
queuedUserMessageIDs,
stableMessageTurns,
type MessageTurn,
} from "../../context/session-queue"
import { partitionRows, transcriptRows, type TranscriptRow } from "../../context/transcript-rows"
import type { QuestionRequest, SuggestionRequest } from "../../types/messages"
const KiloLogo = (): JSX.Element => {
@@ -88,7 +97,8 @@ export const MessageList: Component<MessageListProps> = (props) => {
})
const [scrollEl, setScrollEl] = createSignal<HTMLElement>()
const positions = new Map<string, { top: number; userScrolled: boolean }>()
const [virtualizer, setVirtualizer] = createSignal<VirtualizerHandle>()
const [layout, setLayout] = createSignal("")
const boundary = () => session.revert()?.messageID
const turns = createMemo((prev: MessageTurn[] | undefined) =>
@@ -107,45 +117,55 @@ export const MessageList: Component<MessageListProps> = (props) => {
const queuedIDs = createMemo(
() => new Set(queuedUserMessageIDs(session.messages(), session.statusInfo(), (msg) => session.getParts(msg.id))),
)
const [held, setHeld] = createSignal<{ sid: string; ids: Set<string> }>()
createEffect(() => {
const id = activeUserID()
const sid = session.currentSessionID()
const paused = autoScroll.userScrolled()
if (!sid || (!id && !paused)) {
setHeld(undefined)
return
}
if (!id) return
if (!paused) {
setHeld({ sid, ids: new Set([id]) })
return
}
setHeld((prev) => {
if (prev?.sid === sid && prev.ids.has(id)) return prev
const ids = prev?.sid === sid ? new Set(prev.ids) : new Set<string>()
ids.add(id)
return { sid, ids }
})
})
const directIDs = createMemo(() => {
const item = held()
const ids = item && item.sid === session.currentSessionID() ? new Set(item.ids) : new Set<string>()
const rows = createMemo((prev: TranscriptRow[] | undefined) => {
const active = activeUserID()
if (active) ids.add(active)
return ids
return transcriptRows(
turns(),
(msg) => session.getParts(msg),
{
queued: queuedIDs(),
live: new Set(active ? [active] : []),
hidden: session.isErrorHidden,
},
prev,
)
})
const partition = createMemo(() => partitionRows(rows()))
const keys = createMemo(() => partition().virtual.map((row) => row.key))
const fingerprint = createMemo(() => rowFingerprint(keys()))
const measurement = createMemo(() => {
const id = session.currentSessionID()
const token = layout()
if (!id || !token || session.loading() || keys().length === 0) return undefined
return getMeasurement(id, fingerprint(), token)
})
// Keep the growing live turn out of Virtua. Resizing a tall virtual item while
// the user reads within it makes Virtua compensate scrollTop as if earlier
// content moved, dragging the viewport downward during streaming. Preserve
// direct-rendered tail turns while paused so completion and queue handoffs do
// not move a turn being read back into the virtualized history.
const partition = createMemo(() => partitionTurns(turns(), directIDs(), queuedIDs()))
const save = (id: string | undefined) => {
let active = { id: session.currentSessionID(), keys: keys(), fingerprint: fingerprint() }
createEffect(() => {
const id = session.currentSessionID()
const current = keys()
const value = fingerprint()
if (!id || session.loading() || active.id !== id) return
active = { id, keys: current, fingerprint: value }
})
const save = (id: string | undefined, saved = active) => {
const el = scrollEl()
if (!id || !el) return
positions.set(id, { top: el.scrollTop, userScrolled: autoScroll.userScrolled() })
if (!id || !el || saved.id !== id) return
const handle = virtualizer()
const token = layout()
if (handle && token && saved.keys.length > 0) {
setMeasurement(id, saved.fingerprint, token, handle.cache)
}
if (!autoScroll.userScrolled()) {
setScroll(id, { type: "bottom" })
return
}
if (!handle || saved.keys.length === 0) return
const index = handle.findStartIndex()
const key = saved.keys[index]
if (!key) return
setScroll(id, { type: "anchor", key, offset: handle.scrollOffset - handle.getItemOffset(index) })
}
const maybeLoadOlder = () => {
@@ -159,16 +179,44 @@ export const MessageList: Component<MessageListProps> = (props) => {
maybeLoadOlder()
}
let resize: ResizeObserver | undefined
const refreshLayout = () => {
const el = scrollEl()
if (!el) return
const style = getComputedStyle(el)
setLayout(
layoutFingerprint({
width: Math.round(el.clientWidth),
ratio: window.devicePixelRatio,
font: style.fontFamily,
size: style.fontSize,
line: style.lineHeight,
}),
)
}
const setScrollRef = (el: HTMLElement | undefined) => {
resize?.disconnect()
setScrollEl(el)
autoScroll.scrollRef(el)
if (!el) return
refreshLayout()
resize = new ResizeObserver(refreshLayout)
resize.observe(el)
}
window.addEventListener("resize", refreshLayout)
document.fonts?.addEventListener("loadingdone", refreshLayout)
onCleanup(() => {
resize?.disconnect()
window.removeEventListener("resize", refreshLayout)
document.fonts?.removeEventListener("loadingdone", refreshLayout)
})
const [pendingRestore, setPendingRestore] = createSignal<string>()
createEffect(
on(session.currentSessionID, (id, prev) => {
save(prev)
active = { id, keys: [], fingerprint: rowFingerprint([]) }
setPendingRestore(id)
}),
)
@@ -185,9 +233,11 @@ export const MessageList: Component<MessageListProps> = (props) => {
if (pendingRestore() !== id) return
const el = scrollEl()
if (!el) return
const pos = positions.get(id)
if (pos?.userScrolled) {
el.scrollTop = pos.top
const state = getScroll(id)
const anchor = resolveAnchor(state, keys())
const handle = virtualizer()
if (state?.type === "anchor" && anchor && handle) {
handle.scrollToIndex(anchor.index, { offset: anchor.offset })
autoScroll.pause()
maybeLoadOlder()
} else {
@@ -262,28 +312,36 @@ export const MessageList: Component<MessageListProps> = (props) => {
{language.t("session.messages.loadEarlier")}
</button>
</Show>
<Show when={partition().virtual.length > 0 || partition().direct.length > 0}>
<div class="message-list-turns">
<Show when={scrollEl() && partition().virtual.length > 0}>
<Show when={partition().virtual.length > 0}>
<div
class="message-list-turns"
data-loaded-messages={session.messages().length}
data-row-count={partition().virtual.length}
data-queued-count={partition().queued.length}
data-kept-count={partition().keep.length}
>
<Show when={scrollEl()}>
<Virtualizer
ref={setVirtualizer}
data={partition().virtual}
scrollRef={scrollEl()}
shift={session.messageMutation() === "prepend"}
overscan={6}
cache={measurement()}
keepMounted={partition().keep}
overscan={2}
itemSize={260}
>
{(turn) => <VscodeSessionTurn turn={turn} onForkMessage={props.onForkMessage} />}
{(row, index) => (
<TranscriptRowView row={row} index={index()} onForkMessage={props.onForkMessage} />
)}
</Virtualizer>
</Show>
<For each={partition().direct}>
{(turn) => <VscodeSessionTurn turn={turn} onForkMessage={props.onForkMessage} />}
</For>
</div>
</Show>
<Show when={boundary()}>
<RevertBanner />
</Show>
<For each={partition().queued}>{(turn) => <VscodeSessionTurn turn={turn} queued />}</For>
<For each={partition().queued}>{(row) => <TranscriptRowView row={row} />}</For>
<WorkingIndicator />
<TurnOutcome />
<For each={props.questions?.()}>{(req) => <QuestionDock request={req} />}</For>
@@ -1,21 +1,13 @@
/**
* TaskTimeline — horizontal strip of colored bars representing session activity.
*
* Each bar = one Part from assistant messages.
* Color = part type (read=blue, write=dark blue, tool=indigo, error=red, text=gray).
* Width = proportional to time between parts.
* Height = proportional to content length.
*
* Interactions: drag scroll, mouse wheel, auto-scroll to latest.
*
* No virtualization needed: SolidJS <Index> creates each element once and
* updates bindings in place (unlike React). Even 1000+ bars are fine.
* Horizontal session activity timeline rendered as color-grouped SVG paths.
* Pointer and keyboard interaction use the same pure bar geometry.
*/
import { Component, Index, Show, createMemo, createEffect, createSignal, on, onCleanup } from "solid-js"
import { Component, For, Show, createMemo, createEffect, createSignal, on, onCleanup } from "solid-js"
import { Portal } from "solid-js/web"
import { useSession } from "../../context/session"
import { color, label } from "../../utils/timeline/colors"
import { geometry, hit, navigate } from "../../utils/timeline/geometry"
import { sizes, pinned, MAX_HEIGHT } from "../../utils/timeline/sizes"
import type { Part, Message } from "../../types/messages"
@@ -56,7 +48,8 @@ export const TaskTimeline: Component = () => {
let dragging = false
let startX = 0
let startScroll = 0
let tipBar: HTMLElement | undefined
const [hover, setHover] = createSignal(-1)
const [active, setActive] = createSignal(-1)
const [tip, setTip] = createSignal<{ text: string; x: number; y: number }>()
const messages = () => session.visibleMessages()
@@ -71,12 +64,20 @@ export const TaskTimeline: Component = () => {
}
const bars = createMemo(() => collect(messages(), allParts()))
const layout = createMemo(() => geometry(bars(), MAX_HEIGHT))
const busy = () => session.status() === "busy"
const selected = () => {
const idx = active()
if (idx >= 0 && idx < bars().length) return idx
return bars().length - 1
}
const aria = () => {
const idx = selected()
const bar = bars()[idx]
if (!bar) return "Session activity timeline, no activity"
return `Session activity timeline, bar ${idx + 1} of ${bars().length}: ${bar.tip}`
}
// Reading scrollWidth and writing scrollLeft synchronously for every appended bar can
// force repeated layout during streamed part updates. Batch those appends behind one
// animation frame, after Solid has applied the current DOM updates. Only follow while
// pinned so inspecting earlier activity is not interrupted by incoming bars.
let prev = 0
let frame: number | undefined
let follow = true
@@ -87,6 +88,7 @@ export const TaskTimeline: Component = () => {
on(
() => bars().length,
(len) => {
if (active() >= len) setActive(len - 1)
if (len > prev && ref && follow && frame === undefined) {
frame = requestAnimationFrame(() => {
frame = undefined
@@ -103,28 +105,32 @@ export const TaskTimeline: Component = () => {
})
const hideTip = () => {
tipBar = undefined
setHover(-1)
setTip(undefined)
}
createEffect(on(bars, hideTip, { defer: true }))
const showTip = (e: PointerEvent) => {
if (dragging || !(e.target instanceof Element)) return
const bar = e.target.closest<HTMLElement>(".task-timeline-bar")
if (!bar || !ref?.contains(bar)) return hideTip()
if (bar === tipBar) return
const rect = bar.getBoundingClientRect()
tipBar = bar
const showTip = (idx: number) => {
const item = layout().items[idx]
const bar = bars()[idx]
if (!ref || !item || !bar) return hideTip()
const rect = ref.getBoundingClientRect()
const margin = Math.min(160, window.innerWidth / 2)
setHover(idx)
setTip({
text: bar.dataset.tip ?? "",
x: Math.max(margin, Math.min(window.innerWidth - margin, rect.left + rect.width / 2)),
y: rect.top,
text: bar.tip,
x: Math.max(margin, Math.min(window.innerWidth - margin, rect.left + item.x - ref.scrollLeft + item.width / 2)),
y: rect.top + MAX_HEIGHT - item.height,
})
}
// ── Drag scroll ──────────────────────────────────────────────────
const pointerIndex = (e: PointerEvent) => {
if (!ref) return -1
const rect = ref.getBoundingClientRect()
return hit(layout().items, e.clientX - rect.left + ref.scrollLeft)
}
const onPointerDown = (e: PointerEvent) => {
hideTip()
if (!ref) return
@@ -137,19 +143,24 @@ export const TaskTimeline: Component = () => {
}
const onPointerMove = (e: PointerEvent) => {
if (!dragging || !ref) return showTip(e)
if (!ref) return
if (!dragging) {
const idx = pointerIndex(e)
if (idx === hover()) return
if (idx < 0) return hideTip()
return showTip(idx)
}
ref.scrollLeft = startScroll - (e.clientX - startX)
}
const onPointerUp = (e: PointerEvent) => {
if (!ref) return
dragging = false
ref.releasePointerCapture(e.pointerId)
if (ref.hasPointerCapture(e.pointerId)) ref.releasePointerCapture(e.pointerId)
ref.style.cursor = "grab"
ref.style.userSelect = ""
}
// ── Wheel → horizontal scroll ────────────────────────────────────
const onWheel = (e: WheelEvent) => {
hideTip()
if (!ref) return
@@ -157,6 +168,20 @@ export const TaskTimeline: Component = () => {
ref.scrollLeft += e.deltaY || e.deltaX
}
const onKeyDown = (e: KeyboardEvent) => {
if (!ref || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return
e.preventDefault()
const idx = navigate(selected(), bars().length, e.key)
setActive(idx)
const item = layout().items[idx]
if (!item) return
const left = item.x
const right = item.x + item.width
if (left < ref.scrollLeft) ref.scrollLeft = left
if (right > ref.scrollLeft + ref.clientWidth) ref.scrollLeft = right - ref.clientWidth
showTip(idx)
}
createEffect(() => {
const el = ref
if (!el) return
@@ -164,13 +189,37 @@ export const TaskTimeline: Component = () => {
onCleanup(() => el.removeEventListener("wheel", onWheel))
})
const overlay = (idx: number, pulse = false) => {
const item = layout().items[idx]
if (!item) return null
return (
<div
class="task-timeline-bar"
classList={{ "task-timeline-bar--active": pulse }}
aria-hidden="true"
style={{
left: `${item.x}px`,
width: `${item.width}px`,
height: `${item.height}px`,
"--timeline-color": item.bg,
}}
/>
)
}
return (
<>
<div class="task-timeline-outer">
<div
ref={ref}
class="task-timeline"
data-timeline-count={bars().length}
role="img"
tabIndex={0}
aria-label={aria()}
style={{ height: `${MAX_HEIGHT}px` }}
onKeyDown={onKeyDown}
onBlur={hideTip}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
@@ -178,34 +227,19 @@ export const TaskTimeline: Component = () => {
onPointerLeave={hideTip}
onScroll={onScroll}
>
<Index each={bars()}>
{(bar) => {
const active = () => busy() && bar().idx === bars().length - 1
return (
<div
class="task-timeline-bar"
data-tip={bar().tip}
role="img"
aria-label={bar().tip}
style={{
width: `${bar().width}px`,
height: `${MAX_HEIGHT}px`,
}}
>
<div
class="task-timeline-bar-fill task-timeline-bar-fill--new"
classList={{
"task-timeline-bar-fill--active": active(),
}}
style={{
background: bar().bg,
height: `${(bar().height / MAX_HEIGHT) * 100}%`,
}}
/>
</div>
)
}}
</Index>
<div class="task-timeline-content" style={{ width: `${layout().width}px`, height: `${MAX_HEIGHT}px` }}>
<svg
class="task-timeline-svg"
width={layout().width}
height={MAX_HEIGHT}
viewBox={`0 0 ${layout().width} ${MAX_HEIGHT}`}
aria-hidden="true"
>
<For each={layout().paths}>{(path) => <path d={path.d} fill={path.bg} />}</For>
</svg>
<Show when={hover() >= 0}>{overlay(hover())}</Show>
<Show when={busy() && bars().length > 0}>{overlay(bars().length - 1, true)}</Show>
</div>
</div>
</div>
<Show when={tip()}>
@@ -0,0 +1,130 @@
import { type Component, Show, createEffect } from "solid-js"
import { UserMessageDisplay } from "@kilocode/kilo-ui/message-part"
import { DiffChanges } from "@kilocode/kilo-ui/diff-changes"
import { Icon } from "@kilocode/kilo-ui/icon"
import { useI18n } from "@kilocode/kilo-ui/context/i18n"
import type { AssistantMessage as SDKAssistantMessage, Part as SDKPart, SnapshotFileDiff } from "@kilocode/sdk/v2"
import type { TranscriptRow } from "../../context/transcript-rows"
import { useSession } from "../../context/session"
import { useServer } from "../../context/server"
import { useLanguage } from "../../context/language"
import { useVSCode } from "../../context/vscode"
import { useFeedback } from "../../context/feedback"
import { AssistantMessage } from "./AssistantMessage"
import { ErrorDisplay, type ErrorDisplayProps } from "./ErrorDisplay"
interface TranscriptRowViewProps {
row: TranscriptRow
index?: number
onForkMessage?: (sessionId: string, messageId: string) => void
}
export const TranscriptRowView: Component<TranscriptRowViewProps> = (props) => {
const session = useSession()
const server = useServer()
const language = useLanguage()
const vscode = useVSCode()
const feedback = useFeedback()
const i18n = useI18n()
createEffect(() => session.hydrateParts([props.row.message.id]))
const open = () => vscode.postMessage({ type: "openChanges", turnId: props.row.message.id })
return (
<div
class="vscode-session-turn"
data-message={props.row.message.id}
data-row={props.row.type}
data-row-key={props.row.key}
data-row-index={props.index}
data-turn={props.row.turn}
data-live={props.row.live ? "" : undefined}
>
<Show when={props.row.type === "user" ? props.row : undefined}>
{(row) => (
<div
class="vscode-session-turn-user"
data-revert-disabled={row().answered && session.status() !== "idle" ? "" : undefined}
title={row().answered && session.status() !== "idle" ? language.t("revert.disabled.agentBusy") : undefined}
>
<UserMessageDisplay
message={row().message as unknown as Parameters<typeof UserMessageDisplay>[0]["message"]}
parts={row().parts as unknown as Parameters<typeof UserMessageDisplay>[0]["parts"]}
interrupted={row().interrupted}
queued={row().queued}
onFork={
props.onForkMessage ? () => props.onForkMessage?.(row().message.sessionID, row().message.id) : undefined
}
onRevert={
row().answered
? () => {
if (session.status() !== "idle") return
session.revertSession(row().message.id)
}
: undefined
}
/>
</div>
)}
</Show>
<Show when={props.row.type === "assistant" ? props.row : undefined}>
{(row) => (
<div class="vscode-session-turn-assistant">
<AssistantMessage
message={row().message as unknown as SDKAssistantMessage}
parts={row().parts as unknown as SDKPart[]}
showAssistantCopyPartID={row().copy}
feedback={{
enabled: feedback.telemetryEnabled(),
rating: feedback.getRating(row().message.id),
onRate: (next) =>
feedback.rate({
messageID: row().message.id,
sessionID: row().message.sessionID,
parentMessageID: row().message.parentID ?? "",
providerID: row().message.providerID ?? row().message.model?.providerID ?? "",
modelID: row().message.modelID ?? row().message.model?.modelID ?? "",
variant: row().message.model?.variant,
next,
}),
}}
/>
</div>
)}
</Show>
<Show when={props.row.type === "diff" ? props.row : undefined}>
{(row) => (
<Show when={server.gitInstalled()}>
<div class="vscode-session-turn-diffs" data-component="session-turn">
<button
type="button"
class="vscode-session-turn-diffs-trigger"
onClick={open}
aria-label={i18n.t("ui.sessionReview.change.modified")}
>
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
<span data-slot="session-turn-diffs-count">
{row().diffs.length}{" "}
{i18n.t(row().diffs.length === 1 ? "ui.common.file.one" : "ui.common.file.other")}
</span>
<span data-slot="session-turn-diffs-meta">
<DiffChanges changes={row().diffs as SnapshotFileDiff[]} variant="bars" />
</span>
<span data-slot="session-turn-diffs-chevron" aria-hidden="true">
<Icon name="chevron-right" size="small" />
</span>
</button>
</div>
</Show>
)}
</Show>
<Show when={props.row.type === "error" ? props.row : undefined}>
{(row) => <ErrorDisplay error={row().error as ErrorDisplayProps["error"]} onLogin={server.goToLogin} />}
</Show>
</div>
)
}
@@ -0,0 +1,85 @@
import type { CacheSnapshot } from "virtua"
const MEASUREMENT_LIMIT = 16
const SCROLL_LIMIT = 50
export interface LayoutMetrics {
width: number
ratio: number
font: string
size: string
line: string
}
export type ScrollState =
| { type: "bottom" }
| {
type: "anchor"
key: string
offset: number
}
interface MeasurementState {
keys: string
layout: string
cache: CacheSnapshot
}
const measurements = new Map<string, MeasurementState>()
const scrolls = new Map<string, ScrollState>()
function touch<T>(cache: Map<string, T>, key: string, value: T, limit: number) {
cache.delete(key)
cache.set(key, value)
if (cache.size <= limit) return
const oldest = cache.keys().next().value
if (oldest !== undefined) cache.delete(oldest)
}
export function rowFingerprint(keys: readonly string[]) {
return keys.map((key) => `${key.length}:${key}`).join("|")
}
export function layoutFingerprint(metrics: LayoutMetrics) {
return [metrics.width, metrics.ratio, metrics.font, metrics.size, metrics.line]
.map((value) => `${String(value).length}:${value}`)
.join("|")
}
export function setMeasurement(id: string, keys: string, layout: string, cache: CacheSnapshot) {
touch(measurements, id, { keys, layout, cache }, MEASUREMENT_LIMIT)
}
export function getMeasurement(id: string, keys: string, layout: string) {
const value = measurements.get(id)
if (!value) return undefined
if (value.keys !== keys || value.layout !== layout) {
measurements.delete(id)
return undefined
}
touch(measurements, id, value, MEASUREMENT_LIMIT)
return value.cache
}
export function setScroll(id: string, state: ScrollState) {
touch(scrolls, id, state, SCROLL_LIMIT)
}
export function getScroll(id: string) {
const value = scrolls.get(id)
if (!value) return undefined
touch(scrolls, id, value, SCROLL_LIMIT)
return value
}
export function resolveAnchor(state: ScrollState | undefined, keys: readonly string[]) {
if (!state || state.type === "bottom") return undefined
const index = keys.indexOf(state.key)
if (index < 0) return undefined
return { index, offset: state.offset }
}
export function resetTranscriptCaches() {
measurements.clear()
scrolls.clear()
}
@@ -91,9 +91,11 @@ export const ModelPreview: Component<Props> = (props) => {
})()}
</Show>
</div>
<Show when={model().isFree}>
<Show when={model().isFree || isDataCollectedModel(model())}>
<span class="model-preview-free-data">
<span class="model-preview-badge model-preview-badge--free">{freeLabel()}</span>
<Show when={model().isFree}>
<span class="model-preview-badge model-preview-badge--free">{freeLabel()}</span>
</Show>
<Show when={isDataCollectedModel(model())}>
<Tooltip value={dataLabel()} placement="top">
<span class="model-preview-free-data-icon" aria-label={dataLabel()}>
@@ -828,11 +828,13 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
)
})()}
</span>
<Show when={isFree(model)}>
<Show when={isFree(model) || isDataCollectedModel(model)}>
<span class="model-selector-free-data">
<span class="model-selector-data-badge">
<Tag data-variant="member">{freeLabel()}</Tag>
</span>
<Show when={isFree(model)}>
<span class="model-selector-data-badge">
<Tag data-variant="member">{freeLabel()}</Tag>
</span>
</Show>
<Show when={isDataCollectedModel(model)}>
<Tooltip value={dataLabel()} placement="top">
<span class="model-selector-free-data-icon" aria-label={dataLabel()}>
@@ -22,8 +22,8 @@ export function isFree(model: Pick<EnrichedModel, "isFree">): boolean {
return model.isFree === true
}
export function isDataCollectedModel(model: Pick<EnrichedModel, "providerID" | "isFree">): boolean {
return model.isFree === true && model.providerID === KILO_GATEWAY_ID
export function isDataCollectedModel(model: Pick<EnrichedModel, "mayTrainOnYourPrompts">): boolean {
return model.mayTrainOnYourPrompts === true
}
export function freeDataLabel(_free: string, data: string): string {
@@ -0,0 +1,193 @@
import type { Message, Part } from "../types/messages"
import type { MessageTurn } from "./session-queue"
interface TranscriptMeta {
turn: string
partial: boolean
queued: boolean
live: boolean
}
export interface TranscriptUserRow extends TranscriptMeta {
type: "user"
key: string
message: Message
parts: Part[]
interrupted: boolean
answered: boolean
}
export interface TranscriptAssistantRow extends TranscriptMeta {
type: "assistant"
key: string
message: Message
parts: Part[]
copy?: string
}
export interface TranscriptDiffRow extends TranscriptMeta {
type: "diff"
key: string
message: Message
diffs: unknown[]
}
export interface TranscriptErrorRow extends TranscriptMeta {
type: "error"
key: string
message: Message
error: NonNullable<Message["error"]>
}
export type TranscriptRow = TranscriptUserRow | TranscriptAssistantRow | TranscriptDiffRow | TranscriptErrorRow
export interface TranscriptOptions {
size?: number
queued?: ReadonlySet<string>
live?: ReadonlySet<string>
hidden?: (id: string) => boolean
}
export interface TranscriptPartition {
virtual: TranscriptRow[]
keep: number[]
queued: TranscriptRow[]
}
function same<T>(a: T[], b: T[]) {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false
}
return true
}
function meta(a: TranscriptRow, b: TranscriptRow) {
return a.turn === b.turn && a.partial === b.partial && a.queued === b.queued && a.live === b.live
}
function equal(a: TranscriptRow, b: TranscriptRow) {
if (a.type !== b.type || !meta(a, b)) return false
if (a.type === "user" && b.type === "user") {
return (
a.message === b.message && same(a.parts, b.parts) && a.interrupted === b.interrupted && a.answered === b.answered
)
}
if (a.type === "assistant" && b.type === "assistant") {
return a.message === b.message && same(a.parts, b.parts) && a.copy === b.copy
}
if (a.type === "diff" && b.type === "diff") {
return a.message === b.message && same(a.diffs, b.diffs)
}
if (a.type === "error" && b.type === "error") {
return a.message === b.message && a.error === b.error
}
return false
}
function diffs(msg: Message) {
if (!msg.summary || typeof msg.summary === "boolean") return []
return msg.summary.diffs ?? []
}
function copy(messages: Message[], getParts: (id: string) => Part[]) {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const parts = getParts(messages[i]!.id)
for (let j = parts.length - 1; j >= 0; j -= 1) {
const part = parts[j]
if (part?.type !== "text" || part.synthetic || !part.text.trim()) continue
return part.id
}
}
return undefined
}
export function transcriptRows(
turns: MessageTurn[],
getParts: (id: string) => Part[],
opts: TranscriptOptions = {},
prev: TranscriptRow[] = [],
): TranscriptRow[] {
const size = Math.max(1, Math.floor(opts.size ?? 8))
const rows: TranscriptRow[] = []
for (const turn of turns) {
const meta = {
turn: turn.id,
partial: turn.partial === true,
queued: opts.queued?.has(turn.id) === true,
live: opts.live?.has(turn.id) === true,
}
const copied = copy(turn.assistant, getParts)
if (!turn.partial) {
rows.push({
...meta,
type: "user",
key: `${turn.id}:user`,
message: turn.user,
parts: getParts(turn.user.id),
interrupted: turn.assistant.some((msg) => msg.error?.name === "MessageAbortedError"),
answered: turn.assistant.length > 0,
})
}
for (const msg of turn.assistant) {
const parts = getParts(msg.id)
if (parts.length === 0) {
rows.push({
...meta,
type: "assistant",
key: `${turn.id}:assistant:${msg.id}:empty`,
message: msg,
parts,
copy: copied,
})
continue
}
for (let start = 0; start < parts.length; start += size) {
const chunk = parts.slice(start, start + size)
rows.push({
...meta,
type: "assistant",
key: `${turn.id}:assistant:${msg.id}:${chunk[0]!.id}`,
message: msg,
parts: chunk,
copy: copied,
})
}
}
const changes = diffs(turn.user)
if (changes.length > 0) {
rows.push({ ...meta, type: "diff", key: `${turn.id}:diff`, message: turn.user, diffs: changes })
}
const failed = turn.assistant.find(
(msg) => msg.error && msg.error.name !== "MessageAbortedError" && opts.hidden?.(msg.id) !== true,
)
if (failed?.error) {
rows.push({ ...meta, type: "error", key: `${turn.id}:error:${failed.id}`, message: failed, error: failed.error })
}
}
if (prev.length === 0) return rows
const prior = new Map(prev.map((row) => [row.key, row]))
return rows.map((row) => {
const old = prior.get(row.key)
return old && equal(old, row) ? old : row
})
}
export function partitionRows(rows: TranscriptRow[], limit = 2): TranscriptPartition {
const queued = rows.filter((row) => row.queued)
const virtual = rows.filter((row) => !row.queued)
const size = Math.max(0, Math.floor(limit))
const keep: number[] = []
for (let i = virtual.length - 1; i >= 0 && keep.length < size; i -= 1) {
const row = virtual[i]!
if (!row.live) break
keep.unshift(i)
}
return { virtual, keep, queued }
}
@@ -121,8 +121,7 @@
}
.task-timeline {
display: flex;
align-items: flex-end;
position: relative;
overflow-x: auto;
overflow-y: hidden;
cursor: grab;
@@ -130,37 +129,36 @@
scrollbar-width: none; /* Firefox */
}
.task-timeline:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.task-timeline::-webkit-scrollbar {
display: none;
}
.task-timeline-bar {
.task-timeline-content {
position: relative;
flex-shrink: 0;
margin-right: 1px;
overflow: hidden;
}
.task-timeline-bar-fill {
.task-timeline-svg {
display: block;
overflow: visible;
}
.task-timeline-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
box-sizing: border-box;
border-radius: 2px 2px 0 0;
transition: opacity 0.15s;
}
.task-timeline-bar:hover .task-timeline-bar-fill {
background: var(--timeline-color);
opacity: 0.7;
pointer-events: none;
}
/* Fade-in animation for new bars */
.task-timeline-bar-fill--new {
animation: timeline-fade-in 0.3s ease-out;
}
/* Active (latest) bar pulse when session is busy */
.task-timeline-bar-fill--active {
.task-timeline-bar--active {
opacity: 1;
animation: timeline-pulse 2s ease-in-out infinite 0.5s;
}
@@ -172,18 +170,6 @@
pointer-events: none;
}
@keyframes timeline-fade-in {
from {
opacity: 0;
transform: scaleY(0);
transform-origin: bottom;
}
to {
opacity: 1;
transform: scaleY(1);
}
}
@keyframes timeline-pulse {
0%,
100% {
@@ -18,6 +18,7 @@ export interface ProviderModel {
options?: { description?: string }
recommendedIndex?: number
isFree?: boolean
mayTrainOnYourPrompts?: boolean
terminalBench?: {
overallScore: number
avgAttemptCostUsd: number
@@ -0,0 +1,73 @@
export interface TimelineGeometryBar {
bg: string
width: number
height: number
}
export interface TimelineGeometryItem extends TimelineGeometryBar {
idx: number
x: number
}
export interface TimelineGeometry {
items: TimelineGeometryItem[]
paths: Array<{ bg: string; d: string }>
width: number
}
const shape = (x: number, width: number, height: number, max: number) => {
const y = max - height
const radius = Math.min(2, width / 2, height)
return `M${x},${max}V${y + radius}Q${x},${y} ${x + radius},${y}H${x + width - radius}Q${x + width},${y} ${x + width},${y + radius}V${max}Z`
}
export function geometry(bars: TimelineGeometryBar[], max: number, gap = 1): TimelineGeometry {
const paths = new Map<string, string[]>()
const items: TimelineGeometryItem[] = []
let x = 0
for (const [idx, bar] of bars.entries()) {
const item = { ...bar, idx, x }
items.push(item)
const path = paths.get(bar.bg) ?? []
path.push(shape(x, bar.width, bar.height, max))
paths.set(bar.bg, path)
x += bar.width + gap
}
return {
items,
paths: Array.from(paths, ([bg, parts]) => ({ bg, d: parts.join("") })),
width: x,
}
}
export function hit(items: TimelineGeometryItem[], x: number) {
let low = 0
let high = items.length - 1
while (low <= high) {
const mid = Math.floor((low + high) / 2)
const item = items[mid]!
if (x < item.x) {
high = mid - 1
continue
}
if (x >= item.x + item.width) {
low = mid + 1
continue
}
return item.idx
}
return -1
}
export function navigate(index: number, count: number, key: string) {
if (count === 0) return -1
if (key === "Home") return 0
if (key === "End") return count - 1
if (key === "ArrowLeft") return Math.max(0, index < 0 ? count - 1 : index - 1)
if (key === "ArrowRight") return Math.min(count - 1, index < 0 ? 0 : index + 1)
return index
}
@@ -1,7 +1,7 @@
export const FreeModelDisclosure = {
label: "May train",
panel: "Free - data may be used for training",
collectsData(model: { isFree?: boolean; api?: { npm?: string } }): boolean {
return model.isFree === true && model.api?.npm === "@kilocode/kilo-gateway"
panel: "Data may be used for training",
collectsData(model: { mayTrainOnYourPrompts?: boolean }): boolean {
return model.mayTrainOnYourPrompts === true
},
} as const
@@ -35,6 +35,7 @@ export const KILO_MODEL_SCHEMA_EXTENSIONS = {
recommendedIndex: optionalOmitUndefined(Schema.Finite),
prompt: Schema.optional(Schema.Literals(PROMPTS)),
isFree: Schema.optional(Schema.Boolean),
mayTrainOnYourPrompts: Schema.optional(Schema.Boolean),
terminalBench: optionalOmitUndefined(
Schema.Struct({
overallScore: Schema.Finite,
@@ -49,12 +50,12 @@ export const KILO_MODEL_SCHEMA_EXTENSIONS = {
// ---------------------------------------------------------------------------
export function patchModelsDevModel(providerID: string, source: any) {
const free = providerID === "kilo" && source.cost?.input === 0 && source.cost?.output === 0
return {
variants: providerID === "kilo" ? (source.variants ?? {}) : {},
recommendedIndex: source.recommendedIndex,
prompt: source.prompt,
isFree: source.isFree ?? (free ? true : undefined),
isFree: source.isFree,
mayTrainOnYourPrompts: source.mayTrainOnYourPrompts,
terminalBench: source.terminalBench,
ai_sdk_provider: source.ai_sdk_provider,
options: source.options ?? {},
@@ -70,6 +71,7 @@ export function patchConfigModel(cfg: any, existing: any) {
recommendedIndex: cfg.recommendedIndex ?? existing?.recommendedIndex,
prompt: cfg.prompt ?? existing?.prompt,
isFree: cfg.isFree ?? existing?.isFree,
mayTrainOnYourPrompts: cfg.mayTrainOnYourPrompts ?? existing?.mayTrainOnYourPrompts,
terminalBench: existing?.terminalBench,
ai_sdk_provider: cfg.ai_sdk_provider ?? existing?.ai_sdk_provider,
variants: cfg.variants
@@ -4,27 +4,12 @@ import { FreeModelDisclosure } from "../../src/kilocode/components/free-model-di
describe("FreeModelDisclosure", () => {
test("uses compact CLI labels", () => {
expect(FreeModelDisclosure.label).toBe("May train")
expect(FreeModelDisclosure.panel).toBe("Free - data may be used for training")
expect(FreeModelDisclosure.panel).toBe("Data may be used for training")
})
test("only Kilo Gateway free models get the training disclosure", () => {
expect(
FreeModelDisclosure.collectsData({
isFree: true,
api: { npm: "@kilocode/kilo-gateway" },
}),
).toBe(true)
expect(
FreeModelDisclosure.collectsData({
isFree: true,
api: { npm: "@openrouter/ai-sdk-provider" },
}),
).toBe(false)
expect(
FreeModelDisclosure.collectsData({
isFree: false,
api: { npm: "@kilocode/kilo-gateway" },
}),
).toBe(false)
test("uses only explicit prompt training metadata", () => {
expect(FreeModelDisclosure.collectsData({ mayTrainOnYourPrompts: true })).toBe(true)
expect(FreeModelDisclosure.collectsData({ mayTrainOnYourPrompts: false })).toBe(false)
expect(FreeModelDisclosure.collectsData({})).toBe(false)
})
})
@@ -86,6 +86,8 @@ function layer() {
id: "paid-model",
name: "Paid Model",
cost: { input: 1, output: 2 },
isFree: false,
mayTrainOnYourPrompts: true,
limit: { context: 128000, output: 4096 },
},
},
@@ -129,11 +131,13 @@ it.live("assembles paid Kilo models without auth", () =>
id: "paid-model",
providerID: "kilo",
cost: { input: 1, output: 2 },
isFree: false,
mayTrainOnYourPrompts: true,
})
}),
)
it.live("marks zero-cost Kilo models as free when the catalog omits isFree", () =>
it.live("does not infer free status from zero catalog prices", () =>
Effect.gen(function* () {
const providers = yield* ModelsDev.Service.use((models) => models.get()).pipe(
Effect.provide(layer()),
@@ -141,7 +145,7 @@ it.live("marks zero-cost Kilo models as free when the catalog omits isFree", ()
)
const kilo = Provider.fromModelsDevProvider(providers.kilo)
expect(kilo.models["free-model"].isFree).toBe(true)
expect(kilo.models["free-model"].isFree).toBeUndefined()
}),
)
+112 -111
View File
@@ -5,16 +5,10 @@ export type ClientOptions = {
}
export type Event =
| EventServerInstanceDisposed
| EventServerConnected
| EventGlobalDisposed
| EventGlobalConfigUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow1
| EventTuiSessionSelect
| EventKilocodeAgentManagerStart
| EventIndexingStatus
| EventServerInstanceDisposed
| EventFileEdited
| EventFileWatcherUpdated
| EventQuestionAsked
@@ -22,6 +16,10 @@ export type Event =
| EventQuestionRejected
| EventLspClientDiagnostics
| EventLspUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow1
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventSessionNetworkAsked
@@ -46,6 +44,7 @@ export type Event =
| EventSessionCompacted
| EventCommandExecuted
| EventProjectUpdated
| EventKilocodeAgentManagerStart
| EventVcsBranchUpdated
| EventKiloSessionsRemoteStatusChanged
| EventWorkspaceReady
@@ -59,6 +58,7 @@ export type Event =
| EventPtyDeleted
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventIndexingStatus
export type OAuth = {
type: "oauth"
@@ -85,71 +85,6 @@ export type WellKnownAuth = {
export type Auth = OAuth | ApiAuth | WellKnownAuth
export type EventTuiPromptAppend = {
id: string
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
id: string
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
id: string
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
export type EventTuiSessionSelect = {
id: string
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby"
export type IndexingStatus = {
state: IndexingStatusState
message: string
processedFiles: number
totalFiles: number
percent: number
}
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
@@ -212,6 +147,61 @@ export type QuestionRejected = {
requestID: string
}
export type EventTuiPromptAppend = {
id: string
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
id: string
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
id: string
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
export type EventTuiSessionSelect = {
id: string
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type SessionNetworkWait = {
id: string
sessionID: string
@@ -426,6 +416,16 @@ export type Pty = {
sessionID?: string | null
}
export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby"
export type IndexingStatus = {
state: IndexingStatusState
message: string
processedFiles: number
totalFiles: number
percent: number
}
export type OutputFormatText = {
type: "text"
}
@@ -859,16 +859,10 @@ export type GlobalEvent = {
project?: string
workspace?: string
payload:
| EventServerInstanceDisposed
| EventServerConnected
| EventGlobalDisposed
| EventGlobalConfigUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventKilocodeAgentManagerStart
| EventIndexingStatus
| EventServerInstanceDisposed
| EventFileEdited
| EventFileWatcherUpdated
| EventQuestionAsked
@@ -876,6 +870,10 @@ export type GlobalEvent = {
| EventQuestionRejected
| EventLspClientDiagnostics
| EventLspUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventSessionNetworkAsked
@@ -900,6 +898,7 @@ export type GlobalEvent = {
| EventSessionCompacted
| EventCommandExecuted
| EventProjectUpdated
| EventKilocodeAgentManagerStart
| EventVcsBranchUpdated
| EventKiloSessionsRemoteStatusChanged
| EventWorkspaceReady
@@ -913,6 +912,7 @@ export type GlobalEvent = {
| EventPtyDeleted
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventIndexingStatus
| SyncEventMessageUpdated
| SyncEventMessageRemoved
| SyncEventMessagePartUpdated
@@ -1511,6 +1511,7 @@ export type Model = {
recommendedIndex?: number
prompt?: "codex" | "gemini" | "beast" | "anthropic" | "trinity" | "anthropic_without_todo" | "ling" | "gpt55"
isFree?: boolean
mayTrainOnYourPrompts?: boolean
terminalBench?: {
overallScore: number
avgAttemptCostUsd: number
@@ -2722,6 +2723,14 @@ export type SyncEventSessionNextCompactionEnded = {
}
}
export type EventServerInstanceDisposed = {
id: string
type: "server.instance.disposed"
properties: {
directory: string
}
}
export type EventServerConnected = {
id: string
type: "server.connected"
@@ -2746,38 +2755,6 @@ export type EventGlobalConfigUpdated = {
}
}
export type EventKilocodeAgentManagerStart = {
id: string
type: "kilocode.agent_manager.start"
properties: {
requestID: string
sessionID: string
mode: "worktree" | "local"
versions?: boolean
tasks: Array<{
prompt?: string
name?: string
branchName?: string
}>
}
}
export type EventIndexingStatus = {
id: string
type: "indexing.status"
properties: {
status: IndexingStatus
}
}
export type EventServerInstanceDisposed = {
id: string
type: "server.instance.disposed"
properties: {
directory: string
}
}
export type EventFileEdited = {
id: string
type: "file.edited"
@@ -3055,6 +3032,22 @@ export type EventProjectUpdated = {
properties: Project
}
export type EventKilocodeAgentManagerStart = {
id: string
type: "kilocode.agent_manager.start"
properties: {
requestID: string
sessionID: string
mode: "worktree" | "local"
versions?: boolean
tasks: Array<{
prompt?: string
name?: string
branchName?: string
}>
}
}
export type EventVcsBranchUpdated = {
id: string
type: "vcs.branch.updated"
@@ -3163,6 +3156,14 @@ export type EventInstallationUpdateAvailable = {
}
}
export type EventIndexingStatus = {
id: string
type: "indexing.status"
properties: {
status: IndexingStatus
}
}
export type PromptSource = {
start: number
end: number
+312 -309
View File
@@ -14344,6 +14344,9 @@
"schemas": {
"Event": {
"anyOf": [
{
"$ref": "#/components/schemas/EventServerInstanceDisposed"
},
{
"$ref": "#/components/schemas/EventServerConnected"
},
@@ -14353,27 +14356,6 @@
{
"$ref": "#/components/schemas/EventGlobalConfigUpdated"
},
{
"$ref": "#/components/schemas/Event.tui.prompt.append"
},
{
"$ref": "#/components/schemas/Event.tui.command.execute"
},
{
"$ref": "#/components/schemas/EventTuiToastShow1"
},
{
"$ref": "#/components/schemas/Event.tui.session.select"
},
{
"$ref": "#/components/schemas/EventKilocodeAgent_managerStart"
},
{
"$ref": "#/components/schemas/EventIndexingStatus"
},
{
"$ref": "#/components/schemas/EventServerInstanceDisposed"
},
{
"$ref": "#/components/schemas/EventFileEdited"
},
@@ -14395,6 +14377,18 @@
{
"$ref": "#/components/schemas/EventLspUpdated"
},
{
"$ref": "#/components/schemas/Event.tui.prompt.append"
},
{
"$ref": "#/components/schemas/Event.tui.command.execute"
},
{
"$ref": "#/components/schemas/EventTuiToastShow1"
},
{
"$ref": "#/components/schemas/Event.tui.session.select"
},
{
"$ref": "#/components/schemas/EventMcpToolsChanged"
},
@@ -14467,6 +14461,9 @@
{
"$ref": "#/components/schemas/EventProjectUpdated"
},
{
"$ref": "#/components/schemas/EventKilocodeAgent_managerStart"
},
{
"$ref": "#/components/schemas/EventVcsBranchUpdated"
},
@@ -14505,6 +14502,9 @@
},
{
"$ref": "#/components/schemas/EventInstallationUpdate-available"
},
{
"$ref": "#/components/schemas/EventIndexingStatus"
}
]
},
@@ -14585,170 +14585,6 @@
}
]
},
"Event.tui.prompt.append": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.prompt.append"]
},
"properties": {
"type": "object",
"properties": {
"text": {
"type": "string"
}
},
"required": ["text"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.command.execute": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.command.execute"]
},
"properties": {
"type": "object",
"properties": {
"command": {
"anyOf": [
{
"type": "string",
"enum": [
"session.list",
"session.new",
"session.share",
"session.interrupt",
"session.compact",
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
"session.first",
"session.last",
"prompt.clear",
"prompt.submit",
"agent.cycle"
]
},
{
"type": "string"
}
]
}
},
"required": ["command"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.toast.show": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.toast.show"]
},
"properties": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"message": {
"type": "string"
},
"variant": {
"type": "string",
"enum": ["info", "success", "warning", "error"]
},
"duration": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["message", "variant"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.session.select": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.session.select"]
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses",
"description": "Session ID to navigate to"
}
},
"required": ["sessionID"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"IndexingStatusState": {
"type": "string",
"enum": ["Disabled", "In Progress", "Complete", "Error", "Standby"]
},
"IndexingStatus": {
"type": "object",
"properties": {
"state": {
"$ref": "#/components/schemas/IndexingStatusState"
},
"message": {
"type": "string"
},
"processedFiles": {
"type": "integer",
"minimum": 0
},
"totalFiles": {
"type": "integer",
"minimum": 0
},
"percent": {
"type": "integer",
"minimum": 0,
"maximum": 100
}
},
"required": ["state", "message", "processedFiles", "totalFiles", "percent"],
"additionalProperties": false
},
"QuestionOption": {
"type": "object",
"properties": {
@@ -14891,6 +14727,140 @@
"required": ["sessionID", "requestID"],
"additionalProperties": false
},
"Event.tui.prompt.append": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.prompt.append"]
},
"properties": {
"type": "object",
"properties": {
"text": {
"type": "string"
}
},
"required": ["text"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.command.execute": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.command.execute"]
},
"properties": {
"type": "object",
"properties": {
"command": {
"anyOf": [
{
"type": "string",
"enum": [
"session.list",
"session.new",
"session.share",
"session.interrupt",
"session.compact",
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
"session.first",
"session.last",
"prompt.clear",
"prompt.submit",
"agent.cycle"
]
},
{
"type": "string"
}
]
}
},
"required": ["command"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.toast.show": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.toast.show"]
},
"properties": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"message": {
"type": "string"
},
"variant": {
"type": "string",
"enum": ["info", "success", "warning", "error"]
},
"duration": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["message", "variant"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.session.select": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.session.select"]
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses",
"description": "Session ID to navigate to"
}
},
"required": ["sessionID"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"SessionNetworkWait": {
"type": "object",
"properties": {
@@ -15520,6 +15490,36 @@
"required": ["id", "title", "command", "args", "cwd", "status", "pid"],
"additionalProperties": false
},
"IndexingStatusState": {
"type": "string",
"enum": ["Disabled", "In Progress", "Complete", "Error", "Standby"]
},
"IndexingStatus": {
"type": "object",
"properties": {
"state": {
"$ref": "#/components/schemas/IndexingStatusState"
},
"message": {
"type": "string"
},
"processedFiles": {
"type": "integer",
"minimum": 0
},
"totalFiles": {
"type": "integer",
"minimum": 0
},
"percent": {
"type": "integer",
"minimum": 0,
"maximum": 100
}
},
"required": ["state", "message", "processedFiles", "totalFiles", "percent"],
"additionalProperties": false
},
"OutputFormatText": {
"type": "object",
"properties": {
@@ -16871,6 +16871,9 @@
},
"payload": {
"anyOf": [
{
"$ref": "#/components/schemas/EventServerInstanceDisposed"
},
{
"$ref": "#/components/schemas/EventServerConnected"
},
@@ -16880,27 +16883,6 @@
{
"$ref": "#/components/schemas/EventGlobalConfigUpdated"
},
{
"$ref": "#/components/schemas/Event.tui.prompt.append"
},
{
"$ref": "#/components/schemas/Event.tui.command.execute"
},
{
"$ref": "#/components/schemas/Event.tui.toast.show"
},
{
"$ref": "#/components/schemas/Event.tui.session.select"
},
{
"$ref": "#/components/schemas/EventKilocodeAgent_managerStart"
},
{
"$ref": "#/components/schemas/EventIndexingStatus"
},
{
"$ref": "#/components/schemas/EventServerInstanceDisposed"
},
{
"$ref": "#/components/schemas/EventFileEdited"
},
@@ -16922,6 +16904,18 @@
{
"$ref": "#/components/schemas/EventLspUpdated"
},
{
"$ref": "#/components/schemas/Event.tui.prompt.append"
},
{
"$ref": "#/components/schemas/Event.tui.command.execute"
},
{
"$ref": "#/components/schemas/Event.tui.toast.show"
},
{
"$ref": "#/components/schemas/Event.tui.session.select"
},
{
"$ref": "#/components/schemas/EventMcpToolsChanged"
},
@@ -16994,6 +16988,9 @@
{
"$ref": "#/components/schemas/EventProjectUpdated"
},
{
"$ref": "#/components/schemas/EventKilocodeAgent_managerStart"
},
{
"$ref": "#/components/schemas/EventVcsBranchUpdated"
},
@@ -17033,6 +17030,9 @@
{
"$ref": "#/components/schemas/EventInstallationUpdate-available"
},
{
"$ref": "#/components/schemas/EventIndexingStatus"
},
{
"$ref": "#/components/schemas/SyncEventMessageUpdated"
},
@@ -18684,6 +18684,9 @@
"isFree": {
"type": "boolean"
},
"mayTrainOnYourPrompts": {
"type": "boolean"
},
"terminalBench": {
"type": "object",
"properties": {
@@ -22633,6 +22636,30 @@
"required": ["type", "name", "id", "seq", "aggregateID", "data"],
"additionalProperties": false
},
"EventServerInstanceDisposed": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["server.instance.disposed"]
},
"properties": {
"type": "object",
"properties": {
"directory": {
"type": "string"
}
},
"required": ["directory"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventServerConnected": {
"type": "object",
"properties": {
@@ -22687,109 +22714,6 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventKilocodeAgent_managerStart": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["kilocode.agent_manager.start"]
},
"properties": {
"type": "object",
"properties": {
"requestID": {
"type": "string"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"mode": {
"type": "string",
"enum": ["worktree", "local"]
},
"versions": {
"type": "boolean"
},
"tasks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"prompt": {
"type": "string"
},
"name": {
"type": "string"
},
"branchName": {
"type": "string"
}
},
"additionalProperties": false
},
"minItems": 1,
"maxItems": 20
}
},
"required": ["requestID", "sessionID", "mode", "tasks"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventIndexingStatus": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["indexing.status"]
},
"properties": {
"type": "object",
"properties": {
"status": {
"$ref": "#/components/schemas/IndexingStatus"
}
},
"required": ["status"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventServerInstanceDisposed": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["server.instance.disposed"]
},
"properties": {
"type": "object",
"properties": {
"directory": {
"type": "string"
}
},
"required": ["directory"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventFileEdited": {
"type": "object",
"properties": {
@@ -23629,6 +23553,61 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventKilocodeAgent_managerStart": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["kilocode.agent_manager.start"]
},
"properties": {
"type": "object",
"properties": {
"requestID": {
"type": "string"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"mode": {
"type": "string",
"enum": ["worktree", "local"]
},
"versions": {
"type": "boolean"
},
"tasks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"prompt": {
"type": "string"
},
"name": {
"type": "string"
},
"branchName": {
"type": "string"
}
},
"additionalProperties": false
},
"minItems": 1,
"maxItems": 20
}
},
"required": ["requestID", "sessionID", "mode", "tasks"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventVcsBranchUpdated": {
"type": "object",
"properties": {
@@ -23957,6 +23936,30 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventIndexingStatus": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["indexing.status"]
},
"properties": {
"type": "object",
"properties": {
"status": {
"$ref": "#/components/schemas/IndexingStatus"
}
},
"required": ["status"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"PromptSource": {
"type": "object",
"properties": {