Merge origin/main into OpenCode v1.15.4

This commit is contained in:
marius-kilocode
2026-06-12 16:19:19 +02:00
141 changed files with 3824 additions and 1369 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Prevent unnecessary repeat auto-compactions when providers report inconsistent token totals.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Preserve existing Agent Manager worktrees and use deterministic suffixes when explicit branch names collide.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Allow hosted runtimes to cap shell command duration and explain environment-enforced timeouts.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Indicate when no models are available in model-not-found errors.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Group Kilo Code commands in the VS Code command palette and clarify the Open in Tab command title.
-7
View File
@@ -1,7 +0,0 @@
---
"@kilocode/cli": minor
"@kilocode/kilo-indexing": minor
"kilo-code": minor
---
Use embedded LanceDB as the default semantic search vector store so indexing works without a separate Qdrant server. Existing Qdrant users and Intel Mac users can select `qdrant` with `indexing.vectorStore`.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Dismiss stale permission prompts when another view has already answered them.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Speed up the first Agent Manager prompt in new worktrees by seeding snapshots from the checkout's Git index.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Keep large chat transcripts responsive by mounting only viewport-visible conversation rows.
-7
View File
@@ -1,7 +0,0 @@
---
"@kilocode/cli": patch
"@kilocode/kilo-ui": patch
"kilo-code": patch
---
Speed up large session forks by retaining final task outcomes instead of duplicating resumable subagent histories, and load completed task details only when expanded.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Refresh connected provider model lists when the models catalog updates.
-7
View File
@@ -1,7 +0,0 @@
---
"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.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Keep HTTP status codes beside tool error titles when error details wrap.
-6
View File
@@ -1,6 +0,0 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Prevent task subagents from asking questions that users cannot answer from the parent session.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Show the first Agent Manager Local prompt and progress indicator immediately while its session is being created.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Prevent duplicate CLI attention alerts and route Kilo prompts through the configurable notification system.
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Remove the unsupported paste-summary toggle from VS Code settings.
@@ -1,7 +0,0 @@
---
"kilo-code": patch
"@kilocode/cli": patch
"@kilocode/kilo-gateway": patch
---
Prevent streamed tool calls from executing twice and leaving answered questions disabled in VS Code.
-6
View File
@@ -1,6 +0,0 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Remove the unsupported code search tool.
-6
View File
@@ -1,6 +0,0 @@
---
"@kilocode/cli": patch
"@kilocode/kilo-gateway": patch
---
Update the Vercel AI SDK providers for Cerebras, xAI, and OpenAI-compatible endpoints.
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Keep custom-answer selection in sync and show one submit action while entering custom text.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Preserve complete session state when applying partial session updates in VS Code.
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Restore persisted sessions across development extension branches and worktrees.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Restore streamed responses in the CLI TUI and move code indexing status into the session sidebar.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Start MCP servers while Agent Manager worktree sessions initialize to reduce the delay before the first response.
+39 -18
View File
@@ -125,6 +125,15 @@ jobs:
createdAt
}
}
reviewThreads(last: 100) {
nodes {
comments(last: 1) {
nodes {
createdAt
}
}
}
}
}
}
}
@@ -159,23 +168,35 @@ jobs:
core.info(`Found ${allPrs.length} open pull requests`)
const stalePrs = allPrs.filter((pr) => {
const dates = [
new Date(pr.createdAt),
pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
].filter((d) => d !== null)
function entry(source, date) {
if (!date) return null
const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
return { source, date: new Date(date) }
}
if (!lastActivity || lastActivity > prCutoff) {
core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
return false
function latest(items) {
return items.filter(Boolean).sort((a, b) => b.date.getTime() - a.date.getTime())[0]
}
const stalePrs = allPrs.flatMap((pr) => {
const activity = latest([
entry("created", pr.createdAt),
entry("commit", pr.commits.nodes[0]?.commit.committedDate),
entry("comment", pr.comments.nodes[0]?.createdAt),
entry("review", pr.reviews.nodes[0]?.createdAt),
...pr.reviewThreads.nodes.map((t) => entry("review comment", t.comments.nodes[0]?.createdAt)),
])
const text = activity
? `${activity.date.toISOString()} via ${activity.source}`
: "unknown"
if (!activity || activity.date > prCutoff) {
core.info(`PR #${pr.number} is fresh (last activity: ${text})`)
return []
}
core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
return true
core.info(`PR #${pr.number} is STALE (last activity: ${text})`)
return [{ ...pr, activity }]
})
core.info(`Found ${stalePrs.length} stale pull requests`)
@@ -194,7 +215,7 @@ jobs:
const closeComment = `To stay organized pull requests are automatically closed after ${PR_DAYS_INACTIVE} days of inactivity. If the pull request is still relevant please open a new one.`
if (dryRun) {
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'} (last activity: ${pr.activity.date.toISOString()} via ${pr.activity.source}): ${pr.title}`)
continue
}
@@ -222,7 +243,7 @@ jobs:
)
closedPrCount++
core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'} (last activity: ${pr.activity.date.toISOString()} via ${pr.activity.source}): ${pr.title}`)
// Delay before processing next PR
await sleep(prDelayMs)
@@ -272,7 +293,7 @@ jobs:
continue
}
staleIssues.push(issue)
staleIssues.push({ ...issue, activity: updated })
}
if (!stop) {
@@ -296,7 +317,7 @@ jobs:
const closeComment = `To stay organized issues are automatically closed after ${ISSUE_DAYS_INACTIVE} days of no activity. If the issue is still relevant please open a new one.`
if (dryRun) {
core.info(`[dry-run] Would close issue #${issue.number}: ${issue.title}`)
core.info(`[dry-run] Would close issue #${issue.number} (last activity: ${issue.activity.toISOString()} via updated): ${issue.title}`)
continue
}
@@ -323,7 +344,7 @@ jobs:
)
closedIssueCount++
core.info(`Closed issue #${issue.number}: ${issue.title}`)
core.info(`Closed issue #${issue.number} (last activity: ${issue.activity.toISOString()} via updated): ${issue.title}`)
await sleep(issueDelayMs)
} catch (error) {
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "7.3.42"
version = "7.3.44"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/Kilo-Org/kilocode"
@@ -11,26 +11,26 @@ name = "Kilo"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.42/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.44/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.42/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.44/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.42/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.44/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.42/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.44/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.42/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.44/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+4 -1
View File
@@ -52,6 +52,7 @@ export type ProjectConsoleQuery = ProjectQuery & {
export type ProjectConsoleSnapshot = {
project: ProjectItem
config: EffectiveConfig
vcs: VcsInfo
worktrees: string[]
terminals: ProjectTerminalItem[]
@@ -439,7 +440,8 @@ export async function loadProjectConsole(input: ProjectConsoleQuery): Promise<Pr
const project = await resolved(input)
const query = { url: input.url, dir: project.worktree }
const sdk = client(query)
const [vcs, worktrees] = await Promise.all([
const [config, vcs, worktrees] = await Promise.all([
sdk.config.overlay({ scope: "global" }),
sdk.vcs.get({ directory: query.dir }),
sdk.worktree.list({ directory: query.dir }),
])
@@ -450,6 +452,7 @@ export async function loadProjectConsole(input: ProjectConsoleQuery): Promise<Pr
return {
project,
config: demand("Config", config).effective,
vcs: demand("VCS", vcs),
worktrees: dirs,
terminals: terminals.flat(),
@@ -1,4 +1,4 @@
import { Show } from "solid-js"
import { createUniqueId, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { Icon } from "@kilocode/kilo-web-ui/icon"
@@ -14,21 +14,31 @@ type Props = {
}
export function ConfirmDialog(props: Props) {
const id = createUniqueId()
const title = `${id}-title`
const message = `${id}-message`
return (
<Show when={props.open}>
<div class="confirm-scrim">
<section class="confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="confirm-title">
<div class="confirm-scrim" onKeyDown={(event) => event.key === "Escape" && props.onCancel()}>
<section
class="confirm-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby={title}
aria-describedby={props.message ? message : undefined}
>
<div class="confirm-body">
<div class="confirm-icon" aria-hidden="true">
<Icon name="warning" />
</div>
<div>
<h2 id="confirm-title">{props.title}</h2>
<Show when={props.message}>{(text) => <p>{text()}</p>}</Show>
<h2 id={title}>{props.title}</h2>
<Show when={props.message}>{(text) => <p id={message}>{text()}</p>}</Show>
</div>
</div>
<footer class="confirm-actions">
<Button variant="ghost" disabled={props.busy} onClick={props.onCancel}>
<Button variant="ghost" disabled={props.busy} onClick={props.onCancel} autofocus>
{props.cancel ?? "Cancel"}
</Button>
<Button variant="primary" disabled={props.busy} onClick={props.onConfirm}>
@@ -0,0 +1,73 @@
import { Button } from "@kilocode/kilo-web-ui/button"
import { Input } from "@kilocode/kilo-web-ui/input"
import { createUniqueId, Show } from "solid-js"
type Props = {
open: boolean
title: string
message?: string
label: string
value: string
placeholder?: string
confirm?: string
cancel?: string
busy?: boolean
onInput: (value: string) => void
onCancel: () => void
onConfirm: () => void
}
export function PromptDialog(props: Props) {
const id = createUniqueId()
const title = `${id}-title`
const message = `${id}-message`
const input = `${id}-input`
return (
<Show when={props.open}>
<div class="confirm-scrim" onKeyDown={(event) => event.key === "Escape" && props.onCancel()}>
<section
class="confirm-dialog prompt-dialog"
role="dialog"
aria-modal="true"
aria-labelledby={title}
aria-describedby={props.message ? message : undefined}
>
<form
class="prompt-form"
onSubmit={(event) => {
event.preventDefault()
props.onConfirm()
}}
>
<div class="prompt-body">
<div class="prompt-copy">
<h2 id={title}>{props.title}</h2>
<Show when={props.message}>{(text) => <p id={message}>{text()}</p>}</Show>
</div>
<label class="prompt-field" for={input}>
<span>{props.label}</span>
<Input
id={input}
value={props.value}
placeholder={props.placeholder}
disabled={props.busy}
autofocus
onInput={(event) => props.onInput(event.currentTarget.value)}
/>
</label>
</div>
<footer class="confirm-actions">
<Button type="button" variant="ghost" disabled={props.busy} onClick={props.onCancel}>
{props.cancel ?? "Cancel"}
</Button>
<Button type="submit" variant="primary" disabled={props.busy}>
{props.confirm ?? "Confirm"}
</Button>
</footer>
</form>
</section>
</div>
</Show>
)
}
@@ -30,13 +30,6 @@ function ConfigContent(props: { children?: JSX.Element }) {
</Card>
)}
</Show>
<Show when={ctx.saving()}>
{(item) => (
<Card class="banner" variant="info">
{item()}...
</Card>
)}
</Show>
<Show when={ctx.data.error}>
<Card class="banner" variant="error">
<strong>Dashboard request failed</strong>
@@ -4,13 +4,12 @@ import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { ConfigRow, SectionTitle } from "@kilocode/kilo-web-ui/console"
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag"
import { CustomSelect, type SelectOption } from "../../components/CustomSelect"
import { SearchField } from "../../components/SearchField"
import { useConfig } from "../../context/config"
import { settings } from "../../shared/navigation"
import { toolCapabilities, toolName } from "../../shared/utils"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage"
import { ActionSelect, label as actionLabel, tone as actionTone } from "./PermissionsRoute"
import { agentEditable, agentTitle, snippets, useAgentBuilder, type AgentEntry, type AgentItem } from "./state/agents"
import type { PermissionAction } from "./state/permissions"
@@ -1,7 +1,6 @@
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { Tag } from "@kilocode/kilo-web-ui/tag"
import { ConfigPage } from "./ConfigPage"
import { ConfigPage, ConfigTag as Tag } from "./ConfigPage"
import { useTuiNotificationSettings } from "./state/ui"
function Toggle(props: {
@@ -1,10 +1,9 @@
import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { Tag } from "@kilocode/kilo-web-ui/tag"
import { CustomSelect, type SelectOption } from "../../components/CustomSelect"
import { SearchField } from "../../components/SearchField"
import { ConfigPage } from "./ConfigPage"
import { ConfigPage, ConfigTag as Tag } from "./ConfigPage"
import { type Theme, themeTitle, useTuiUiSettings } from "./state/ui"
const diffs = [
@@ -1,7 +1,6 @@
import type { JSX } from "solid-js"
import { Show } from "solid-js"
import { CountTag as ConfigCountTag, SourceBadge as UiSourceBadge } from "@kilocode/kilo-web-ui/console"
import { Tag as ConfigTag } from "@kilocode/kilo-web-ui/tag"
import { ConfigCountTag, ConfigTag, SourceBadge as UiSourceBadge } from "@kilocode/kilo-web-ui/console"
export { ConfigCountTag, ConfigTag }
@@ -32,6 +32,7 @@ export function ConfigSidebar() {
const href = (path: string) => `${path === "/" ? base() : `${base()}${path}`}${loc.search}`
const current = (path: string) => path === active() || (path !== "/" && active().startsWith(`${path}/`))
const group = (item: ConfigNode): item is ConfigGroup => "items" in item
const navigation = createMemo(() => configNav.filter((item) => !project() || !group(item) || !item.globalOnly))
return (
<aside class="config-sidebar" aria-label="Configuration sections">
@@ -42,7 +43,7 @@ export function ConfigSidebar() {
</span>
</div>
<nav class="config-options">
<For each={configNav}>
<For each={navigation()}>
{(item) => {
if (!group(item)) {
return (
@@ -0,0 +1,100 @@
import { Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { Spinner } from "@kilocode/kilo-web-ui/spinner"
import { CustomSelect, type SelectOption } from "../../components/CustomSelect"
import { ConfigPage } from "./ConfigPage"
import {
MAX_CONTEXT_SIDEBAR_WIDTH,
MIN_CONTEXT_SIDEBAR_WIDTH,
type ConsoleDiffStyle,
useConsoleUiSettings,
} from "./state/console"
const styles = [
{ value: "unified", label: "Unified" },
{ value: "split", label: "Split" },
] satisfies SelectOption<ConsoleDiffStyle>[]
export function ConsoleUiRoute() {
const state = useConsoleUiSettings()
return (
<ConfigPage
title="Console UI"
description="Configure the local Kilo Console interface. These preferences are saved in your user config."
actions={
<>
<Show when={state.configured()}>
<Button variant="secondary" disabled={Boolean(state.ctx.saving())} onClick={state.reset}>
Use default
</Button>
</Show>
<Button
variant="primary"
disabled={Boolean(state.ctx.saving()) || !state.dirty()}
aria-busy={Boolean(state.ctx.saving())}
onClick={state.save}
>
<Show when={state.ctx.saving()}>
<Spinner />
</Show>
Save
</Button>
</>
}
>
<div class="ui-settings">
<Card class="ui-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Project context sidebar</h2>
<p>Set the default width used by the Context and Changes panel in project consoles.</p>
</div>
</header>
<div class="ui-form">
<label class="ui-field">
<span>Sidebar width</span>
<input
type="number"
min={MIN_CONTEXT_SIDEBAR_WIDTH}
max={MAX_CONTEXT_SIDEBAR_WIDTH}
step="1"
value={state.width()}
onInput={(event) => state.setWidth(event.currentTarget.value)}
/>
<small>
Width in pixels, between {MIN_CONTEXT_SIDEBAR_WIDTH} and {MAX_CONTEXT_SIDEBAR_WIDTH}. You can also
resize the sidebar by dragging its left edge.
</small>
</label>
</div>
</Card>
<Card class="ui-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Diff review</h2>
<p>Choose the default layout used when reviewing changed files in project consoles.</p>
</div>
</header>
<div class="ui-form">
<div class="ui-field">
<span>Diff layout</span>
<CustomSelect
class="console-diff-select"
label="Diff layout"
value={state.style()}
options={styles}
disabled={Boolean(state.ctx.saving())}
onSelect={state.setStyle}
/>
<small>
Unified shows changes in one column. Split shows the original and modified file side by side.
</small>
</div>
</div>
</Card>
</div>
</ConfigPage>
)
}
@@ -2,8 +2,7 @@ import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { ConfigRow, SectionTitle } from "@kilocode/kilo-web-ui/console"
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage"
import { useFormatterSettings, type ToolRow } from "./state/formatters"
type Kind = "formatter" | "lsp"
@@ -2,9 +2,8 @@ import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { ConfigRow, SectionTitle } from "@kilocode/kilo-web-ui/console"
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag"
import { SearchField } from "../../components/SearchField"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage"
import { useKeybindSettings } from "./state/keybinds"
export function KeybindsRoute() {
@@ -2,12 +2,11 @@ import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag"
import { StatusTag } from "@kilocode/kilo-web-ui/status-tag"
import { ConfirmDialog } from "../../components/ConfirmDialog"
import { CustomSelect, type SelectOption } from "../../components/CustomSelect"
import { SearchField } from "../../components/SearchField"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage"
import { useMcpSettings } from "./state/mcp"
type StatusFilter = "all" | "installed" | "notInstalled"
@@ -1,11 +1,10 @@
import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { Tag } from "@kilocode/kilo-web-ui/tag"
import type { Model } from "@kilocode/sdk/v2/client"
import { SearchField } from "../../components/SearchField"
import { text } from "../../shared/utils"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage"
import { type Capability, type ModelField, useModelSettings } from "./state/models"
function money(n: number) {
@@ -2,9 +2,8 @@ import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { ConfigRow, SectionTitle } from "@kilocode/kilo-web-ui/console"
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag"
import { toolName } from "../../shared/utils"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage"
import { actions, usePermissionSettings, type PermissionAction, type PermissionRule } from "./state/permissions"
export function tone(action: PermissionAction) {
@@ -3,12 +3,11 @@ import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { ProviderIcon } from "@kilocode/kilo-web-ui/provider-icon"
import { CountTag } from "@kilocode/kilo-web-ui/tag"
import { StatusTag } from "@kilocode/kilo-web-ui/status-tag"
import { ConfirmDialog } from "../../components/ConfirmDialog"
import { CustomSelect } from "../../components/CustomSelect"
import { SearchField } from "../../components/SearchField"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { ConfigCountTag as CountTag, ConfigPage, SourceBadge } from "./ConfigPage"
import { useProviderSettings } from "./state/providers"
export function ProvidersRoute() {
@@ -1,9 +1,8 @@
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { Tag } from "@kilocode/kilo-web-ui/tag"
import { For, Show, createMemo, createSignal } from "solid-js"
import { useConfig } from "../../context/config"
import { ConfigPage, ConfigToolbar } from "./ConfigPage"
import { ConfigPage, ConfigTag as Tag, ConfigToolbar } from "./ConfigPage"
type Server = {
id: string
@@ -1,7 +1,6 @@
import { For, Show } from "solid-js"
import { Tag } from "@kilocode/kilo-web-ui/tag"
import { useConfig } from "../../context/config"
import { ConfigPage, ConfigToolbar } from "./ConfigPage"
import { ConfigPage, ConfigTag as Tag, ConfigToolbar } from "./ConfigPage"
export function SourcesRoute() {
const ctx = useConfig()
@@ -1,10 +1,9 @@
import { createMemo, createSignal, For, Show } from "solid-js"
import { ConfigRow, SectionTitle, StatusTag } from "@kilocode/kilo-web-ui/console"
import { CountTag } from "@kilocode/kilo-web-ui/tag"
import { SearchField } from "../../components/SearchField"
import { useConfig } from "../../context/config"
import { toolCapabilities, toolName } from "../../shared/utils"
import { ConfigPage } from "./ConfigPage"
import { ConfigCountTag as CountTag, ConfigPage } from "./ConfigPage"
export function ToolsRoute() {
const ctx = useConfig()
@@ -3,6 +3,7 @@ import type { IconProps } from "@kilocode/kilo-web-ui/icon"
import { AgentBuilderRoute, AgentsRoute } from "./AgentsRoute"
import { CliNotificationsRoute } from "./CliNotificationsRoute"
import { CliUiRoute } from "./CliUiRoute"
import { ConsoleUiRoute } from "./ConsoleUiRoute"
import { FormattersRoute, LspRoute } from "./FormattersRoute"
import { IndexingRoute } from "./IndexingRoute"
import { KeybindsRoute } from "./KeybindsRoute"
@@ -26,6 +27,7 @@ export type ConfigSection = {
export type ConfigGroup = {
id: string
label: string
globalOnly?: boolean
items: ConfigSection[]
}
@@ -130,6 +132,14 @@ export const configNav: ConfigNode[] = [
},
],
},
{
id: "console",
label: "Console",
globalOnly: true,
items: [
{ path: "/console/ui", href: "/settings/console/ui", icon: "sliders", label: "UI", component: ConsoleUiRoute },
],
},
{
id: "advanced",
label: "Advanced",
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import {
DEFAULT_CONTEXT_SIDEBAR_WIDTH,
MAX_CONTEXT_SIDEBAR_WIDTH,
MIN_CONTEXT_SIDEBAR_WIDTH,
normalizeConsoleDiffStyle,
normalizeContextSidebarWidth,
parseContextSidebarWidth,
} from "./console"
describe("console UI config state", () => {
test("defaults the diff layout to unified", () => {
expect(normalizeConsoleDiffStyle(undefined)).toBe("unified")
expect(normalizeConsoleDiffStyle("unified")).toBe("unified")
expect(normalizeConsoleDiffStyle("split")).toBe("split")
expect(normalizeConsoleDiffStyle("side-by-side")).toBe("unified")
})
test("normalizes missing and out-of-range widths", () => {
expect(normalizeContextSidebarWidth(undefined)).toBe(DEFAULT_CONTEXT_SIDEBAR_WIDTH)
expect(normalizeContextSidebarWidth(Number.NaN)).toBe(DEFAULT_CONTEXT_SIDEBAR_WIDTH)
expect(normalizeContextSidebarWidth(100)).toBe(MIN_CONTEXT_SIDEBAR_WIDTH)
expect(normalizeContextSidebarWidth(900)).toBe(MAX_CONTEXT_SIDEBAR_WIDTH)
expect(normalizeContextSidebarWidth(411.6)).toBe(412)
})
test("accepts only integer widths within the supported range", () => {
expect(parseContextSidebarWidth("352")).toBe(352)
expect(parseContextSidebarWidth(String(MIN_CONTEXT_SIDEBAR_WIDTH))).toBe(MIN_CONTEXT_SIDEBAR_WIDTH)
expect(parseContextSidebarWidth(String(MAX_CONTEXT_SIDEBAR_WIDTH))).toBe(MAX_CONTEXT_SIDEBAR_WIDTH)
expect(parseContextSidebarWidth("249")).toBeUndefined()
expect(parseContextSidebarWidth("801")).toBeUndefined()
expect(parseContextSidebarWidth("352.5")).toBeUndefined()
expect(parseContextSidebarWidth("wide")).toBeUndefined()
})
})
@@ -0,0 +1,80 @@
import { createEffect, createSignal } from "solid-js"
import { useConfig } from "../../../context/config"
export const DEFAULT_CONTEXT_SIDEBAR_WIDTH = 300
export const MIN_CONTEXT_SIDEBAR_WIDTH = 250
export const MAX_CONTEXT_SIDEBAR_WIDTH = 800
export const DEFAULT_CONSOLE_DIFF_STYLE = "unified" as const
export type ConsoleDiffStyle = "unified" | "split"
export function normalizeConsoleDiffStyle(value: unknown): ConsoleDiffStyle {
return value === "split" ? "split" : DEFAULT_CONSOLE_DIFF_STYLE
}
export function normalizeContextSidebarWidth(value: unknown) {
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_CONTEXT_SIDEBAR_WIDTH
return Math.min(MAX_CONTEXT_SIDEBAR_WIDTH, Math.max(MIN_CONTEXT_SIDEBAR_WIDTH, Math.round(value)))
}
export function parseContextSidebarWidth(value: string) {
const width = Number(value)
if (!Number.isInteger(width)) return undefined
if (width < MIN_CONTEXT_SIDEBAR_WIDTH || width > MAX_CONTEXT_SIDEBAR_WIDTH) return undefined
return width
}
export function useConsoleUiSettings() {
const ctx = useConfig()
const [width, setWidth] = createSignal(String(DEFAULT_CONTEXT_SIDEBAR_WIDTH))
const [style, setStyle] = createSignal<ConsoleDiffStyle>(DEFAULT_CONSOLE_DIFF_STYLE)
const [dirty, setDirty] = createSignal(false)
createEffect(() => {
if (dirty()) return
const config = ctx.data()?.effective.console
setWidth(String(normalizeContextSidebarWidth(config?.context_sidebar_width)))
setStyle(normalizeConsoleDiffStyle(config?.diff_style))
})
function save() {
const value = parseContextSidebarWidth(width())
if (value === undefined) {
ctx.fail(`Enter a sidebar width between ${MIN_CONTEXT_SIDEBAR_WIDTH} and ${MAX_CONTEXT_SIDEBAR_WIDTH} pixels.`)
return
}
ctx.patch({ console: { context_sidebar_width: value, diff_style: style() } })
setWidth(String(value))
setDirty(false)
}
function reset() {
ctx.unset([
["console", "context_sidebar_width"],
["console", "diff_style"],
])
setWidth(String(DEFAULT_CONTEXT_SIDEBAR_WIDTH))
setStyle(DEFAULT_CONSOLE_DIFF_STYLE)
setDirty(false)
}
return {
ctx,
width,
setWidth: (value: string) => {
setWidth(value)
setDirty(true)
},
style,
setStyle: (value: string) => {
setStyle(normalizeConsoleDiffStyle(value))
setDirty(true)
},
dirty,
configured: () => {
const config = ctx.data()?.effective.console
return config?.context_sidebar_width !== undefined || config?.diff_style !== undefined
},
save,
reset,
}
}
@@ -1,8 +1,17 @@
import { A, useLocation, useParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, For, on, onCleanup, Show } from "solid-js"
import { Badge } from "@kilocode/kilo-web-ui/badge"
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { Icon } from "@kilocode/kilo-web-ui/icon"
import { ResizeHandle } from "@kilocode/kilo-web-ui/resize-handle"
import { Spinner } from "@kilocode/kilo-web-ui/spinner"
import { File } from "@opencode-ai/ui/file"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { SessionReview, type SessionReviewDiffStyle } from "@opencode-ai/ui/session-review"
import { ConfirmDialog } from "../../components/ConfirmDialog"
import { LoadingScreen } from "../../components/LoadingScreen"
import { PromptDialog } from "../../components/PromptDialog"
import {
createProjectPty,
createProjectWorktree,
@@ -12,6 +21,7 @@ import {
loadProjectConsole,
loadProjectDiff,
loadProjectDiffFile,
patchConfig,
removeProjectPty,
removeProjectWorktree,
resetProjectWorktree,
@@ -21,6 +31,7 @@ import {
viewProjectSessions,
type ProjectConsoleEvent,
type ProjectConsoleQuery,
type ProjectDiffItem,
type ProjectTerminalItem,
type Query,
} from "../../client"
@@ -30,6 +41,14 @@ import {
clearUnread as storeClearUnread,
sessionHasUnread,
} from "../../shared/terminal-status"
import {
DEFAULT_CONSOLE_DIFF_STYLE,
DEFAULT_CONTEXT_SIDEBAR_WIDTH,
MAX_CONTEXT_SIDEBAR_WIDTH,
MIN_CONTEXT_SIDEBAR_WIDTH,
normalizeConsoleDiffStyle,
normalizeContextSidebarWidth,
} from "../config/state/console"
import { GhosttyTerminal } from "./terminal/GhosttyTerminal"
const ui = new Set(["3017", "3018"])
@@ -41,6 +60,13 @@ type Context = {
kind: "local" | "worktree"
}
type Editor = { kind: "create"; value: string } | { kind: "rename"; item: Context; value: string }
type Pending = {
kind: "delete" | "reset"
item: Context
}
function discoverable(search: URLSearchParams) {
if (search.get("server")) return false
return ui.has(window.location.port)
@@ -125,13 +151,21 @@ export function ProjectConsoleRoute() {
const [selected, setSelected] = createSignal(window.localStorage.getItem(`kilo.console.${params.project}.dir`) ?? "")
const [active, setActive] = createSignal(window.localStorage.getItem(`kilo.console.${params.project}.pty`) ?? "")
const [local, setLocal] = createSignal<ProjectTerminalItem[]>([])
const [file, setFile] = createSignal<string | undefined>()
const [openFiles, setOpenFiles] = createSignal<string[]>([])
const [details, setDetails] = createSignal<Record<string, ProjectDiffItem>>({})
const [infoWidth, setInfoWidth] = createSignal(DEFAULT_CONTEXT_SIDEBAR_WIDTH)
const [viewport, setViewport] = createSignal(window.innerWidth)
const [diffStyle, setDiffStyle] = createSignal<SessionReviewDiffStyle>(DEFAULT_CONSOLE_DIFF_STYLE)
const [saving, setSaving] = createSignal<string | undefined>()
const [failure, setFailure] = createSignal<string | undefined>()
const [unread, setUnread] = createSignal(new Set<string>())
const [closing, setClosing] = createSignal(new Set<string>())
const [labelRev, setLabelRev] = createSignal(0)
const [editor, setEditor] = createSignal<Editor | undefined>()
const [pending, setPending] = createSignal<Pending | undefined>()
const events = { timer: undefined as number | undefined }
const resize = { timer: undefined as number | undefined, pending: false }
const detailPending = new Set<string>()
const project = () => params.project ?? ""
const query = createMemo<ProjectConsoleQuery | undefined>(() => {
const target = clean(url()) || fallback()
@@ -139,6 +173,7 @@ export function ProjectConsoleRoute() {
return { url: target, dir: "", project: project() }
})
const [snap, { refetch }] = createResource(query, loadProjectConsole)
const visibleInfoWidth = createMemo(() => Math.min(infoWidth(), maxInfoWidth()))
const contexts = createMemo<Context[]>(() => {
const data = snap()
@@ -185,11 +220,13 @@ export function ProjectConsoleRoute() {
return { input: item, dir: item.dir }
})
const [diffs] = createResource(diffKey, (item) => loadProjectDiff(item.input, item.dir))
const detailKey = createMemo(() => {
const item = target()
const path = file()
if (!item || !path) return undefined
return { input: item, dir: item.dir, file: path }
// Diff summary items carry no content (patch/before/after empty); overlay full file
// diffs as they load so the review renders syntax-highlighted changes per file.
const reviewDiffs = createMemo<ProjectDiffItem[]>(() => {
const map = details()
return (diffs() ?? [])
.filter((item): item is ProjectDiffItem & { file: string } => typeof item.file === "string")
.map((item) => map[item.file] ?? item)
})
const terminal = createMemo(() => {
const item = activeTerminal()
@@ -205,7 +242,6 @@ export function ProjectConsoleRoute() {
return items
})
const terminalKeys = createMemo(() => Array.from(terminalMap().keys()))
const [detail] = createResource(detailKey, (item) => loadProjectDiffFile(item.input, item.dir, item.file))
const settings = createMemo(() => {
const q = search().toString()
return `/projects/${encodeURIComponent(project())}/settings${q ? `?${q}` : ""}`
@@ -300,18 +336,67 @@ export function ProjectConsoleRoute() {
setSelected(item.dir)
const pty = terminalsFor(item.dir)[0]
setActive(pty?.id ?? "")
setFile(undefined)
remember(item.dir, pty?.id)
}
function selectTerminal(item: ProjectTerminalItem) {
setSelected(item.directory)
setActive(item.id)
setFile(undefined)
clearUnread(item)
remember(item.directory, item.id)
}
function fetchDetail(path: string) {
const base = target()
if (!base) return
if (details()[path] || detailPending.has(path)) return
detailPending.add(path)
void loadProjectDiffFile(base, base.dir, path)
.then((item) => {
if (item && item.file) setDetails((prev) => ({ ...prev, [item.file!]: item }))
})
.catch((err) => console.warn("Worktree diff file:", err))
.finally(() => detailPending.delete(path))
}
function openReviewFiles(next: string[]) {
setOpenFiles(next)
for (const path of next) fetchDetail(path)
}
function changeDiffStyle(style: SessionReviewDiffStyle) {
setDiffStyle(style)
const base = query()
if (!base) return
void patchConfig({ url: base.url, dir: "", scope: "global" }, { console: { diff_style: style } }).catch((err) =>
console.warn(`Console diff style: ${errMsg(err)}`),
)
}
function resizeInfo(value: number) {
const width = normalizeContextSidebarWidth(value)
setInfoWidth(width)
resize.pending = true
if (resize.timer) window.clearTimeout(resize.timer)
resize.timer = window.setTimeout(() => {
resize.timer = undefined
const base = query()
if (!base) {
resize.pending = false
return
}
void patchConfig({ url: base.url, dir: "", scope: "global" }, { console: { context_sidebar_width: width } })
.catch((err) => console.warn(`Console sidebar width: ${errMsg(err)}`))
.finally(() => {
resize.pending = false
})
}, 350)
}
function maxInfoWidth() {
return Math.max(MIN_CONTEXT_SIDEBAR_WIDTH, Math.min(MAX_CONTEXT_SIDEBAR_WIDTH, viewport() - 604))
}
function run(label: string, job: () => Promise<unknown>) {
setSaving(label)
setFailure(undefined)
@@ -322,10 +407,26 @@ export function ProjectConsoleRoute() {
}
function addWorktree() {
if (!projectInput()) return
setEditor({ kind: "create", value: "" })
}
function submitEditor() {
const state = editor()
if (!state) return
if (state.kind === "rename") {
const value = state.value.trim()
if (value) window.localStorage.setItem(labelKey(state.item.dir), value)
if (!value) window.localStorage.removeItem(labelKey(state.item.dir))
setLabelRev((revision) => revision + 1)
setEditor(undefined)
return
}
const input = projectInput()
const data = snap()
if (!input || !data) return
const name = window.prompt("Worktree name") ?? undefined
if (!input) return
const name = state.value.trim() || undefined
setEditor(undefined)
run("Creating worktree", async () => {
const next = await createProjectWorktree(input, name)
setSelected(next.directory)
@@ -395,27 +496,12 @@ export function ProjectConsoleRoute() {
function renameWorktree(item: Context) {
if (item.kind === "local") return
const input = window.prompt("Worktree label", displayLabel(item))
if (input === null) return
const next = input.trim()
if (next) window.localStorage.setItem(labelKey(item.dir), next)
else window.localStorage.removeItem(labelKey(item.dir))
setLabelRev((value) => value + 1)
setEditor({ kind: "rename", item, value: displayLabel(item) })
}
function removeWorktree(item: Context) {
const input = projectInput()
if (!input || item.kind === "local") return
if (!window.confirm(`Remove worktree ${displayLabel(item)}?`)) return
run("Removing worktree", async () => {
await removeProjectWorktree(input, item.dir)
window.localStorage.removeItem(labelKey(item.dir))
setLabelRev((value) => value + 1)
if (selected() === item.dir) {
setSelected(input.dir)
remember(input.dir)
}
})
if (!projectInput() || item.kind === "local") return
setPending({ kind: "delete", item })
}
function removeSelected() {
@@ -425,18 +511,88 @@ export function ProjectConsoleRoute() {
}
function resetSelected() {
const input = projectInput()
const item = current()
if (!input || !item || item.kind === "local") return
if (!window.confirm(`Reset worktree ${displayLabel(item)}?`)) return
run("Resetting worktree", async () => resetProjectWorktree(input, item.dir))
if (!projectInput() || !item || item.kind === "local") return
setPending({ kind: "reset", item })
}
function confirmWorktree() {
const state = pending()
const input = projectInput()
if (!state || !input) return
setPending(undefined)
if (state.kind === "reset") {
run("Resetting worktree", async () => resetProjectWorktree(input, state.item.dir))
return
}
run("Removing worktree", async () => {
await removeProjectWorktree(input, state.item.dir)
window.localStorage.removeItem(labelKey(state.item.dir))
setLabelRev((revision) => revision + 1)
if (selected() === state.item.dir) {
setSelected(input.dir)
remember(input.dir)
}
})
}
function updateEditor(value: string) {
setEditor((state) => {
if (!state) return state
return { ...state, value }
})
}
function editorTitle() {
const state = editor()
if (!state || state.kind === "create") return "Create worktree"
return `Rename ${displayLabel(state.item)}`
}
function editorMessage() {
if (editor()?.kind === "rename") return "Leave the name blank to restore the generated worktree name."
return "Choose a recognizable name, or leave it blank to generate one automatically."
}
function pendingTitle() {
const state = pending()
if (!state) return ""
const action = state.kind === "reset" ? "Reset" : "Delete"
return `${action} worktree ${displayLabel(state.item)}?`
}
function pendingMessage() {
if (pending()?.kind === "reset") return "This discards all uncommitted changes in the worktree."
return "This removes the worktree directory and its files from the project."
}
createEffect(() => {
const data = snap()
if (!data) return
if (!resize.pending) setInfoWidth(normalizeContextSidebarWidth(data.config.console?.context_sidebar_width))
setDiffStyle(normalizeConsoleDiffStyle(data.config.console?.diff_style))
})
createEffect(() => {
const next = search().get("server")
if (next && next !== url()) setUrl(next)
})
// Reset review state when the selected worktree changes so one worktree's
// file contents never leak into another.
createEffect(
on(
() => target()?.dir,
() => {
setOpenFiles([])
setDetails({})
detailPending.clear()
},
{ defer: true },
),
)
createEffect(() => {
if (!discoverable(search())) return
void resolveServer().then((value) => {
@@ -507,8 +663,13 @@ export function ProjectConsoleRoute() {
onCleanup(stop)
})
const updateViewport = () => setViewport(window.innerWidth)
window.addEventListener("resize", updateViewport)
onCleanup(() => {
if (events.timer) window.clearTimeout(events.timer)
if (resize.timer) window.clearTimeout(resize.timer)
window.removeEventListener("resize", updateViewport)
})
createEffect(() => {
@@ -525,7 +686,7 @@ export function ProjectConsoleRoute() {
})
return (
<section class="project-console">
<section class="project-console" style={`--project-info-width: ${visibleInfoWidth()}px`}>
<aside class="project-console-sidebar" aria-label="Project console sections">
<div class="project-console-title">
<span class="project-console-heading">
@@ -734,52 +895,87 @@ export function ProjectConsoleRoute() {
</main>
<aside class="project-console-info" aria-label="Project details">
<div class="project-info-card">
<div class="project-panel-heading">Context</div>
<strong>{currentLabel()}</strong>
<code>{current()?.dir ?? snap()?.project.worktree ?? project()}</code>
<ResizeHandle
direction="horizontal"
edge="start"
size={visibleInfoWidth()}
min={MIN_CONTEXT_SIDEBAR_WIDTH}
max={maxInfoWidth()}
aria-label="Resize project context sidebar"
onResize={resizeInfo}
/>
<div class="project-info-card project-info-context">
<div class="project-info-context-head">
<span class="project-panel-heading">Context</span>
<Badge variant="outline">{current()?.kind === "worktree" ? "Worktree" : "Local"}</Badge>
</div>
<strong class="project-info-title">{currentLabel()}</strong>
<code class="project-info-path" title={current()?.dir}>
{current()?.dir ?? snap()?.project.worktree ?? project()}
</code>
<Show when={current()?.kind === "worktree"}>
<div class="project-info-actions">
<button type="button" onClick={resetSelected} disabled={!!saving()}>
<Button variant="secondary" size="small" onClick={resetSelected} disabled={!!saving()}>
Reset
</button>
<button type="button" onClick={removeSelected} disabled={!!saving()}>
Remove
</button>
</Button>
<Button variant="destructive" size="small" onClick={removeSelected} disabled={!!saving()}>
Delete
</Button>
</div>
</Show>
</div>
<div class="project-info-card grow">
<div class="project-panel-heading">Changes</div>
<Show when={diffs.loading && !diffs()}>
<p class="empty">Loading diff...</p>
<div class="project-info-review">
<Show
when={!diffs.error}
fallback={<div class="project-review-state project-review-state-error">{errMsg(diffs.error)}</div>}
>
<Show
when={!(diffs.loading && !diffs())}
fallback={
<div class="project-review-state">
<Spinner />
<span>Loading changes</span>
</div>
}
>
<FileComponentProvider component={File}>
<SessionReview
diffs={reviewDiffs()}
title={<span>Changes</span>}
diffStyle={diffStyle()}
onDiffStyleChange={changeDiffStyle}
open={openFiles()}
onOpenChange={openReviewFiles}
empty={<div class="project-review-empty">No changes detected.</div>}
/>
</FileComponentProvider>
</Show>
</Show>
<Show when={diffs.error}>
<p class="empty">{errMsg(diffs.error)}</p>
</Show>
<Show when={!diffs.loading && (diffs() ?? []).length === 0 && !diffs.error}>
<p class="empty">No changes detected.</p>
</Show>
<div class="project-diff-list">
<For each={diffs() ?? []}>
{(item) => (
<button
type="button"
class="project-diff-row"
classList={{ active: file() === item.file }}
onClick={() => setFile(item.file)}
>
<span>{item.file}</span>
<small>
+{item.additions} -{item.deletions}
</small>
</button>
)}
</For>
</div>
<Show when={detail()}>{(item) => <pre class="project-diff-detail">{item()?.patch ?? ""}</pre>}</Show>
</div>
</aside>
<PromptDialog
open={Boolean(editor())}
title={editorTitle()}
message={editorMessage()}
label="Worktree name"
value={editor()?.value ?? ""}
placeholder="feature-name"
confirm={editor()?.kind === "rename" ? "Save" : "Create"}
busy={Boolean(saving())}
onInput={updateEditor}
onCancel={() => setEditor(undefined)}
onConfirm={submitEditor}
/>
<ConfirmDialog
open={Boolean(pending())}
title={pendingTitle()}
message={pendingMessage()}
confirm={pending()?.kind === "reset" ? "Reset" : "Delete"}
busy={Boolean(saving())}
onCancel={() => setPending(undefined)}
onConfirm={confirmWorktree}
/>
</section>
)
}
@@ -8,6 +8,20 @@
background: var(--card);
}
.kilo-console .ui-card:has(.console-diff-select[open]) {
position: relative;
z-index: 40;
overflow: visible;
}
.kilo-console .console-diff-select[open] {
z-index: 41;
}
.kilo-console .console-diff-select .models-select-menu {
z-index: 42;
}
.kilo-console .ui-card-header,
.kilo-console .ui-card-footer {
display: flex;
@@ -1,6 +1,8 @@
.kilo-console .project-console {
display: grid;
grid-template-columns: 13.75rem minmax(24rem, 1fr) minmax(16rem, 22rem);
grid-template-columns:
13.75rem minmax(24rem, 1fr)
clamp(250px, var(--project-info-width, 300px), min(800px, calc(100vw - 37.75rem)));
height: 100%;
min-height: 0;
overflow: hidden;
@@ -280,8 +282,7 @@
.kilo-console .project-context span,
.kilo-console .project-terminal-row span,
.kilo-console .project-settings-link span,
.kilo-console .project-diff-row span {
.kilo-console .project-settings-link span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -340,14 +341,6 @@
color: var(--foreground) !important;
}
.kilo-console .project-diff-row small {
overflow: hidden;
color: var(--muted-foreground);
font-size: 0.6875rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.kilo-console .project-sidebar-bottom {
flex: 0 0 auto;
border-top: 1px solid var(--border);
@@ -460,27 +453,40 @@
display: flex;
flex-direction: column;
gap: 0.75rem;
position: relative;
overflow: hidden;
border-left: 1px solid var(--border);
background: var(--background);
padding: clamp(1rem, 2.4vw, 2rem) clamp(0.875rem, 1.8vw, 1.25rem);
padding: 0.875rem;
}
.kilo-console .project-console-info > [data-component="resize-handle"]::after {
background: var(--ring);
}
.kilo-console .project-info-card {
display: grid;
gap: 0.625rem;
min-height: 0;
gap: 0.5rem;
flex: 0 0 auto;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in oklab, var(--muted) 30%, transparent);
background: var(--card);
padding: 0.75rem;
}
.kilo-console .project-info-card.grow {
overflow: auto;
.kilo-console .project-info-context-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.kilo-console .project-info-card strong {
.kilo-console .project-info-context-head .project-panel-heading {
min-height: 0;
padding: 0;
}
.kilo-console .project-info-title {
overflow: hidden;
color: var(--foreground);
font-size: 0.875rem;
@@ -489,7 +495,7 @@
white-space: nowrap;
}
.kilo-console .project-info-card code {
.kilo-console .project-info-path {
overflow: hidden;
color: var(--muted-foreground);
font-family: var(--font-family-mono, monospace);
@@ -502,64 +508,81 @@
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin-top: 0.125rem;
}
.kilo-console .project-info-actions button,
.kilo-console .project-diff-row {
.kilo-console .project-info-actions [data-component="button"] {
width: 100%;
}
/* Rich review panel — fills remaining height; SessionReview scrolls internally. */
.kilo-console .project-info-review {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--input-base);
color: var(--foreground);
cursor: pointer;
font: inherit;
padding: 0.5rem 0.625rem;
text-align: left;
background: var(--card);
}
.kilo-console .project-info-actions button:hover,
.kilo-console .project-info-actions button:focus-visible,
.kilo-console .project-diff-row:hover,
.kilo-console .project-diff-row:focus-visible,
.kilo-console .project-diff-row.active {
border-color: var(--ring);
outline: 0;
.kilo-console .project-info-review [data-component="session-review"] {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
height: 100%;
}
.kilo-console .project-info-actions button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.kilo-console .project-diff-list {
display: grid;
gap: 0.375rem;
}
.kilo-console .project-diff-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.125rem;
}
.kilo-console .project-diff-row.active {
background: color-mix(in oklab, var(--muted) 55%, transparent);
}
.kilo-console .project-diff-detail {
overflow: auto;
max-height: 18rem;
margin: 0.5rem 0 0;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--background-base);
color: var(--foreground);
font-family: var(--font-family-mono, monospace);
font-size: 0.6875rem;
line-height: 1.4;
.kilo-console .project-info-review [data-slot="session-review-header"] {
box-sizing: border-box;
height: auto;
min-height: 2.5rem;
padding: 0.75rem;
}
.kilo-console .project-info-review [data-slot="session-review-title"] {
display: flex;
align-items: center;
justify-content: flex-start;
min-height: 1.25rem;
color: var(--text-weaker);
font-family: var(--font-family-sans);
font-size: 0.625rem;
font-weight: 500;
line-height: normal;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.kilo-console .project-info-review [data-slot="session-review-actions"] > [data-component="button"] {
width: auto;
min-width: 0;
height: 1.5rem;
gap: 0.25rem;
padding: 0 0.375rem;
font-size: 0.6875rem;
}
.kilo-console .project-review-state,
.kilo-console .project-review-empty {
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 0;
color: var(--muted-foreground);
font-size: 0.75rem;
text-align: center;
padding: 1rem;
}
.kilo-console .project-review-state-error {
color: var(--destructive, #ef4444);
}
@media (max-width: 1180px) {
.kilo-console .project-console {
grid-template-columns: 13.75rem minmax(0, 1fr);
+32 -5
View File
@@ -408,7 +408,7 @@
}
.kilo-console .confirm-dialog {
width: min(48rem, 100%);
width: min(28rem, 100%);
overflow: hidden;
border: 1px solid var(--border-weak-base);
border-radius: var(--radius-lg);
@@ -435,16 +435,21 @@
height: 3rem;
}
.kilo-console .confirm-body h2 {
.kilo-console .confirm-body h2,
.kilo-console .prompt-copy h2 {
margin: 0;
color: var(--text-strong);
font-size: clamp(1.15rem, 2vw, 1.35rem);
line-height: 1.35;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
}
.kilo-console .confirm-body p {
.kilo-console .confirm-body p,
.kilo-console .prompt-copy p {
margin: 0.5rem 0 0;
color: var(--text-weak);
font-size: 0.75rem;
line-height: 1.625;
}
.kilo-console .confirm-actions {
@@ -455,3 +460,25 @@
background: color-mix(in srgb, var(--background-base) 65%, transparent);
padding: 1rem;
}
.kilo-console .prompt-form {
margin: 0;
}
.kilo-console .prompt-body {
display: grid;
gap: 1rem;
padding: 1.5rem;
}
.kilo-console .prompt-field {
display: grid;
gap: 0.5rem;
color: var(--text-strong);
font-size: 0.75rem;
font-weight: 500;
}
.kilo-console .prompt-field input {
height: 2rem;
}
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fc50e7ce14bc197134d5fdf38ac92087940906f7b627e2507c082a5be0527d31
size 17378
oid sha256:129ea4c06998d8418bdb8edc8e61e094849835edb3f40cf6e1ddc34568168e61
size 52396
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3838b7760984c6c822078de1bdb74067839775d29ab8a0435626d0afff8cbb30
size 24826
oid sha256:1bd31cd4e01c2e2a5ed579bce1b542368e7a11fc878d375a93fb238ba4f82469
size 53288
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f5e9dd9a8b232e960fbc1925a2447a7634b2db241f6fe9b9ba8527665a5ca4c4
size 14248
oid sha256:47b7b7bfb4a6b5631d8fdb808c1b3a9eb631b6f96911ddf15880f0c2fee4f7e0
size 38558
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:810d109acde3456d250df74029ac031929cb4b2fffe13a4c765cda9d2a62f175
size 46116
oid sha256:add00c319d54f53c7c9859428b18182fa91da1b17e4c03b1ee8015ed08f73d19
size 48653
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d7ad739b2099b5b35abf3989bca8e8f63c2926906decfddf7530d6b97fb0e6b1
size 50711
oid sha256:d9fbce155629b97a30ee9f95f226ef883b6febf8002a657516ff439e3fae0f6c
size 51829
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a67058f86869ec7457058feec03f880b7a3ac31c00b87ae6db5a55d977295856
size 57421
oid sha256:3330276aded477430e615ababb2caa7eeb2fa7f7406974b9b829e6092d5a619a
size 51792
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bb78008c0fcbc47cb4b73fcea0739ce975c64b31d58852d74fd31078f3774694
size 54958
@@ -15,6 +15,12 @@ export type KiloEmbeddingModelCatalog = {
aliases: Record<string, string>
}
export type KiloEmbeddingModelCatalogIssue = {
code: "http" | "invalid-response" | "network"
message: string
status?: number
}
export const EMPTY_KILO_EMBEDDING_MODEL_CATALOG: KiloEmbeddingModelCatalog = {
defaultModel: "",
models: [],
@@ -39,25 +45,66 @@ type Options = {
baseURL?: string
token?: string
signal?: AbortSignal
attempts?: number
onError?: (issue: KiloEmbeddingModelCatalogIssue) => void
}
const retryable = (status: number) => status === 408 || status === 425 || status === 429 || status >= 500
function wait(ms: number, signal?: AbortSignal) {
if (signal?.aborted) return Promise.reject(signal.reason)
return new Promise<void>((resolve, reject) => {
const abort = () => {
clearTimeout(timer)
reject(signal?.reason)
}
const timer = setTimeout(() => {
signal?.removeEventListener("abort", abort)
resolve()
}, ms)
signal?.addEventListener("abort", abort, { once: true })
})
}
export async function fetchKiloEmbeddingModelCatalog(options: Options = {}): Promise<KiloEmbeddingModelCatalog> {
const url = new URL("embedding-models", resolveKiloGatewayBaseUrl({ baseURL: options.baseURL, token: options.token }))
const requested = options.attempts ?? 3
const attempts = Number.isFinite(requested) ? Math.min(3, Math.max(1, Math.floor(requested))) : 3
const issue = { current: undefined as KiloEmbeddingModelCatalogIssue | undefined }
try {
const response = await fetch(url, { signal: options.signal })
if (!response.ok) {
console.warn(`[Kilo Gateway] Failed to fetch embedding model catalog: ${response.status}`)
return EMPTY_KILO_EMBEDDING_MODEL_CATALOG
for (const attempt of Array.from({ length: attempts }, (_, index) => index)) {
if (options.signal?.aborted) throw options.signal.reason
try {
const response = await fetch(url, { signal: options.signal, redirect: "error" })
if (!response.ok) {
issue.current = {
code: "http",
message: `Unable to load Kilo embedding models (HTTP ${response.status}).`,
status: response.status,
}
if (!retryable(response.status) || attempt === attempts - 1) break
await wait(200 * 2 ** attempt, options.signal)
continue
}
const body = await response.json().catch(() => undefined)
const parsed = catalog.safeParse(body)
if (parsed.success) return parsed.data
issue.current = {
code: "invalid-response",
message: "Kilo returned an invalid embedding model catalog.",
}
break
} catch (err) {
if (options.signal?.aborted) throw options.signal.reason
issue.current = {
code: "network",
message: "Unable to connect to Kilo to load embedding models. Check your network connection and try again.",
}
if (attempt === attempts - 1) break
await wait(200 * 2 ** attempt, options.signal)
}
const parsed = catalog.safeParse(await response.json())
if (!parsed.success) {
console.warn("[Kilo Gateway] Embedding model catalog response validation failed:", parsed.error.format())
return EMPTY_KILO_EMBEDDING_MODEL_CATALOG
}
return parsed.data
} catch (err) {
console.warn("[Kilo Gateway] Error fetching embedding model catalog:", err)
return EMPTY_KILO_EMBEDDING_MODEL_CATALOG
}
if (issue.current) options.onError?.(issue.current)
return EMPTY_KILO_EMBEDDING_MODEL_CATALOG
}
+1
View File
@@ -39,6 +39,7 @@ export {
fetchKiloEmbeddingModelCatalog,
type KiloEmbeddingModel,
type KiloEmbeddingModelCatalog,
type KiloEmbeddingModelCatalogIssue,
} from "./api/embedding-models.js"
export { resolveKiloGatewayBaseUrl, resolveKiloOpenRouterBaseUrl } from "./api/url.js"
export {
@@ -1,43 +1,81 @@
import { describe, expect, mock, test } from "bun:test"
import { describe, expect, mock, spyOn, test } from "bun:test"
import { EMPTY_KILO_EMBEDDING_MODEL_CATALOG, fetchKiloEmbeddingModelCatalog } from "../../src/api/embedding-models"
const response = () =>
new Response(
JSON.stringify({
defaultModel: "provider/model",
models: [{ id: "provider/model", name: "Provider Model", dimension: 1024, scoreThreshold: 0.4 }],
aliases: { model: "provider/model" },
}),
)
describe("fetchKiloEmbeddingModelCatalog", () => {
test("fetches catalog from Kilo Gateway", async () => {
const prev = global.fetch
const fn = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
defaultModel: "provider/model",
models: [{ id: "provider/model", name: "Provider Model", dimension: 1024, scoreThreshold: 0.4 }],
aliases: { model: "provider/model" },
}),
),
),
) as unknown as typeof fetch
const fn = mock(() => Promise.resolve(response())) as unknown as typeof fetch
global.fetch = fn
try {
const catalog = await fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test" })
expect(catalog.defaultModel).toBe("provider/model")
expect((fn as unknown as { mock: { calls: Array<[URL]> } }).mock.calls[0]?.[0].toString()).toBe(
"https://example.test/api/gateway/embedding-models",
)
const call = (fn as unknown as { mock: { calls: Array<[URL, RequestInit]> } }).mock.calls[0]
expect(call?.[0].toString()).toBe("https://example.test/api/gateway/embedding-models")
expect(call?.[1].redirect).toBe("error")
} finally {
global.fetch = prev
}
})
test("falls back when the request fails", async () => {
test("retries transient transport failures", async () => {
const prev = global.fetch
const fn = mock(() => Promise.reject(new TypeError("fetch failed")))
fn.mockImplementationOnce(() => Promise.reject(new TypeError("fetch failed")))
fn.mockImplementationOnce(() => Promise.resolve(response()))
global.fetch = fn as unknown as typeof fetch
try {
const catalog = await fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test" })
expect(catalog.models).toHaveLength(1)
expect(fn).toHaveBeenCalledTimes(2)
} finally {
global.fetch = prev
}
})
test("bounds caller-controlled retry attempts", async () => {
const prev = global.fetch
const fn = mock(() => Promise.resolve(new Response("nope", { status: 500 })))
global.fetch = fn as unknown as typeof fetch
try {
await fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test", attempts: Number.POSITIVE_INFINITY })
expect(fn).toHaveBeenCalledTimes(3)
} finally {
global.fetch = prev
}
})
test("reports a final failure without writing to the console", async () => {
const prev = global.fetch
const warn = spyOn(console, "warn").mockImplementation(() => undefined)
const issue = mock(() => undefined)
global.fetch = mock(() => Promise.resolve(new Response("nope", { status: 500 }))) as unknown as typeof fetch
try {
await expect(fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test" })).resolves.toEqual(
EMPTY_KILO_EMBEDDING_MODEL_CATALOG,
)
await expect(
fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test", attempts: 1, onError: issue }),
).resolves.toEqual(EMPTY_KILO_EMBEDDING_MODEL_CATALOG)
expect(issue).toHaveBeenCalledWith({
code: "http",
message: "Unable to load Kilo embedding models (HTTP 500).",
status: 500,
})
expect(warn).not.toHaveBeenCalled()
} finally {
warn.mockRestore()
global.fetch = prev
}
})
+62
View File
@@ -1,5 +1,67 @@
# kilo-code
## 7.3.44
### Minor Changes
- [#11082](https://github.com/Kilo-Org/kilocode/pull/11082) [`a16e82a`](https://github.com/Kilo-Org/kilocode/commit/a16e82a77abf883c2c07c11464d50e08a518acd7) - Use embedded LanceDB as the default semantic search vector store so indexing works without a separate Qdrant server. Existing Qdrant users and Intel Mac users can select `qdrant` with `indexing.vectorStore`.
### Patch Changes
- [#11001](https://github.com/Kilo-Org/kilocode/pull/11001) [`b64d7e0`](https://github.com/Kilo-Org/kilocode/commit/b64d7e00953a6b99b3e41019d811a3917c465880) - Preserve existing Agent Manager worktrees and use deterministic suffixes when explicit branch names collide.
- [#10084](https://github.com/Kilo-Org/kilocode/pull/10084) [`f180658`](https://github.com/Kilo-Org/kilocode/commit/f180658b99e88825f27fdd84dd34cdddb5347792) Thanks [@sylwester-liljegren](https://github.com/sylwester-liljegren)! - Group Kilo Code commands in the VS Code command palette and clarify the Open in Tab command title.
- [#11118](https://github.com/Kilo-Org/kilocode/pull/11118) [`143ae8e`](https://github.com/Kilo-Org/kilocode/commit/143ae8e99205e7c8bc4743a1ff33e6ac8d456cc6) - Dismiss stale permission prompts when another view has already answered them.
- [#11094](https://github.com/Kilo-Org/kilocode/pull/11094) [`4acc1f8`](https://github.com/Kilo-Org/kilocode/commit/4acc1f8bcccc48f01fc046d799f5546bff247407) - Keep large chat transcripts responsive by mounting only viewport-visible conversation rows.
- [#11154](https://github.com/Kilo-Org/kilocode/pull/11154) [`9129844`](https://github.com/Kilo-Org/kilocode/commit/9129844225e2e0bc551cd91dcdbacf046992e94b) - Load large Agent Manager change reviews faster without deferring file previews.
- [#11075](https://github.com/Kilo-Org/kilocode/pull/11075) [`e17ce0c`](https://github.com/Kilo-Org/kilocode/commit/e17ce0c9ecaf4cc4cad3e0fd99b28bef561705fc) - Speed up large session forks by retaining final task outcomes instead of duplicating resumable subagent histories, and load completed task details only when expanded.
- [#11081](https://github.com/Kilo-Org/kilocode/pull/11081) [`9c279a1`](https://github.com/Kilo-Org/kilocode/commit/9c279a16b4a14fc117f34d7aa19e771149031931) - Show model free and prompt-training indicators only when their explicit catalog metadata is enabled.
- [#11100](https://github.com/Kilo-Org/kilocode/pull/11100) [`5b38d29`](https://github.com/Kilo-Org/kilocode/commit/5b38d299c439e076857e84390a9355372190557a) - Keep HTTP status codes beside tool error titles when error details wrap.
- [#11101](https://github.com/Kilo-Org/kilocode/pull/11101) [`294c532`](https://github.com/Kilo-Org/kilocode/commit/294c532f6a355b78ed86d2188891883b07e90cc8) - Prevent task subagents from asking questions that users cannot answer from the parent session.
- [#11103](https://github.com/Kilo-Org/kilocode/pull/11103) [`8b2a100`](https://github.com/Kilo-Org/kilocode/commit/8b2a100084496deca171243ddd50a26fa74343f2) - Show the first Agent Manager Local prompt and progress indicator immediately while its session is being created.
- [#11104](https://github.com/Kilo-Org/kilocode/pull/11104) [`514af4c`](https://github.com/Kilo-Org/kilocode/commit/514af4c36145610e22d3888b7b5836fab1351272) - Open subagent transcripts faster while preserving live reasoning and paginating long histories.
- [#11151](https://github.com/Kilo-Org/kilocode/pull/11151) [`77fe7c9`](https://github.com/Kilo-Org/kilocode/commit/77fe7c9e3fee173276401ad45774e2031d394443) - Keep binary and audio files collapsed in diff reviews instead of showing empty diff panels.
- [#11097](https://github.com/Kilo-Org/kilocode/pull/11097) [`f8ec1dc`](https://github.com/Kilo-Org/kilocode/commit/f8ec1dc7a8c0ab0b88d1cd14e35182adef33847a) - Remove the unsupported paste-summary toggle from VS Code settings.
- [#11091](https://github.com/Kilo-Org/kilocode/pull/11091) [`57bef8a`](https://github.com/Kilo-Org/kilocode/commit/57bef8ae68793c9b627ba0400b596bf932311e17) - Prevent streamed tool calls from executing twice and leaving answered questions disabled in VS Code.
- [#11031](https://github.com/Kilo-Org/kilocode/pull/11031) [`bbfd59b`](https://github.com/Kilo-Org/kilocode/commit/bbfd59b85c383277fd8db77fcfd0ec56ea1a25d8) - Remove the unsupported code search tool.
- [#11133](https://github.com/Kilo-Org/kilocode/pull/11133) [`68fd8cc`](https://github.com/Kilo-Org/kilocode/commit/68fd8ccce4d0a9b6987ea9c620a072a15b924c9c) - Keep permission approvals working when continuing or switching Agent Manager worktree sessions.
- [#10866](https://github.com/Kilo-Org/kilocode/pull/10866) [`d5112ed`](https://github.com/Kilo-Org/kilocode/commit/d5112edf90d33333d1064c7ab885cf0a4d92d892) - Support configuring code indexing separately for global and project settings in Kilo Console, the CLI TUI, and VS Code.
- [#11051](https://github.com/Kilo-Org/kilocode/pull/11051) [`856fc66`](https://github.com/Kilo-Org/kilocode/commit/856fc6665617d0a9753bc857fcd2c745b663196f) - Keep custom-answer selection in sync and show one submit action while entering custom text.
- [#11031](https://github.com/Kilo-Org/kilocode/pull/11031) [`b2798ef`](https://github.com/Kilo-Org/kilocode/commit/b2798ef41e2b7875442aacaac65397a2d2be3b74) - Preserve complete session state when applying partial session updates in VS Code.
- [#11087](https://github.com/Kilo-Org/kilocode/pull/11087) [`5969a4c`](https://github.com/Kilo-Org/kilocode/commit/5969a4c210beaf3dc968a56da541cfcdd7989d84) - Restore persisted sessions across development extension branches and worktrees.
- [#11153](https://github.com/Kilo-Org/kilocode/pull/11153) [`7955f86`](https://github.com/Kilo-Org/kilocode/commit/7955f865d8f05920bf6a03e8daa5193da6e6b5f9) - Keep streamed conversation content visually stable while preserving virtualized transcript history.
- [#11144](https://github.com/Kilo-Org/kilocode/pull/11144) [`287f75a`](https://github.com/Kilo-Org/kilocode/commit/287f75aa2bc84d931baccfb217dda0618be07ea9) - Agent Manager terminals now use `terminal.integrated.fontFamily` and `terminal.integrated.fontSize` (including Nerd Font glyphs) instead of the editor font.
- [#11154](https://github.com/Kilo-Org/kilocode/pull/11154) [`9129844`](https://github.com/Kilo-Org/kilocode/commit/9129844225e2e0bc551cd91dcdbacf046992e94b) - Keep expanded Agent Manager reviews responsive by virtualizing file rows and preserving anchored scrolling.
- [#11074](https://github.com/Kilo-Org/kilocode/pull/11074) [`915ecfb`](https://github.com/Kilo-Org/kilocode/commit/915ecfbefbaf1b0a7f5876dedbca6c79008ee771) - Start MCP servers while Agent Manager worktree sessions initialize to reduce the delay before the first response.
- Updated dependencies [[`a16e82a`](https://github.com/Kilo-Org/kilocode/commit/a16e82a77abf883c2c07c11464d50e08a518acd7), [`e17ce0c`](https://github.com/Kilo-Org/kilocode/commit/e17ce0c9ecaf4cc4cad3e0fd99b28bef561705fc), [`9c279a1`](https://github.com/Kilo-Org/kilocode/commit/9c279a16b4a14fc117f34d7aa19e771149031931), [`57bef8a`](https://github.com/Kilo-Org/kilocode/commit/57bef8ae68793c9b627ba0400b596bf932311e17), [`b75af0d`](https://github.com/Kilo-Org/kilocode/commit/b75af0de8865234a745f71eac03bf2bdea2271b4)]:
- @kilocode/kilo-indexing@7.4.0
- @kilocode/kilo-ui@7.3.43
- @kilocode/kilo-gateway@7.3.43
- @opencode-ai/ui@7.3.43
## 7.3.42
### Minor Changes
@@ -31,7 +31,7 @@ import { WorktreeDiffController } from "./worktree-diff-controller"
import { WorktreeImporter } from "./worktree-importer"
import { recordPromotionHandoff } from "./promotion-handoff"
import { restoreWorktrees } from "./state-recovery"
import { diffSummary as localDiffSummary, diffFile as localDiffFile } from "./local-diff"
import { createLocalDiff, diffSummary as localDiffSummary } from "./local-diff"
import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start"
import { stopSessionProcesses } from "../kilo-provider/background-process"
@@ -97,13 +97,8 @@ export class AgentManagerProvider implements Disposable {
getRoot: () => this.getRoot(),
getWorktreePath: (id) => this.getStateManager()?.getWorktree(id)?.path,
log: (...args) => this.log("[XTerm]", ...args),
post: (msg) => {
if (msg.type === "agentManager.terminal.created") {
this.postToWebview({ ...msg, font: readTerminalFont() })
return
}
this.postToWebview(msg)
},
post: (msg) => this.postToWebview(msg),
getTerminalFont: () => readTerminalFont(),
})
this.unsubFont = watchTerminalFont((font) => {
this.postToWebview({ type: "agentManager.terminal.fontChanged", font })
@@ -131,13 +126,14 @@ export class AgentManagerProvider implements Disposable {
})
const semaphore = new Semaphore(3)
this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore })
const local = createLocalDiff(this.gitOps, (...args) => this.log(...args))
this.diffs = new WorktreeDiffController({
getState: () => this.getStateManager(),
getRoot: () => this.getRoot(),
getStateReady: () => this.stateReady,
git: this.gitOps,
localDiff: (dir, base) => localDiffSummary(this.gitOps, dir, base, (...args) => this.log(...args)),
localDiffFile: (dir, base, file) => localDiffFile(this.gitOps, dir, base, file, (...args) => this.log(...args)),
localDiff: local.summary,
localDiffFile: local.file,
post: (msg) => this.postToWebview(msg),
log: (...args) => this.log(...args),
})
@@ -254,6 +254,33 @@ export async function diffSummary(git: GitOps, dir: string, base: string, log?:
return items.map(summarize)
}
export function createLocalDiff(git: GitOps, log?: Log) {
const states = new Map<string, { anc: string; metas: Map<string, Meta> }>()
return {
summary: async (dir: string, base: string): Promise<WorktreeDiffEntry[]> => {
const id = `${dir}\0${base}`
const anc = await ancestor(git, dir, base, log)
if (!anc) {
states.delete(id)
return []
}
const items = await list(git, dir, anc, log)
states.delete(id)
states.set(id, { anc, metas: new Map(items.map((item) => [item.file, item])) })
if (states.size > 8) states.delete(states.keys().next().value!)
return items.map(summarize)
},
file: async (dir: string, base: string, file: string): Promise<WorktreeDiffEntry | null> => {
const state = states.get(`${dir}\0${base}`)
const meta = state?.metas.get(file)
if (!state || !meta) return diffFile(git, dir, base, file, log)
return materialize(git, dir, state.anc, meta, log)
},
}
}
async function detailMeta(git: GitOps, dir: string, anc: string, file: string): Promise<Meta | undefined> {
const tracked = await git.execGit(["ls-files", "--error-unmatch", "--", file], dir)
if (tracked.code !== 0) {
@@ -354,8 +381,11 @@ export async function diffFile(
if (!anc) return null
const meta = await detailMeta(git, dir, anc, file)
if (!meta) return null
if (meta.binary) return summarize(meta)
return materialize(git, dir, anc, meta, log)
}
async function materialize(git: GitOps, dir: string, anc: string, meta: Meta, log?: Log): Promise<WorktreeDiffEntry> {
if (meta.binary) return summarize(meta)
// Cheap size probe before materializing content — protects the extension
// host from OOM on huge tracked files. `git cat-file -s` returns the blob
// size without streaming its contents, and `fs.stat` is a plain syscall.
@@ -27,6 +27,8 @@ export function resolveTerminalFont(
}
}
/** Resolve the user's integrated-terminal font, mirroring VS Code's own
* family fallback while preserving the terminal's independent size. */
export function readTerminalFont(): TerminalFont {
const term = vscode.workspace.getConfiguration("terminal.integrated")
const editor = vscode.workspace.getConfiguration("editor")
@@ -37,6 +39,7 @@ export function readTerminalFont(): TerminalFont {
)
}
/** True when a config change affects the effective terminal family or size. */
export function affectsTerminalFont(e: vscode.ConfigurationChangeEvent): boolean {
return (
e.affectsConfiguration("terminal.integrated.fontFamily") ||
@@ -45,6 +48,7 @@ export function affectsTerminalFont(e: vscode.ConfigurationChangeEvent): boolean
)
}
/** Subscribe to terminal-font config changes. Returns a cleanup function. */
export function watchTerminalFont(callback: (font: TerminalFont) => void): () => void {
const sub = vscode.workspace.onDidChangeConfiguration((e) => {
if (affectsTerminalFont(e)) callback(readTerminalFont())
@@ -15,7 +15,7 @@
*/
import type { KiloClient } from "@kilocode/sdk/v2/client"
import type { AgentManagerInMessage, AgentManagerOutMessage } from "./types"
import type { AgentManagerInMessage, AgentManagerOutMessage, TerminalFont } from "./types"
import { TerminalManager } from "./terminal-manager"
interface ServerConfig {
@@ -36,6 +36,8 @@ export interface TerminalRoutingDeps {
log(...args: unknown[]): void
/** Send a message back to the webview. */
post(message: AgentManagerOutMessage): void
/** Return the current terminal font settings. */
getTerminalFont(): TerminalFont
}
/** True iff the message belongs to the terminal-tab subsystem. */
@@ -106,6 +108,7 @@ export class TerminalRouter {
terminalId: created.terminalId,
title: created.title,
wsUrl: created.wsUrl,
font: this.deps.getTerminalFont(),
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
+10 -13
View File
@@ -14,6 +14,9 @@ import type { ApplyConflict } from "./GitOps"
import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import"
import type { ExternalWorktreeItem } from "./WorktreeManager"
import type { RunStatus } from "./run/manager"
import type { TerminalFont } from "./terminal-font"
export type { TerminalFont }
// ---------------------------------------------------------------------------
// Shared payload types
@@ -147,18 +150,7 @@ interface TerminalCreatedMessage {
terminalId: string
title: string
wsUrl: string
font?: {
fontFamily: string
fontSize: number
}
}
interface TerminalFontChangedMessage {
type: "agentManager.terminal.fontChanged"
font: {
fontFamily: string
fontSize: number
}
font: TerminalFont
}
interface TerminalClosedMessage {
@@ -172,6 +164,11 @@ interface TerminalErrorMessage {
message: string
}
interface TerminalFontChangedMessage {
type: "agentManager.terminal.fontChanged"
font: TerminalFont
}
interface ErrorOutMessage {
type: "error"
message: string
@@ -324,9 +321,9 @@ export type AgentManagerOutMessage =
| ActionOutMessage
| RunStatusMessage
| TerminalCreatedMessage
| TerminalFontChangedMessage
| TerminalClosedMessage
| TerminalErrorMessage
| TerminalFontChangedMessage
// ---------------------------------------------------------------------------
// Webview → Extension messages (onMessage)
@@ -5,6 +5,7 @@ import type { DiffFile } from "../diff/types"
import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "../diff/sources/types"
import type { ApplyConflict, GitOps } from "./GitOps"
import { shouldStopDiffPolling } from "./delete-worktree"
import { Semaphore } from "./semaphore"
import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager"
import type { AgentManagerOutMessage, WorktreeDiffEntry } from "./types"
@@ -33,6 +34,7 @@ export interface WorktreeDiffControllerContext {
export class WorktreeDiffController {
private readonly controller: SourceController
private readonly details = new Semaphore(3)
private target: Target | undefined
private applying: string | undefined
@@ -251,15 +253,17 @@ export class WorktreeDiffController {
private async fetchFile(sessionId: string, file: string): Promise<DiffFile | null> {
await this.ready("stateReady rejected, continuing diff detail resolve:")
const target = await this.ensureTarget(sessionId)
if (!target) return null
return this.details.run(async () => {
const target = await this.ensureTarget(sessionId)
if (!target) return null
try {
return (await this.ctx.localDiffFile(target.directory, target.baseBranch, file)) as AgentManagerDiffFile | null
} catch (error) {
this.ctx.log("Failed to fetch worktree diff file:", error)
return null
}
try {
return (await this.ctx.localDiffFile(target.directory, target.baseBranch, file)) as AgentManagerDiffFile | null
} catch (error) {
this.ctx.log("Failed to fetch worktree diff file:", error)
return null
}
})
}
private async revertFile(sessionId: string, file: string): Promise<{ ok: boolean; message: string }> {
@@ -718,5 +718,5 @@ export function isEventFromForeignProject(event: StreamEvent, expectedProjectID:
}
if (event.name !== "session.updated.1") return false
const project = event.data.info.projectID
return project != null && project !== expectedProjectID
return project !== undefined && project !== expectedProjectID
}
@@ -73,8 +73,50 @@ test("preserves diff scroll position while an agent edit refreshes a file", asyn
expect(next).toBeCloseTo(top, 0)
})
test("remounts diff rows when the review context changes", async ({ page }) => {
test("preserves scroll while adding and editing a review comment", async ({ page }) => {
await openStory(page)
const scroller = page.locator(".am-review-diff")
const target = page.locator('[data-file-path="src/target.ts"]')
const align = async () => {
await scroller.evaluate((el) => {
const target = el.querySelector('[data-file-path="src/target.ts"]')
if (!(target instanceof HTMLElement)) throw new Error("Target diff row not found")
el.scrollTop += target.getBoundingClientRect().top - el.getBoundingClientRect().top - 24
})
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))
}
await align()
await align()
const line = target.locator('[data-line="1"]').last()
await line.hover()
await target.locator("[data-utility-button]").last().click()
await expect(target.locator(".am-annotation-textarea")).toBeVisible()
await target.locator(".am-annotation-textarea").fill("Keep this stable")
const top = await target.evaluate((el) => el.getBoundingClientRect().top)
const before = await scroller.evaluate((el) => el.scrollTop)
await page.getByRole("button", { name: "Apply agent edit" }).click()
await expect(page.getByTestId("agent-edit-version")).toHaveText("after")
await expect(target.locator(".am-annotation-textarea")).toHaveValue("Keep this stable")
await expect.poll(async () => scroller.evaluate((el) => el.scrollTop)).toBeCloseTo(before, 0)
await expect.poll(async () => target.evaluate((el) => el.getBoundingClientRect().top)).toBeCloseTo(top, 0)
await target.getByRole("button", { name: "Comment" }).click()
await expect(target.getByText("Keep this stable")).toBeVisible()
const saved = await scroller.evaluate((el) => el.scrollTop)
await target.getByTitle("Edit").click()
await target.locator(".am-annotation-textarea").fill("Still stable")
await target.getByRole("button", { name: "Save" }).click()
await expect(target.getByText("Still stable")).toBeVisible()
await expect.poll(async () => scroller.evaluate((el) => el.scrollTop)).toBeCloseTo(saved, 0)
})
test("resets virtual measurements and scroll when the review context changes", async ({ page }) => {
const first = await openStory(page)
const scroller = page.locator(".am-review-diff")
await page.evaluate(() => {
class IdleObserver {
readonly root = null
@@ -96,7 +138,15 @@ test("remounts diff rows when the review context changes", async ({ page }) => {
})
})
// Move away from the origin so the context switch must reset both the
// virtualizer's cached measurements and the shared scroller position.
await scroller.evaluate((el) => {
el.scrollTop = 2_000
})
await expect.poll(async () => scroller.evaluate((el) => el.scrollTop)).toBeGreaterThan(1_000)
await page.getByRole("button", { name: "Switch review context" }).click()
await expect(page.getByTestId("review-context")).toHaveText("changed-context")
await expect.poll(async () => first.evaluate((el) => el.getBoundingClientRect().height)).toBe(1_200)
await expect.poll(async () => scroller.evaluate((el) => el.scrollTop)).toBe(0)
})
@@ -12,6 +12,7 @@ const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern"
const STORY_ID = "settings--indexing-provider-blur-race"
const KILO_STORY_ID = "settings--indexing-kilo-model-preset"
const KILO_LOADING_STORY_ID = "settings--indexing-kilo-catalog-loading"
const SCOPE_STORY_ID = "settings--indexing-scope-switch"
type Saved = {
provider?: string
@@ -19,6 +20,7 @@ type Saved = {
dimension?: number | null
openai?: { apiKey?: string }
gemini?: { apiKey?: string }
qdrant?: { url?: string; apiKey?: string }
}
function storyUrl(id = STORY_ID) {
@@ -76,6 +78,41 @@ test("provider switch writes to selected provider bucket", async ({ page }) => {
await expect(model).toHaveAttribute("placeholder", "Enter model ID")
})
test("scope switching preserves raw overrides and commits blur to the original scope", async ({ page }) => {
await page.setViewportSize({ width: 420, height: 720 })
await page.goto(storyUrl(SCOPE_STORY_ID), { waitUntil: "load" })
await disableAnimations(page)
await page.waitForSelector("#storybook-root *", { state: "attached" })
await expect(page.locator('[data-slot="settings-row"] [data-component="tag"]')).toHaveCount(0)
const url = field(page, "Qdrant URL").first()
await url.fill("http://edited-global:6333")
await page.getByRole("button", { name: "Local", exact: true }).click()
const global = page.getByTestId("indexing-global-save")
await expect
.poll(async () => {
const cfg = JSON.parse(((await global.textContent()) ?? "{}").trim()) as Saved
return cfg.qdrant?.url
})
.toBe("http://edited-global:6333")
const project = JSON.parse(((await page.getByTestId("indexing-project-save").textContent()) ?? "{}").trim()) as Saved
expect(project.qdrant?.url).toBeUndefined()
await expect(field(page, "Embedding model").first()).toHaveValue("")
await expect(url).toHaveValue("http://edited-global:6333")
const urlRow = page.locator('[data-slot="settings-row"]', { hasText: "Qdrant URL" })
const keyRow = page.locator('[data-slot="settings-row"]', { hasText: "Qdrant API key" })
const modelRow = page.locator('[data-slot="settings-row"]', { hasText: "Embedding model" })
const tuningRow = page.locator('[data-slot="settings-row"]', { hasText: "Search max results" })
await expect(urlRow.locator('[data-component="tag"]')).toHaveText("Global")
await expect(keyRow.locator('[data-component="tag"]')).toHaveText("Local")
await expect(modelRow.locator('[data-component="tag"]')).toHaveText("Local")
await expect(tuningRow.locator('[data-component="tag"]')).toHaveText("Default")
})
test("Kilo exposes only supported embedding model presets", async ({ page }) => {
await page.setViewportSize({ width: 420, height: 720 })
await page.goto(storyUrl(KILO_STORY_ID), { waitUntil: "load" })
@@ -110,11 +147,18 @@ test("enabling Kilo before its catalog loads does not store an empty model", asy
await page.goto(storyUrl(KILO_LOADING_STORY_ID), { waitUntil: "load" })
await disableAnimations(page)
await page.waitForSelector("#storybook-root *", { state: "attached" })
await page.locator('[data-component="switch"] [data-slot="switch-control"]').nth(1).click()
await page.getByRole("button", { name: "Local", exact: true }).click()
await page
.locator('[data-slot="settings-row"]', { hasText: "Enable for this project" })
.locator('[data-slot="switch-control"]')
.click()
await verify()
await page.goto(storyUrl(KILO_LOADING_STORY_ID), { waitUntil: "load" })
await page.waitForSelector("#storybook-root *", { state: "attached" })
await page.locator('[data-component="switch"] [data-slot="switch-control"]').first().click()
await page
.locator('[data-slot="settings-row"]', { hasText: "Enable globally" })
.locator('[data-slot="switch-control"]')
.click()
await verify()
})
@@ -1,14 +1,13 @@
import { describe, expect, it } from "bun:test"
import { mergeWorktreeDiffs } from "../../webview-ui/diff-viewer/diff-state"
import {
EAGER_DIFF_REVIEW_LINES,
EXTREME_DIFF_CHANGED_LINES,
allOpenFiles,
eagerDiffFiles,
expandableOpenFiles,
initialOpenFiles,
isDiffExpandable,
sanitizeOpenFiles,
shouldVirtualizeDiff,
toggleOpenFiles,
} from "../../webview-ui/diff-viewer/diff-open-policy"
import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages"
@@ -69,7 +68,7 @@ describe("agent manager diff state", () => {
expect(result.stale).toEqual(new Set(["src/app.ts"]))
})
it("opens reviewable diffs initially", () => {
it("opens every diff initially", () => {
expect(
initialOpenFiles([
diff({ file: "src/app.ts", generatedLike: false, additions: 3 }),
@@ -77,13 +76,13 @@ describe("agent manager diff state", () => {
diff({ file: "audio/notification.wav", summarized: false, additions: 0 }),
diff({ file: "src/huge.ts", additions: EXTREME_DIFF_CHANGED_LINES + 1 }),
]),
).toEqual(["src/app.ts"])
).toEqual(["src/app.ts", "node_modules/pkg/index.js", "src/huge.ts"])
const many = Array.from({ length: 26 }, (_, i) => diff({ file: `src/${i}.ts` }))
expect(initialOpenFiles(many)).toHaveLength(26)
})
it("expands only reviewable files from the bulk action", () => {
it("keeps generated and large files in the expanded review", () => {
expect(
expandableOpenFiles([
diff({ file: "src/app.ts", generatedLike: false, additions: 3 }),
@@ -91,10 +90,10 @@ describe("agent manager diff state", () => {
diff({ file: "assets/archive.zip", summarized: false, additions: 0 }),
diff({ file: "src/huge.ts", additions: EXTREME_DIFF_CHANGED_LINES + 1 }),
]),
).toEqual(["src/app.ts"])
).toEqual(["src/app.ts", "src/generated.ts", "src/huge.ts"])
})
it("toggles reviewable files based on whether every reviewable file is open", () => {
it("toggles all files based on whether every file is open", () => {
const diffs = [
diff({ file: "src/app.ts" }),
diff({ file: "src/panel.ts" }),
@@ -106,13 +105,20 @@ describe("agent manager diff state", () => {
expect(allOpenFiles(diffs, [])).toBe(false)
expect(allOpenFiles(diffs, ["stale.ts"])).toBe(false)
expect(allOpenFiles(diffs, ["src/app.ts"])).toBe(false)
expect(allOpenFiles(diffs, ["src/app.ts", "src/panel.ts"])).toBe(true)
expect(allOpenFiles(diffs, ["stale.ts", "src/app.ts", "src/panel.ts", "src/generated.ts"])).toBe(true)
expect(allOpenFiles(diffs, ["src/app.ts", "src/panel.ts"])).toBe(false)
expect(allOpenFiles(diffs, ["stale.ts", "src/app.ts", "src/panel.ts", "src/generated.ts"])).toBe(false)
expect(
allOpenFiles(
diffs,
diffs.map((item) => item.file),
),
).toBe(true)
expect(toggleOpenFiles(diffs, [])).toEqual(["src/app.ts", "src/panel.ts"])
expect(toggleOpenFiles(diffs, ["stale.ts"])).toEqual(["src/app.ts", "src/panel.ts"])
expect(toggleOpenFiles(diffs, ["src/app.ts"])).toEqual(["src/app.ts", "src/panel.ts"])
expect(toggleOpenFiles(diffs, ["src/app.ts", "src/panel.ts"])).toEqual([])
const files = expandableOpenFiles(diffs)
expect(toggleOpenFiles(diffs, [])).toEqual(files)
expect(toggleOpenFiles(diffs, ["stale.ts"])).toEqual(files)
expect(toggleOpenFiles(diffs, ["src/app.ts"])).toEqual(files)
expect(toggleOpenFiles(diffs, files)).toEqual([])
})
it("prevents non-text diffs from entering open state", () => {
@@ -125,45 +131,21 @@ describe("agent manager diff state", () => {
})
})
describe("eager diff files", () => {
it("renders hunk-bounded detailed patches eagerly", () => {
const diffs = [
diff({ file: "src/a.ts", patch: "@@ -1 +1 @@\n-a\n+b\n", additions: 10, deletions: 5 }),
diff({ file: "src/b.ts", patch: "@@ -1 +1 @@\n-a\n+b\n", additions: 3, deletions: 0 }),
]
expect(eagerDiffFiles(diffs)).toEqual(new Set(["src/a.ts", "src/b.ts"]))
describe("diff line virtualization", () => {
it("renders normal hunk patches directly inside virtual file rows", () => {
expect(
shouldVirtualizeDiff(diff({ file: "src/a.ts", patch: "@@ -1 +1 @@\n-a\n+b\n", additions: 10, deletions: 5 })),
).toBe(false)
})
it("virtualizes a full-content detail without a hunk-bounded patch", () => {
const diffs = [diff({ file: "src/large-source.ts", before: "a\n".repeat(4000), after: "b\n", additions: 1 })]
expect(eagerDiffFiles(diffs)).toEqual(new Set())
})
it("virtualizes files larger than the large-file threshold", () => {
const diffs = [
diff({ file: "src/big.ts", patch: "large", additions: EXTREME_DIFF_CHANGED_LINES + 1, deletions: 0 }),
diff({ file: "src/small.ts", patch: "small", additions: 5, deletions: 0 }),
]
expect(eagerDiffFiles(diffs)).toEqual(new Set(["src/small.ts"]))
})
it("stops rendering eagerly once the review budget is exhausted", () => {
// Each file is under the large-file threshold, but together they exceed the
// aggregate budget, so the overflow falls back to virtualization.
const diffs = [
diff({ file: "src/a.ts", patch: "a", additions: 2000, deletions: 0 }),
diff({ file: "src/b.ts", patch: "b", additions: 2000, deletions: 0 }),
diff({ file: "src/c.ts", patch: "c", additions: 2000, deletions: 0 }),
diff({ file: "src/d.ts", patch: "d", additions: EAGER_DIFF_REVIEW_LINES - 6005, deletions: 0 }),
diff({ file: "src/e.ts", patch: "e", additions: 2000, deletions: 0 }),
diff({ file: "src/f.ts", patch: "f", additions: 5, deletions: 0 }),
]
const eager = eagerDiffFiles(diffs)
expect(eager.has("src/a.ts")).toBe(true)
expect(eager.has("src/d.ts")).toBe(true)
// Budget exhausted, so the next sizeable file virtualizes.
expect(eager.has("src/e.ts")).toBe(false)
// A smaller later file still fits within the remaining budget.
expect(eager.has("src/f.ts")).toBe(true)
it("virtualizes full-content and extreme individual files", () => {
expect(
shouldVirtualizeDiff(diff({ file: "src/source.ts", before: "a\n".repeat(4000), after: "b\n", additions: 1 })),
).toBe(true)
expect(
shouldVirtualizeDiff(
diff({ file: "src/big.ts", patch: "large", additions: EXTREME_DIFF_CHANGED_LINES + 1, deletions: 0 }),
),
).toBe(true)
})
})
@@ -1,5 +1,17 @@
import { describe, expect, it } from "bun:test"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { createRoot } from "solid-js"
import { affectsTerminalFont, resolveTerminalFont } from "../../src/agent-manager/terminal-font"
import { TerminalRouter } from "../../src/agent-manager/terminal-routing"
import type { AgentManagerOutMessage, TerminalFont } from "../../src/agent-manager/types"
import { createTerminalMessageHandler, createTerminalState } from "../../webview-ui/agent-manager/terminal/state"
import { LOCAL } from "../../webview-ui/agent-manager/navigate"
import type { ExtensionMessage } from "../../webview-ui/src/types/messages/extension-messages"
const font: TerminalFont = {
fontFamily: "MesloLGS NF",
fontSize: 18,
}
describe("Agent Manager terminal font", () => {
it("resolves terminal settings without inheriting the editor size", () => {
@@ -17,7 +29,7 @@ describe("Agent Manager terminal font", () => {
})
})
it("isolates terminal settings from Kilo webview font size changes", () => {
it("watches only settings that affect the terminal family or size", () => {
const event = (key: string) =>
({
affectsConfiguration: (target: string) => target === key,
@@ -29,4 +41,61 @@ describe("Agent Manager terminal font", () => {
expect(affectsTerminalFont(event("editor.fontSize"))).toBe(false)
expect(affectsTerminalFont(event("terminal.integrated.letterSpacing"))).toBe(false)
})
it("includes the current font when creating a terminal", async () => {
const client = {
pty: {
create: async () => ({ data: { id: "pty-1", title: "Terminal 1" } }),
remove: async () => ({ data: true }),
update: async () => ({ data: true }),
},
} as unknown as KiloClient
const message = new Promise<AgentManagerOutMessage>((resolve) => {
const router = new TerminalRouter({
getClient: () => client,
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
getRoot: () => "/workspace",
getWorktreePath: () => undefined,
log: () => undefined,
post: resolve,
getTerminalFont: () => font,
})
expect(router.handle({ type: "agentManager.terminal.create", worktreeId: null })).toBe(true)
})
const created = await message
expect(created.type).toBe("agentManager.terminal.created")
if (created.type !== "agentManager.terminal.created") return
expect(created.font).toEqual(font)
expect(created.worktreeId).toBeNull()
expect(created.wsUrl).toContain("/pty/pty-1/connect")
})
it("keeps the created font in terminal state", () => {
createRoot((dispose) => {
const state = createTerminalState(() => LOCAL)
const activated: string[] = []
const handler = createTerminalMessageHandler({
state,
activate: (id) => activated.push(id),
saveTabMemory: () => undefined,
setSelection: () => undefined,
showError: () => undefined,
})
const message = {
type: "agentManager.terminal.created",
worktreeId: null,
terminalId: "terminal-1",
title: "Terminal 1",
wsUrl: "ws://127.0.0.1/pty/pty-1/connect",
font,
} satisfies ExtensionMessage
expect(handler(message)).toBe(true)
expect(state.forSelection(LOCAL)[0]?.font).toEqual(font)
expect(activated).toEqual(["terminal-1"])
dispose()
})
})
})
@@ -1,5 +1,12 @@
import { describe, it, expect } from "bun:test"
import { deepMerge, stripNulls, ConfigState } from "../../webview-ui/src/utils/config-utils"
import {
configUnsetPaths,
ConfigState,
deepMerge,
mergeScopedConfig,
pruneConfigSet,
stripNulls,
} from "../../webview-ui/src/utils/config-utils"
import type { Config } from "../../webview-ui/src/types/messages"
// ---------------------------------------------------------------------------
@@ -42,6 +49,38 @@ describe("deepMerge", () => {
})
})
describe("scoped config normalization", () => {
it("preserves indexing null overrides while stripping unrelated nulls", () => {
const target = { username: "alice", indexing: { model: "global", dimension: 1024 } } as Config
const source = { username: null, indexing: { model: null, dimension: null } } as unknown as Partial<Config>
expect(mergeScopedConfig(target, source)).toEqual({ indexing: { model: null, dimension: null } })
})
it("builds clean set and unset payloads while preserving indexing null overrides", () => {
const patch = {
formatter: {},
username: null,
indexing: {
model: null,
dimension: null,
searchMinScore: undefined,
qdrant: { apiKey: undefined },
},
}
expect(pruneConfigSet(patch)).toEqual({
formatter: {},
indexing: { model: null, dimension: null },
})
expect(configUnsetPaths(patch)).toEqual([
["username"],
["indexing", "searchMinScore"],
["indexing", "qdrant", "apiKey"],
])
})
})
describe("stripNulls", () => {
it("removes null values", () => {
const cfg = { snapshot: true, username: null } as unknown as Config
@@ -10,21 +10,26 @@ import {
} from "../../webview-ui/src/components/settings/indexing-tab-state"
describe("indexing tab scope state", () => {
it("uses inherited and explicit project enablement", () => {
it("uses the global value when project enablement is inherited", () => {
expect(indexingEnabled("project", { enabled: true }, {})).toBe(true)
expect(indexingEnabled("project", { enabled: false }, {})).toBe(false)
expect(indexingEnabledInherited("project", { enabled: true }, {})).toBe(true)
expect(indexingEnabledInherited("project", { enabled: false }, {})).toBe(true)
})
it("uses explicit project overrides", () => {
expect(indexingEnabled("project", { enabled: true }, { enabled: false })).toBe(false)
expect(indexingEnabled("project", { enabled: false }, { enabled: true })).toBe(true)
expect(indexingEnabledInherited("project", { enabled: true }, { enabled: false })).toBe(false)
})
it("preserves explicit null overrides and recursively inherits undefined leaves", () => {
expect(
indexingConfig(
"project",
{ model: "global-model", dimension: 1024, qdrant: { url: "http://global", apiKey: "secret" } },
{ model: null, dimension: null, qdrant: { url: "http://project", apiKey: undefined } },
),
).toEqual({ model: null, dimension: null, qdrant: { url: "http://project", apiKey: "secret" } })
it("ignores project values in global scope", () => {
const global = { enabled: false, provider: "openai" as const, openai: { apiKey: "global" } }
const project = { enabled: true, provider: "ollama" as const, ollama: { baseUrl: "http://project" } }
expect(indexingEnabled("global", global, project)).toBe(false)
expect(indexingEnabledInherited("global", global, {})).toBe(false)
expect(indexingConfig("global", global, project)).toEqual(global)
})
it("keeps inherited values out of project updates", () => {
@@ -38,22 +43,87 @@ describe("indexing tab scope state", () => {
).toEqual({ enabled: false, qdrant: { url: "http://project" } })
})
it("classifies inherited and mixed fields", () => {
it("preserves explicit null overrides and recursively inherits undefined leaves", () => {
expect(
indexingConfig(
"project",
{
model: "global-model",
dimension: 1024,
qdrant: { url: "http://global", apiKey: "global-secret" },
},
{
model: null,
dimension: null,
qdrant: { url: "http://project", apiKey: undefined },
},
),
).toEqual({
model: null,
dimension: null,
qdrant: { url: "http://project", apiKey: "global-secret" },
})
})
it("classifies inherited and partially inherited fields", () => {
const global = {
provider: "openai-compatible" as const,
model: "global-model",
dimension: 1024,
"openai-compatible": { baseUrl: "https://global.test", apiKey: "secret" },
}
const project = { "openai-compatible": { baseUrl: "https://project.test" } }
const paths = [
["openai-compatible", "baseUrl"],
["openai-compatible", "apiKey"],
]
const project = {
model: null,
"openai-compatible": { baseUrl: "https://project.test" },
}
expect(indexingInheritance("project", global, project, [["provider"]])).toBe("inherited")
expect(indexingSource("project", global, project, paths)).toBe("mixed")
expect(indexingDescription("Configure this value.", "partial")).toBe(
"Configure this value. Some values are inherited from global config.",
)
expect(indexingInheritance("project", global, project, [["model"]])).toBe("none")
expect(indexingInheritance("project", global, project, [["dimension"]])).toBe("inherited")
expect(
indexingInheritance("project", global, project, [
["openai-compatible", "baseUrl"],
["openai-compatible", "apiKey"],
]),
).toBe("partial")
expect(indexingInheritance("global", global, project, [["provider"]])).toBe("none")
expect(indexingInheritance("project", {}, {}, [["vectorStore"]])).toBe("none")
expect(indexingSource("project", global, project, [["provider"]])).toBe("global")
expect(indexingSource("project", global, project, [["model"]])).toBe("local")
expect(
indexingSource("project", global, project, [
["openai-compatible", "baseUrl"],
["openai-compatible", "apiKey"],
]),
).toBe("mixed")
expect(indexingSource("project", {}, {}, [["vectorStore"]])).toBe("default")
expect(indexingSource("global", global, project, [["provider"]])).toBe("none")
expect(indexingDescription("Configure this value.", "inherited")).toBe(
"Configure this value. Inherited from global config.",
)
})
it("merges inherited values with project overrides", () => {
expect(
indexingConfig(
"project",
{
enabled: true,
provider: "openai",
model: "global-model",
vectorStore: "qdrant",
openai: { apiKey: "global" },
qdrant: { url: "http://global", apiKey: "global-secret" },
},
{ provider: "ollama", qdrant: { url: "http://project" } },
),
).toEqual({
enabled: true,
provider: "ollama",
model: "global-model",
vectorStore: "qdrant",
openai: { apiKey: "global" },
qdrant: { url: "http://project", apiKey: "global-secret" },
})
})
})
@@ -10,7 +10,12 @@ type Internals = {
cachedIndexingStatusMessage: unknown
handleEvent: (event: unknown, directory?: string) => void
reloadAfterAuthChange: () => Promise<void>
handleUpdateConfig: (partial: Partial<Config>) => Promise<void>
handleUpdateConfig: (
partial: Partial<Config>,
project?: Partial<Config>,
globalUnset?: string[][],
projectUnset?: string[][],
) => Promise<void>
fetchAndSendConfig: () => Promise<void>
fetchAndSendProviders: () => Promise<void>
fetchAndSendAgents: () => Promise<void>
@@ -22,6 +27,7 @@ type Internals = {
function createConnection() {
let drains = 0
const patches: unknown[] = []
const client = {
global: {
config: {
@@ -33,12 +39,16 @@ function createConnection() {
get: async () => ({ data: {} }),
update: async () => ({ data: {} }),
overlay: async () => ({ data: { project: {} } }),
overlayUpdate: async () => ({ data: {} }),
overlayUpdate: async (patch: unknown) => {
patches.push(patch)
return { data: {} }
},
},
}
return {
drains: () => drains,
patches: () => patches,
service: {
drainPendingPrompts: async () => {
drains += 1
@@ -99,6 +109,33 @@ describe("KiloProvider indexing refresh", () => {
expect(indexing).toBe(0)
})
it("passes scoped unset paths to the config overlay endpoint", async () => {
const conn = createConnection()
const provider = new KiloProvider({} as never, conn.service as never)
const internal = provider as unknown as Internals
internal.connectionState = "connected"
await internal.handleUpdateConfig(
{ indexing: { qdrant: { apiKey: undefined } } },
{ indexing: { searchMinScore: undefined } },
[["indexing", "qdrant", "apiKey"]],
[["indexing", "searchMinScore"]],
)
expect(conn.patches()).toEqual([
expect.objectContaining({
scope: "global",
set: { indexing: { qdrant: { apiKey: undefined } } },
unset: [["indexing", "qdrant", "apiKey"]],
}),
expect.objectContaining({
scope: "project",
set: { indexing: { searchMinScore: undefined } },
unset: [["indexing", "searchMinScore"]],
}),
])
})
it("fetchAndSendIndexingStatus uses current session directory header", async () => {
const worktree = "/repo/.kilo/.kilocode/worktrees/feature"
const calls: { input: RequestInfo | URL; init?: RequestInit }[] = []
@@ -2,7 +2,14 @@ import { describe, it, expect } from "bun:test"
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"
import { diffSummary, diffFile, generatedLike, resolveBase, MAX_DETAIL_BYTES } from "../../src/agent-manager/local-diff"
import {
createLocalDiff,
diffSummary,
diffFile,
generatedLike,
resolveBase,
MAX_DETAIL_BYTES,
} from "../../src/agent-manager/local-diff"
import { GitOps } from "../../src/agent-manager/GitOps"
import { WorktreeDiffReverter } from "../../src/diff/shared/reverter"
import { resolveLocalDiffTarget } from "../../src/diff/shared/target"
@@ -290,6 +297,56 @@ describe("diffFile", () => {
})
})
it("loads full detail from the latest summary snapshot", async () => {
await withRepo(async (dir, base) => {
await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncached\n")
const local = createLocalDiff(git())
const summary = await local.summary(dir, base)
const entry = summary.find((item) => item.file === "seed.txt")
const result = await local.file(dir, base, "seed.txt")
expect(entry?.summarized).toBe(true)
expect(result?.summarized).toBe(false)
expect(result?.additions).toBe(entry?.additions)
expect(result?.deletions).toBe(entry?.deletions)
expect(result?.stamp).toBe(entry?.stamp)
expect(result?.before).toBe("seed\n")
expect(result?.after).toBe("seed\ncached\n")
expect(result?.patch).toContain("+cached")
})
})
it("does not materialize binary detail from a cached summary", async () => {
await withRepo(async (dir, base) => {
await fs.writeFile(path.join(dir, "tone.wav"), Buffer.from([0x52, 0x49, 0x46, 0x46, 0x00, 0x01, 0x02, 0x03]))
const local = createLocalDiff(git())
await local.summary(dir, base)
const result = await local.file(dir, base, "tone.wav")
expect(result?.summarized).toBe(false)
expect(result?.patch).toBe("")
expect(result?.before).toBe("")
expect(result?.after).toBe("")
})
})
it("keeps summary snapshots isolated by worktree", async () => {
await withRepo(async (first, firstBase) => {
await withRepo(async (second, secondBase) => {
await fs.writeFile(path.join(first, "seed.txt"), "seed\nfirst\n")
await fs.writeFile(path.join(second, "seed.txt"), "seed\nsecond\n")
const local = createLocalDiff(git())
await local.summary(first, firstBase)
await local.summary(second, secondBase)
expect((await local.file(first, firstBase, "seed.txt"))?.after).toBe("seed\nfirst\n")
expect((await local.file(second, secondBase, "seed.txt"))?.after).toBe("seed\nsecond\n")
})
})
})
it("falls back to summarized entry when the working-copy file exceeds the detail cap", async () => {
await withRepo(async (dir, base) => {
// Write a tracked file that's ~2.5x the cap on the working-copy side.
@@ -339,9 +339,15 @@ describe("fetchAndSendPendingPermissions", () => {
clearPermissionDirectory: (id) => {
permDirs.delete(id)
},
prunePermissionDirectories: (active) => {
for (const key of permDirs.keys()) {
if (!active.has(key)) permDirs.delete(key)
prunePermissionDirectories: (active, dirs) => {
for (const [key, dir] of permDirs) {
if (active.has(key)) {
continue
}
if (dirs && !dirs.has(dir)) {
continue
}
permDirs.delete(key)
}
},
}
@@ -155,7 +155,7 @@ describe("transcriptRows", () => {
})
describe("partitionRows", () => {
it("pins only a bounded live suffix and virtualizes completed history", () => {
it("keeps completed history and the active user row virtualized", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const u2 = user("u2")
@@ -164,23 +164,34 @@ describe("partitionRows", () => {
const rows = transcriptRows(messageTurns([u1, a1, u2, a2]), lookup({ a1: [part("old", "a1")], a2: parts }), {
live: new Set(["u2"]),
})
const result = partitionRows(rows)
const result = partitionRows(rows, new Set(["u2"]))
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)
expect(result.virtual.map((row) => `${row.turn}:${row.type}`)).toEqual([
"u1:user",
"u1:assistant",
"u2:user",
"u2:assistant",
"u2:assistant",
])
expect(result.direct.flatMap((row) => (row.type === "assistant" ? row.parts : [])).map((item) => item.id)).toEqual([
"p16",
"p17",
])
})
it("returns completed live metadata to virtual history after queue handoff", () => {
it("keeps trailing diff and error rows after the direct assistant suffix", () => {
const u1 = user("u1", { summary: { diffs: [{ file: "a.ts" }] } })
const a1 = assistant("a1", "u1", { error: { name: "ProviderError" } })
const rows = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [part("p1", "a1")] }), {
live: new Set(["u1"]),
})
const result = partitionRows(rows, new Set(["u1"]))
expect(result.virtual.map((row) => row.type)).toEqual(["user"])
expect(result.direct.map((row) => row.type)).toEqual(["assistant", "diff", "error"])
})
it("returns a completed suffix to virtual history after queue handoff", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const u2 = user("u2")
@@ -188,31 +199,55 @@ describe("partitionRows", () => {
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"])
const active = partitionRows(first, new Set(["u1"]))
expect(active.virtual.map((row) => row.type)).toEqual(["user"])
expect(active.direct.map((row) => row.turn)).toEqual(["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)
const handed = partitionRows(second, new Set(["u2"]))
expect(handed.keep.map((idx) => handed.virtual[idx]?.turn)).toEqual(["u2"])
expect(handed.direct).toEqual([])
expect(handed.virtual.filter((row) => row.turn === "u1")).toHaveLength(2)
expect(handed.virtual.filter((row) => row.turn === "u2")).toHaveLength(1)
})
it("keeps queued rows in visual order inside virtual data", () => {
it("does not retain an older turn after a newer visible turn", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const u2 = user("u2")
const rows = transcriptRows(messageTurns([u1, a1, u2]), lookup({ a1: [part("p1", "a1")] }))
const result = partitionRows(rows, new Set(["u1"]))
expect(result.virtual.map((row) => `${row.turn}:${row.type}`)).toEqual(["u1:user", "u1:assistant", "u2:user"])
expect(result.direct).toEqual([])
})
it("skips a held turn without assistant output", () => {
const u1 = user("u1")
const u2 = user("u2")
const rows = transcriptRows(messageTurns([u1, u2]), lookup({}), {
const a2 = assistant("a2", "u2")
const rows = transcriptRows(messageTurns([u1, u2, a2]), lookup({ a2: [part("p1", "a2")] }))
const result = partitionRows(rows, new Set(["u1", "u2"]))
expect(result.virtual.map((row) => row.turn)).toEqual(["u1", "u2"])
expect(result.direct.map((row) => `${row.turn}:${row.type}`)).toEqual(["u2:assistant"])
})
it("keeps queued rows after virtual and direct rows", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const u2 = user("u2")
const rows = transcriptRows(messageTurns([u1, a1, u2]), lookup({ a1: [part("p1", "a1")] }), {
live: new Set(["u1"]),
queued: new Set(["u2"]),
})
const result = partitionRows(rows)
const result = partitionRows(rows, new Set(["u1"]))
expect(result.virtual.map((row) => row.turn)).toEqual(["u1"])
expect(result.keep).toEqual([0])
expect(result.virtual.map((row) => row.type)).toEqual(["user"])
expect(result.direct.map((row) => row.type)).toEqual(["assistant"])
expect(result.queued.map((row) => row.turn)).toEqual(["u2"])
expect(result.queued[0]).toMatchObject({ type: "user", queued: true })
})
@@ -1129,7 +1129,7 @@ const AgentManagerContent: Component = () => {
onCreated: (contextKey, terminalId) => appendToTabOrder(contextKey, terminalId),
})
const unsubTerminals = vscode.onMessage((msg) => {
terminalDispatch(msg as unknown as { type: string } & Record<string, unknown>)
terminalDispatch(msg)
})
const unsub = vscode.onMessage((msg) => {
@@ -1,4 +1,5 @@
import { type Component, createSignal, createMemo, For, Show, createEffect, on } from "solid-js"
import { type Component, createSignal, createMemo, Show, createEffect, on } from "solid-js"
import type { VirtualizerHandle } from "virtua/solid"
import { Diff } from "@kilocode/kilo-ui/diff"
import { Accordion } from "@kilocode/kilo-ui/accordion"
import { StickyAccordionHeader } from "@kilocode/kilo-ui/sticky-accordion-header"
@@ -45,12 +46,15 @@ import { createReviewAnnotationSpeechRenderer } from "../diff-viewer/review-anno
import {
LONG_DIFF_MARKER_FILE_COUNT,
allOpenFiles,
eagerDiffFiles,
initialOpenFiles,
isDiffExpandable,
isLargeDiffFile,
sanitizeOpenFiles,
shouldVirtualizeDiff,
toggleOpenFiles,
} from "../diff-viewer/diff-open-policy"
import { DiffEndMarker } from "../diff-viewer/DiffEndMarker"
import { VirtualDiffList } from "../diff-viewer/VirtualDiffList"
import { treeOrder } from "../diff-viewer/file-tree-utils"
import { isMarkdownFile, MarkdownDiffView } from "../diff-viewer/MarkdownDiffView"
import { createDiffRows, diffToken } from "../diff-viewer/diff-state"
@@ -124,17 +128,16 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
keys: speechKeys,
})
let nextId = 0
// Tracks the session key for which initial open state has already run. When the
// key changes (different worktree) we expand reviewable files. Within the same key,
// only pruning happens so the user's manual collapse state is preserved.
// Initialize each worktree with every file expanded, then preserve manual
// collapse state while adding and removing files from live summaries.
let initializedKey: string | undefined
let known = new Set<string>()
const requested = new Map<string, string>()
// Reorder diffs to match the file-tree's depth-first visual order so
// scrolling through the accordion matches the tree grouping.
const sorted = createMemo(() => treeOrder(props.diffs))
const rows = createDiffRows(sorted, () => props.sessionKey)
const eager = createMemo(() => eagerDiffFiles(sorted()))
const comments = () => props.comments
const setComments = (next: ReviewComment[]) => props.onCommentsChange(next)
@@ -148,7 +151,8 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
// Ref to the scrollable container — used to preserve scroll position when
// annotation changes cause pierre to fully re-render diffs
let rootRef: HTMLDivElement | undefined
let scroller: HTMLDivElement | undefined
const [scroller, setScroller] = createSignal<HTMLDivElement>()
const [virtualizer, setVirtualizer] = createSignal<VirtualizerHandle>()
const focusRoot = () => {
requestAnimationFrame(() => {
@@ -164,19 +168,20 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
return false
}
// Run a callback while preserving the scroll position of the diff container.
// Pierre destroys and rebuilds the DOM on annotation changes (via innerHTML = ""),
// which resets scrollTop. We capture it before the update and restore it across
// two animation frames to account for the async shadow-DOM render of <diffs-container>.
// Preserve the visible file and its intra-row offset while Pierre rebuilds a
// row. Raw scrollTop is not stable once the virtualizer remeasures dynamic rows.
const preserveScroll = (fn: () => void) => {
const el = scroller
if (!el) return fn()
const top = el.scrollTop
const handle = virtualizer()
const index = handle?.findStartIndex()
const file = index === undefined ? undefined : rows()[index]?.file
const offset = index === undefined ? 0 : (handle?.scrollOffset ?? 0) - (handle?.getItemOffset(index) ?? 0)
fn()
if (!file) return
requestAnimationFrame(() => {
el.scrollTop = top
requestAnimationFrame(() => {
el.scrollTop = top
const next = rows().findIndex((diff) => diff.file === file)
if (next < 0) return
virtualizer()?.scrollToIndex(next, { offset })
})
})
}
@@ -209,16 +214,19 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
// New context: initialize open state from the diff policy.
if (key !== initializedKey) {
initializedKey = key
known = fileSet
setOpen(initialOpenFiles(diffs))
return
}
// Already initialized for this key — preserve manual expand/collapse,
// only prune files that no longer exist (e.g. deleted during session)
// Preserve manual collapse state for known files, while keeping newly
// arriving files expanded when a live summary grows.
const added = diffs.filter((diff) => !known.has(diff.file)).map((diff) => diff.file)
known = fileSet
setOpen((prev) => {
const filtered = prev.filter((file) => fileSet.has(file))
if (filtered.length === prev.length && prev.every((f) => fileSet.has(f))) return prev
return filtered
const next = sanitizeOpenFiles(diffs, [...prev.filter((file) => fileSet.has(file)), ...added])
if (next.length === prev.length && next.every((file, index) => file === prev[index])) return prev
return next
})
},
),
@@ -248,11 +256,10 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
if (!files.has(file)) requested.delete(file)
}
if (!props.onRequestDiff) return
const loading = props.loadingFiles ?? new Set<string>()
for (const file of next) {
if (loading.has(file)) continue
if (props.loadingFiles?.has(file)) continue
const diff = props.diffs.find((item) => item.file === file)
if (!diff || diff.summarized !== true) continue
if (!diff || !isDiffExpandable(diff) || diff.summarized !== true) continue
const value = diffToken(diff)
if (requested.get(file) === value) continue
requested.set(file, value)
@@ -360,6 +367,17 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
}
return map
})
const pinned = createMemo(() => {
const files = new Set<string>()
const current = draft()
if (current) files.add(current.file)
const edit = editing()
if (edit) {
const comment = comments().find((item) => item.id === edit)
if (comment) files.add(comment.file)
}
return rows().flatMap((diff, index) => (files.has(diff.file) ? [index] : []))
})
const annotationsForFile = (file: string): DiffLineAnnotation<AnnotationMeta>[] => {
const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta, editMeta)
@@ -440,8 +458,8 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
files: props.diffs.length,
additions: props.diffs.reduce((sum, diff) => sum + diff.additions, 0),
deletions: props.diffs.reduce((sum, diff) => sum + diff.deletions, 0),
large: props.diffs.filter((diff) => isLargeDiffFile(diff)).length,
collapsed: Math.max(props.diffs.length - open().length, 0),
large: props.diffs.filter((diff) => isDiffExpandable(diff) && isLargeDiffFile(diff)).length,
collapsed: props.diffs.filter((diff) => isDiffExpandable(diff) && !open().includes(diff.file)).length,
}))
const allOpen = createMemo(() => allOpenFiles(props.diffs, open()))
const openLabel = () => (allOpen() ? t("ui.sessionReview.collapseAll") : t("ui.sessionReview.expandAll"))
@@ -526,10 +544,15 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
</Show>
<Show when={props.diffs.length > 0}>
<div class="am-diff-content" data-component="session-review" ref={scroller}>
<Accordion multiple value={open()} onChange={setOpen}>
<For each={rows()}>
{(diff) => {
<div class="am-diff-content" data-component="session-review" ref={setScroller}>
<Accordion multiple value={open()} onChange={(files) => setOpen(sanitizeOpenFiles(props.diffs, files))}>
<VirtualDiffList
context={props.sessionKey}
data={rows()}
scroll={scroller()}
keep={pinned()}
onReady={setVirtualizer}
render={(diff) => {
const isAdded = () => diff.status === "added"
const isDeleted = () => diff.status === "deleted"
const isLargeCollapsed = () => isLargeDiffFile(diff) && !open().includes(diff.file)
@@ -537,7 +560,11 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
const fileCommentCount = () => (commentsByFile().get(diff.file) ?? []).length
return (
<Accordion.Item value={diff.file} data-slot="session-review-accordion-item">
<Accordion.Item
value={diff.file}
data-slot="session-review-accordion-item"
data-file-path={diff.file}
>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-review-trigger-content">
@@ -629,9 +656,11 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
/>
</Tooltip>
</Show>
<span data-slot="session-review-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
<Show when={isDiffExpandable(diff)}>
<span data-slot="session-review-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
</Show>
</div>
</div>
</Accordion.Trigger>
@@ -659,7 +688,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
after={{ name: diff.file, contents: diff.after }}
patch={diff.patch}
diffStyle={props.diffStyle ?? "unified"}
virtualized={!eager().has(diff.file)}
virtualized={shouldVirtualizeDiff(diff)}
annotations={annotationsForFile(diff.file)}
renderAnnotation={buildAnnotation}
enableGutterUtility={true}
@@ -689,7 +718,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
</Accordion.Item>
)
}}
</For>
/>
</Accordion>
<Show when={props.diffs.length > LONG_DIFF_MARKER_FILE_COUNT}>
<DiffEndMarker />
@@ -396,7 +396,6 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
<div class="prompt-input-ghost-wrapper am-prompt-input-ghost-wrapper">
<textarea
ref={textareaRef}
autofocus
class="prompt-input am-prompt-input"
placeholder={t(
isMac
@@ -25,6 +25,8 @@ import type { TerminalFont } from "./state"
interface Props {
terminalId: string
wsUrl: string
/** Terminal font settings forwarded from the extension host. Used on
* initial mount; live changes arrive via `agentManager.terminal.fontChanged`. */
font: TerminalFont
/** Whether this terminal is currently the focused tab.
*
@@ -298,7 +300,9 @@ export const TerminalTab: Component<Props> = (props) => {
return
}
// Kilo webview font changes do not affect the integrated terminal font.
// fontSizeChanged/ready control the Kilo chat UI font — do not apply
// them to the terminal, which has its own independent font settings.
// Keep the repaint for any downstream layout side-effects.
const size =
message.type === "fontSizeChanged" ? message.fontSize : message.type === "ready" ? message.fontSize : undefined
if (size === undefined) return
@@ -329,10 +329,8 @@ export interface TerminalMessageHandlerDeps {
* out of the main webview component.
*/
export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) {
return (msg: { type: string } & Record<string, unknown>): boolean => {
const message = msg as ExtensionMessage
if (message.type === "agentManager.terminal.created") {
const msg = message
return (msg: ExtensionMessage): boolean => {
if (msg.type === "agentManager.terminal.created") {
const contextKey = msg.worktreeId === null ? LOCAL : msg.worktreeId
deps.state.add(msg.worktreeId, {
id: msg.terminalId,
@@ -346,13 +344,13 @@ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) {
deps.activate(msg.terminalId)
return true
}
if (message.type === "agentManager.terminal.closed") {
deps.state.remove(message.terminalId)
if (deps.state.activeId() === message.terminalId) deps.state.setActiveId(undefined)
if (msg.type === "agentManager.terminal.closed") {
deps.state.remove(msg.terminalId)
if (deps.state.activeId() === msg.terminalId) deps.state.setActiveId(undefined)
return true
}
if (message.type === "agentManager.terminal.error") {
deps.showError(message.message)
if (msg.type === "agentManager.terminal.error") {
deps.showError(msg.message)
return true
}
return false
@@ -1,4 +1,5 @@
import { type Component, createSignal, createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import { type Component, createSignal, createMemo, createEffect, on, onCleanup, Show } from "solid-js"
import type { VirtualizerHandle } from "virtua/solid"
// Styles are imported by the component so every consumer (sidebar diff viewer,
// agent manager, storybook) picks them up automatically. Keep these imports here —
// see tests/unit/diff-viewer-css-arch.test.ts for the invariant.
@@ -47,14 +48,15 @@ import { createReviewAnnotationSpeechRenderer } from "./review-annotation-speech
import {
LONG_DIFF_MARKER_FILE_COUNT,
allOpenFiles,
eagerDiffFiles,
initialOpenFiles,
isDiffExpandable,
isLargeDiffFile,
sanitizeOpenFiles,
shouldVirtualizeDiff,
toggleOpenFiles,
} from "./diff-open-policy"
import { DiffEndMarker } from "./DiffEndMarker"
import { VirtualDiffList } from "./VirtualDiffList"
import { isMarkdownFile, MarkdownDiffView } from "./MarkdownDiffView"
import { createDiffRows, diffToken } from "./diff-state"
@@ -134,20 +136,20 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
let nextId = 0
let draftMeta: AnnotationMeta | null = composer().draft
let editMeta: AnnotationMeta | null = composer().edit
// Tracks the session key for which initial open state has already run. When the
// key changes (different worktree) we expand reviewable files. Within the same key,
// only pruning happens so the user's manual collapse state is preserved.
// Initialize each worktree with every file expanded, then preserve manual
// collapse state while adding and removing files from live summaries.
let initializedKey: string | undefined
let known = new Set<string>()
const requested = new Map<string, string>()
let rootRef: HTMLDivElement | undefined
let scrollRef: HTMLDivElement | undefined
const [scroller, setScroller] = createSignal<HTMLDivElement>()
const [virtualizer, setVirtualizer] = createSignal<VirtualizerHandle>()
let syncFrame: number | undefined
// Reorder diffs to match the file-tree's depth-first visual order so
// scrolling through the diff panel matches the tree on the left.
const sorted = createMemo(() => treeOrder(props.diffs))
const rows = createDiffRows(sorted, () => props.sessionKey)
const eager = createMemo(() => eagerDiffFiles(sorted()))
const comments = () => props.comments
const setComments = (next: ReviewComment[]) => props.onCommentsChange(next)
@@ -168,14 +170,17 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
}
const preserveScroll = (fn: () => void) => {
const el = scrollRef
if (!el) return fn()
const top = el.scrollTop
const handle = virtualizer()
const index = handle?.findStartIndex()
const file = index === undefined ? undefined : rows()[index]?.file
const offset = index === undefined ? 0 : (handle?.scrollOffset ?? 0) - (handle?.getItemOffset(index) ?? 0)
fn()
if (!file) return
requestAnimationFrame(() => {
el.scrollTop = top
requestAnimationFrame(() => {
el.scrollTop = top
const next = rows().findIndex((diff) => diff.file === file)
if (next < 0) return
virtualizer()?.scrollToIndex(next, { offset })
})
})
}
@@ -215,19 +220,19 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
// New context: initialize open state from the diff policy.
if (key !== initializedKey) {
initializedKey = key
known = fileSet
setOpen(initialOpenFiles(diffs))
return
}
// Already initialized for this key — preserve manual expand/collapse,
// only prune files that no longer exist (e.g. deleted during session)
// Preserve manual collapse state for known files, while keeping newly
// arriving files expanded when a live summary grows.
const added = diffs.filter((diff) => !known.has(diff.file)).map((diff) => diff.file)
known = fileSet
setOpen((prev) => {
const filtered = sanitizeOpenFiles(
diffs,
prev.filter((file) => fileSet.has(file)),
)
if (filtered.length === prev.length && prev.every((file) => filtered.includes(file))) return prev
return filtered
const next = sanitizeOpenFiles(diffs, [...prev.filter((file) => fileSet.has(file)), ...added])
if (next.length === prev.length && next.every((file, index) => file === prev[index])) return prev
return next
})
},
),
@@ -257,9 +262,8 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
if (!files.has(file)) requested.delete(file)
}
if (!props.onRequestDiff) return
const loading = props.loadingFiles ?? new Set<string>()
for (const file of next) {
if (loading.has(file)) continue
if (props.loadingFiles?.has(file)) continue
const diff = props.diffs.find((item) => item.file === file)
if (!diff || !isDiffExpandable(diff) || diff.summarized !== true) continue
const value = diffToken(diff)
@@ -374,6 +378,17 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
}
return map
})
const pinned = createMemo(() => {
const files = new Set<string>()
const current = draft()
if (current) files.add(current.file)
const edit = editing()
if (edit) {
const comment = comments().find((item) => item.id === edit)
if (comment) files.add(comment.file)
}
return rows().flatMap((diff, index) => (files.has(diff.file) ? [index] : []))
})
const annotationsForFile = (file: string): DiffLineAnnotation<AnnotationMeta>[] => {
const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta, editMeta)
@@ -442,21 +457,13 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
const handleFileSelect = (path: string) => {
setActiveFile(path)
// Ensure the accordion is open for this file
const diff = props.diffs.find((item) => item.file === path)
if (diff && isDiffExpandable(diff) && !open().includes(path)) {
setOpen((prev) => [...prev, path])
}
// Scroll to the file in the diff viewer
if (diff && isDiffExpandable(diff) && !open().includes(path)) setOpen((prev) => [...prev, path])
requestAnimationFrame(() => {
const container = scrollRef
const el = container?.querySelector(`[data-slot="accordion-item"][data-file-path="${CSS.escape(path)}"]`)
if (!(container instanceof HTMLElement)) return
if (!(el instanceof HTMLElement)) return
const gap = 8
const top = container.scrollTop + el.getBoundingClientRect().top - container.getBoundingClientRect().top - gap
container.scrollTo({ top: Math.max(0, top), behavior: "smooth" })
const index = rows().findIndex((diff) => diff.file === path)
if (index < 0) return
const current = virtualizer()?.findStartIndex() ?? index
virtualizer()?.scrollToIndex(index, { offset: -8, smooth: Math.abs(index - current) <= 8 })
})
}
@@ -465,21 +472,10 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
}
const syncActiveFileFromScroll = () => {
const container = scrollRef
if (!container) return
const headers = Array.from(container.querySelectorAll<HTMLElement>('[data-slot="accordion-item"][data-file-path]'))
if (headers.length === 0) return
const top = container.getBoundingClientRect().top + 1
const first = headers[0]?.dataset.filePath
const selected = headers.reduce<string | undefined>((carry, header) => {
const path = header.dataset.filePath
if (!path) return carry
if (header.getBoundingClientRect().top <= top) return path
return carry
}, first)
if (selected) setActiveFile(selected)
const handle = virtualizer()
if (!handle) return
const file = rows()[handle.findStartIndex()]?.file
if (file) setActiveFile(file)
}
const scheduleSyncActiveFile = () => {
@@ -492,7 +488,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
// Keep file tree selection in sync with viewport during scroll in both directions.
createEffect(() => {
const container = scrollRef
const container = scroller()
if (!container) return
const onScroll = () => scheduleSyncActiveFile()
const resize = new ResizeObserver(() => scheduleSyncActiveFile())
@@ -608,7 +604,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
onResize={(w) => setTreeWidth(Math.max(160, Math.min(w, 400)))}
/>
</div>
<div class="am-review-diff" ref={scrollRef}>
<div class="am-review-diff" ref={setScroller}>
<Show when={props.loading && props.diffs.length === 0}>
<div class="am-diff-loading">
<Spinner />
@@ -625,8 +621,13 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
<Show when={props.diffs.length > 0}>
<div class="am-review-diff-content" data-component="session-review">
<Accordion multiple value={open()} onChange={(files) => setOpen(sanitizeOpenFiles(props.diffs, files))}>
<For each={rows()}>
{(diff) => {
<VirtualDiffList
context={props.sessionKey}
data={rows()}
scroll={scroller()}
keep={pinned()}
onReady={setVirtualizer}
render={(diff) => {
const isAdded = () => diff.status === "added"
const isDeleted = () => diff.status === "deleted"
const isLargeCollapsed = () => isLargeDiffFile(diff) && !open().includes(diff.file)
@@ -758,7 +759,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
after={{ name: diff.file, contents: diff.after }}
patch={diff.patch}
diffStyle={props.diffStyle}
virtualized={!eager().has(diff.file)}
virtualized={shouldVirtualizeDiff(diff)}
annotations={annotationsForFile(diff.file)}
renderAnnotation={buildAnnotation}
enableGutterUtility={props.canComment !== false}
@@ -788,7 +789,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
</Accordion.Item>
)
}}
</For>
/>
</Accordion>
<Show when={props.diffs.length > LONG_DIFF_MARKER_FILE_COUNT}>
<DiffEndMarker />
@@ -0,0 +1,40 @@
import { createMemo, Show, type Accessor, type JSX } from "solid-js"
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
interface VirtualDiffListProps<T> {
context: string | undefined
data: T[]
scroll: HTMLElement | undefined
keep: number[]
onReady: (handle?: VirtualizerHandle) => void
render: (item: T, index: Accessor<number>) => JSX.Element
}
export function VirtualDiffList<T>(props: VirtualDiffListProps<T>) {
// Virtua caches dynamic measurements by index. A new review needs a fresh
// store and scroll origin even when it happens to contain the same row count.
const state = createMemo(() => {
const scroll = props.scroll
const context = props.context
if (!scroll) return
scroll.scrollTop = 0
return { context, scroll }
})
return (
<Show when={state()} keyed>
{(state) => (
<Virtualizer
ref={props.onReady}
data={props.data}
scrollRef={state.scroll}
keepMounted={props.keep}
overscan={4}
itemSize={420}
>
{props.render}
</Virtualizer>
)}
</Show>
)
}
@@ -2,30 +2,15 @@ import type { WorktreeFileDiff } from "../src/types/messages"
export const LONG_DIFF_MARKER_FILE_COUNT = 50
export const EXTREME_DIFF_CHANGED_LINES = 2_000
// Total changed rows rendered eagerly (non-virtualized) across one review before
// the remainder falls back to virtualization. Bounds eager DOM for huge reviews
// while letting normal reviews render fully so scrolling never shows gap buffers.
export const EAGER_DIFF_REVIEW_LINES = 8_000
export function isLargeDiffFile(diff: WorktreeFileDiff): boolean {
return diff.additions + diff.deletions > EXTREME_DIFF_CHANGED_LINES
}
// Files whose hunk-bounded patches should render eagerly (no row virtualization)
// so their rows stay mounted and never re-render on scroll. Never eager-render a
// detail without a patch: a tiny change in a very large source file would make
// Pierre re-diff and render full before/after contents on the main thread.
export function eagerDiffFiles(diffs: WorktreeFileDiff[]): Set<string> {
const eager = new Set<string>()
let used = 0
for (const diff of diffs) {
if (!diff.patch || isLargeDiffFile(diff)) continue
const size = diff.additions + diff.deletions
if (used + size > EAGER_DIFF_REVIEW_LINES) continue
used += size
eager.add(diff.file)
}
return eager
// The outer file-row virtualizer bounds the review DOM. Pierre only needs its
// nested line virtualizer when a single file is extreme or lacks a hunk patch.
export function shouldVirtualizeDiff(diff: WorktreeFileDiff): boolean {
return !diff.patch || isLargeDiffFile(diff)
}
export function isDiffExpandable(diff: WorktreeFileDiff): boolean {
@@ -38,9 +23,7 @@ export function sanitizeOpenFiles(diffs: WorktreeFileDiff[], open: string[]): st
}
export function expandableOpenFiles(diffs: WorktreeFileDiff[]): string[] {
return diffs
.filter((diff) => isDiffExpandable(diff) && !isLargeDiffFile(diff) && diff.generatedLike !== true)
.map((diff) => diff.file)
return diffs.filter(isDiffExpandable).map((diff) => diff.file)
}
export function initialOpenFiles(diffs: WorktreeFileDiff[]): string[] {
@@ -13,6 +13,14 @@
// plain render. The diff wrapper still needs to keep that initial render cheap,
// which is why review surfaces pass hunk-bounded patches instead of full files.
import { WorkerPoolManager } from "@pierre/diffs/worker"
import { ensureKiloDiffTheme, KILO_DIFF_THEME } from "@opencode-ai/ui/pierre/kilo-diff-theme"
// Register the "Kilo" theme before any pool initializes. resolveThemes([theme])
// runs on the main thread during initialize() and throws "resolveTheme: No valid
// loader for Kilo" if the theme name was never registered. Registering here makes
// the worker self-sufficient rather than depending on the markdown context module
// having been imported first.
ensureKiloDiffTheme()
export type WorkerPoolStyle = "unified" | "split"
@@ -36,9 +44,9 @@ export function workerFactory(): Worker {
function createPool(lineDiffType: "none" | "word-alt") {
const pool = new WorkerPoolManager(
{ workerFactory, poolSize: 2 },
{ theme: "Kilo", lineDiffType, preferredHighlighter: ENGINE },
{ theme: KILO_DIFF_THEME, lineDiffType, preferredHighlighter: ENGINE },
)
void pool.initialize()
void pool.initialize().catch((err) => console.warn("[Kilo New] Failed to initialize Pierre worker pool", err))
return pool
}
@@ -46,17 +54,17 @@ let unified: WorkerPoolManager | undefined
let split: WorkerPoolManager | undefined
export function getWorkerPool(style: WorkerPoolStyle | undefined): WorkerPoolManager | undefined {
// No injected worker URI means we can't spawn the worker; returning undefined
// makes Pierre fall back to the existing main-thread highlighter.
// A missing URI or a pool still starting up uses Pierre's main-thread fallback.
// Passing a half-ready pool drops its first plain-text render before workers drain.
if (!uri()) return undefined
if (style === "split") {
if (!split) split = createPool("word-alt")
return split
return split.isInitialized() ? split : undefined
}
if (!unified) unified = createPool("none")
return unified
return unified.isInitialized() ? unified : undefined
}
export function getWorkerPools() {
@@ -130,7 +130,33 @@ export const MessageList: Component<MessageListProps> = (props) => {
prev,
)
})
const partition = createMemo(() => partitionRows(rows()))
const [held, setHeld] = createSignal<{ sid: string; turn: 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, turn: id })
return
}
setHeld((prev) => (prev?.sid === sid ? prev : { sid, turn: id }))
})
const direct = createMemo(() => {
const item = held()
const ids = new Set<string>()
if (item && item.sid === session.currentSessionID()) ids.add(item.turn)
const active = activeUserID()
if (active) ids.add(active)
return ids
})
// Virtua continues to own completed history and stable live chunks, but not
// the growing assistant suffix whose measurements would produce visible jumps.
const partition = createMemo(() => partitionRows(rows(), direct()))
const keys = createMemo(() => partition().virtual.map((row) => row.key))
const fingerprint = createMemo(() => rowFingerprint(keys()))
const measurement = createMemo(() => {
@@ -312,22 +338,21 @@ export const MessageList: Component<MessageListProps> = (props) => {
{language.t("session.messages.loadEarlier")}
</button>
</Show>
<Show when={partition().virtual.length > 0}>
<Show when={partition().virtual.length > 0 || partition().direct.length > 0}>
<div
class="message-list-turns"
data-loaded-messages={session.messages().length}
data-row-count={partition().virtual.length}
data-direct-count={partition().direct.length}
data-queued-count={partition().queued.length}
data-kept-count={partition().keep.length}
>
<Show when={scrollEl()}>
<Show when={scrollEl() && partition().virtual.length > 0}>
<Virtualizer
ref={setVirtualizer}
data={partition().virtual}
scrollRef={scrollEl()}
shift={session.messageMutation() === "prepend"}
cache={measurement()}
keepMounted={partition().keep}
overscan={2}
itemSize={260}
>
@@ -336,6 +361,9 @@ export const MessageList: Component<MessageListProps> = (props) => {
)}
</Virtualizer>
</Show>
<For each={partition().direct}>
{(row) => <TranscriptRowView row={row} onForkMessage={props.onForkMessage} />}
</For>
</div>
</Show>
<Show when={boundary()}>
@@ -105,7 +105,7 @@ const IndexingTab: Component = () => {
const [scope, setScope] = createSignal<IndexingScope>("global")
const globalCfg = createMemo<IndexingConfig>(() => globalConfig().indexing ?? {})
const projectCfg = createMemo<IndexingConfig>(() => projectConfig?.().indexing ?? {})
const projectCfg = createMemo<IndexingConfig>(() => projectConfig().indexing ?? {})
const raw = createMemo<IndexingConfig>(() => (scope() === "global" ? globalCfg() : projectCfg()))
const cfg = createMemo<IndexingConfig>(() => indexingConfig(scope(), globalCfg(), projectCfg()))
const enabled = createMemo(() => indexingEnabled(scope(), globalCfg(), projectCfg()))
@@ -128,7 +128,7 @@ const IndexingTab: Component = () => {
updateGlobalConfig(patch)
return
}
updateProjectConfig?.(patch)
updateProjectConfig(patch)
}
const vectorStore = () => cfg().vectorStore ?? DEFAULT_VECTOR_STORE
@@ -383,12 +383,12 @@ const IndexingTab: Component = () => {
<Show when={fields().length > 0 ? selectedProvider() : undefined} keyed>
{(group) => {
const fields = providerFields(group)
const label = allProviders.find((item) => item.value === group)?.label ?? group
const name = allProviders.find((item) => item.value === group)?.label ?? group
return (
<For each={fields}>
{(field, index) => (
<SettingsRow
title={`${label} ${field.label}`}
title={`${name} ${field.label}`}
description={description(language.t("settings.indexing.providerField.description"), [
[group, field.key],
])}
@@ -34,7 +34,7 @@ export interface SaveError {
interface ConfigContextValue {
config: Accessor<Config>
globalConfig: Accessor<Config>
projectConfig?: Accessor<Config>
projectConfig: Accessor<Config>
settings: Accessor<Record<string, unknown>>
features: Accessor<FeatureFlags>
loading: Accessor<boolean>
@@ -43,7 +43,7 @@ interface ConfigContextValue {
saveError: Accessor<SaveError | null>
updateConfig: (partial: Partial<Config>) => void
updateGlobalConfig: (partial: Partial<Config>) => void
updateProjectConfig?: (partial: Partial<Config>) => void
updateProjectConfig: (partial: Partial<Config>) => void
updateSetting: (key: string, value: unknown) => void
saveConfig: () => void
discardConfig: () => void
@@ -50,7 +50,7 @@ export interface TranscriptOptions {
export interface TranscriptPartition {
virtual: TranscriptRow[]
keep: number[]
direct: TranscriptRow[]
queued: TranscriptRow[]
}
@@ -179,15 +179,26 @@ export function transcriptRows(
})
}
export function partitionRows(rows: TranscriptRow[], limit = 2): TranscriptPartition {
export function partitionRows(rows: TranscriptRow[], direct: ReadonlySet<string> = new Set()): 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)
const visible = rows.filter((row) => !row.queued)
const turn = visible.at(-1)?.turn
// Only the latest visible turn can render directly.
if (!turn || !direct.has(turn)) return { virtual: visible, direct: [], queued }
let boundary = -1
for (let i = 0; i < visible.length; i += 1) {
const row = visible[i]!
if (row.turn === turn && row.type === "assistant") boundary = i
}
// The selected turn has no renderable assistant row.
if (boundary === -1) return { virtual: visible, direct: [], queued }
// Boundary starts the direct suffix, preserving rows after the streaming assistant.
return {
virtual: visible.slice(0, boundary),
direct: visible.slice(boundary),
queued,
}
return { virtual, keep, queued }
}
@@ -274,16 +274,30 @@ interface StoryProvidersProps {
sessionID?: string
/** When provided, injects a mock ConfigContext with this config instead of the real ConfigProvider. */
config?: Config
globalConfig?: Config
projectConfig?: Config
onConfigChange?: (config: Config) => void
onGlobalConfigChange?: (config: Config) => void
onProjectConfigChange?: (config: Config) => void
kiloAuth?: boolean
/** When true, renders children without the default 12px padding wrapper */
noPadding?: boolean
}
/** Wraps children with either a mock ConfigContext (when config prop is given) or the real ConfigProvider. */
const ConfigWrapper: ParentComponent<{ config?: Config; onConfigChange?: (config: Config) => void }> = (props) => {
const ConfigWrapper: ParentComponent<{
config?: Config
globalConfig?: Config
projectConfig?: Config
onConfigChange?: (config: Config) => void
onGlobalConfigChange?: (config: Config) => void
onProjectConfigChange?: (config: Config) => void
}> = (props) => {
if (props.config) {
const scoped = props.globalConfig !== undefined || props.projectConfig !== undefined
const [cfg, setCfg] = createSignal(props.config)
const [global, setGlobal] = createSignal(props.globalConfig ?? props.config)
const [project, setProject] = createSignal(props.projectConfig ?? props.config)
const [settings, setSettings] = createSignal<Record<string, unknown>>({})
const [dirty, setDirty] = createSignal(false)
const features = createMemo(() => {
@@ -298,7 +312,8 @@ const ConfigWrapper: ParentComponent<{ config?: Config; onConfigChange?: (config
const value = {
config: createMemo(() => cfg()),
globalConfig: createMemo(() => cfg()),
globalConfig: createMemo(() => (scoped ? global() : cfg())),
projectConfig: createMemo(() => (scoped ? project() : cfg())),
settings,
features,
loading: () => false,
@@ -314,11 +329,25 @@ const ConfigWrapper: ParentComponent<{ config?: Config; onConfigChange?: (config
setDirty(true)
},
updateGlobalConfig: (partial: Partial<Config>) => {
setCfg((prev) => {
const update = (prev: Config) => {
const next = merge(prev as Record<string, unknown>, partial as Record<string, unknown>) as Config
props.onGlobalConfigChange?.(next)
props.onConfigChange?.(next)
return next
})
}
if (scoped) setGlobal(update)
if (!scoped) setCfg(update)
setDirty(true)
},
updateProjectConfig: (partial: Partial<Config>) => {
const update = (prev: Config) => {
const next = merge(prev as Record<string, unknown>, partial as Record<string, unknown>) as Config
props.onProjectConfigChange?.(next)
props.onConfigChange?.(next)
return next
}
if (scoped) setProject(update)
if (!scoped) setCfg(update)
setDirty(true)
},
updateSetting: (key: string, value: unknown) => {
@@ -349,7 +378,14 @@ export const StoryProviders: ParentComponent<StoryProvidersProps> = (props) => {
<VSCodeProvider>
<ServerProvider>
<FeedbackProvider>
<ConfigWrapper config={props.config} onConfigChange={props.onConfigChange}>
<ConfigWrapper
config={props.config}
globalConfig={props.globalConfig}
projectConfig={props.projectConfig}
onConfigChange={props.onConfigChange}
onGlobalConfigChange={props.onGlobalConfigChange}
onProjectConfigChange={props.onProjectConfigChange}
>
<DisplayProvider>
<MockProviderProvider kiloAuth={props.kiloAuth}>
<DialogProvider>
@@ -17,6 +17,7 @@ import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
import { createSignal, type JSX } from "solid-js"
import type { WorktreeFileDiff, WorktreeState, WorktreeGitStats, PRStatus } from "../types/messages"
import type { ReviewComment } from "../../diff-viewer/review-comments"
import "../../agent-manager/agent-manager.css"
import "../../agent-manager/agent-manager-review.css"
@@ -283,6 +284,7 @@ export const FullScreenDiffAgentEditScroll: Story = {
const [diffs, setDiffs] = createSignal([edited("before"), tail])
const [version, setVersion] = createSignal("before")
const [key, setKey] = createSignal("agent-edit-scroll")
const [comments, setComments] = createSignal<ReviewComment[]>([])
const update = () => {
setDiffs([edited("after"), tail])
setVersion("after")
@@ -311,8 +313,8 @@ export const FullScreenDiffAgentEditScroll: Story = {
sessionKey={key()}
diffStyle="unified"
onDiffStyleChange={() => {}}
comments={[]}
onCommentsChange={() => {}}
comments={comments()}
onCommentsChange={setComments}
onClose={() => {}}
/>
</div>
@@ -443,6 +443,49 @@ export const IndexingProviderBlurRace: Story = {
},
}
export const IndexingScopeSwitch: Story = {
name: "IndexingTab - global and local scopes",
render: () => {
const [global, setGlobal] = createSignal<Record<string, unknown>>({})
const [project, setProject] = createSignal<Record<string, unknown>>({})
const globalConfig: Config = {
indexing: {
enabled: true,
provider: "openai",
model: "text-embedding-3-large",
dimension: 3072,
vectorStore: "qdrant",
openai: { apiKey: "global-secret" },
qdrant: { url: "http://global:6333", apiKey: "global-qdrant" },
searchMinScore: 0.4,
},
}
const projectConfig: Config = {
indexing: {
model: null,
qdrant: { apiKey: "project-qdrant" },
},
}
return (
<>
<StoryProviders
config={globalConfig}
globalConfig={globalConfig}
projectConfig={projectConfig}
onGlobalConfigChange={(next) => setGlobal((next.indexing ?? {}) as Record<string, unknown>)}
onProjectConfigChange={(next) => setProject((next.indexing ?? {}) as Record<string, unknown>)}
>
<div style={{ width: "420px", "max-height": "700px", overflow: "auto" }}>
<IndexingTab />
</div>
</StoryProviders>
<pre data-testid="indexing-global-save">{JSON.stringify(global(), null, 2)}</pre>
<pre data-testid="indexing-project-save">{JSON.stringify(project(), null, 2)}</pre>
</>
)
},
}
export const IndexingKiloModelPreset: Story = {
name: "IndexingTab - Kilo stale custom model fallback",
render: () => {
@@ -5,6 +5,11 @@ export interface TerminalFont {
export type WorktreeErrorCode = "git_not_found" | "not_git_repo" | "lfs_missing"
export interface TerminalFont {
fontFamily: string
fontSize: number
}
// Agent Manager worktree state types (mirrored from WorktreeStateManager)
export interface WorktreeState {
id: string
@@ -1,9 +1,17 @@
import { Show, splitProps, type ComponentProps, type JSX } from "solid-js"
import { Card, CardContent, CardHeader, CardTitle } from "./card"
import { Icon, type IconProps } from "./icon"
import { Tag, CountTag } from "./tag"
import { Tag } from "./tag"
export { CountTag }
export function ConfigTag(props: ComponentProps<typeof Tag>) {
return <Tag {...props} data-slot="config-tag" />
}
export function ConfigCountTag(props: ComponentProps<"span">) {
return <ConfigTag {...props} tone="neutral" />
}
export { ConfigCountTag as CountTag }
type Tone = "neutral" | "success" | "warning" | "critical" | "info" | "brand"
type Source = "default" | "global" | "project" | "system" | "inherited" | "local override" | string | undefined
@@ -39,10 +47,10 @@ export function StatusTag(props: {
return { tone: "neutral" as const, label: props.status || "Unknown" }
}
return (
<Tag tone={meta().tone} class="kw-status-tag">
<ConfigTag tone={meta().tone} class="kw-status-tag">
<StatusDot tone={meta().tone === "critical" ? "critical" : meta().tone === "success" ? "success" : "neutral"} />
{meta().label}
</Tag>
</ConfigTag>
)
}
@@ -50,9 +58,9 @@ export function SourceBadge(props: { source?: Source; inherited?: boolean; overr
const source = () => kind(props)
const label = () => source().toUpperCase()
return (
<Tag class="mono" tone={source() === "project" ? "info" : "neutral"}>
<ConfigTag class="mono" tone={source() === "project" ? "info" : "neutral"}>
{label()}
</Tag>
</ConfigTag>
)
}
+52
View File
@@ -1,5 +1,57 @@
# @kilocode/cli
## 7.3.44
### Minor Changes
- [#11082](https://github.com/Kilo-Org/kilocode/pull/11082) [`a16e82a`](https://github.com/Kilo-Org/kilocode/commit/a16e82a77abf883c2c07c11464d50e08a518acd7) - Use embedded LanceDB as the default semantic search vector store so indexing works without a separate Qdrant server. Existing Qdrant users and Intel Mac users can select `qdrant` with `indexing.vectorStore`.
### Patch Changes
- [#10922](https://github.com/Kilo-Org/kilocode/pull/10922) [`bc3af9a`](https://github.com/Kilo-Org/kilocode/commit/bc3af9a145c8bd5f90fa0c9b22a48cceb095f8b4) - Prevent unnecessary repeat auto-compactions when providers report inconsistent token totals.
- [#11160](https://github.com/Kilo-Org/kilocode/pull/11160) [`78d83c0`](https://github.com/Kilo-Org/kilocode/commit/78d83c0651d5343c0f9f877265dc5136cd7761f0) - Preserve the calling model's reasoning effort when task subagents inherit that model.
- [#10478](https://github.com/Kilo-Org/kilocode/pull/10478) [`5bc8df8`](https://github.com/Kilo-Org/kilocode/commit/5bc8df843a2492d2eee01963b5a2c1a55beab56c) - Allow hosted runtimes to cap shell command duration and explain environment-enforced timeouts.
- [#11085](https://github.com/Kilo-Org/kilocode/pull/11085) [`2a6596b`](https://github.com/Kilo-Org/kilocode/commit/2a6596b0c578b20ea803fa69a8427fc3e4c2e823) - Indicate when no models are available in model-not-found errors.
- [#11072](https://github.com/Kilo-Org/kilocode/pull/11072) [`6920f37`](https://github.com/Kilo-Org/kilocode/commit/6920f37b77f820d9f8542d352cf60e061670933b) - Speed up the first Agent Manager prompt in new worktrees by seeding snapshots from the checkout's Git index.
- [#11075](https://github.com/Kilo-Org/kilocode/pull/11075) [`e17ce0c`](https://github.com/Kilo-Org/kilocode/commit/e17ce0c9ecaf4cc4cad3e0fd99b28bef561705fc) - Speed up large session forks by retaining final task outcomes instead of duplicating resumable subagent histories, and load completed task details only when expanded.
- [#11143](https://github.com/Kilo-Org/kilocode/pull/11143) [`12144cf`](https://github.com/Kilo-Org/kilocode/commit/12144cf8275200a7dd8e29cf478c39504da59b04) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Warn when `kilo console` or `kilo daemon` is invoked with an explicit `--port` outside the discovery range (40974116).
- [#11006](https://github.com/Kilo-Org/kilocode/pull/11006) [`69a0b38`](https://github.com/Kilo-Org/kilocode/commit/69a0b384e6c61d190241087f88f2be4312e7517e) - Refresh connected provider model lists when the models catalog updates.
- [#11081](https://github.com/Kilo-Org/kilocode/pull/11081) [`9c279a1`](https://github.com/Kilo-Org/kilocode/commit/9c279a16b4a14fc117f34d7aa19e771149031931) - Show model free and prompt-training indicators only when their explicit catalog metadata is enabled.
- [#11101](https://github.com/Kilo-Org/kilocode/pull/11101) [`294c532`](https://github.com/Kilo-Org/kilocode/commit/294c532f6a355b78ed86d2188891883b07e90cc8) - Prevent task subagents from asking questions that users cannot answer from the parent session.
- [#11102](https://github.com/Kilo-Org/kilocode/pull/11102) [`8a72708`](https://github.com/Kilo-Org/kilocode/commit/8a727084ae0327fbf195149660c19d2215fb558a) - Prevent duplicate CLI attention alerts and route Kilo prompts through the configurable notification system.
- [#10866](https://github.com/Kilo-Org/kilocode/pull/10866) [`d5112ed`](https://github.com/Kilo-Org/kilocode/commit/d5112edf90d33333d1064c7ab885cf0a4d92d892) - Stabilize code indexing workers, retry Kilo model catalog downloads, reduce progress log noise, and show indexing failures as TUI notifications instead of writing over the terminal interface.
- [#11147](https://github.com/Kilo-Org/kilocode/pull/11147) [`9a187d5`](https://github.com/Kilo-Org/kilocode/commit/9a187d5aad5c3bf90a6dac589a0b26069057c3b0) - Configure the project context sidebar width and default diff layout from Global Settings.
- [#11091](https://github.com/Kilo-Org/kilocode/pull/11091) [`57bef8a`](https://github.com/Kilo-Org/kilocode/commit/57bef8ae68793c9b627ba0400b596bf932311e17) - Prevent streamed tool calls from executing twice and leaving answered questions disabled in VS Code.
- [#11139](https://github.com/Kilo-Org/kilocode/pull/11139) [`7226635`](https://github.com/Kilo-Org/kilocode/commit/72266359d497f407f951c1b468a50d3093ec9dc3) - Restore Kilo branding, fork-specific CLI commands, and CLI lifecycle initialization after upstream merges.
- [#11031](https://github.com/Kilo-Org/kilocode/pull/11031) [`bbfd59b`](https://github.com/Kilo-Org/kilocode/commit/bbfd59b85c383277fd8db77fcfd0ec56ea1a25d8) - Remove the unsupported code search tool.
- [#11117](https://github.com/Kilo-Org/kilocode/pull/11117) [`b75af0d`](https://github.com/Kilo-Org/kilocode/commit/b75af0de8865234a745f71eac03bf2bdea2271b4) - Update the Vercel AI SDK providers for Cerebras, xAI, and OpenAI-compatible endpoints.
- [#10866](https://github.com/Kilo-Org/kilocode/pull/10866) [`d5112ed`](https://github.com/Kilo-Org/kilocode/commit/d5112edf90d33333d1064c7ab885cf0a4d92d892) - Support configuring code indexing separately for global and project settings in Kilo Console, the CLI TUI, and VS Code.
- [#11031](https://github.com/Kilo-Org/kilocode/pull/11031) [`28a26b1`](https://github.com/Kilo-Org/kilocode/commit/28a26b11c133686a4656af8be21af619c919301a) - Restore streamed responses in the CLI TUI and move code indexing status into the session sidebar.
- Updated dependencies [[`a16e82a`](https://github.com/Kilo-Org/kilocode/commit/a16e82a77abf883c2c07c11464d50e08a518acd7), [`9c279a1`](https://github.com/Kilo-Org/kilocode/commit/9c279a16b4a14fc117f34d7aa19e771149031931), [`57bef8a`](https://github.com/Kilo-Org/kilocode/commit/57bef8ae68793c9b627ba0400b596bf932311e17), [`b75af0d`](https://github.com/Kilo-Org/kilocode/commit/b75af0de8865234a745f71eac03bf2bdea2271b4)]:
- @kilocode/kilo-indexing@7.4.0
- @kilocode/kilo-gateway@7.3.43
- @kilocode/kilo-telemetry@7.3.43
- @opencode-ai/ui@7.3.43
## 7.3.42
### Patch Changes

Some files were not shown because too many files have changed in this diff Show More