Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .changeset/renderer-memory-guardrails.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---

Reduce memory use when switching long VS Code sessions while preserving queued prompts, unsent drafts, and inline edit diffs.
61 changes: 61 additions & 0 deletions packages/kilo-vscode/src/kilo-provider/slim-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,63 @@ function slimBash(state: Record<string, unknown>): Record<string, unknown> {
return next
}

/** websearch: preserve provider metadata and cap rendered results. */
function slimWebsearch(state: Record<string, unknown>): Record<string, unknown> {
const next = slimOutput(state)
const meta = state.metadata
if (isObj(meta)) {
const slim: Record<string, unknown> = {}
if (typeof meta.provider === "string") slim.provider = meta.provider
next.metadata = slim
}
return next
}

/** webfetch: preserve the requested URL and cap rendered response content. */
function slimWebfetch(state: Record<string, unknown>): Record<string, unknown> {
const next = slimOutput(state)
const input = state.input
if (isObj(input)) {
const slim: Record<string, unknown> = {}
if (typeof input.url === "string") slim.url = input.url
next.input = slim
}
return next
}

/** codesearch: preserve query/token count and cap rendered results. */
function slimCodesearch(state: Record<string, unknown>): Record<string, unknown> {
const next = slimOutput(state)
const input = state.input
if (isObj(input)) {
const slim: Record<string, unknown> = {}
if (typeof input.query === "string") slim.query = input.query
if (typeof input.tokensNum === "number") slim.tokensNum = input.tokensNum
next.input = slim
}
return next
}

/** task: preserve rendered labels and sessionId while capping sub-agent output. */
function slimTask(state: Record<string, unknown>): Record<string, unknown> {
const next = slimOutput(state)
const input = state.input
if (isObj(input)) {
const slim: Record<string, unknown> = {}
if (typeof input.description === "string") slim.description = input.description
if (typeof input.subagent_type === "string") slim.subagent_type = input.subagent_type
if (typeof input.sessionId === "string") slim.sessionId = input.sessionId
next.input = slim
}
const meta = state.metadata
if (isObj(meta) && typeof meta.sessionId === "string") {
next.metadata = { sessionId: meta.sessionId }
} else {
delete next.metadata
}
return next
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -214,6 +271,10 @@ const slimmers: Record<string, (state: Record<string, unknown>) => Record<string
multiedit: slimMultiedit,
write: slimWrite,
bash: slimBash,
websearch: slimWebsearch,
webfetch: slimWebfetch,
codesearch: slimCodesearch,
task: slimTask,
}

