mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 04:46:43 +08:00
Merge origin/main into OpenCode v1.15.4
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Prevent unnecessary repeat auto-compactions when providers report inconsistent token totals.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Preserve existing Agent Manager worktrees and use deterministic suffixes when explicit branch names collide.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Allow hosted runtimes to cap shell command duration and explain environment-enforced timeouts.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Indicate when no models are available in model-not-found errors.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Dismiss stale permission prompts when another view has already answered them.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep large chat transcripts responsive by mounting only viewport-visible conversation rows.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Refresh connected provider model lists when the models catalog updates.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep HTTP status codes beside tool error titles when error details wrap.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Prevent task subagents from asking questions that users cannot answer from the parent session.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show the first Agent Manager Local prompt and progress indicator immediately while its session is being created.
|
||||
@@ -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.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Remove the unsupported code search tool.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Restore streamed responses in the CLI TUI and move code indexing status into the session sidebar.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Start MCP servers while Agent Manager worktree sessions initialize to reduce the delay before the first response.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fc50e7ce14bc197134d5fdf38ac92087940906f7b627e2507c082a5be0527d31
|
||||
size 17378
|
||||
oid sha256:129ea4c06998d8418bdb8edc8e61e094849835edb3f40cf6e1ddc34568168e61
|
||||
size 52396
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3838b7760984c6c822078de1bdb74067839775d29ab8a0435626d0afff8cbb30
|
||||
size 24826
|
||||
oid sha256:1bd31cd4e01c2e2a5ed579bce1b542368e7a11fc878d375a93fb238ba4f82469
|
||||
size 53288
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f5e9dd9a8b232e960fbc1925a2447a7634b2db241f6fe9b9ba8527665a5ca4c4
|
||||
size 14248
|
||||
oid sha256:47b7b7bfb4a6b5631d8fdb808c1b3a9eb631b6f96911ddf15880f0c2fee4f7e0
|
||||
size 38558
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:810d109acde3456d250df74029ac031929cb4b2fffe13a4c765cda9d2a62f175
|
||||
size 46116
|
||||
oid sha256:add00c319d54f53c7c9859428b18182fa91da1b17e4c03b1ee8015ed08f73d19
|
||||
size 48653
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d7ad739b2099b5b35abf3989bca8e8f63c2926906decfddf7530d6b97fb0e6b1
|
||||
size 50711
|
||||
oid sha256:d9fbce155629b97a30ee9f95f226ef883b6febf8002a657516ff439e3fae0f6c
|
||||
size 51829
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a67058f86869ec7457058feec03f880b7a3ac31c00b87ae6db5a55d977295856
|
||||
size 57421
|
||||
oid sha256:3330276aded477430e615ababb2caa7eeb2fa7f7406974b9b829e6092d5a619a
|
||||
size 51792
|
||||
|
||||
+3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (4097–4116).
|
||||
|
||||
- [#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
Reference in New Issue
Block a user