/** Strip provider metadata that the webview never reads from reasoning parts. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from "bun:test"
import fs from "node:fs"
import path from "node:path"

const file = path.resolve(import.meta.dir, "../../webview-ui/src/components/chat/MessageList.tsx")
const source = fs.readFileSync(file, "utf-8")

function virtualizer() {
const start = source.indexOf("<Virtualizer")
const end = source.indexOf("</Virtualizer>", start)
return source.slice(start, end)
}

describe("MessageList rendering", () => {
it("marks only partitioned queued turns as queued", () => {
expect(virtualizer()).not.toContain("queued=")
expect(source).toMatch(/<For each=\{partition\(\)\.queued\}>[\s\S]*?<VscodeSessionTurn turn=\{turn\} queued \/>/)
})

it("disables automatic history expansion when configured", () => {
expect(source).toContain("config().auto_expand_history === false")
})
})
27 changes: 26 additions & 1 deletion packages/kilo-vscode/tests/unit/prompt-drafts.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
import { describe, it, expect } from "bun:test"
import { beforeEach, describe, it, expect } from "bun:test"
import { deleteDraftsForSession, drafts, imageDrafts, reviewDrafts } from "../../webview-ui/src/utils/draft-store"
import { pendingDraftKey, scopeDraftKey, sessionDraftKey } from "../../webview-ui/src/utils/prompt-drafts"

beforeEach(() => {
drafts.clear()
reviewDrafts.clear()
imageDrafts.clear()
})

describe("deleteDraftsForSession", () => {
it("clears deleted-session drafts without touching other sessions", () => {
drafts.set("prompt:default:session:a", "draft a")
drafts.set("prompt:default:pending:a", "pending a")
drafts.set("prompt:default:session:b", "draft b")
reviewDrafts.set("prompt:default:session:a", [])
imageDrafts.set("prompt:default:session:a", [])

deleteDraftsForSession("a")

expect(drafts.has("prompt:default:session:a")).toBe(false)
expect(drafts.has("prompt:default:pending:a")).toBe(false)
expect(drafts.get("prompt:default:session:b")).toBe("draft b")
expect(reviewDrafts.has("prompt:default:session:a")).toBe(false)
expect(imageDrafts.has("prompt:default:session:a")).toBe(false)
})
})

describe("sessionDraftKey", () => {
it("prefixes session ids", () => {
expect(sessionDraftKey("abc")).toBe("session:abc")
Expand Down
27 changes: 27 additions & 0 deletions packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import path from "node:path"
const ROOT = path.resolve(import.meta.dir, "../..")
const SESSION_FILE = path.join(ROOT, "webview-ui/src/context/session.tsx")
const CHATVIEW_FILE = path.join(ROOT, "webview-ui/src/components/chat/ChatView.tsx")
const PROMPT_INPUT_FILE = path.join(ROOT, "webview-ui/src/components/chat/PromptInput.tsx")
const PROMPT_UTILS_FILE = path.join(ROOT, "webview-ui/src/components/chat/prompt-input-utils.ts")

function readFile(filePath: string): string {
Expand Down Expand Up @@ -75,6 +76,32 @@ describe("sendCommand dismisses pending tool requests", () => {
})
})

describe("session draft retention contract", () => {
const source = readFile(SESSION_FILE)
const input = readFile(PROMPT_INPUT_FILE)
const select = extractFunctionBody(source, "selectSession")
const remove = extractFunctionBody(source, "handleSessionDeleted")

it("does not treat a session switch as draft deletion", () => {
expect(select).not.toContain("deleteDraftsForSession")
expect(select).not.toContain("sessionDeleted")
})

it("cleans the draft maps only on real deletion", () => {
expect(input).toContain('import { drafts, imageDrafts, reviewDrafts } from "../../utils/draft-store"')
expect(remove).toContain("deleteDraftsForSession(sessionID)")
})

it("clears off-store transcript caches unless the session becomes active again", () => {
const guard = select.indexOf("if (currentSessionID() === oldID) return")
const cleanup = select.indexOf("stash.remove(mid)")

expect(guard).toBeGreaterThan(-1)
expect(cleanup).toBeGreaterThan(guard)
expect(select).toContain("delete map[oldID]")
})
})

describe("ChatView prompt-block contract", () => {
const source = readFile(CHATVIEW_FILE)

Expand Down
68 changes: 68 additions & 0 deletions packages/kilo-vscode/tests/unit/slim-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,4 +367,72 @@ describe("slimPart", () => {
expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES)
})
})

// -----------------------------------------------------------------------
// websearch / webfetch / codesearch / task
// -----------------------------------------------------------------------
describe("web tools", () => {
it("caps websearch output and preserves provider metadata", () => {
const heavy = part("websearch", {
status: "completed",
input: { query: "renderer memory" },
output: BIG,
metadata: { provider: "exa", results: BIG },
})
const slim = slimPart(heavy) as Record<string, any>

expect(slim.state.output.length).toBeLessThan(BIG.length)
expect(slim.state.metadata).toEqual({ provider: "exa" })
expect(bytes(slim)).toBeLessThan(MAX_SLIM_BYTES)
})

it("caps webfetch output and preserves only the URL input", () => {
const heavy = part("webfetch", {
status: "completed",
input: { url: "https://example.com", format: "markdown" },
output: BIG,
metadata: {},
})
const slim = slimPart(heavy) as Record<string, any>

expect(slim.state.output.length).toBeLessThan(BIG.length)
expect(slim.state.input).toEqual({ url: "https://example.com" })
expect(bytes(slim)).toBeLessThan(MAX_SLIM_BYTES)
})

it("caps codesearch output and preserves query/token inputs", () => {
const heavy = part("codesearch", {
status: "completed",
input: { query: "SolidJS createMemo", tokensNum: 5000, extra: BIG },
output: BIG,
metadata: {},
})
const slim = slimPart(heavy) as Record<string, any>

expect(slim.state.output.length).toBeLessThan(BIG.length)
expect(slim.state.input).toEqual({ query: "SolidJS createMemo", tokensNum: 5000 })
expect(bytes(slim)).toBeLessThan(MAX_SLIM_BYTES)
})
})

describe("task", () => {
it("caps output and preserves only rendered input plus child session metadata", () => {
const heavy = part("task", {
status: "completed",
input: { description: "Explore renderer", prompt: BIG, subagent_type: "explore", sessionId: "child" },
output: BIG,
metadata: { sessionId: "child", model: BIG },
})
const slim = slimPart(heavy) as Record<string, any>

expect(slim.state.output.length).toBeLessThan(BIG.length)
expect(slim.state.input).toEqual({
description: "Explore renderer",
subagent_type: "explore",
sessionId: "child",
})
expect(slim.state.metadata).toEqual({ sessionId: "child" })
expect(bytes(slim)).toBeLessThan(MAX_SLIM_BYTES)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { createAutoScroll } from "@kilocode/kilo-ui/hooks"
import { useSession } from "../../context/session"
import { useServer } from "../../context/server"
import { useLanguage } from "../../context/language"
import { useConfig } from "../../context/config"
import { recentSessions } from "../../context/session-utils"
import { formatRelativeDate } from "../../utils/date"
import { FeedbackDialog } from "./FeedbackDialog"
Expand Down Expand Up @@ -68,6 +69,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
const session = useSession()
const server = useServer()
const language = useLanguage()
const { config } = useConfig()
const dialog = useDialog()

const autoScroll = createAutoScroll({
Expand Down Expand Up @@ -143,7 +145,7 @@ export const MessageList: Component<MessageListProps> = (props) => {

const maybeLoadOlder = () => {
const el = scrollEl()
if (!el || el.scrollTop > 600) return
if (!el || el.scrollTop > 600 || config().auto_expand_history === false) return
session.loadOlderMessages()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,9 @@ import {
} from "./prompt-input-utils"
import type { ReviewComment, TextPart } from "../../types/messages"
import { formatReviewCommentsMarkdown } from "../../utils/review-comment-markdown"
import { drafts, imageDrafts, reviewDrafts } from "../../utils/draft-store"
import { pendingDraftKey, scopeDraftKey, sessionDraftKey } from "../../utils/prompt-drafts"

// Per-session input text storage (module-level so it survives remounts)
const drafts = new Map<string, string>()
const reviewDrafts = new Map<string, ReviewComment[]>()
const imageDrafts = new Map<string, ImageAttachment[]>()

function mergeReviewComments(current: ReviewComment[], incoming: ReviewComment[]): ReviewComment[] {
if (incoming.length === 0) return current
const map = new Map(current.map((item) => [item.id, item]))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,19 @@ const DisplayTab: Component = () => {
</Switch>
</SettingsRow>

<SettingsRow
title={language.t("settings.display.autoExpandHistory.title")}
description={language.t("settings.display.autoExpandHistory.description")}
>
<Switch
checked={config().auto_expand_history !== false}
onChange={(checked: boolean) => updateConfig({ auto_expand_history: checked ? undefined : false })}
hideLabel
>
{language.t("settings.display.autoExpandHistory.title")}
</Switch>
</SettingsRow>

<SettingsRow
title={language.t("settings.display.terminalCommand.title")}
description={language.t("settings.display.terminalCommand.description")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const KNOWN_KEYS: ReadonlyArray<string> = [
"tools",
"layout",
"auto_collapse_reasoning",
"auto_expand_history",
"terminal_command_display",
"indexing",
"experimental",
Expand Down
48 changes: 48 additions & 0 deletions packages/kilo-vscode/webview-ui/src/context/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { state as todoState } from "./todo-revert"
import { getVariant, sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store"
import { KILO_AUTO, parseModelString } from "../../../src/shared/provider-model"
import { visibleMessages as filterVisibleMessages } from "./session-queue"
import { deleteDraftsForSession } from "../utils/draft-store"

const RECENT_LIMIT = 5
const MESSAGE_PAGE_LIMIT = 80
Expand Down Expand Up @@ -1700,6 +1701,7 @@ export const SessionProvider: ParentComponent = (props) => {
setLoading(false)
}
})
deleteDraftsForSession(sessionID)
}

// Splices the message from the store and deletes its parts.
Expand Down Expand Up @@ -2190,7 +2192,53 @@ export const SessionProvider: ParentComponent = (props) => {
console.warn("[Kilo New] Cannot select cloud preview session via selectSession")
return
}
const oldID = currentSessionID()
const ready = loaded().has(id)
if (oldID && oldID !== id) {
const msgs = store.messages[oldID] ?? []
const msgIds = msgs.map((m) => m.id)
queueMicrotask(() => {

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: Race condition with rapid session switching (A→B→A)

The queueMicrotask captures oldID but runs after selectSession returns. If the user switches A→B then quickly back to A before the microtask fires:

  1. A→B: oldID=A, microtask queued to delete A's store data, currentSessionID = B
  2. B→A: ready = loaded().has("A") === true (microtask hasn't fired yet), setCurrentSessionID("A"), mode: "focus" sent
  3. Microtask 1 fires: deletes A's messages, parts, todos, etc. from store → the active session A now shows empty
  4. Reconcile from mode: "focus" eventually repopulates, but there's a visible blank flash

A guard inside the microtask would prevent this: skip the delete if currentSessionID() has already switched back to oldID.

queueMicrotask(() => {
  if (currentSessionID() === oldID) return // switched back — don't evict
  ...
})

if (currentSessionID() === oldID) return
for (const mid of msgIds) stash.remove(mid)
setStore(
"messages",
produce((messages) => {
delete messages[oldID]
}),
)
setStore(
"parts",
produce((parts) => {
for (const mid of msgIds) {
delete parts[mid]
}
}),
)
setStore(
"todos",
produce((todos) => {
delete todos[oldID]
}),
)
setStore(
"agentSelections",
produce((selections) => {
delete selections[oldID]
}),
)
setPages(
produce((map) => {
delete map[oldID]
}),
)
setLoaded((prev) => {
if (!prev.has(oldID)) return prev
const next = new Set(prev)
next.delete(oldID)
return next
})
})
}
setCurrentSessionID(id)
setDraftSessionID(id)
setLoading(!ready)
Expand Down
2 changes: 2 additions & 0 deletions packages/kilo-vscode/webview-ui/src/i18n/ar.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading