Merge origin/main into feat-stt-model-discovery

This commit is contained in:
marius-kilocode
2026-08-04 10:12:33 +02:00
204 changed files with 7189 additions and 837 deletions
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Fix Agent Manager mode shortcuts in the New Worktree dialog so the selected mode and its matching model stay in sync.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Show aggregate added and removed line counts for multi-file patch tool calls.
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Prevent configured compaction thresholds from interrupting active tool sequences.
@@ -1,5 +0,0 @@
---
"@kilocode/cli": minor
---
Show why a tool call was auto-approved or denied in the TUI, and record the denial reason on the tool call metadata (visible in `kilo export`) alongside the existing auto-approval reason.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Keep config-defined subagents routable when an installed primary agent uses the same name.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Speed up local session recall searches across large conversation histories.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"@kilocode/kilo-telemetry": patch
---
Skip API and telemetry lifecycle work for informational CLI commands and avoid profile requests when telemetry is disabled.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Show and hide Agent Manager worktree hover cards instantly.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Keep the JetBrains prompt send/stop button in sync when attachments are added or removed while a session is busy.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Fix JetBrains diff views to show compact workspace-relative file paths and keep added-file content visible in large branch diffs.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Fix JetBrains chat transcripts rendering cropped when opening existing sessions.
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Keep the prompt controls at a consistent height when the model selector shows the prompt-training indicator.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Keep Kilo's persona out of generated conversation titles and Agent Manager branch names.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Start a fresh shell in the same Agent Manager terminal tab when the user types after the terminal ends.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Stop treating `` !`cmd` `` shown as an inline code example in skill documentation as a live command, so it no longer triggers a shell permission prompt.
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Fix skill folder path and URL rows clipping and pushing the remove (×) button off-screen in narrow Skills settings panels. Long paths and URLs now truncate within their row, and hovering a truncated value shows the full path or URL in a tooltip.
+1
View File
@@ -21,6 +21,7 @@ Hard rules:
- Never remove or rename pages. Never document unreleased behavior. Never copy internal PR discussion into the docs; write user-facing documentation.
- Do not run git commands and do not commit anything; automation handles git.
- Keep the change small and precise. Do not rewrite sections that are already accurate.
- Never create, modify, or delete packages/kilo-docs/LEARNINGS.md. Automation owns that file.
When finished, write the summary JSON file named in the batch specifics below: a JSON array with exactly one entry per batch PR, consumed by automation (this file is never committed). Use `action` values like `updated <path>`, `created <path>`, or `skipped`. Example:
+19 -13
View File
@@ -19,6 +19,7 @@ import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs"
import { readLearningsBlock } from "./learn.mjs"
const BATCH_SIZE = 5
const ATTEMPTS = 3
@@ -26,7 +27,7 @@ const OUT_DIR = "docs-sync-out"
export const SUMMARY_FILE = ".docs-sync-summary.json"
const HERE = path.dirname(fileURLToPath(import.meta.url))
const basePrompt = fs.readFileSync(path.join(HERE, "edit-prompt.md"), "utf8")
const basePrompt = fs.readFileSync(path.join(HERE, "edit-prompt.md"), "utf8") + readLearningsBlock("edit")
const model = process.env.EDIT_MODEL
if (!model) throw new Error("EDIT_MODEL is required")
@@ -58,14 +59,7 @@ function editBatch(batch, index, budgetDeadline) {
const triageFile = `${OUT_DIR}/edit-batch-triage-${index}.json`
const summaryFile = `${OUT_DIR}/edit-summary-${index}.json`
fs.writeFileSync(batchFile, JSON.stringify(batch, null, 2))
fs.writeFileSync(
triageFile,
JSON.stringify(
batch.map((d) => priority.get(d.url)).filter(Boolean),
null,
2,
),
)
fs.writeFileSync(triageFile, JSON.stringify(batch.map((d) => priority.get(d.url)).filter(Boolean), null, 2))
const prompt = `${basePrompt}
@@ -88,7 +82,21 @@ Batch specifics for this run: the PRs to handle are in the attached ${batchFile}
// permission.bash map via KILO_CONFIG_CONTENT should replace --auto once the
// required shell patterns are stable (see PR #12605 review thread).
const result = runKilo({
args: ["run", "--auto", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile],
args: [
"run",
"--auto",
prompt,
"-m",
model,
"--variant",
"high",
"--dir",
process.cwd(),
"-f",
batchFile,
"-f",
triageFile,
],
timeoutMs: Math.min(BATCH_TIMEOUT_MS, left),
streamStdout: true,
label: `edit batch ${index} attempt ${attempt}`,
@@ -120,9 +128,7 @@ Batch specifics for this run: the PRs to handle are in the attached ${batchFile}
console.warn(`batch ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`)
sleepSync(wait)
} else if (wait > 0) {
console.warn(
`batch ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`,
)
console.warn(`batch ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`)
}
}
}
+937
View File
@@ -0,0 +1,937 @@
// kilocode_change - new file
/**
* Learns general rules of thumb from maintainer corrections to the docs-sync
* bot's rolling pull request, and writes them into packages/kilo-docs/LEARNINGS.md
* so the triage and edit passes follow them on every subsequent run.
*
* Two modes:
* node learn.mjs — extraction: fetch corrections, call the model, validate
* node learn.mjs --apply — apply: write learnings.json into LEARNINGS.md
*
* Env: TRIAGE_MODEL (provider/model, reused), GH_TOKEN (or GITHUB_TOKEN).
* Budget: LEARNINGS_BUDGET_MINUTES (default 10).
* Test hook: DOCS_SYNC_FIXTURE. When set to a fixture JSON path, skips every
* GitHub API call and writes any marker PATCH to <fixture>.patched instead of
* the network. The workflow never sets it — only selftests do.
*
* Test hook: DOCS_SYNC_BACKOFF_MS replaces wait between extraction retries, same as
* lib.mjs:138 documents for triage.mjs and edit.mjs.
*
* Patch suppression: DRY_RUN=true or LEARNINGS_NO_PATCH=1 suppress the marker PATCH.
*/
import { execFileSync } from "node:child_process"
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
const LEARNINGS_FILE = "packages/kilo-docs/LEARNINGS.md"
const OUT_DIR = "docs-sync-out"
const ATTEMPTS = 2
const LEARNINGS_BUDGET_MINUTES = Number(process.env.LEARNINGS_BUDGET_MINUTES) || 10
const EXTRACTION_TIMEOUT_MS = LEARNINGS_BUDGET_MINUTES * 60 * 1000
const COMMENT_BODY_CAP = 5000
const HERE = path.dirname(fileURLToPath(import.meta.url))
const LINE_RE =
/^- (?<rule>.+?) <!-- id=(?<id>[a-z0-9][a-z0-9-]{2,48}) scope=(?<scope>triage|edit|both) source=(?<source>commit:[0-9a-f]{7,40}|comment:\d+) date=(?<date>\d{4}-\d{2}-\d{2}) -->$/
const LEARNED_THROUGH_RE = /<!--\s*docs-sync:\s*learned-through\s+commit=(\S+)\s+comment=(\S+)\s*-->/
// Agent-generated strings land in the PR body next to machine-read markers.
// Identical to clean() at upsert-pr.mjs:37.
function clean(value) {
return String(value ?? "")
.replaceAll("<!--", "")
.replaceAll("-->", "")
}
function warn(msg) {
console.warn(`::warning::${msg}`)
}
function log(msg) {
console.log(msg)
}
// --- pure exports ---
/**
* Parse the LEARNINGS.md file text into an entry array.
* Drops lines inside the markers that do not match the format.
*/
export function parseLearnings(text) {
const m = String(text ?? "").match(
/<!--\s*docs-sync:learnings:start\s*-->([\s\S]*?)<!--\s*docs-sync:learnings:end\s*-->/,
)
if (!m) return []
const entries = []
for (const line of m[1].split("\n")) {
const trimmed = line.trim()
if (!trimmed) continue
const parsed = trimmed.match(LINE_RE)
if (!parsed) {
warn(`LEARNINGS.md: dropping unparseable line: ${trimmed.slice(0, 80)}`)
continue
}
entries.push({
id: parsed.groups.id,
rule: clean(parsed.groups.rule).replaceAll("\n", " "),
scope: parsed.groups.scope,
source: parsed.groups.source,
date: parsed.groups.date,
})
}
return entries
}
/** Render the full LEARNINGS.md file text from an entry array. Deterministic order. */
export function renderLearnings(entries) {
const list = [...entries].sort((a, b) => {
if (a.date !== b.date) return a.date < b.date ? -1 : 1
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
})
const lines = list.map(
(e) =>
`- ${clean(e.rule).replaceAll("\n", " ")} <!-- id=${e.id} scope=${e.scope} source=${e.source} date=${e.date} -->`,
)
return [
"# docs-sync learnings",
"",
"Rules the docs-sync bot learned from maintainer corrections to its rolling pull request.",
"The bot reads this file at the start of every run and follows every rule below.",
"",
"To unlearn a rule, delete its line and commit. The next run reads this file from the",
"branch, so the rule is gone from its input, and the deletion itself is a correction the",
"extraction step is instructed not to undo.",
"",
"<!-- docs-sync:learnings:start -->",
...lines,
"<!-- docs-sync:learnings:end -->",
"",
].join("\n")
}
/** Parse the learned-through watermark from a PR body. Returns { commit, comment } with nulls for absent/none. */
export function parseLearnedThrough(body) {
const m = String(body ?? "").match(LEARNED_THROUGH_RE)
if (!m) return { commit: null, comment: null }
const commit = m[1] === "none" ? null : m[1]
const comment = m[2] === "none" ? null : m[2]
return { commit, comment }
}
/** Render a single learned-through marker line. */
export function renderLearnedThrough({ commit, comment }) {
const c = commit ?? "none"
const m = comment ?? "none"
return `<!-- docs-sync: learned-through commit=${c} comment=${m} -->`
}
/** Replace or append the learned-through marker in a PR body. Pure — no API call. */
export function patchMarkerIntoBody(body, marker) {
const b = String(body ?? "")
if (LEARNED_THROUGH_RE.test(b)) {
return b.replace(LEARNED_THROUGH_RE, marker)
}
return b + "\n" + marker + "\n"
}
/**
* Extract { add, remove } from raw model stdout.
* Mirrors parseTriageEntries at extract-json.mjs:14-38, adapted for an object.
* `kilo run` prints the assistant message twice; the last copy wins.
* Walk "{" positions from right to left; return the first that parses to an object
* holding an array `add` or an array `remove`.
*/
export function parseDelta(raw) {
const r = String(raw ?? "")
const end = r.lastIndexOf("}")
if (end < 0) return null
const starts = []
for (let i = 0; i <= end; i++) {
if (r[i] === "{") starts.push(i)
}
for (let s = starts.length - 1; s >= 0; s--) {
let parsed
try {
parsed = JSON.parse(r.slice(starts[s], end + 1))
} catch {
continue
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue
if (Array.isArray(parsed.add) || Array.isArray(parsed.remove)) {
return {
add: Array.isArray(parsed.add) ? parsed.add : [],
remove: Array.isArray(parsed.remove) ? parsed.remove : [],
}
}
}
return null
}
/** Cap a review comment body so a single long comment cannot dominate extraction input. */
function capBody(body) {
const b = String(body ?? "")
if (b.length <= COMMENT_BODY_CAP) return b
return b.slice(0, COMMENT_BODY_CAP) + " [truncated]"
}
/** Normalize rule text for duplicate comparison: lowercase, strip punctuation and whitespace runs. */
function norm(text) {
return String(text ?? "")
.toLowerCase()
.replace(/[^\w\s]/g, "")
.replace(/\s+/g, " ")
.trim()
}
/**
* Validate a delta against the existing entries and constraints.
* Returns { add, remove, rejected }. Never throws.
*/
export function validateDelta(delta, { existing, candidateSources, deletedInWindow }) {
const add = Array.isArray(delta.add) ? delta.add : []
const remove = Array.isArray(delta.remove) ? delta.remove : []
const ex = Array.isArray(existing) ? existing : []
const candidates = Array.isArray(candidateSources) ? candidateSources : []
const deleted = Array.isArray(deletedInWindow) ? deletedInWindow : []
const rejected = []
const valid = []
const toRemove = []
const existingIds = new Set(ex.map((e) => e.id))
// One model response can repeat an id or a rule. Both would render two lines for
// one id, so an accepted addition also blocks the next one.
const acceptedIds = new Set()
const acceptedRules = new Set()
// Process remove first so toRemove is populated before the add loop checks
// for id collisions with entries listed in remove (criterion 8).
for (const id of remove) {
if (!existingIds.has(id)) {
rejected.push({ entry: { id, remove: id }, reason: `remove target ${id} not in existing entries` })
} else {
toRemove.push(id)
}
}
for (const a of add) {
let reason = null
// Reject null, undefined, and non-object entries before any property access.
if (a === null || a === undefined || typeof a !== "object" || Array.isArray(a)) {
rejected.push({ entry: a, reason: "add entry is null, undefined, or not a plain object" })
continue
}
if (!a.rule || String(a.rule).length < 10 || String(a.rule).length > 300) {
reason = "rule text absent, shorter than 10 characters, or longer than 300"
} else if (!["triage", "edit", "both"].includes(a.scope)) {
reason = `invalid scope: ${a.scope}`
} else if (!/^commit:[0-9a-f]{7,40}$/.test(a.source) && !/^comment:\d+$/.test(a.source)) {
reason = `invalid source format: ${a.source}`
} else if (!candidates.includes(a.source)) {
reason = `source ${a.source} not in candidate sources`
} else if (!/^[a-z0-9][a-z0-9-]{2,48}$/.test(a.id)) {
reason = `invalid id format: ${a.id}`
} else if (existingIds.has(a.id) && !toRemove.includes(a.id)) {
reason = `id ${a.id} collides with an existing entry not listed in remove`
} else if (acceptedIds.has(a.id)) {
reason = `id ${a.id} collides with an earlier addition in this delta`
} else if (!/^\d{4}-\d{2}-\d{2}$/.test(a.date)) {
reason = `invalid date format: ${a.date}`
} else {
// Check that date is a real calendar date.
const d = new Date(a.date + "T00:00:00Z")
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== a.date) {
reason = `invalid calendar date: ${a.date}`
}
}
if (reason) {
rejected.push({ entry: a, reason })
continue
}
const n = norm(a.rule)
// Duplicate of an existing entry not being removed.
if (ex.some((e) => norm(e.rule) === n && !remove.includes(e.id))) {
reason = `rule text is a duplicate of an existing entry not listed in remove`
rejected.push({ entry: a, reason })
continue
}
// Duplicate of an earlier addition in the same response.
if (acceptedRules.has(n)) {
reason = `rule text is a duplicate of an earlier addition in this delta`
rejected.push({ entry: a, reason })
continue
}
// Names a PR, URL, person, or docs page. The URL clause keeps docs-check-links.yml green.
if (String(a.rule).match(/#\d{2,}|https?:\/\/|@[A-Za-z0-9-]|packages\/kilo-docs|\.md\b/)) {
reason = "rule names a PR, URL, person, or docs page"
rejected.push({ entry: a, reason })
continue
}
// Duplicate of a rule deleted in this window.
if (deleted.some((d) => norm(d) === n)) {
reason = "rule text matches a line a maintainer deleted in this window"
rejected.push({ entry: a, reason })
continue
}
acceptedIds.add(a.id)
acceptedRules.add(n)
valid.push({
id: a.id,
rule: clean(String(a.rule)).replaceAll("\n", " "),
scope: a.scope,
source: a.source,
date: a.date,
})
}
return { add: valid, remove: toRemove, rejected }
}
/** Apply a validated delta to an existing entry array. Drops removed ids, appends adds. */
export function applyDelta(existing, delta) {
const ex = Array.isArray(existing) ? existing : []
const remove = new Set(Array.isArray(delta.remove) ? delta.remove : [])
const add = Array.isArray(delta.add) ? delta.add : []
return [...ex.filter((e) => !remove.has(e.id)), ...add]
}
/** Trust a review comment whose author_association is OWNER, MEMBER, or COLLABORATOR and is not a bot. */
export function isTrustedComment(comment) {
if (!comment) return false
const login = String(comment.user?.login ?? "")
if (login.endsWith("[bot]")) return false
return ["OWNER", "MEMBER", "COLLABORATOR"].includes(comment.author_association)
}
/** Render the prompt block for a given scope. Returns "" when no entry matches. */
export function promptBlock(entries, scope) {
const matches = (Array.isArray(entries) ? entries : []).filter((e) => e.scope === scope || e.scope === "both")
if (matches.length === 0) return ""
return [
"## Learnings from maintainer corrections",
"",
"Follow every rule below. Each was extracted from a correction a maintainer made to an",
"earlier run of this bot. A rule here outranks a general instruction above when they conflict.",
"",
...matches.map((e) => `- ${e.rule}`),
].join("\n")
}
/** Read a prompt block artifact from docs-sync-out. Returns the content or "" when absent. */
export function readLearningsBlock(scope) {
const file = `${OUT_DIR}/learnings-${scope}.md`
try {
return fs.readFileSync(file, "utf8")
} catch {
return ""
}
}
// --- helpers for main ---
function git(args) {
return execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] })
.toString()
.trim()
}
// --- main ---
async function main() {
// Step 0: ensure docs-sync-out exists. collect.mjs:139 is the only other unconditional
// mkdirSync of this directory, and it runs after the learn step. Without this line the
// empty-candidate path throws ENOENT on its first write, continue-on-error swallows it,
// and the feature silently never works.
fs.mkdirSync(OUT_DIR, { recursive: true })
if (process.argv.includes("--apply")) {
await apply()
return
}
await extract()
}
// --- apply mode ---
async function apply() {
const learningsPath = `${OUT_DIR}/learnings.json`
if (!fs.existsSync(learningsPath)) {
log("learnings.json absent — extraction was skipped or failed; nothing to apply")
return
}
const entries = JSON.parse(fs.readFileSync(learningsPath, "utf8"))
const file = renderLearnings(entries)
fs.writeFileSync(LEARNINGS_FILE, file)
log(`wrote ${LEARNINGS_FILE} with ${entries.length} entries`)
}
// --- extraction mode ---
async function extract() {
// Step 0: seed the prompt artifacts from the checked-out file before any fallible
// work. Every later step can throw, the workflow step is continue-on-error, and
// triage and edit read only these two files. Without the seed one failed API call
// silently drops every learned rule for the whole run. Later steps replace them
// with the rolling-branch copy and then with the validated delta.
writePromptArtifacts(parseLearnings(readFileOrEmpty(LEARNINGS_FILE)))
// Load fixture when DOCS_SYNC_FIXTURE is set.
const fixturePath = process.env.DOCS_SYNC_FIXTURE
let fixture = null
let patchFile = null
if (fixturePath) {
fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8"))
patchFile = fixturePath + ".patched"
}
const { api, repo, searchIssues, appendOutput, appendSummary, backoffMsForAttempt, runKilo, sleepSync } =
await import("./lib.mjs")
let prData
let prBody = ""
let prNumber = ""
let branch = ""
if (fixture) {
// Fixture mode: skip all API calls.
prData = fixture.pr
prBody = prData.body ?? ""
prNumber = String(prData.number ?? 1)
branch = prData.head?.ref ?? "docs/auto-sync"
} else {
// Step 1: resolve the rolling PR. Use prepare-branch.mjs's selection rule so both
// target the same branch. searchIssues takes prs[0] with no author filter (like
// prepare-branch.mjs:69). But trust the body marker only when authored by
// github-actions[bot] (like watermark.mjs:35). The two rules differ on purpose:
// the branch must match what prepare-branch.mjs will check out, but a body is
// editable so its marker needs the author filter.
const r = repo()
const prs = await searchIssues(`repo:${r} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 })
if (prs.length === 0) {
log("no open rolling pull request — nothing to learn from")
// Read existing learnings from main for empty-state artifacts.
let existing = []
try {
const existingText = git(["show", `origin/main:${LEARNINGS_FILE}`])
existing = parseLearnings(existingText)
} catch {
existing = []
}
log(`no-PR existing entries from main: ${existing.length}`)
writeEmptyStateArtifacts(existing)
appendOutput("count", String(existing.length))
appendSummary("### docs-sync learnings\n\nNo open auto-docs pull request; extraction skipped.")
return
}
prData = await api(`/repos/${r}/pulls/${prs[0].number}`)
prBody = prData.body ?? ""
prNumber = String(prData.number)
branch = prData.head?.ref ?? "docs/auto-sync"
}
// Step 2: read existing entries.
let existing = []
let existingText = ""
if (fixture) {
existingText = readFileOrEmpty(LEARNINGS_FILE)
existing = parseLearnings(existingText)
} else {
try {
existingText = git(["show", `origin/${branch}:${LEARNINGS_FILE}`])
} catch {
// branch copy absent — fall back to main, then empty.
// Required for the first live run: the rolling branch predates the seeded file.
try {
existingText = git(["show", `origin/main:${LEARNINGS_FILE}`])
} catch {
existingText = ""
}
}
existing = parseLearnings(existingText)
}
log(`existing entries: ${existing.length}`)
// Replace the seed with the rolling-branch copy. Every step below can throw, and
// these two files are all triage and edit read.
writePromptArtifacts(existing)
// Step 3: parse marker. Trust only when authored by github-actions[bot] (like watermark.mjs:35).
let commitWm = null
let commentWm = null
const trusted = prData.user?.login === "github-actions[bot]"
if (trusted) {
;({ commit: commitWm, comment: commentWm } = parseLearnedThrough(prBody))
} else {
log("PR author is not github-actions[bot]; ignoring body marker")
}
log(`watermark: commit=${commitWm ?? "none"} comment=${commentWm ?? "none"}`)
// Step 4: fetch and tip SHA.
let tipSha
if (fixture) {
tipSha = git(["rev-parse", "HEAD"])
} else {
git(["fetch", "origin", "main", branch])
tipSha = git(["rev-parse", `origin/${branch}`])
}
// Step 5: candidate commits.
let rangeArgs = [`origin/main..origin/${branch}`]
if (fixture) {
// In fixture mode, work from the local repo state.
try {
git(["rev-parse", "--verify", branch])
rangeArgs = [`origin/main..${branch}`]
} catch {
rangeArgs = [`origin/main..HEAD`]
}
}
if (commitWm) {
let wmExists = false
try {
git(["cat-file", "-e", `${commitWm}^{commit}`])
wmExists = true
} catch {
wmExists = false
}
if (wmExists) {
rangeArgs.push(`^${commitWm}`)
}
// A missing watermark commit (force-push, rebase) drops the exclusion.
// The duplicate-rule-text rejection in validateDelta blocks the re-added duplicate.
}
const logOut = git(["log", "--no-merges", "--format=%H|%ae|%cI|%s", ...rangeArgs])
const rawCommits = logOut ? logOut.split("\n").filter(Boolean) : []
const botEmail = "41898282+github-actions[bot]@users.noreply.github.com"
const candidates = []
const candidateSources = []
const deletedInWindow = []
for (const line of rawCommits) {
const [sha, email, dateIso] = line.split("|")
// Drop commits authored by the sync job itself (criterion 5).
if (email === botEmail) continue
// Everything reachable from main is already excluded by the range (criterion 6).
// Get the full file list.
let files = []
try {
const out = git(["show", "--name-only", "--format=", sha])
files = out
? out
.split("\n")
.filter(Boolean)
.filter((f) => f)
: []
} catch {
continue
}
// Get the docs-scoped diff and message.
let message = ""
let docDiff = ""
try {
message = git(["show", "--format=%B", "--no-patch", sha]).trim()
docDiff = git(["show", "--format=", sha, "--", "packages/kilo-docs"])
// Cap diff sizes.
if (docDiff.length > 20000) docDiff = docDiff.slice(0, 20000) + "\n[truncated]"
} catch {
// skip on error
}
// Drop commits whose docs-scoped diff is empty.
if (!docDiff.trim()) continue
// Collect deleted rule lines from LEARNINGS.md.
for (const dl of docDiff.split("\n")) {
if (!dl.startsWith("-")) continue
const stripped = dl.slice(1).trim()
const parsed = stripped.match(LINE_RE)
if (parsed) {
deletedInWindow.push(clean(parsed.groups.rule).replaceAll("\n", " "))
}
}
// Cap total diff data.
const totalDiff = candidates.reduce((n, c) => n + (c.diff ? c.diff.length : 0), 0)
if (totalDiff > 120000) {
log(`diff cap reached at commit ${sha.slice(0, 7)}; truncating`)
candidates.push({
source: `commit:${sha.slice(0, 7)}`,
iso: dateIso,
date: dateIso.slice(0, 10),
message,
files,
diff: "[truncated]",
})
candidateSources.push(`commit:${sha.slice(0, 7)}`)
break
}
candidates.push({
source: `commit:${sha.slice(0, 7)}`,
iso: dateIso,
date: dateIso.slice(0, 10),
message,
files,
diff: docDiff,
})
candidateSources.push(`commit:${sha.slice(0, 7)}`)
}
// Step 6: candidate comments.
let allComments = []
let maxCommentAt = "none"
if (fixture && fixture.comments) {
allComments = fixture.comments
} else if (prNumber) {
const pages = []
for (let page = 1; page <= 5; page++) {
const batch = await api(`/repos/${repo()}/pulls/${prNumber}/comments?per_page=100&page=${page}`)
pages.push(...batch)
if (batch.length < 100) break
}
allComments = pages
}
if (allComments.length > 0) {
let max = ""
for (const c of allComments) {
if (c.created_at && c.created_at > max) max = c.created_at
}
maxCommentAt = max || "none"
}
// Filter trusted comments.
const trustedComments = allComments.filter((c) => {
if (!isTrustedComment(c)) return false
if (commentWm && c.created_at <= commentWm) return false
return true
})
// Step 7: correlate comments to commits.
// A comment is a commit's trigger when c.path is in that commit's full file list
// and c.created_at < commit date. The earliest such commit claims it.
// Compare parsed timestamps so different timezone offsets do not skew the ordering.
for (const c of trustedComments) {
let best = null
const cTime = Date.parse(c.created_at)
for (const cc of candidates) {
if (!Array.isArray(cc.files) || !cc.files.includes(c.path)) continue
const ccTime = Date.parse(cc.iso)
if (cTime < ccTime) {
if (!best || ccTime < Date.parse(best.iso)) {
best = cc
}
}
}
if (best) {
best.comment = {
author_association: c.author_association,
path: c.path,
body: capBody(c.body),
}
} else {
candidates.push({
source: `comment:${c.id}`,
date: (c.created_at ?? "").slice(0, 10),
path: c.path,
body: capBody(c.body),
author_association: c.author_association,
})
candidateSources.push(`comment:${c.id}`)
}
}
// Step 8: no candidates → empty delta route.
const hasCandidates = candidates.length > 0
if (!hasCandidates) {
log("no candidate corrections; advancing marker with no model call")
writeEmptyStateArtifacts(existing)
appendOutput("count", String(existing.length))
appendSummary(
`### docs-sync learnings\n\nNo new candidate corrections. Entries: ${existing.length}. Marker route: empty (no candidates).`,
)
const marker = renderLearnedThrough({ commit: tipSha, comment: maxCommentAt })
await patchOrLogMarker({ prBody, prNumber, marker, fixture, patchFile })
return
}
// Step 9: write learnings input.
const input = {
existing: existing.map((e) => ({ id: e.id, rule: e.rule, scope: e.scope, source: e.source, date: e.date })),
deleted_in_window: deletedInWindow,
corrections: candidates,
}
const inputFile = `${OUT_DIR}/learnings-input.json`
fs.writeFileSync(inputFile, JSON.stringify(input, null, 2))
log(`wrote ${inputFile} with ${candidates.length} candidates`)
// Step 10: call the model.
// Deliberately no --auto. Every input is in the attached file and the output goes to
// stdout, so the agent needs no tool. Omitting --auto makes "the extraction step never
// writes outside LEARNINGS.md" structurally true instead of prompt-deep. triage.mjs:76
// and edit.mjs:86 carry the opposite comment; do not copy them without updating the reason.
const prompt = fs.readFileSync(path.join(HERE, "learnings-prompt.md"), "utf8")
const model = process.env.TRIAGE_MODEL
if (!model) throw new Error("TRIAGE_MODEL is required")
const budgetDeadline = Date.now() + EXTRACTION_TIMEOUT_MS
let raw = null
let lastCause = "extraction failed"
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
const left = Math.max(0, budgetDeadline - Date.now())
if (left <= 0) {
log("budget exhausted before extraction attempt")
break
}
const result = runKilo({
args: ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", inputFile],
timeoutMs: Math.min(EXTRACTION_TIMEOUT_MS, left),
streamStdout: false,
label: "learnings extraction",
})
if (result.stdout) {
fs.writeFileSync(`${OUT_DIR}/learnings-raw.txt`, result.stdout)
raw = result.stdout
const delta = parseDelta(raw)
if (delta) break
lastCause = `parseDelta returned null (attempt ${attempt})`
} else {
lastCause = result.timedOut ? "timed out" : `exit ${result.exitCode}`
}
if (attempt < ATTEMPTS) {
const wait = backoffMsForAttempt(1) // 60s, same as the sibling convention
if (wait > 0) {
log(`backing off ${wait / 1000}s before attempt ${attempt + 1}`)
sleepSync(wait)
}
}
}
// Step 11: parse and validate.
const delta = raw ? parseDelta(raw) : null
if (!delta) {
// parseDelta null after every try — retryable unhappy.
warn(`extraction failed: ${lastCause}. Leaving learnings untouched.`)
writeEmptyStateArtifacts(existing)
appendOutput("count", String(existing.length))
appendSummary(
`### docs-sync learnings\n\nExtraction failed: ${lastCause}. Entries unchanged: ${existing.length}. No marker advance.`,
)
return
}
const validated = validateDelta(delta, { existing, candidateSources, deletedInWindow })
if (validated.rejected.length > 0) {
for (const r of validated.rejected) {
warn(`rejected: ${r.reason}` + (r.entry?.id ? ` (id=${r.entry.id})` : ""))
}
}
const nonEmpty = validated.add.length > 0 || validated.remove.length > 0
// Step 12: route by outcome (G5 table, exact).
if (nonEmpty) {
// Non-empty validated delta.
const newEntries = applyDelta(existing, { add: validated.add, remove: validated.remove })
fs.writeFileSync(`${OUT_DIR}/learnings.json`, JSON.stringify(newEntries, null, 2))
const marker = renderLearnedThrough({ commit: tipSha, comment: maxCommentAt })
const suppressed = process.env.DRY_RUN === "true" || process.env.LEARNINGS_NO_PATCH === "1"
if (!suppressed) appendOutput("learned_through", marker)
if (suppressed) log(`learned-through output suppressed: ${marker}`)
const added = validated.add.length
const removed = validated.remove.length
const rejected = validated.rejected.length
log(`delta: +${added} -${removed} (${rejected} rejected)`)
appendSummary(
`### docs-sync learnings\n\n- added: ${added}\n- removed: ${removed}\n- rejected: ${rejected}\n- candidates: ${candidates.length}\n- marker route: upsert (non-empty delta)\n`,
)
writePromptArtifacts(newEntries)
appendOutput("count", String(newEntries.length))
// Marker rides through LEARNED_THROUGH into upsert-pr.mjs. No direct PATCH.
} else {
// Empty validated delta (nothing added, nothing removed, including every-add-rejected).
log("empty validated delta; advancing marker directly")
fs.writeFileSync(`${OUT_DIR}/learnings.json`, JSON.stringify(existing, null, 2))
writePromptArtifacts(existing)
appendOutput("count", String(existing.length))
const marker = renderLearnedThrough({ commit: tipSha, comment: maxCommentAt })
await patchOrLogMarker({ prBody, prNumber, marker, fixture, patchFile })
const rejected = validated.rejected.length
appendSummary(
`### docs-sync learnings\n\n- added: 0\n- removed: 0\n- rejected: ${rejected}\n- candidates: ${candidates.length}\n- marker route: direct PATCH (empty delta)\n`,
)
}
}
// --- shared helpers ---
function writeEmptyStateArtifacts(entries) {
fs.writeFileSync(`${OUT_DIR}/learnings.json`, JSON.stringify(entries, null, 2))
writePromptArtifacts(entries)
}
// A later call must be able to shrink a seeded block back to nothing, so an empty
// block removes the file instead of leaving the earlier content in place.
function writePromptArtifacts(entries) {
writeOrRemove(`${OUT_DIR}/learnings-triage.md`, promptBlock(entries, "triage"))
writeOrRemove(`${OUT_DIR}/learnings-edit.md`, promptBlock(entries, "edit"))
}
function writeOrRemove(file, text) {
if (text) fs.writeFileSync(file, text)
else fs.rmSync(file, { force: true })
}
function readFileOrEmpty(file) {
try {
return fs.readFileSync(file, "utf8")
} catch {
return ""
}
}
async function patchOrLogMarker({ prBody, prNumber, marker, fixture, patchFile }) {
const suppressed = process.env.DRY_RUN === "true" || process.env.LEARNINGS_NO_PATCH === "1"
if (suppressed) {
log(
`marker PATCH suppressed (DRY_RUN=${process.env.DRY_RUN}, LEARNINGS_NO_PATCH=${process.env.LEARNINGS_NO_PATCH})`,
)
log(`would have written marker: ${marker}`)
return
}
if (fixture) {
// Write to the fixture patch file instead of the network.
fs.writeFileSync(patchFile, marker)
log(`wrote marker to ${patchFile}`)
return
}
// Live PATCH: body-only, one line changed. The job already holds pull-requests: write.
// Re-read the body first. The body in hand was fetched before the extraction call, so
// patching that copy would drop any edit made in the minutes since. GitHub has no
// conditional update for a pull request body, so a short fetch-to-PATCH race remains.
const { api, repo } = await import("./lib.mjs")
let latestBody = prBody
try {
const fresh = await api(`/repos/${repo()}/pulls/${prNumber}`)
latestBody = fresh.body ?? ""
} catch (err) {
warn(`could not re-read PR #${prNumber} before the marker PATCH: ${err.message}. Using the earlier body.`)
}
const newBody = patchMarkerIntoBody(latestBody, marker)
await api(`/repos/${repo()}/pulls/${prNumber}`, {
method: "PATCH",
body: { body: newBody },
})
log(`PATCHed learned-through marker on PR #${prNumber}`)
}
// --- entry point ---
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
// --- self-test harness (run: node .github/docs-sync/learn.mjs --self-test) ---
if (isMain && process.argv.includes("--self-test")) {
const failures = []
const check = (label, fn) => {
try {
const ok = fn()
if (!ok) failures.push(label)
} catch (e) {
failures.push(label + " THREW: " + e.message)
}
}
check("null in add does not throw", () => {
const r = validateDelta({ add: [null], remove: [] }, { existing: [], candidateSources: [], deletedInWindow: [] })
return r.add.length === 0 && r.rejected.length === 1 && r.rejected[0].reason.includes("not a plain object")
})
check("undefined in add does not throw", () => {
const r = validateDelta(
{ add: [undefined], remove: [] },
{ existing: [], candidateSources: [], deletedInWindow: [] },
)
return r.add.length === 0 && r.rejected.length === 1 && r.rejected[0].reason.includes("not a plain object")
})
check("mixed valid and null retains valid", () => {
const r = validateDelta(
{
add: [
{
id: "valid-a",
rule: "Do not document experimental features",
scope: "both",
source: "commit:bbbbbbb",
date: "2026-08-03",
},
null,
{
id: "valid-b",
rule: "Keep release notes concise",
scope: "edit",
source: "commit:bbbbbbb",
date: "2026-08-03",
},
],
remove: [],
},
{ existing: [], candidateSources: ["commit:bbbbbbb"], deletedInWindow: [] },
)
return r.add.length === 2 && r.rejected.length === 1
})
if (failures.length) {
console.error("SELF-TEST FAILURES:", failures)
process.exit(1)
}
console.log("SELF-TEST PASSED (" + 3 + " checks)")
process.exit(0)
}
if (isMain) {
main().catch((err) => {
console.error(err)
process.exit(1)
})
}
+84
View File
@@ -0,0 +1,84 @@
You are the extraction pass of an automated documentation pipeline for Kilo Code. Your only job: extract general rules of thumb from maintainer corrections to the docs-sync bot's rolling pull request. A correction is a commit or review comment a maintainer made to fix something the bot got wrong, and a learning is the general principle behind it that the bot should follow from now on.
The attached `learnings-input.json` file contains:
- `existing`: rules the bot already knows, each with `id`, `rule`, `scope`, `source`, and `date`.
- `deleted_in_window`: rule texts (not ids) a maintainer deleted from the learnings file in this extraction window. A maintainer deleted these on purpose — do not re-add them.
- `corrections`: the maintainer corrections to learn from. Each entry has a `source` (commit or comment id), `date`, and the relevant context. Commit entries have `message`, `files`, and `diff`. Comment entries have `path` and `body`. Some commits also carry an attached inline review `comment` that triggered them.
Before writing anything:
1. Read every correction in `corrections` and every rule in `existing`.
2. For each correction, decide whether it implies a general rule of thumb the bot should follow. Not every correction does — returning no new rules is a valid and expected answer.
3. When a correction implies a rule, write it as one imperative sentence stating the general principle, not what the specific correction did.
Response format: a strict JSON object with no prose, no markdown fences, no comments:
```json
{
"add": [
{
"id": "kebab-case-slug",
"rule": "One imperative sentence.",
"scope": "triage|edit|both",
"source": "commit:<sha>|comment:<id>",
"date": "<yyyy-mm-dd>"
}
],
"remove": []
}
```
- `id`: a short kebab-case slug unique across this response.
- `rule`: one general imperative sentence. Never name a pull request, a number, a URL, a docs page, a file path, or a person. State the rule the correction implies, not what the correction changed.
- `scope`: `triage` when the rule changes which pull requests deserve documentation; `edit` when it changes how a page is written; `both` when it changes both.
- `source`: copied verbatim from the correction's `source` field. Never invent one.
- `date`: the correction's date, copied verbatim.
The `remove` array lists `id` values of existing entries to drop. Remove an id only when a new rule contradicts or supersedes it.
Hard rules:
- The list of `add` entries may be empty. Returning `{"add": [], "remove": []}` is a valid and expected answer when no correction implies a general rule.
- Never re-add a rule listed in `deleted_in_window`, and never add a reworded near-duplicate of one. A maintainer deleted it.
- When a new rule is a near-duplicate of an existing one, merge them into one `add` and list the old id in `remove`.
- When a new rule contradicts an existing rule, `add` the new one and `remove` the contradicted id.
- Every `add` entry must have a `source` that appears in the input's `corrections` list. Never invent a source.
- Do not read files and do not run commands. Every input is already attached.
Example. Input:
```json
{
"existing": [],
"deleted_in_window": [],
"corrections": [
{
"source": "commit:9dd2c07",
"date": "2026-08-03",
"message": "docs: remove experimental features page",
"files": ["packages/kilo-docs/pages/code-with-ai/experimental-features.md"],
"diff": "- removed the entire experimental features page\n- the page documented features behind unreleased flags"
}
]
}
```
Expected output:
```json
{
"add": [
{
"id": "no-experimental-features",
"rule": "Do not document features that are behind unreleased flags.",
"scope": "both",
"source": "commit:9dd2c07",
"date": "2026-08-03"
}
],
"remove": []
}
```
The rule is `both` because documenting unreleased features is wrong at triage time (the feature is not docs-worthy yet) and at edit time (the page should not exist).
+3 -1
View File
@@ -9,7 +9,9 @@
import { spawnSync } from "node:child_process"
import fs from "node:fs"
const API = "https://api.github.com"
// Test hook: DOCS_SYNC_API_BASE points the API at a local stub server. The workflow
// never sets it — only selftests do.
const API = process.env.DOCS_SYNC_API_BASE || "https://api.github.com"
const MAX_RETRIES = 3
export function token() {
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -21,6 +21,7 @@ import path from "node:path"
import { fileURLToPath } from "node:url"
import { parseTriageEntries } from "./extract-json.mjs"
import { appendSummary, backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs"
import { readLearningsBlock } from "./learn.mjs"
const CHUNK_SIZE = 25
const ATTEMPTS = 3
@@ -28,7 +29,7 @@ const OUT_DIR = "docs-sync-out"
const CHUNK_TIMEOUT_MS = 10 * 60 * 1000
const HERE = path.dirname(fileURLToPath(import.meta.url))
const prompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8")
const prompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8") + readLearningsBlock("triage")
const model = process.env.TRIAGE_MODEL
if (!model) throw new Error("TRIAGE_MODEL is required")
@@ -115,9 +116,7 @@ function triageChunk(chunk, index, budgetDeadline) {
console.warn(`chunk ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`)
sleepSync(wait)
} else if (wait > 0) {
console.warn(
`chunk ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`,
)
console.warn(`chunk ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`)
}
}
}
@@ -125,7 +124,12 @@ function triageChunk(chunk, index, budgetDeadline) {
console.warn(
`::warning::chunk ${index} failed triage after up to ${ATTEMPTS} attempts; marking ${chunk.length} PRs pending`,
)
return chunk.map((d) => pendingEntry(d, lastCause.includes("triage failed") ? lastCause : `triage failed to classify this PR (${lastCause})`))
return chunk.map((d) =>
pendingEntry(
d,
lastCause.includes("triage failed") ? lastCause : `triage failed to classify this PR (${lastCause})`,
),
)
}
const chunks = []
+93 -14
View File
@@ -27,15 +27,23 @@ const FILE_CAP = 15
const ROW_CAP = 150
const PENDING_DISPLAY_CAP = 60
const SUMMARY_FILE = ".docs-sync-summary.json"
// Owner of the rolling docs PR: assigned and asked for review on creation.
const DOCS_OWNER = "emilieschario"
const DOCS_PATH = "packages/kilo-docs"
export const LEARNINGS_FILE = "packages/kilo-docs/LEARNINGS.md"
const git = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim()
const git = (args) =>
execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] })
.toString()
.trim()
// Agent-generated strings land in the PR body next to machine-read markers.
// Strip HTML-comment sequences so a crafted/adversarial value cannot forge
// section boundaries or the processed-through watermark.
function clean(value) {
return String(value ?? "").replaceAll("<!--", "").replaceAll("-->", "")
return String(value ?? "")
.replaceAll("<!--", "")
.replaceAll("-->", "")
}
function shortRef(url) {
@@ -52,7 +60,9 @@ function skippedRow(e) {
}
function pendingRow(e) {
const reason = clean(e.reason ?? e.cause ?? "").replaceAll("|", "\\|").replaceAll("\n", " ")
const reason = clean(e.reason ?? e.cause ?? "")
.replaceAll("|", "\\|")
.replaceAll("\n", " ")
return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |`
}
@@ -79,7 +89,18 @@ function section(name, header, rows) {
return `<!-- docs-sync:${name}:start -->\n${body}\n<!-- docs-sync:${name}:end -->`
}
export function renderBody({ date, since, through, changesRows, pendingRows, skippedRows, verified, draftReasons, note }) {
export function renderBody({
date,
since,
through,
learnedThrough = "",
changesRows,
pendingRows,
skippedRows,
verified,
draftReasons,
note,
}) {
const pendingDisplay =
pendingRows.length > PENDING_DISPLAY_CAP
? [...pendingRows.slice(0, PENDING_DISPLAY_CAP), `| +${pendingRows.length - PENDING_DISPLAY_CAP} more | |`]
@@ -108,7 +129,7 @@ ${section("skipped", "| PR | Reason |", skippedRows)}
(bot) Generated by the docs-sync workflow. Humans review and merge; while this PR stays open, the next daily run appends new changes here. Branch: \`${BRANCH}\`.
<!-- docs-sync: processed-through ${through} -->
`
${learnedThrough ? learnedThrough + "\n" : ""}`
}
function mergeRows(oldRows, newRows) {
@@ -242,6 +263,23 @@ export function computeProcessedThrough({ uncovered, digest, now, fallback }) {
return new Date(earliest - 1).toISOString()
}
/**
* Content gate: legitimate bot edits are docs pages and nav files. Only
* those are built and tested during verify (content-integrity.test.ts
* walks pages/ only), so anything else in the docs package forces human
* review. LEARNINGS.md is a root-level .md file, like the three sibling
* .md files already at that level, so it is not built or tested and is
* safe to exclude from the gate.
*/
export function nonContentFiles(changedFiles) {
return (Array.isArray(changedFiles) ? changedFiles : []).filter(
(f) =>
f !== LEARNINGS_FILE &&
!f.startsWith("packages/kilo-docs/pages/") &&
!f.startsWith("packages/kilo-docs/lib/nav/"),
)
}
/**
* Route summary + triage into the three body sections.
* changesRows = action neither skipped nor pending
@@ -280,6 +318,22 @@ export function dropLegacySkipped(rows) {
})
}
/**
* Resolve the learned-through marker for renderBody.
*
* Order: env LEARNED_THROUGH when set and non-empty; else the marker
* parsed out of the existing PR body; else "".
* The fallback is load-bearing: a run where extraction was skipped,
* failed, or already PATCHed the marker itself must not clobber a good
* marker.
*/
export function resolveLearnedThrough({ envValue, prBody }) {
const fromEnv = String(envValue ?? "").trim()
if (fromEnv) return fromEnv
const m = String(prBody ?? "").match(/<!--\s*docs-sync:\s*learned-through\s+commit=\S+\s+comment=\S+\s*-->/)
return m ? m[0] : ""
}
/**
* No-diff early-return report. Returns summary markdown and an optional
* replay warning. Warns IFF sinceOverride && uncovered non-empty (no commit
@@ -347,18 +401,14 @@ async function main() {
const through = computeProcessedThrough({ uncovered, digest, now, fallback: since })
// The draft cap bounds the cumulative PR diff, not just this run's commit.
const changedFiles = git(["diff", "--name-only", "origin/main...HEAD", "--", DOCS_PATH])
.split("\n")
.filter(Boolean)
const changedFiles = git(["diff", "--name-only", "origin/main...HEAD", "--", DOCS_PATH]).split("\n").filter(Boolean)
const draftReasons = []
if (changedFiles.length > FILE_CAP) draftReasons.push(`diff exceeds ${FILE_CAP} files (${changedFiles.length})`)
if (!verified) draftReasons.push("docs build/tests not passing")
// Content gate: legitimate bot edits are docs pages and nav files. Anything
// else in the docs package (build config, components, tests) executes
// during the verify build, so force human review before merge.
const nonContent = changedFiles.filter(
(f) => !f.startsWith("packages/kilo-docs/pages/") && !f.startsWith("packages/kilo-docs/lib/nav/"),
)
const nonContent = nonContentFiles(changedFiles)
if (nonContent.length > 0) {
// File paths are agent-chosen; sanitize before they land in the PR body.
const listed = nonContent
@@ -369,9 +419,17 @@ async function main() {
}
const draft = draftReasons.length > 0
git(mode === "update" ? ["push", "origin", `HEAD:${BRANCH}`] : ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`])
git(
mode === "update"
? ["push", "origin", `HEAD:${BRANCH}`]
: ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`],
)
const { changesRows: changesNew, pendingRows: pendingNew, skippedRows: skippedNew } = routeRows({
const {
changesRows: changesNew,
pendingRows: pendingNew,
skippedRows: skippedNew,
} = routeRows({
summary: agentSummary,
triage,
uncovered,
@@ -380,8 +438,10 @@ async function main() {
let oldChanges = []
let oldSkipped = []
let oldPending = []
let prBody = ""
if (mode === "update" && existingPr) {
const pr = await api(`/repos/${repo()}/pulls/${existingPr}`)
prBody = pr.body ?? ""
oldChanges = extractSectionRows(pr.body, "changes")
oldSkipped = dropLegacySkipped(extractSectionRows(pr.body, "skipped"))
oldPending = extractSectionRows(pr.body, "pending")
@@ -392,10 +452,13 @@ async function main() {
// extractSectionRows stays exercised; discarded deliberately.
void oldPending
const learnedThrough = resolveLearnedThrough({ envValue: process.env.LEARNED_THROUGH, prBody })
const body = renderBody({
date,
since,
through,
learnedThrough,
changesRows: mergeRows(oldChanges, changesNew),
pendingRows: pendingNew,
skippedRows: mergeRows(oldSkipped, skippedNew),
@@ -445,6 +508,20 @@ async function main() {
prNumber = pr.number
prUrl = pr.html_url
await api(`/repos/${repo()}/issues/${prNumber}/labels`, { method: "POST", body: { labels: ["auto-docs"] } })
// Best effort: the PR already exists here, so a non-collaborator or a
// revoked account must not fail the run.
try {
await api(`/repos/${repo()}/issues/${prNumber}/assignees`, {
method: "POST",
body: { assignees: [DOCS_OWNER] },
})
await api(`/repos/${repo()}/pulls/${prNumber}/requested_reviewers`, {
method: "POST",
body: { reviewers: [DOCS_OWNER] },
})
} catch (err) {
console.warn(`::warning::docs-sync: could not assign or request review from ${DOCS_OWNER}: ${err.message}`)
}
if (mode === "conflict" && existingPr) {
await api(`/repos/${repo()}/issues/${existingPr}/comments`, {
method: "POST",
@@ -459,7 +536,9 @@ async function main() {
appendSummary(
`### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n- uncovered: ${uncovered.length}\n- processed-through: ${through}\n`,
)
console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length}, uncovered=${uncovered.length}, through=${through})`)
console.log(
`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length}, uncovered=${uncovered.length}, through=${through})`,
)
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
+16 -2
View File
@@ -66,13 +66,13 @@ jobs:
sync:
if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request'
runs-on: blacksmith-4vcpu-ubuntu-2404
# Budget: 4 setup/collect + 90 triage + 120 edit + 2 verify + 10 fix + 2 upsert = 228 min, 12-minute reserve.
# Budget: 4 setup/collect + 10 learn + 90 triage + 120 edit + 2 verify + 10 fix + 2 upsert = 238 min, 12-minute reserve.
# These are ceilings, not costs: a caught-up run triages ~2 chunks and edits
# ~1 batch and finishes in ~25 min. The old 35/50 pair was the binding
# constraint on backlog drain — run 30306629290 deferred 54 PRs untriaged and
# 31 unedited purely on budget, with no attempt made. See the throughput note
# in the PR description for the arithmetic.
timeout-minutes: 240
timeout-minutes: 250
env:
# Both are required: without KILO_ORG_ID the gateway bills the key
# owner's personal balance (402 "Add credits") instead of the org.
@@ -110,6 +110,15 @@ jobs:
INPUT_SINCE: ${{ inputs.since }}
run: node .github/docs-sync/watermark.mjs
- name: Learn from maintainer corrections
id: learn
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
LEARNINGS_BUDGET_MINUTES: "10"
DRY_RUN: ${{ inputs.dry_run }}
run: node .github/docs-sync/learn.mjs
- name: Collect merged PRs
id: collect
env:
@@ -215,10 +224,15 @@ jobs:
echo "ok=false" >> "$GITHUB_OUTPUT"
fi
- name: Write the learnings file
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
run: node .github/docs-sync/learn.mjs --apply
- name: Upsert rolling PR
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
env:
GH_TOKEN: ${{ github.token }}
LEARNED_THROUGH: ${{ steps.learn.outputs.learned_through }}
PROCESSED_THROUGH: ${{ steps.wm.outputs.now }}
SINCE: ${{ steps.wm.outputs.since }}
SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }}
+157
View File
@@ -0,0 +1,157 @@
---
name: chart
description: Use when the user asks to visualize data with charts, graphs, or plots using the `chart` tool (bar, line, scatter, pie, time series, etc.).
---
# Data Visualization
The `chart` tool is ALWAYS available in this environment. When the user asks to visualize data (charts, graphs, plots), you MUST call the `chart` tool. Never output the config as text, never say the tool is unavailable, never suggest external renderers. Always use the tool call — it is the only correct response for data visualization requests. Do NOT repeat or echo the config JSON in your text response.
Use the `chart` tool only when the user explicitly asks for a chart, graph, or plot. Only use these supported Chart.js v4 types: `bar`, `bubble`, `pie`, `doughnut`, `line`, `mixed`, `polarArea`, `radar`, `scatter`. For area charts, use `line` with `fill: true` on the dataset — do NOT use `area` as a type.
Use mermaid fenced code blocks (` ```mermaid `) when:
- The user asks for a diagram, flowchart, sequence diagram, ER diagram, or architecture diagram
- Visualizing relationships, processes, or structure — not data values
Mermaid is NOT a tool and is NOT Chart.js — never call the `chart` tool for mermaid diagrams. Just write the mermaid syntax directly in your text response inside a fenced code block. No tool call needed.
Do not use either for: code, text, or data that is already clear in prose or table form.
The `chart` tool input accepts:
- `title` (string) — short label shown in the tool header
- `description` (string, optional) — subtitle shown below the title
- `spec` (string) — a Chart.js config object as a JSON string
The `spec` field must be a Chart.js config JSON string with `type`, `data`, and optionally `options`. Examples:
Bar chart:
```json
{
"type": "bar",
"data": {
"labels": ["A", "B", "C"],
"datasets": [{ "label": "Value", "data": [10, 20, 15] }]
}
}
```
Area chart (line with fill):
```json
{
"type": "line",
"data": {
"labels": ["Jan", "Feb", "Mar", "Apr"],
"datasets": [{ "label": "Value", "data": [10, 28, 19, 45], "fill": true }]
}
}
```
Line chart:
```json
{
"type": "line",
"data": {
"labels": ["Jan", "Feb", "Mar", "Apr"],
"datasets": [{ "label": "Value", "data": [10, 28, 19, 45], "fill": false }]
}
}
```
Scatter plot:
```json
{
"type": "scatter",
"data": {
"datasets": [{
"label": "Points",
"data": [{ "x": 1, "y": 5 }, { "x": 2, "y": 8 }, { "x": 3, "y": 3 }]
}]
}
}
```
Time series:
```json
{
"type": "line",
"data": {
"labels": ["2024-01", "2024-02", "2024-03", "2024-04"],
"datasets": [{ "label": "Value", "data": [120, 145, 132, 178], "fill": true }]
}
}
```
Pie chart:
```json
{
"type": "pie",
"data": {
"labels": ["A", "B", "C"],
"datasets": [{ "data": [30, 50, 20] }]
}
}
```
Doughnut chart:
```json
{
"type": "doughnut",
"data": {
"labels": ["A", "B", "C"],
"datasets": [{ "data": [30, 50, 20] }]
}
}
```
Radar chart:
```json
{
"type": "radar",
"data": {
"labels": ["Speed", "Power", "Agility", "Stamina"],
"datasets": [{ "label": "Player", "data": [80, 60, 90, 70] }]
}
}
```
Bubble chart:
```json
{
"type": "bubble",
"data": {
"datasets": [{
"label": "Group A",
"data": [{ "x": 10, "y": 20, "r": 8 }, { "x": 15, "y": 10, "r": 5 }]
}]
}
}
```
Polar area chart:
```json
{
"type": "polarArea",
"data": {
"labels": ["A", "B", "C", "D"],
"datasets": [{ "data": [11, 16, 7, 14] }]
}
}
```
Mixed chart (bar + line):
```json
{
"type": "bar",
"data": {
"labels": ["Jan", "Feb", "Mar"],
"datasets": [
{ "type": "bar", "label": "Revenue", "data": [100, 120, 90] },
{ "type": "line", "label": "Trend", "data": [95, 115, 100] }
]
}
}
```
You may customize colors by setting `backgroundColor` and `borderColor` arrays on datasets. The renderer handles sizing — do not set width or height.
Only include `scales` in `options` for cartesian chart types: `bar`, `line`, `scatter`, `bubble`. Do NOT include `scales` for `pie`, `doughnut`, `polarArea`, `radar`, or `mixed` — it will cause them to fail.
+30 -25
View File
@@ -32,7 +32,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "7.4.18",
"version": "7.4.19",
"bin": {
"opencode": "./bin/opencode",
},
@@ -127,7 +127,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -141,7 +141,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"effect": "catalog:",
},
@@ -153,7 +153,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.74",
"@effect/platform-node-shared": "4.0.0-beta.74",
@@ -174,7 +174,7 @@
},
"packages/kilo-console": {
"name": "@kilocode/kilo-console",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"@kilocode/kilo-indexing": "workspace:*",
"@kilocode/kilo-web-ui": "workspace:*",
@@ -197,7 +197,7 @@
},
"packages/kilo-docs": {
"name": "@kilocode/kilo-docs",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"@docsearch/css": "^4",
"@docsearch/js": "^4",
@@ -227,7 +227,7 @@
},
"packages/kilo-gateway": {
"name": "@kilocode/kilo-gateway",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.82",
@@ -263,7 +263,7 @@
},
"packages/kilo-i18n": {
"name": "@kilocode/kilo-i18n",
"version": "7.4.18",
"version": "7.4.19",
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
@@ -273,7 +273,7 @@
},
"packages/kilo-indexing": {
"name": "@kilocode/kilo-indexing",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "3.1005.0",
"@aws-sdk/credential-provider-ini": "3.972.31",
@@ -309,7 +309,7 @@
},
"packages/kilo-memory": {
"name": "@kilocode/kilo-memory",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"effect": "catalog:",
"zod": "catalog:",
@@ -323,7 +323,7 @@
},
"packages/kilo-sandbox": {
"name": "@kilocode/sandbox",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "catalog:",
"effect": "catalog:",
@@ -338,7 +338,7 @@
},
"packages/kilo-telemetry": {
"name": "@kilocode/kilo-telemetry",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"@kilocode/kilo-gateway": "workspace:*",
"posthog-node": "4.4.0",
@@ -352,7 +352,7 @@
},
"packages/kilo-ui": {
"name": "@kilocode/kilo-ui",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "0.13.11",
@@ -362,6 +362,7 @@
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/rootless": "1.5.2",
"chart.js": "4.5.1",
"diff": "catalog:",
"lucide-solid": "0.576.0",
"motion": "12.34.5",
@@ -389,7 +390,7 @@
},
"packages/kilo-vscode": {
"name": "kilo-code",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@kilocode/kilo-gateway": "workspace:*",
@@ -458,7 +459,7 @@
},
"packages/kilo-web-ui": {
"name": "@kilocode/kilo-web-ui",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"@kilocode/kilo-ui": "workspace:*",
"@kobalte/core": "catalog:",
@@ -475,7 +476,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -493,7 +494,7 @@
},
"packages/opencode": {
"name": "@kilocode/cli",
"version": "7.4.18",
"version": "7.4.19",
"bin": {
"kilo": "./bin/kilo",
"kilocode": "./bin/kilo",
@@ -660,7 +661,7 @@
},
"packages/plugin": {
"name": "@kilocode/plugin",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"effect": "catalog:",
@@ -688,7 +689,7 @@
},
"packages/plugin-atomic-chat": {
"name": "@kilocode/plugin-atomic-chat",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"@kilocode/plugin": "workspace:*",
},
@@ -702,7 +703,7 @@
},
"packages/script": {
"name": "@opencode-ai/script",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"semver": "^7.6.3",
},
@@ -713,7 +714,7 @@
},
"packages/sdk/js": {
"name": "@kilocode/sdk",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -728,7 +729,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"drizzle-orm": "catalog:",
@@ -742,7 +743,7 @@
},
"packages/storybook": {
"name": "@opencode-ai/storybook",
"version": "7.4.18",
"version": "7.4.19",
"devDependencies": {
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -765,7 +766,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"@kilocode/plugin": "workspace:*",
"@kilocode/sdk": "workspace:*",
@@ -792,7 +793,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "catalog:",
@@ -1557,6 +1558,8 @@
"@kobalte/utils": ["@kobalte/utils@0.9.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.2.14", "@solid-primitives/keyed": "^1.2.0", "@solid-primitives/map": "^0.4.7", "@solid-primitives/media": "^2.2.4", "@solid-primitives/props": "^3.1.8", "@solid-primitives/refs": "^1.0.5", "@solid-primitives/utils": "^6.2.1" }, "peerDependencies": { "solid-js": "^1.8.8" } }, "sha512-eeU60A3kprIiBDAfv9gUJX1tXGLuZiKMajUfSQURAF2pk4ZoMYiqIzmrMBvzcxP39xnYttgTyQEVLwiTZnrV4w=="],
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="],
"@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="],
@@ -2865,6 +2868,8 @@
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
"chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
"cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-sid/FCql4CmiAb1MNGMH33fwNs6esv9UNC819Yb03bQ=",
"aarch64-linux": "sha256-1H1873nLqmcKrumAjnjPPts5+EyJLBm1Z5VKsOYANeg=",
"aarch64-darwin": "sha256-i7D5RSPIVxXNY1gUnsYdFhVU2FxYEcjqYcQx6zZrJjg=",
"x86_64-darwin": "sha256-5lSTqolFVcaKqN6ndKGJLcCUP8JQ8i60np+3eAulnZg="
"x86_64-linux": "sha256-gQFDvDbTsTNge6ji5hToanPCbXHUqbx4ccAN/isBl3c=",
"aarch64-linux": "sha256-iLfVhjLJoCyudlcdw1tPW0MYt4fLdgxooC9lGb0wTy0=",
"aarch64-darwin": "sha256-ohr/Nu0GbHAECsqpB93AaNBbKHWJTjbqQgEwFoim5kw=",
"x86_64-darwin": "sha256-vWaL8d4k01J4R9ROd44BJYTsPLGt7yiismBNThlwlOY="
}
}
+1 -1
View File
@@ -173,6 +173,6 @@
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch"
},
"version": "7.4.18",
"version": "7.4.19",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.4.18",
"version": "7.4.19",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+1
View File
@@ -11,6 +11,7 @@ export class Info extends Schema.Class<Info>("CommandV2.Info")({
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: ModelV2.Ref.pipe(Schema.optional),
variant: ModelV2.VariantID.pipe(Schema.optional), // kilocode_change - support variant-only command overrides
subtask: Schema.Boolean.pipe(Schema.optional),
}) {}
+1 -1
View File
@@ -3,7 +3,7 @@ export * as ConfigCommand from "./command"
import { Schema } from "effect"
export class Info extends Schema.Class<Info>("ConfigV2.Command")({
template: Schema.String,
template: Schema.String.pipe(Schema.optional), // kilocode_change - allow partial command overrides
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: Schema.String.pipe(Schema.optional),
+26 -15
View File
@@ -29,22 +29,33 @@ export const Plugin = PluginV2.define({
}).pipe(Effect.map((documents) => documents.flat()))
yield* transform((editor) => {
for (const document of documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
editor.update(name, (item) => {
item.template = command.template
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined) {
const model = ModelV2.parse(command.model)
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
}
if (command.variant !== undefined && item.model !== undefined) {
item.model.variant = ModelV2.VariantID.make(command.variant)
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
const items = documents.flatMap((document) => Object.entries(document.commands ?? {}))
// Register every template first, preserving the normal source priority for
// metadata in the second pass. // kilocode_change
for (const [name, command] of items) {
if (command.template === undefined) {
continue
}
const template = command.template
editor.update(name, (item) => {
item.template = template
})
}
for (const [name, command] of items) {
if (command.template === undefined && !editor.get(name)) continue // kilocode_change
editor.update(name, (item) => {
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined) {
const model = ModelV2.parse(command.model)
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
}
if (command.variant !== undefined) item.variant = ModelV2.VariantID.make(command.variant) // kilocode_change
if (command.variant !== undefined && item.model !== undefined) {
item.model.variant = ModelV2.VariantID.make(command.variant)
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
}
})
}),
@@ -0,0 +1,53 @@
import type { Match } from "../filesystem/schema"
export interface Options {
readonly context?: number
readonly literal?: boolean
readonly ignoreCase?: boolean
}
export type GrepMatch = Match & {
readonly context: boolean
readonly textTruncated: boolean
}
export const flags = (input: Options) => [
...(input.literal ? ["--fixed-strings"] : []),
...(input.ignoreCase ? ["--ignore-case"] : []),
...(input.context ? [`--context=${input.context}`] : []),
]
export const stop = (limit: number) => {
let matches = 0
return (row: { readonly context: boolean }) => !row.context && ++matches > limit
}
export const select = <
A extends {
readonly context: boolean
readonly path: { readonly text: string }
readonly line_number: number
},
>(
input: { readonly limit: number; readonly context?: number },
items: readonly A[],
) => {
let count = 0
const overflow = items.findIndex((row) => !row.context && ++count > input.limit)
const selected = items.slice(0, overflow === -1 ? items.length : overflow)
const matches = selected.filter((row) => !row.context)
return selected.filter(
(row) =>
!row.context ||
matches.some(
(match) =>
match.path.text === row.path.text && Math.abs(match.line_number - row.line_number) <= (input.context ?? 0),
),
)
}
export const decorate = (match: Match, context: boolean, textTruncated: boolean): GrepMatch => ({
...match,
context,
textTruncated,
})
+29 -9
View File
@@ -6,6 +6,7 @@ import path from "path"
import { LayerNode } from "./effect/layer-node"
import { Entry, Match } from "./filesystem/schema"
import { FSUtil } from "./fs-util"
import * as KiloGrep from "./kilocode/ripgrep-grep" // kilocode_change
import * as SpawnValidation from "./kilocode/spawn-validation" // kilocode_change
import { AppProcess, collectStream, waitForAbort } from "./process"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
@@ -23,7 +24,7 @@ const MAX_RECORD_BYTES = 64 * 1024
const MAX_SUBMATCHES = 100
const RawMatch = Schema.Struct({
type: Schema.Literal("match"),
type: Schema.Literals(["match", "context"]), // kilocode_change - retain requested context records
data: Schema.Struct({
path: Schema.Struct({ text: Schema.String }),
lines: Schema.Struct({ text: Schema.String }),
@@ -39,7 +40,7 @@ const RawMatch = Schema.Struct({
}),
})
type RawMatchData = (typeof RawMatch.Type)["data"]
type RawMatchData = (typeof RawMatch.Type)["data"] & { readonly context: boolean } // kilocode_change
export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
message: Schema.String,
@@ -71,7 +72,8 @@ export interface GlobInput {
readonly validate?: Effect.Effect<void, unknown> // kilocode_change - bind approved searches at spawn
}
export interface GrepInput {
export interface GrepInput extends KiloGrep.Options {
// kilocode_change
readonly cwd: string
readonly pattern: string
readonly file?: string
@@ -84,7 +86,7 @@ export interface GrepInput {
export interface Interface {
readonly find: (input: FindInput) => Effect.Effect<readonly Entry[], Error>
readonly glob: (input: GlobInput) => Effect.Effect<SearchResult<Entry>, Error> // kilocode_change
readonly grep: (input: GrepInput) => Effect.Effect<SearchResult<Match>, Error | InvalidPatternError> // kilocode_change
readonly grep: (input: GrepInput) => Effect.Effect<SearchResult<KiloGrep.GrepMatch>, Error | InvalidPatternError> // kilocode_change
}
// kilocode_change start - retain truncation state through model-facing tools
@@ -116,6 +118,7 @@ export const layer = Layer.effect(
readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
readonly pattern?: string
readonly onItem?: (item: A) => Effect.Effect<void>
readonly stop?: (item: A) => boolean // kilocode_change - stop bounded searches at the overflow match
readonly validate?: Effect.Effect<void, unknown> // kilocode_change - spawn-bound target validation
}) => {
const program = Effect.scoped(
@@ -136,6 +139,13 @@ export const layer = Layer.effect(
Effect.forkScoped,
)
let observed = 0
let stopped = false // kilocode_change
const take = input.stop // kilocode_change start
? Stream.takeUntil<A>((row) => {
stopped = input.stop?.(row) ?? false
return stopped
})
: Stream.take(input.limit + 1) // kilocode_change end
const rows = yield* Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
@@ -145,11 +155,12 @@ export const layer = Layer.effect(
if (!input.onItem || observed++ >= input.limit) return Effect.void
return input.onItem(row)
}),
Stream.take(input.limit + 1),
take, // kilocode_change
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const truncated = rows.length > input.limit
if (stopped) return { items: rows, truncated: true, partial: false } // kilocode_change
const truncated = input.stop ? false : rows.length > input.limit // kilocode_change - custom stop predicates own truncation
if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
const code = yield* handle.exitCode
@@ -249,11 +260,13 @@ export const layer = Layer.effect(
grep: (input) =>
run<RawMatchData>({
...input,
stop: KiloGrep.stop(input.limit), // kilocode_change
args: [
"--no-config",
"--json",
"--hidden",
"--no-messages",
...KiloGrep.flags(input), // kilocode_change
...(input.include ? [`--glob=${input.include}`] : []),
"--glob=!**/.git/**",
"--",
@@ -269,13 +282,19 @@ export const layer = Layer.effect(
})
).pipe(
Effect.flatMap((json) => {
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match")
if (
!json ||
typeof json !== "object" ||
!("type" in json) ||
(json.type !== "match" && json.type !== "context") // kilocode_change
)
return Effect.succeed(undefined)
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
Effect.map((match) => ({
...match.data,
path: { text: match.data.path.text.replace(/^\.[\\/]/, "") },
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
context: match.type === "context", // kilocode_change
})),
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
)
@@ -285,13 +304,13 @@ export const layer = Layer.effect(
// kilocode_change start - retain spawn metadata after mapping matches
Effect.map((result) => ({
...result,
items: result.items.map((match) => {
items: KiloGrep.select(input, result.items).map((match) => {
const relative = match.path.text
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
const absolute = path.resolve(input.cwd, relative)
return new Match({
const item = new Match({
entry: new Entry({
path: RelativePath.make(relative),
type: "file",
@@ -306,6 +325,7 @@ export const layer = Layer.effect(
end: submatch.end,
})),
})
return KiloGrep.decorate(item, match.context, match.lines.text.length > 2_000)
}),
})),
// kilocode_change end
+1 -1
View File
@@ -3,7 +3,7 @@ export * as ConfigCommandV1 from "./command"
import { Schema } from "effect"
export const Info = Schema.Struct({
template: Schema.String,
template: Schema.optional(Schema.String), // kilocode_change - allow global workflow model/variant overrides
description: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String),
model: Schema.optional(Schema.String),
+2
View File
@@ -207,6 +207,7 @@ export const SubtaskPart = Schema.Struct({
modelID: ModelV2.ID,
}),
),
variant: Schema.optional(Schema.String), // kilocode_change - preserve workflow subtask variant
command: Schema.optional(Schema.String),
}).annotate({ identifier: "SubtaskPart" })
export type SubtaskPart = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPart>>
@@ -500,6 +501,7 @@ export const SubtaskPartInput = Schema.Struct({
modelID: ModelV2.ID,
}),
),
variant: Schema.optional(Schema.String), // kilocode_change - preserve workflow subtask variant
command: Schema.optional(Schema.String),
}).annotate({ identifier: "SubtaskPartInput" })
export type SubtaskPartInput = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPartInput>>
+35
View File
@@ -69,6 +69,7 @@ Review files`,
id: ModelV2.ID.make("claude"),
variant: ModelV2.VariantID.make("high"),
},
variant: ModelV2.VariantID.make("high"),
subtask: true,
}),
new CommandV2.Info({ name: "empty", template: "" }),
@@ -78,4 +79,38 @@ Review files`,
),
),
)
it.effect("applies a global partial override after project command definitions", () =>
Effect.gen(function* () {
const command = yield* CommandV2.Service
yield* ConfigCommandPlugin.Plugin.effect.pipe(
Effect.provideService(CommandV2.Service, command),
Effect.provideService(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({ commands: { review: { model: "anthropic/claude", variant: "high" } } }),
}),
new Config.Document({
type: "document",
info: decode({ commands: { review: { template: "Review files" } } }),
}),
]),
}),
),
)
expect(yield* command.get("review")).toMatchObject({
template: "Review files",
model: {
providerID: ProviderV2.ID.make("anthropic"),
id: ModelV2.ID.make("claude"),
variant: ModelV2.VariantID.make("high"),
},
})
}),
)
})
+7 -4
View File
@@ -11,6 +11,8 @@ import { testEffect } from "../lib/effect"
type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID }
const PTY_TEST_TIMEOUT = "15 seconds" // kilocode_change - PTY startup can exceed the default test timeout on macOS CI
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
@@ -59,7 +61,7 @@ const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number)
return picked
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
duration: PTY_TEST_TIMEOUT, // kilocode_change
orElse: () => Effect.fail(new Error("timeout waiting for pty events")),
}),
)
@@ -73,6 +75,7 @@ const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function*
onData: (chunk) => Queue.offerUnsafe(output, chunk),
onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)),
})
if (attachment.replay) Queue.offerUnsafe(output, attachment.replay)
attachment.activate()
return { attachment, output, ended }
})
@@ -84,7 +87,7 @@ const waitForOutput = (output: Queue.Queue<string>, text: string) =>
return received
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
duration: PTY_TEST_TIMEOUT, // kilocode_change
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
}),
)
@@ -144,7 +147,7 @@ describe("pty", () => {
Effect.gen(function* () {
const pty = yield* Pty.Service
const marker = "café-über-北京-🚀"
const info = yield* createPty("sh", ["-c", `printf '${marker}\\n'`])
const info = yield* createPty("sh", ["-c", "printf 'caf\\303\\251-\\303\\274ber-\\345\\214\\227\\344\\272\\254-\\360\\237\\232\\200\\n'"])
const attached = yield* attachCollecting(info.id)
expect(yield* waitForOutput(attached.output, marker)).toContain(marker)
}),
@@ -268,7 +271,7 @@ describe("pty", () => {
attachment.write("ignored")
yield* pty.remove(info.id)
attachment.activate()
expect(yield* Deferred.await(ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 7 })
expect(yield* Deferred.await(ended).pipe(Effect.timeout(PTY_TEST_TIMEOUT))).toEqual({ exitCode: 7 })
attachment.detach()
}),
)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.4.18",
"version": "7.4.19",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.4.18",
"version": "7.4.19",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "7.4.18"
version = "7.4.19"
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.4.18/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.19/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.4.18/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.19/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.18/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.19/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.4.18/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.19/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.4.18/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.19/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.4.18",
"version": "7.4.19",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-console",
"version": "7.4.18",
"version": "7.4.19",
"private": true,
"type": "module",
"scripts": {
+11
View File
@@ -0,0 +1,11 @@
# docs-sync learnings
Rules the docs-sync bot learned from maintainer corrections to its rolling pull request.
The bot reads this file at the start of every run and follows every rule below.
To unlearn a rule, delete its line and commit. The next run reads this file from the
branch, so the rule is gone from its input, and the deletion itself is a correction the
extraction step is instructed not to undo.
<!-- docs-sync:learnings:start -->
<!-- docs-sync:learnings:end -->
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-docs",
"version": "7.4.18",
"version": "7.4.19",
"private": true,
"scripts": {
"dev": "next dev --webpack --port 3002",
@@ -159,9 +159,9 @@ Each request can include 1-20 tasks. Each task must include at least one of `pro
The companion `agent_manager_models` tool searches models and their supported reasoning variants on demand. Results are grouped by model name (with the offering providers listed for reference) and limited to 20 per call, so the full catalog is never added to the conversation context.
The same tool also manages existing sessions. It can return a compact overview of sections, worktrees, and local sessions, send a prompt to one managed session, or stop a managed session. Stopping aborts the session's active work and removes it from the panel, just like closing the session tab.
The same tool also manages existing sessions. It can return an overview of sections, worktrees, and local sessions, send a prompt to one managed session, stop a managed session, or move a session's worktree into a section. The overview includes section IDs, each section's assigned worktrees, worktree IDs, and session IDs. Use those exact IDs for a subsequent move. Moving accepts a section ID from the overview, or `null` to ungroup the worktree. Moving a session moves its whole worktree, including multi-version siblings. Local sessions cannot be assigned to a section. Stopping aborts the session's active work and removes it from the panel, just like closing the session tab.
The tool uses the `agent_manager` permission. Approval prompts are scoped to the requested capability, so approving `worktree` does not automatically approve `local`, an overview, or a targeted prompt. Prompting an existing managed session requires an explicit `prompt` approval the first time, even if Agent Manager session creation was previously approved broadly. Stopping a session likewise requires an explicit `stop` approval.
The tool uses the `agent_manager` permission. Approval prompts are scoped to the requested capability, so approving `worktree` does not automatically approve `local`, an overview, or a targeted prompt. Prompting an existing managed session requires an explicit `prompt` approval the first time, even if Agent Manager session creation was previously approved broadly. Stopping a session likewise requires an explicit `stop` approval, and moving a worktree requires an explicit `move` approval.
## Sections
@@ -217,11 +217,39 @@ Press `Cmd+D` (macOS) / `Ctrl+D` (Windows/Linux) to toggle the diff panel. It sh
- Markdown files include an eye/code toggle in the file header to switch between rendered Markdown and the raw diff
- **Drag file headers into chat** — drag a file header from the diff panel into the chat input to insert an `@file` mention, giving the agent context about specific changed files
### Diff Scope
A scope selector in the diff toolbar (both in the side panel and the full-screen review) chooses which changes the diff shows:
- **Branch** (default) — the full worktree diff against its parent branch, matching the review behavior above
- **Staged** — staged changes in the selected worktree
- **Unstaged** — unstaged changes in the selected worktree
- **Session** — changes from the selected session
The Branch scope also has a base-branch picker next to it for overriding the comparison branch. **Apply to local** works only on the Branch scope — switch back to Branch to apply.
See [Agent Manager Workflows](/docs/automate/agent-manager-workflows#merging-worktree-and-parent-branch) for the full integration story, including when to apply locally vs. merge directly vs. open a pull request.
## Terminals
Each session has a dedicated integrated terminal rooted in the session's worktree directory. Press `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) to focus the terminal for the active session.
Each session has a dedicated terminal rooted in the session's worktree directory. Press `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) to focus the terminal for the active session.
### Choosing the Terminal Destination
The toolbar's terminal button is a split button: click it to open a terminal, or use its dropdown to choose where terminals open:
- **VS Code terminal** (default) — opens or focuses the VS Code integrated terminal at the bottom of the window
- **Agent Manager panel** — opens an embedded terminal in the side panel that also hosts the diff view, so the shell stays inside the Agent Manager layout
The dropdown choice is remembered per panel and becomes the default for new panels. You can also set the default directly with the `kilo-code.new.agentManager.terminalButtonDestination` setting (`vscode` or `agentManager`). The `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) shortcut follows the same destination.
With the **Agent Manager panel** destination, the terminal works like the diff panel: press `Cmd+/` to reveal it and press again to hide it. Hiding never stops the terminal — scrollback and running processes continue in the background, and focus returns to the chat input. A terminal stops only when you close its tab in the panel.
### Multiple Terminals
The side panel hosts multiple terminals per context (the local workspace or a worktree). The panel header is a tab strip: click a tab to switch, click **+** to open another terminal, and click **X** (or middle-click) to close a single terminal. Drag tabs to reorder them. Closing a terminal no longer hides the panel — closing the last one lands on the empty state. Pressing `Cmd+W` (macOS) / `Ctrl+W` (Windows/Linux) with a focused side terminal closes exactly that terminal.
New terminals are named "Terminal N" using the lowest free number, and tabs pick up the live title from the shell or running program, so a dev server or editor names its own tab.
### Switching Between Terminal and Agent Manager
@@ -62,7 +62,31 @@ These tools help Kilo Code run commands:
These tools help Kilo Code access web content:
- `webfetch` - Fetches a URL and returns the content
- `websearch` - Searches the web (available to Kilo/OpenRouter users)
- `websearch` - Searches the web
#### Web Search Availability
`websearch` is available automatically with the Kilo provider. For models from other providers it is off by default; enable it for all providers by setting `web_search` in `kilo.jsonc`:
```json
{
"web_search": true
}
```
In the VS Code extension, the same option lives under **Settings → Web Tools → Web Search → Enable for All Providers**. The `KILO_ENABLE_EXA` and `KILO_ENABLE_PARALLEL` environment flags also enable it.
#### Web Search Providers
`websearch` routes through the Exa or Parallel search providers. When the Exa provider is used and you are signed into Kilo, requests go through the Kilo proxy automatically — no separate Exa API key is required. Setting `EXA_API_KEY` uses your own Exa key instead. Exa searches return at most 10 results.
Set the `KILO_WEBSEARCH_PROVIDER` environment variable to force a provider:
| Value | Behavior |
|---|---|
| `exa` | Use Exa — through the Kilo proxy when signed in, through `EXA_API_KEY` when set |
| `parallel` | Use Parallel |
| `kilo-exa` | Always route Exa searches through the Kilo proxy (requires Kilo sign-in) |
### Browser Tools
@@ -43,6 +43,30 @@ The underlying models behind each Auto Model tier are updated server-side as bet
You get lean costs on routine work and stronger models when the work demands it — with no manual switching.
### Custom Efficient pools
You can constrain `kilo-auto/efficient` to the exact models you trust by configuring an **Efficient model pool** on the **Auto routing** card:
- **Personal** — your [profile page](https://app.kilo.ai/profile)
- **Organization** — your organization's **Providers & Models** page. Owners and billing managers can edit; members see a read-only view.
A pool holds 110 exact model and thinking-variant pairs. Variants stay distinct, so the same model with different thinking variants (for example `max` and `xhigh`) counts as separate entries. Leave the pool empty to inherit: an organization without a pool uses each member's personal pool, and a member without a personal pool uses the platform pool.
New entries are benchmarked on demand before they can serve traffic. Each entry shows a status:
| Status | Meaning |
|---|---|
| Benchmarking | The pair is being measured and is not used for routing yet |
| Ready | Proven accurate enough and eligible for routing |
| Failed | Benchmarking failed — retry the entry |
| Unavailable | The model or variant is no longer in your catalog — remove the entry |
Routing decides only among ready entries. If no pool entry can serve a request, the request falls back to the Balanced tier, so quality never drops below Balanced.
{% callout type="note" %}
You can benchmark up to 10 new or retried pairs per owner per rolling 24 hours. Entries that are already ready or benchmarking don't count against this limit.
{% /callout %}
{% callout type="warning" title="Data handling for Auto Free" %}
Auto Free may route your requests to providers that log prompts and outputs and use them to improve their services. Do not submit personal or confidential data when using Auto Free. In particular, it may route to NVIDIA's free endpoints.
@@ -16,7 +16,7 @@ Browser Use requires an advanced agentic model. It is typically most reliable wi
{% tabs %}
{% tab label="VSCode" %}
Browser automation is built into the extension and requires no manual setup. Enable it from **Settings → Browser** and Kilo handles the rest automatically.
Browser automation is built into the extension and requires no manual setup. Enable it from **Settings → Web Tools → Browser Automation** and Kilo handles the rest automatically.
{% /tab %}
{% tab label="CLI" %}
@@ -93,7 +93,7 @@ Key characteristics:
{% tabs %}
{% tab label="VSCode" %}
Browser automation settings are available under **Settings → Browser**:
Browser automation settings are available under **Settings → Web Tools → Browser Automation**:
- **Enable browser automation**: Toggle to enable or disable browser automation
- **Headless mode**: Run the browser without a visible window (default: disabled)
@@ -54,6 +54,8 @@ When you are signed in to the enabled Kilo provider, a microphone button appears
3. Click again to stop recording
4. Your speech is transcribed into text
You can also use **Cmd/Ctrl+K** while a Kilo prompt or review comment field is focused. Tap it to start or stop recording, or hold it while speaking and release to transcribe and submit the focused field. Press it during transcription to cancel.
The feature includes real-time audio level visualization and voice activity detection to automatically detect when you're speaking.
---
@@ -123,6 +123,7 @@ The `kilo console` command and its browser interface are deprecated and will be
| `/help` | - | Show help |
| `/reload` | - | Reload config, skills, agents, and commands from disk |
| `/editor` | - | Open external editor |
| `/auto-approve` | `/autoapprove`, `/approve-all`, `/approveall` | Toggle auto-approve mode for all permission prompts (saved to global config) |
| `/exit` | `/quit`, `/q` | Exit the app |
#### Kilo Gateway Commands (when connected)
@@ -542,6 +543,8 @@ This instructs the AI to proceed without user input.
- `124`: Timeout (task exceeded time limit)
- `1`: Error (initialization or execution failure)
Without `--auto`, a non-interactive run cannot prompt for approval and auto-rejects any permission request it receives. If a run auto-rejected at least one request, it exits `1` with a stderr diagnostic naming the cause, since the task likely did not complete. Pass `--auto` for autonomous use.
### Example CI/CD Integration
```yaml
@@ -124,6 +124,7 @@ When remote mode is enabled in the CLI, your active local sessions appear in the
- **Agent questions** appear in both places — answer wherever you are
- **Permission requests** route to your active connection
- **Full editing capabilities** work remotely
- **Session renames** sync in both directions between the CLI and the web or mobile app
### Enabling Remote Mode
@@ -16,3 +16,13 @@ Open **Settings → Tools → Kilo Code** to configure the plugin. The JetBrains
- **Auto-Approve** — set per-tool permission levels (Allow / Ask / Deny) and manage granular command and path exceptions without editing config by hand. Permission prompts offer one-time approvals alongside saved allow/reject rules. See [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions) for the shared permission model.
- **Context** — toggle auto-compaction, set the auto-compaction limit (the percentage of the model window that triggers compaction), enable pruning of old tool outputs, and manage file watcher ignore patterns. See [Context Condensing](/docs/customize/context/context-condensing) and [.kilocodeignore](/docs/customize/context/kilocodeignore) for what these settings control.
- **Agent Behavior → Skills** — inspect loaded skills, add extra skill sources (local paths or remote URLs), edit or remove custom skills, and open skill files in the editor. See [Skills](/docs/customize/skills) for the skill format and discovery rules.
## Reviewing session changes
- **Modified files per turn** — each assistant turn that changed files shows a **Modified** card with the affected files and their diff stats. Expand a file to see its diff inline, or open all of the turn's changes in the **Changed files** diff viewer.
- **Branch comparison** — when the workspace differs from the base branch, the session header shows a changes badge. Click it (**Compare with base branch**) to open a diff editor with a file tree and per-file navigation.
- **Stale diff refresh** — diff views detect when files change on disk and offer a **Refresh** action to reload them instead of showing outdated content.
## Permission requests
When the agent asks for several approvals at once, permission requests queue up instead of replacing each other. Resolve the current request to advance to the next one in the queue.
@@ -21,7 +21,9 @@ The mobile app lets you:
- Monitor and view all non-remote sessions in one place.
- Create, onboard, and manage KiloClaw instances.
- Send follow-up messages while a session is still running — they are queued and processed in order.
- Run slash commands (like `/compact`) on connected remote CLI sessions, and start a new session in the same workspace with `/new`. Older CLI versions that do not support remote commands prompt you to upgrade.
- Run slash commands (like `/compact`) on connected remote CLI sessions, and start a new session in the same workspace with `/new`. The new session inherits the current session's mode and model. Older CLI versions that do not support remote commands prompt you to upgrade.
- Clear the visible transcript of a remote CLI session with `/clear`. Clearing is client-side only, so it works on any CLI version; server history is kept and may reappear when you re-enter the session.
- Rename a remote CLI session from the app or the CLI — renames sync in both directions.
- Review GitHub pull requests end to end — diffs, checks, comments, and merging.
- Start a new session on a connected `kilo remote` CLI instance with the **Run on** picker.
@@ -50,7 +52,7 @@ The new-session screen includes a **Run on** picker that chooses where your sess
- **Cloud Agent** — the managed cloud environment (the default).
- **A connected CLI instance** — a `kilo remote` CLI running on your own machine. The picker lists the instances currently connected to your account.
Remote sessions use the CLI's own defaults, so the composer skips model, mode, and repository selection; you type your first prompt in the chat after the session starts. Sessions started in an organization context always run on the Cloud Agent, so the picker does not appear there.
Remote sessions start with the mode and model selected on the new-session screen; older CLI versions that don't accept those fields fall back to their own defaults. The workspace is always the CLI's own checkout, so there is no repository selection you type your first prompt in the chat after the session starts. The picker also appears in organization context, where the spawned session is attributed to the organization.
## Queueing follow-up messages
@@ -38,6 +38,10 @@ Key features include:
Settings apply across extension surfaces, including the sidebar and Agent Manager. The standalone CLI uses the same `~/.config/kilo/kilo.jsonc` (global) and `./kilo.jsonc` (project) files when used directly.
## Interface Language
The extension UI follows VS Code's display language by default. Override it with the `kilo-code.new.language` setting (for example `en`, `de`, `ja`, or `fa`). Right-to-left languages such as Arabic and Persian switch the layout direction automatically.
## Proxy and Certificate Troubleshooting
Kilo Code for VS Code starts its embedded runtime from the extension and applies the relevant VS Code network settings to that runtime. On managed networks, configure proxy and certificate trust in VS Code settings rather than in a separate CLI install.
@@ -332,6 +332,29 @@ my-skill/
These additional files can be referenced from your skill's instructions, allowing the agent to read documentation, execute scripts, or use templates as needed.
## Shell commands in skills
A `SKILL.md` body can embed shell commands with the `` !`command` `` syntax. When the agent loads the skill, each command runs and its standard output replaces the placeholder before the skill content reaches the model, grounding the skill in live data:
```markdown
---
name: repo-status
description: Summarize the current state of the repository
---
The working tree currently contains:
!`git status --short`
```
Because the agent decides when to load a skill, embedded commands never run silently:
- **Trusted skills only** — commands execute only in skills from trusted locations: global skills (such as `~/.kilo/skills/`, `~/.agents/skills/`, and `~/.claude/skills/`), skills built into Kilo Code, and absolute skill paths declared in global config. Project skills (`.kilo/skills/` in a repository) and skills fetched from remote URLs never execute commands; their placeholders are replaced with a marker noting the skill is untrusted.
- **Approval required** — when the agent loads a trusted skill containing commands, every command in the file is listed in a single permission prompt before anything runs. Approving runs all of them; rejecting aborts the skill load. This prompt appears even when bash commands are otherwise auto-approved, and a deny rule on any command still blocks it.
- **Kill switch** — set the `KILO_DISABLE_SKILL_SHELL` environment variable to disable embedded command execution entirely.
Commands run in the project directory with a per-command timeout, and output is truncated before inlining. Placeholders inside fenced code blocks are treated as documentation examples and never execute, and command output is never re-scanned for further placeholders.
## Example: Creating a Skill
{% tabs %}
@@ -105,6 +105,10 @@ Valid values are `expanded` and `collapsed`.
Markdown files in Kilo diff viewers can be shown as rendered Markdown instead of a raw text diff. Use the eye/code toggle in a Markdown file header, or set `kilo-code.new.diff.renderMarkdown` to `true` to render Markdown files by default.
### Web Search
See [Web Search Availability](/docs/automate/tools#web-search-availability) for how to enable the `websearch` tool for models from all providers.
### Export and Import
You can export and import settings from the **About Kilo Code** tab in the Settings UI:
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a7d7d047f14440d3dafc8a202131af6e755f80875012fd934a56580de21d36d5
size 52887
oid sha256:c3863da7e15cc40467319046521fbbfed0ee6ceab9f1f3ad03adb8941f454666
size 52895
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bcb4fc19e832e52e4eda969d66be90cd4c6df26544bea0d117f9d3412dfcf9d9
size 33866
oid sha256:94227c822d88aa83f286136c05d92c8209b2cb08eaf6870cf79172aa004e8402
size 39173
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3b1eb6cf15d5c2dc66c769a91fcf09bcf25bc2e8d17696ad0e9ab4181ed3038c
size 658647
oid sha256:fd794db4d2c038984f9e753ba70b8b0473d97a16713a9ff7255f0d2cf505d41a
size 758306
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-gateway",
"version": "7.4.18",
"version": "7.4.19",
"type": "module",
"license": "MIT",
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-i18n",
"version": "7.4.18",
"version": "7.4.19",
"type": "module",
"license": "MIT",
"description": "Kilo-specific i18n translations and overrides",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-indexing",
"version": "7.4.18",
"version": "7.4.19",
"type": "module",
"license": "MIT",
"description": "Standalone indexing engine and host helpers for Kilo Code",
+11
View File
@@ -1,5 +1,15 @@
# Changelog
## 7.4.18
### Patch Changes
- [#12746](https://github.com/Kilo-Org/kilocode/pull/12746) [`1a506a7`](https://github.com/Kilo-Org/kilocode/commit/1a506a712c43d317a5a34b250df16845b641eff8) - Keep the JetBrains prompt send/stop button in sync when attachments are added or removed while a session is busy.
- [#12746](https://github.com/Kilo-Org/kilocode/pull/12746) [`64f0373`](https://github.com/Kilo-Org/kilocode/commit/64f0373056b75546a015816dc0f18b1e380ad93f) - Fix JetBrains diff views to show compact workspace-relative file paths and keep added-file content visible in large branch diffs.
- [#12746](https://github.com/Kilo-Org/kilocode/pull/12746) [`c1f6a75`](https://github.com/Kilo-Org/kilocode/commit/c1f6a75377b438edfc5c3b5dd85ebdc301302e7a) - Fix JetBrains chat transcripts rendering cropped when opening existing sessions.
## 7.5.0
### Minor Changes
@@ -150,6 +160,7 @@
## [7.0.12-rc.4] - 2026-08-01
### Fixed
- Improve large branch diff performance by capping huge inline diff previews, compacting diff tree paths, and allowing horizontal scrolling for long file names.
- Reflow existing long chat sessions after they load so transcripts lay out at the correct width without needing to resize the tool window.
- Keep the prompt send/stop button synchronized when attachments are added, removed, or cleared while a session is busy.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-memory",
"version": "7.4.18",
"version": "7.4.19",
"type": "module",
"license": "MIT",
"description": "Project memory storage, indexing, recall, and command helpers for Kilo Code",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/sandbox",
"version": "7.4.18",
"version": "7.4.19",
"type": "module",
"license": "MIT",
"private": true,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-telemetry",
"version": "7.4.18",
"version": "7.4.19",
"type": "module",
"license": "MIT",
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
@@ -85,6 +85,19 @@ describe("TelemetryEvent", () => {
})
describe("Telemetry", () => {
test("skips identity updates when disabled", async () => {
const enabled = spyOn(Client, "isEnabled").mockReturnValue(false)
const update = spyOn(Identity, "updateFromKiloAuth").mockResolvedValue()
try {
await Telemetry.updateIdentity("token")
expect(update).not.toHaveBeenCalled()
} finally {
enabled.mockRestore()
update.mockRestore()
}
})
test("includes host OS properties", () => {
const capture = spyOn(Client, "capture").mockImplementation(() => {})
+2
View File
@@ -105,6 +105,8 @@ export namespace Telemetry {
}
export async function updateIdentity(token: string | null, accountId?: string): Promise<void> {
if (!isEnabled()) return
const previousId = Identity.getDistinctId()
await Identity.updateFromKiloAuth(token, accountId)
+4 -2
View File
@@ -1,11 +1,12 @@
{
"name": "@kilocode/kilo-ui",
"version": "7.4.18",
"version": "7.4.19",
"type": "module",
"license": "MIT",
"exports": {
"./font": "./src/components/font.tsx",
"./button": "./src/components/button.tsx",
"./chart": "./src/components/chart.tsx",
"./code": "./src/components/code.tsx",
"./diff": "./src/components/diff.tsx",
"./diff-ssr": "./src/components/diff-ssr.tsx",
@@ -128,6 +129,7 @@
"motion": "12.34.5",
"motion-dom": "12.34.3",
"motion-utils": "12.29.2",
"strip-ansi": "7.1.2"
"strip-ansi": "7.1.2",
"chart.js": "4.5.1"
}
}
+14
View File
@@ -0,0 +1,14 @@
[data-component="chart-container"] {
padding: 12px;
}
[data-slot="chart-render"] {
width: 100%;
max-height: 300px;
}
[data-slot="chart-error"] {
padding: 12px;
color: var(--text-weak);
font-size: var(--kilo-font-size-12, 12px);
}
+146
View File
@@ -0,0 +1,146 @@
/** @jsxImportSource solid-js */
import { createEffect, createSignal, onCleanup } from "solid-js"
import { Chart, registerables } from "chart.js"
import { BasicTool } from "./basic-tool"
import type { ToolProps } from "./message-part"
import { busy } from "./tool-utils"
Chart.register(...registerables)
function getThemeColors() {
const style = getComputedStyle(document.documentElement)
const get = (v: string, fallback: string) => style.getPropertyValue(v).trim() || fallback
return {
text: get("--text-base", "#FAFAFA"),
textWeak: get("--text-weak", "#A3A3A3"),
border: get("--border-weak-base", "#FFFFFF1A"),
surface: get("--surface-raised-base", "#202020"),
series: [
get("--vscode-charts-blue", "#3B82F6"),
get("--vscode-charts-green", "#22C55E"),
get("--vscode-charts-purple", "#A855F7"),
get("--vscode-charts-orange", "#F97316"),
get("--vscode-charts-red", "#EF4444"),
get("--vscode-charts-yellow", "#EAB308"),
],
}
}
type ChartConfig = {
type: string
data: {
labels?: string[]
datasets: {
label?: string
data: number[] | { x: number | string; y: number; r?: number }[]
backgroundColor?: string | string[]
borderColor?: string | string[]
[key: string]: unknown
}[]
}
options?: Record<string, unknown>
}
export function ChartTool(props: ToolProps) {
const [canvas, setCanvas] = createSignal<HTMLCanvasElement>()
const [error, setError] = createSignal<string>()
let rendered = false
createEffect(() => {
const el = canvas()
const raw = props.output
if (!el || !raw || busy(props.status) || rendered) return
let config: ChartConfig
try {
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object" || !parsed.type || !parsed.data) {
setError("Invalid chart config — must include type and data")
return
}
config = parsed
} catch (e) {
// output is not valid JSON — model likely passed an unsupported type
console.warn("[Kilo Chart]: could not parse output as JSON", e)
return
}
let chart: Chart | undefined
onCleanup(() => {
chart?.destroy()
rendered = false
})
if (!el.isConnected) return
const colors = getThemeColors()
const isPolar = config.type === "pie" || config.type === "doughnut" || config.type === "polarArea"
const datasets = config.data.datasets.map((dataset, i) => {
if (dataset.backgroundColor) return dataset
if (isPolar) {
const data = dataset.data as unknown[]
return {
...dataset,
backgroundColor: data.map((_, j) => colors.series[j % colors.series.length]),
borderColor: data.map((_, j) => colors.series[j % colors.series.length]),
}
}
return {
backgroundColor: colors.series[i % colors.series.length],
borderColor: colors.series[i % colors.series.length],
...dataset,
}
})
try {
chart = new Chart(el, {
type: config.type as any,
data: { ...config.data, datasets } as any,
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
labels: { color: colors.textWeak },
},
},
scales: {
x: {
ticks: { color: colors.textWeak },
grid: { color: colors.border },
border: { color: colors.border },
},
y: {
ticks: { color: colors.textWeak },
grid: { color: colors.border },
border: { color: colors.border },
},
},
...config.options,
},
})
rendered = true
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to render chart")
}
})
return (
<BasicTool
{...props}
icon="bullet-list"
trigger={{
title: props.input?.title ?? "Chart",
subtitle: props.input?.description ?? undefined,
args: [],
}}
defaultOpen={props.defaultOpen ?? true}
>
<div data-component="chart-container">
{error() ? <div data-slot="chart-error">{error()}</div> : <canvas ref={setCanvas} data-slot="chart-render" />}
</div>
</BasicTool>
)
}
@@ -3097,3 +3097,9 @@ ToolRegistry.register({
)
},
})
import { ChartTool } from "./chart"
ToolRegistry.register({
name: "chart",
render: ChartTool,
})
+1
View File
@@ -10,6 +10,7 @@
@import "../components/auto-approve-bar.css";
@import "../components/button.css";
@import "../components/card.css";
@import "../components/chart.css";
@import "../components/chat-input.css";
@import "../components/checkbox.css";
@import "../components/code.css";
+36
View File
@@ -1,5 +1,41 @@
# kilo-code
## 7.4.19
### Minor Changes
- [#12729](https://github.com/Kilo-Org/kilocode/pull/12729) [`ce7984f`](https://github.com/Kilo-Org/kilocode/commit/ce7984fc4247fb2805990ef62e1b1d9f286de9d9) - Configure a model and reasoning variant for each workflow from Agent Behaviour settings.
### Patch Changes
- [#12796](https://github.com/Kilo-Org/kilocode/pull/12796) [`20d1648`](https://github.com/Kilo-Org/kilocode/commit/20d1648e7a10f981fde09fc4d5e9de5c89b1dda8) - Fix Agent Manager mode shortcuts in the New Worktree dialog so the selected mode and its matching model stay in sync.
- [#12725](https://github.com/Kilo-Org/kilocode/pull/12725) [`f239f36`](https://github.com/Kilo-Org/kilocode/commit/f239f36f65a1edb5ef89748933b49b3f21639a45) - Show aggregate added and removed line counts for multi-file patch tool calls.
- [#12803](https://github.com/Kilo-Org/kilocode/pull/12803) [`9819c1c`](https://github.com/Kilo-Org/kilocode/commit/9819c1c315bd05631862ad6f21bbef21d73b7bdc) - Restore Agent Manager sections and worktree drag-and-drop when multiple projects are shown, with ordering and section moves scoped to the owning project.
- [#12836](https://github.com/Kilo-Org/kilocode/pull/12836) [`4f7dfe6`](https://github.com/Kilo-Org/kilocode/commit/4f7dfe65cc85ef7d103a9d0d93f86b030f7142e0) - Remove the duplicate border along the Kilo Code sidebar edge.
- [#12814](https://github.com/Kilo-Org/kilocode/pull/12814) [`cd7d053`](https://github.com/Kilo-Org/kilocode/commit/cd7d053f03fa7b5434b6fcae9e2c68e415b54331) - Start voice input faster on macOS with native AVFoundation capture.
- [#12795](https://github.com/Kilo-Org/kilocode/pull/12795) [`37559f8`](https://github.com/Kilo-Org/kilocode/commit/37559f8643ef8ecb68ee04eae770c9b47feee88a) - Restore keyboard focus to the prompt or pending question when switching Agent Manager worktrees and sessions.
- [#12799](https://github.com/Kilo-Org/kilocode/pull/12799) [`7cfaeb2`](https://github.com/Kilo-Org/kilocode/commit/7cfaeb2c8dbe7355b1d92d9a8f62d6bdfb7f6d46) - Show and hide Agent Manager worktree hover cards instantly.
- [#12805](https://github.com/Kilo-Org/kilocode/pull/12805) [`8490124`](https://github.com/Kilo-Org/kilocode/commit/84901241c0c6dcb735d7e229bd9bce2c0f7e5c36) - Keep the prompt controls at a consistent height when the model selector shows the prompt-training indicator.
- [#12810](https://github.com/Kilo-Org/kilocode/pull/12810) [`e04f653`](https://github.com/Kilo-Org/kilocode/commit/e04f6531bf3ef2eed5cde6e1d81a4983f276b9ca) - Update model search results instantly and keep the active match visible while typing.
- [#12815](https://github.com/Kilo-Org/kilocode/pull/12815) [`3d4294e`](https://github.com/Kilo-Org/kilocode/commit/3d4294e3bb50630159233c8063b253dc5f3da8d3) - Allow Agent Manager sessions to move their worktree between sections or ungroup it through the `agent_manager` tool.
- [#12798](https://github.com/Kilo-Org/kilocode/pull/12798) [`dfc1607`](https://github.com/Kilo-Org/kilocode/commit/dfc16076934decb6578730ea41f9705e2acc0923) - Bind voice input to Cmd/Ctrl+K in Kilo prompt and review comment fields, with hold-to-talk and release-to-send support.
- [#12801](https://github.com/Kilo-Org/kilocode/pull/12801) [`4810766`](https://github.com/Kilo-Org/kilocode/commit/4810766d058f53084b8a6cb0fe4d1ff42973e89b) - Preserve prompt or Agent Manager terminal focus independently for each session when switching sessions.
- [#12812](https://github.com/Kilo-Org/kilocode/pull/12812) [`6ae16f9`](https://github.com/Kilo-Org/kilocode/commit/6ae16f9d6272b3bfba998bb516cba7667935d6e4) - Start a fresh shell in the same Agent Manager terminal tab when the user types after the terminal ends.
- [#12733](https://github.com/Kilo-Org/kilocode/pull/12733) [`63220e0`](https://github.com/Kilo-Org/kilocode/commit/63220e019c048d6df639a4b2fbd4c5c3f124547f) Thanks [@rakshith1928](https://github.com/rakshith1928)! - Fix skill folder path and URL rows clipping and pushing the remove (×) button off-screen in narrow Skills settings panels. Long paths and URLs now truncate within their row, and hovering a truncated value shows the full path or URL in a tooltip.
## 7.4.18
### Minor Changes
+13 -7
View File
@@ -2,7 +2,7 @@
"name": "kilo-code",
"displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete",
"description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.",
"version": "7.4.18",
"version": "7.4.19",
"icon": "assets/icons/logo-outline-black.png",
"galleryBanner": {
"color": "#FFFFFF",
@@ -1191,23 +1191,29 @@
},
"scripts": {
"prepare:cli-binary": "bun script/local-bin.ts",
"compile": "bun run prepare:cli-binary -- --force && bun run rebuild-sdk && bun run typecheck && bun run lint && node esbuild.js",
"prepare:sdk": "bun script/prepare-sdk.ts",
"build:launch": "bun run prepare:cli-binary && bun run prepare:sdk && bun run build:check:production",
"compile": "bun run prepare:cli-binary -- --force && bun run rebuild-sdk && bun run build:check",
"watch": "bun run rebuild-sdk && bun run --parallel watch:esbuild watch:tsc",
"watch:esbuild": "bun run prepare:cli-binary && node esbuild.js --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"watch:cli": "bun script/watch-cli.ts",
"package": "bun run prepare:cli-binary && bun run rebuild-sdk && bun run typecheck && bun run lint && node esbuild.js --production",
"package": "bun run prepare:cli-binary && bun run rebuild-sdk && bun run build:check:production",
"build:check": "bun run --parallel check-types check-types:webview lint bundle",
"build:check:production": "bun run --parallel check-types check-types:webview lint bundle:production",
"bundle": "bun esbuild.js",
"bundle:production": "bun esbuild.js --production",
"compile-tests": "tsc -p . --outDir out",
"watch-tests": "tsc -p . -w --outDir out",
"pretest": "bun run compile-tests && bun run compile && bun run lint",
"check-types": "tsc --noEmit",
"check-types:webview": "tsc --noEmit --project webview-ui/tsconfig.json",
"typecheck": "bun run check-types && bun run check-types:webview",
"check-types": "tsgo --noEmit",
"check-types:webview": "tsgo --noEmit --project webview-ui/tsconfig.json",
"typecheck": "bun run --parallel check-types check-types:webview",
"format": "prettier --write .",
"format:check": "prettier --check .",
"knip": "knip",
"check-kilocode-change": "! grep -rIn 'kilocode_change' . ../kilo-ui/ --exclude='package.json' --exclude='*.md' --exclude-dir='node_modules' --exclude-dir='dist' | grep -v '`kilocode_change`'",
"lint": "eslint src webview-ui",
"lint": "eslint --cache --cache-strategy content --cache-location node_modules/.cache/eslint src webview-ui",
"test": "vscode-test",
"test:unit": "bun test tests/unit/ --dots",
"rebuild-sdk": "bun run --cwd ../sdk/js build",
+1 -1
View File
@@ -241,7 +241,7 @@ async function compile() {
}
console.log("[launch] Building extension...")
await $`bun run package`.cwd(root).env(cleanEnv(process.env))
await $`bun run build:launch`.cwd(root).env(cleanEnv(process.env))
console.log("[launch] Build complete")
}
@@ -0,0 +1,84 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { createHash } from "node:crypto"
import { existsSync, mkdirSync } from "node:fs"
import { dirname, join } from "node:path"
const root = join(import.meta.dir, "..")
const repo = join(root, "..", "..")
const sdk = join(repo, "packages", "sdk", "js")
const cache = join(root, "node_modules", ".cache", "sdk-build.json")
const inputs = [
"package.json",
"bun.lock",
"packages/opencode",
"packages/core",
"packages/effect-drizzle-sqlite",
"packages/effect-sqlite-node",
"packages/kilo-gateway",
"packages/kilo-indexing",
"packages/kilo-memory",
"packages/kilo-sandbox",
"packages/kilo-telemetry",
"packages/llm",
"packages/plugin",
"packages/plugin-atomic-chat",
"packages/server",
"packages/util",
"packages/sdk/js/package.json",
"packages/sdk/js/tsconfig.json",
"packages/sdk/js/script",
"packages/kilo-vscode/script/prepare-sdk.ts",
]
const outputs = ["packages/sdk/js/src"]
function log(msg: string) {
console.log(`[prepare-sdk] ${msg}`)
}
async function fingerprint(paths: string[]) {
const [tree, diff, extra] = await Promise.all([
$`git ls-tree -r HEAD -- ${paths}`.cwd(repo).quiet(),
$`git diff --binary HEAD -- ${paths}`.cwd(repo).quiet(),
$`git ls-files --others --exclude-standard -z -- ${paths}`.cwd(repo).quiet(),
])
const hash = createHash("sha256").update(tree.text()).update(diff.text())
const files = extra.text().split("\0").filter(Boolean).sort()
for (const file of files) {
hash.update(file)
hash.update(new Uint8Array(await Bun.file(join(repo, file)).arrayBuffer()))
}
return hash.digest("hex")
}
async function load() {
const file = Bun.file(cache)
if (!(await file.exists())) return
try {
const value: unknown = await file.json()
if (!value || typeof value !== "object") return
const input = Reflect.get(value, "input")
const output = Reflect.get(value, "output")
if (typeof input === "string" && typeof output === "string") return { input, output }
} catch (err) {
log(`Ignoring invalid cache: ${err instanceof Error ? err.message : String(err)}`)
}
}
const input = await fingerprint(inputs)
const prior = await load()
const ready = existsSync(join(sdk, "dist", "index.js")) && existsSync(join(sdk, "dist", "v2", "index.js"))
if (prior?.input === input && prior.output === (await fingerprint(outputs)) && ready) {
log("SDK inputs and generated output are unchanged")
process.exit(0)
}
log("SDK inputs changed, rebuilding generated client")
await $`bun run build`.cwd(sdk)
mkdirSync(dirname(cache), { recursive: true })
await Bun.write(cache, JSON.stringify({ input, output: await fingerprint(outputs) }) + "\n")
+1 -1
View File
@@ -4810,7 +4810,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
workerUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "shiki-worker.js")),
title: "Kilo Code",
port: this.connectionService.getServerInfo()?.port,
extraStyles: `.container { height: 100%; display: flex; flex-direction: column; height: 100vh; border-right: 1px solid var(--border-weak-base); }`,
extraStyles: `.container { height: 100vh; }`,
})
}
@@ -242,6 +242,7 @@ export class AgentManagerProvider implements Disposable {
},
stats: (refresh) => this.statsPoller.snapshot(refresh),
prs: () => this.prBridge.snapshot(),
push: () => this.pushState(),
managed: (id) => this.panelSessions.has(id) || !!this.state?.getSession(id),
close: async (id) => {
await this.onCloseSession(id)
@@ -733,7 +734,11 @@ export class AgentManagerProvider implements Disposable {
return null
}
if (m.type === "agentManager.setWorktreeOrder") {
this.state?.setWorktreeOrder(m.order)
const state = this.getStateManager()
if (state) {
state.setWorktreeOrder(m.order)
this.pushState()
}
return null
}
if (m.type === "agentManager.setSessionsCollapsed") {
@@ -7,6 +7,7 @@ import type { PRStatus } from "./types"
import type { WorktreeStateManager } from "./WorktreeStateManager"
import {
OrchestrationError,
move,
overview,
prompt,
sameManagedDirectory,
@@ -26,11 +27,13 @@ type Request =
| (RequestBase & { operation: "overview"; filter?: OverviewFilter })
| (RequestBase & { operation: "prompt"; targetSessionID: string; prompt: string })
| (RequestBase & { operation: "stop"; targetSessionID: string })
| (RequestBase & { operation: "move"; targetSessionID: string; sectionID: string | null })
type Result =
| { operation: "overview"; overview: Overview }
| { operation: "prompt"; sessionID: string; delivered: true }
| { operation: "stop"; sessionID: string; stopped: true }
| { operation: "move"; sessionID: string; sectionID: string | null; moved: true }
interface Failure {
code: FailureCode | "cancelled" | "disconnected" | "timeout"
@@ -43,6 +46,7 @@ interface Options {
state(): WorktreeStateManager | undefined
stats(refresh?: boolean): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }>
prs(): Map<string, PRStatus>
push(): void
managed(sessionID: string): boolean
close(sessionID: string): Promise<void>
log(...args: unknown[]): void
@@ -279,6 +283,19 @@ export class AgentManagerOrchestrationBridge {
if (this.disposed || active.cancelled) return
return { result: { operation: "prompt", sessionID: request.targetSessionID, delivered: true } }
}
if (request.operation === "move") {
move({ state, sessionID: request.targetSessionID, sectionID: request.sectionID })
this.options.push()
if (this.disposed || active.cancelled) return
return {
result: {
operation: "move",
sessionID: request.targetSessionID,
sectionID: request.sectionID,
moved: true,
},
}
}
if (!this.options.managed(request.targetSessionID)) {
throw new OrchestrationError("unknown_session", "The session is not managed by this Agent Manager workspace")
}
@@ -13,6 +13,7 @@ export type FailureCode =
| "host_error"
| "stale_session"
| "unavailable_session"
| "unknown_section"
| "unknown_session"
| "workspace_unavailable"
@@ -362,3 +363,20 @@ export async function prompt(input: {
{ throwOnError: true },
)
}
export function move(input: { state: WorktreeStateManager; sessionID: string; sectionID: string | null }): void {
const session = input.state.getSession(input.sessionID)
if (!session)
throw new OrchestrationError("unknown_session", "The session is not managed by this Agent Manager workspace")
if (!session.worktreeId) {
if (input.sectionID === null) return
throw new OrchestrationError(
"unavailable_session",
"Only sessions attached to a worktree can be assigned to a section",
)
}
if (input.sectionID !== null && !input.state.getSection(input.sectionID)) {
throw new OrchestrationError("unknown_section", "The target section is not managed by this Agent Manager workspace")
}
input.state.moveToSection([session.worktreeId], input.sectionID)
}
@@ -626,6 +626,7 @@ interface SetTabOrderIn {
interface SetWorktreeOrderIn {
type: "agentManager.setWorktreeOrder"
projectId?: string
order: string[]
}
@@ -34,11 +34,32 @@ type Args = {
input: string[]
}
const macScript = `
ObjC.import("AVFoundation")
ObjC.import("Foundation")
function run(args) {
const settings = $.NSMutableDictionary.alloc.init
settings.setObjectForKey($.NSNumber.numberWithUnsignedInt(1819304813), $.AVFormatIDKey)
settings.setObjectForKey($.NSNumber.numberWithDouble(16000), $.AVSampleRateKey)
settings.setObjectForKey($.NSNumber.numberWithInt(1), $.AVNumberOfChannelsKey)
settings.setObjectForKey($.NSNumber.numberWithInt(16), $.AVLinearPCMBitDepthKey)
settings.setObjectForKey($.NSNumber.numberWithBool(false), $.AVLinearPCMIsFloatKey)
settings.setObjectForKey($.NSNumber.numberWithBool(false), $.AVLinearPCMIsBigEndianKey)
const error = Ref()
const url = $.NSURL.fileURLWithPath(args[0])
const recorder = $.AVAudioRecorder.alloc.initWithURLSettingsError(url, settings, error)
if (!recorder || !recorder.prepareToRecord || !recorder.record) throw new Error("Could not start recording")
console.log("ready")
$.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile
recorder.stop
}`
let active: Recording | undefined
let starting: string | undefined
let ffmpeg: Promise<string> | undefined
export async function prewarmSpeechCapture(): Promise<void> {
if (useMacCapture(process.platform, process.env)) return
await resolveFFmpeg()
}
@@ -47,8 +68,15 @@ export async function startSpeechCapture(input: Input): Promise<boolean> {
starting = input.requestId
try {
const bin = await resolveFFmpeg()
const file = path.join(os.tmpdir(), `kilo-stt-${process.pid}-${Date.now()}.wav`)
if (useMacCapture(process.platform, process.env)) {
const result = await startMac(file, input).catch((err: unknown) => {
console.warn("[Kilo New] Native macOS speech capture failed, falling back to FFmpeg", err)
return undefined
})
if (result) return !result.stopped
}
const bin = await resolveFFmpeg()
const state = await startWithArgs(bin, file, input, await inputArgSets(bin))
return !state.stopped
} finally {
@@ -112,7 +140,7 @@ async function waitForStart(state: Recording): Promise<void> {
onError(new Error(summary(state, "Could not start microphone recording")))
}
const onData = (data: Buffer) => {
if (/Output #0|Press \[q\]|size=\s*\d+/i.test(data.toString())) done()
if (/^ready$|Output #0|Press \[q\]|size=\s*\d+/im.test(data.toString())) done()
}
const timer = setTimeout(() => {
onError(new Error(summary(state, "Timed out starting microphone recording")))
@@ -129,6 +157,21 @@ async function waitForStart(state: Recording): Promise<void> {
})
}
async function startMac(file: string, input: Input): Promise<Recording> {
const proc = spawn("/usr/bin/osascript", macCaptureArgs(file), { stdio: ["pipe", "ignore", "pipe"] })
const state = createState(input, file, proc)
await waitForStart(state)
return state
}
export function macCaptureArgs(file: string): string[] {
return ["-l", "JavaScript", "-e", macScript, file]
}
export function useMacCapture(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean {
return platform === "darwin" && !env.KILO_FFMPEG_PATH && !env.FFMPEG_PATH
}
async function startWithArgs(bin: string, file: string, input: Input, args: Args[]): Promise<Recording> {
const [first, ...rest] = args
if (!first) throw new Error(`Unsupported platform for speech input: ${process.platform}`)
@@ -138,6 +181,18 @@ async function startWithArgs(bin: string, file: string, input: Input, args: Args
: spawn(bin, ["-y", ...first.input, "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", "-f", "wav", file], {
stdio: ["pipe", "ignore", "pipe"],
})
const state = createState(input, file, proc)
try {
await waitForStart(state)
return state
} catch (err) {
if (state.stopped) return state
if (rest.length === 0) throw err
return startWithArgs(bin, file, input, rest)
}
}
function createState(input: Input, file: string, proc: ChildProcess): Recording {
const state: Recording = { ...input, file, proc, stderr: [], stopped: false }
active = state
@@ -152,15 +207,7 @@ async function startWithArgs(bin: string, file: string, input: Input, args: Args
state.stderr.push(err.message)
if (active === state && !state.stopped) active = undefined
})
try {
await waitForStart(state)
return state
} catch (err) {
if (state.stopped) return state
if (rest.length === 0) throw err
return startWithArgs(bin, file, input, rest)
}
return state
}
function pipeProcess(pipe: string[], bin: string, file: string): ChildProcess {
@@ -193,6 +193,18 @@ test("large catalogs keep the rendered tree bounded and navigate to distant mode
// The window mounts before we measure it, yet stays far smaller than the catalog.
await expect.poll(() => tree.getByRole("treeitem").count()).toBeGreaterThan(0)
await expect.poll(() => tree.getByRole("treeitem").count()).toBeLessThan(50)
await expect(page.getByRole("treeitem", { name: "Model 300" })).toBeVisible()
// Searching from deep in the catalog scrolls the first active match into view.
await tree.getByRole("treeitem").last().hover()
await tree.evaluate((el) => el.scrollTo({ top: el.scrollHeight }))
await combobox.pressSequentially("Model 5")
const first = page.getByRole("treeitem", { name: "Model 500" })
await expect(first).toBeVisible()
await expect(combobox).toHaveAttribute("aria-activedescendant", await first.getAttribute("id"))
const hovered = page.getByRole("treeitem", { name: "Model 501" })
await hovered.hover()
await expect(combobox).toHaveAttribute("aria-activedescendant", await hovered.getAttribute("id"))
// Reaching a distant model scrolls it into the mounted window and activates it.
await combobox.fill("Model 599")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"type": "module",
"version": "7.4.18",
"version": "7.4.19",
"dependencies": {},
"devDependencies": {},
"peerDependencies": {}
@@ -0,0 +1,56 @@
import { describe, expect, it } from "bun:test"
import { Window } from "happy-dom"
import { focusQuestionOption, hasQuestionOption } from "../../webview-ui/agent-manager/focus"
describe("Agent Manager focus", () => {
it("focuses the first enabled question option", () => {
const window = new Window()
const root = window.document.createElement("div")
const dock = window.document.createElement("div")
const disabled = window.document.createElement("button")
const option = window.document.createElement("button")
disabled.setAttribute("data-slot", "question-option")
disabled.disabled = true
option.setAttribute("data-slot", "question-option")
dock.setAttribute("data-component", "question-dock")
dock.append(disabled, option)
root.append(dock)
window.document.body.append(root)
expect(focusQuestionOption(root)).toBe(true)
expect(root.ownerDocument.activeElement).toBe(option)
})
it("ignores collapsed question bodies", () => {
const window = new Window()
const root = window.document.createElement("div")
const dock = window.document.createElement("div")
const body = window.document.createElement("div")
const option = window.document.createElement("button")
dock.setAttribute("data-component", "question-dock")
body.setAttribute("inert", "")
option.setAttribute("data-slot", "question-option")
body.append(option)
dock.append(body)
root.append(dock)
window.document.body.append(root)
expect(focusQuestionOption(root)).toBe(false)
expect(root.ownerDocument.activeElement).not.toBe(option)
})
it("only reports enabled options outside inert bodies", () => {
const window = new Window()
const root = window.document.createElement("div")
const dock = window.document.createElement("div")
const option = window.document.createElement("button")
dock.setAttribute("data-component", "question-dock")
option.setAttribute("data-slot", "question-option")
dock.append(option)
root.append(dock)
expect(hasQuestionOption(root)).toBe(true)
dock.setAttribute("inert", "")
expect(hasQuestionOption(root)).toBe(false)
})
})
@@ -45,6 +45,7 @@ describe("AgentManagerOrchestrationBridge", () => {
const managed = new Set(["ses_target"])
const promptAsync = mock(async () => ({ data: undefined }))
const close = mock(async () => undefined)
const push = mock(() => undefined)
const client = {
session: {
get: mock(async () => ({
@@ -97,6 +98,7 @@ describe("AgentManagerOrchestrationBridge", () => {
state: () => state,
stats: async () => ({ worktrees: [] }),
prs: () => new Map(),
push,
managed: (id) => managed.has(id),
close,
log: () => undefined,
@@ -106,7 +108,7 @@ describe("AgentManagerOrchestrationBridge", () => {
{ id: `event-${value.id}`, type: "kilocode.agent_manager.requested", properties: value } as SSEPayload,
directory,
)
return { bridge, client, close, handlers, lists, managed, promptAsync, rejections, replies, request, status }
return { bridge, client, close, handlers, lists, managed, promptAsync, push, rejections, replies, request, status }
}
const request: AgentManagerRequest = {
@@ -185,6 +187,52 @@ describe("AgentManagerOrchestrationBridge", () => {
test.bridge.dispose()
})
it("moves its own worktree into a section and then ungroups it", async () => {
const test = harness()
const section = state.addSection("Review", null)
test.request(
{
id: "amr_move",
sessionID: "ses_target",
operation: "move",
targetSessionID: "ses_target",
sectionID: section.id,
},
dir,
)
await waitFor(() => test.replies.length === 1)
const worktreeID = state.getSession("ses_target")!.worktreeId!
expect(state.getWorktree(worktreeID)?.sectionId).toBe(section.id)
expect(test.push).toHaveBeenCalledTimes(1)
expect(test.replies[0]).toEqual({
requestID: "amr_move",
directory: dir,
result: { operation: "move", sessionID: "ses_target", sectionID: section.id, moved: true },
})
test.request(
{
id: "amr_ungroup",
sessionID: "ses_target",
operation: "move",
targetSessionID: "ses_target",
sectionID: null,
},
dir,
)
await waitFor(() => test.replies.length === 2)
expect(state.getWorktree(worktreeID)?.sectionId).toBeUndefined()
expect(test.replies[1]).toEqual({
requestID: "amr_ungroup",
directory: dir,
result: { operation: "move", sessionID: "ses_target", sectionID: null, moved: true },
})
test.bridge.dispose()
})
it("stops a live panel session before it is persisted", async () => {
const test = harness()
test.managed.add("ses_live")
@@ -22,6 +22,7 @@ function scene(initial: string | null = LOCAL) {
shown: [] as string[],
errors: 0,
running: [] as Array<{ contextKey: string; terminalId: string }>,
sideFocus: [] as boolean[],
}
const tabs = () => state.current().map((term) => term.id)
const handlers = createTerminalHandlers({
@@ -49,17 +50,18 @@ function scene(initial: string | null = LOCAL) {
},
showError: () => events.errors++,
postMessage: (message) => posted.push(message as Record<string, unknown>),
onSideCreated: (_contextKey, _terminalId, focus) => events.sideFocus.push(focus),
onScriptRunning: (contextKey, terminalId) => events.running.push({ contextKey, terminalId }),
})
return { state, selection, setSelection, posted, events, handlers, dispatch }
}
function createdSide(createId: string, terminalId: string, title = "Terminal 1") {
function createdSide(createId: string, terminalId: string, title = "Terminal 1", worktreeId: string | null = null) {
return {
type: "agentManager.terminal.created",
createId,
placement: "side",
worktreeId: null,
worktreeId,
terminalId,
title,
wsUrl: `ws://${terminalId}`,
@@ -344,6 +346,20 @@ describe("Agent Manager terminal state", () => {
placement: "side",
worktreeId: "wt-1",
})
const createId = String(item.posted[0]!.createId)
expect(item.dispatch(createdSide(createId, "terminal:side", "Terminal 1", "wt-1"))).toBe(true)
expect(item.events.sideFocus).toEqual([false])
dispose()
})
})
it("focuses a side terminal only when the user explicitly opens it", () => {
createRoot((dispose) => {
const item = scene()
item.handlers.addSide()
const createId = String(item.posted[0]!.createId)
expect(item.dispatch(createdSide(createId, "terminal:side"))).toBe(true)
expect(item.events.sideFocus).toEqual([true])
dispose()
})
})
@@ -0,0 +1,45 @@
import { describe, expect, it } from "bun:test"
import { createProjectStore } from "../../webview-ui/agent-manager/project/store"
const state = (projectId: string, order: string[]) => ({
type: "agentManager.state" as const,
projectId,
worktrees: order.map((id) => ({
id,
branch: `${projectId}-${id}`,
path: `/repo/${projectId}/${id}`,
parentBranch: "main",
createdAt: "2026-01-01",
})),
sessions: [],
sections: [],
worktreeOrder: order,
})
describe("project stores", () => {
it("keeps worktree order isolated between projects", () => {
const first = createProjectStore("a")
const second = createProjectStore("b")
first.applyState(state("a", ["same", "other"]))
second.applyState(state("b", ["same", "other"]))
first.setWorktreeOrder(["other", "same"])
expect(first.worktreeOrder()).toEqual(["other", "same"])
expect(second.worktreeOrder()).toEqual(["same", "other"])
})
it("preserves live run statuses when state omits them", () => {
const store = createProjectStore("a")
store.applyState(state("a", ["same", "other"]))
store.setRunStatuses({
same: { worktreeId: "same", state: "running" },
})
store.applyState(state("a", ["other", "same"]))
expect(store.runStatuses()).toEqual({
same: { worktreeId: "same", state: "running" },
})
})
})
@@ -7,6 +7,7 @@ import {
isGrouped,
isGroupStart,
isGroupEnd,
sortWorktrees,
} from "../../webview-ui/agent-manager/section-helpers"
import type { WorktreeState, SectionState } from "../../webview-ui/src/types/messages"
@@ -112,6 +113,23 @@ describe("isGrouped", () => {
})
})
describe("sortWorktrees", () => {
it("applies persisted order", () => {
const all = [wt("a"), wt("b"), wt("c")]
expect(sortWorktrees(all, ["c", "a", "b"]).map((item) => item.id)).toEqual(["c", "a", "b"])
})
it("keeps multi-version siblings adjacent at the first group position", () => {
const all = [wt("a", { groupId: "g" }), wt("b"), wt("c", { groupId: "g" })]
expect(sortWorktrees(all, ["b", "c", "a"]).map((item) => item.id)).toEqual(["b", "c", "a"])
})
it("appends worktrees missing from persisted order", () => {
const all = [wt("a"), wt("b"), wt("c")]
expect(sortWorktrees(all, ["b"]).map((item) => item.id)).toEqual(["b", "a", "c"])
})
})
describe("isGroupStart", () => {
const list = [wt("a", { groupId: "g1" }), wt("b", { groupId: "g1" }), wt("c", { groupId: "g2" }), wt("d")]
@@ -1,5 +1,28 @@
import { describe, expect, it } from "bun:test"
import { cleanOutput, parseDshowAudioDevices } from "../../src/speech-to-text/capture"
import { cleanOutput, macCaptureArgs, parseDshowAudioDevices, useMacCapture } from "../../src/speech-to-text/capture"
describe("macCaptureArgs", () => {
it("records 16 kHz mono PCM with the built-in AVFoundation bridge", () => {
const args = macCaptureArgs("/tmp/speech.wav")
expect(args.slice(0, 3)).toEqual(["-l", "JavaScript", "-e"])
expect(args.at(-1)).toBe("/tmp/speech.wav")
expect(args[3]).toContain("AVAudioRecorder")
expect(args[3]).toContain("numberWithDouble(16000)")
expect(args[3]).toContain("numberWithInt(1), $.AVNumberOfChannelsKey")
expect(args[3]).toContain('console.log("ready")')
})
})
describe("useMacCapture", () => {
it("preserves explicit FFmpeg overrides", () => {
expect(useMacCapture("darwin", {})).toBe(true)
expect(useMacCapture("darwin", { KILO_FFMPEG_PATH: "/custom/ffmpeg" })).toBe(false)
expect(useMacCapture("darwin", { FFMPEG_PATH: "/custom/ffmpeg" })).toBe(false)
expect(useMacCapture("linux", {})).toBe(false)
expect(useMacCapture("win32", {})).toBe(false)
})
})
describe("parseDshowAudioDevices", () => {
it("extracts Windows dshow audio device names", () => {
@@ -1,6 +1,14 @@
import { describe, expect, it, mock } from "bun:test"
import { createRoot } from "solid-js"
import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages"
import {
createSpeechShortcut,
isSpeechShortcut,
SPEECH_HOLD_MS,
speechShortcutLabel,
speechShortcutValue,
toggleSpeech,
} from "../../webview-ui/src/components/speech-to-text/shortcut"
type Toast = {
actions?: Array<{ onClick: string | (() => void) }>
@@ -171,3 +179,151 @@ describe("useSpeechToText", () => {
ctx.dispose()
})
})
describe("speech shortcut", () => {
const key = (timeStamp: number, repeat = false) => ({
key: "k",
metaKey: true,
ctrlKey: false,
altKey: false,
shiftKey: false,
repeat,
timeStamp,
})
it("accepts only the platform modifier with K", () => {
expect(isSpeechShortcut({ key: "k", metaKey: true, ctrlKey: false, altKey: false, shiftKey: false }, true)).toBe(
true,
)
expect(isSpeechShortcut({ key: "k", metaKey: false, ctrlKey: true, altKey: false, shiftKey: false }, true)).toBe(
false,
)
expect(isSpeechShortcut({ key: "k", metaKey: false, ctrlKey: true, altKey: false, shiftKey: false }, false)).toBe(
true,
)
expect(isSpeechShortcut({ key: "k", metaKey: true, ctrlKey: false, altKey: false, shiftKey: false }, false)).toBe(
false,
)
expect(isSpeechShortcut({ key: "k", metaKey: true, ctrlKey: false, altKey: true, shiftKey: false }, true)).toBe(
false,
)
expect(isSpeechShortcut({ key: "k", metaKey: true, ctrlKey: false, altKey: false, shiftKey: true }, true)).toBe(
false,
)
})
it("exposes platform-specific labels for the focused input", () => {
expect(speechShortcutLabel(true)).toBe("⌘K")
expect(speechShortcutValue(true)).toBe("Meta+K")
expect(speechShortcutLabel(false)).toBe("Ctrl+K")
expect(speechShortcutValue(false)).toBe("Control+K")
})
it("does not handle a shortcut when speech is unavailable", () => {
const ctx = setup()
let started = 0
expect(toggleSpeech(ctx.speech, true, () => started++)).toBe(false)
expect(started).toBe(0)
ctx.dispose()
})
it("ignores key repeat and keeps a quick press recording", () => {
const ctx = setup()
const shortcut = createSpeechShortcut({
speech: ctx.speech,
disabled: () => false,
start: () => ctx.speech.start({ model: "scribe", insert: () => {} }),
finish: () => ctx.speech.stop(),
mac: true,
})
expect(shortcut.down(key(0))).toBe(true)
expect(shortcut.down(key(50, true))).toBe(true)
expect(shortcut.down(key(100, true))).toBe(true)
expect(shortcut.up(key(SPEECH_HOLD_MS - 1))).toBe(true)
expect(ctx.sent).toHaveLength(1)
expect(ctx.speech.state()).toBe("starting")
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })
expect(ctx.speech.state()).toBe("recording")
ctx.dispose()
})
it("stops recording on a second quick press", () => {
const ctx = setup()
const shortcut = createSpeechShortcut({
speech: ctx.speech,
disabled: () => false,
start: () => ctx.speech.start({ model: "scribe", insert: () => {} }),
finish: () => ctx.speech.stop(),
mac: true,
})
shortcut.down(key(0))
shortcut.up(key(100))
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })
shortcut.down(key(200))
shortcut.down(key(250, true))
shortcut.up(key(300))
expect(ctx.speech.state()).toBe("transcribing")
expect(ctx.sent[1]).toEqual({ type: "speechToTextStop", requestId: start.requestId })
ctx.dispose()
})
it("queues transcription and submit when a held press is released during startup", () => {
const ctx = setup()
let submitted = 0
const shortcut = createSpeechShortcut({
speech: ctx.speech,
disabled: () => false,
start: () => ctx.speech.start({ model: "scribe", insert: () => {} }),
finish: (submit) => ctx.speech.stop(submit ? { done: () => submitted++ } : undefined),
mac: true,
})
shortcut.down(key(0))
shortcut.up(key(SPEECH_HOLD_MS))
expect(ctx.speech.state()).toBe("starting")
expect(ctx.sent).toHaveLength(1)
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })
expect(ctx.speech.state()).toBe("transcribing")
expect(ctx.sent[1]).toEqual({ type: "speechToTextStop", requestId: start.requestId })
ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Held prompt" })
expect(submitted).toBe(1)
ctx.dispose()
})
it("submits when macOS suppresses K key-up and only reports Command release", () => {
const ctx = setup()
let submitted = 0
const shortcut = createSpeechShortcut({
speech: ctx.speech,
disabled: () => false,
start: () => ctx.speech.start({ model: "scribe", insert: () => {} }),
finish: (submit) => ctx.speech.stop(submit ? { done: () => submitted++ } : undefined),
mac: true,
})
shortcut.down(key(0))
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })
expect(shortcut.up({ key: "Meta", timeStamp: SPEECH_HOLD_MS })).toBe(true)
expect(ctx.speech.state()).toBe("transcribing")
expect(ctx.sent[1]).toEqual({ type: "speechToTextStop", requestId: start.requestId })
ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Held prompt" })
expect(submitted).toBe(1)
ctx.dispose()
})
})
@@ -159,6 +159,7 @@ import {
isGrouped,
isGroupStart,
isGroupEnd,
sortWorktrees,
type TopLevelItem,
} from "./section-helpers"
import {} from "./section-dnd"
@@ -174,6 +175,7 @@ import { SidebarToggleButton } from "./SidebarToggleButton"
import { setTabWidths } from "./tab-widths"
import { buildShortcutCategories } from "./shortcuts"
import { tracker } from "./telemetry"
import { createChatFocus, hasQuestionOption } from "./focus"
import "./agent-manager.css"
import "./agent-manager-review.css"
import { cycleAgent as cycle } from "../src/context/session-agent"
@@ -379,7 +381,96 @@ const AgentManagerContent: Component = () => {
const sel = selection()
return sel === null ? null : nsKey(sel)
})
const requestChatFocus = createChatFocus({
term: () => terms.activeId(),
history,
review: reviewActive,
})
createEffect(
on(
() => {
const id = session.currentSessionID()
return `${id ?? ""}:${session
.scopedQuestions(id)
.map((question) => question.id)
.join(",")}`
},
() => {
requestChatFocus()
},
{ defer: true },
),
)
type FocusOwner = "prompt" | { terminal: string }
const focusMemory = new Map<string, FocusOwner>()
const focusKey = () => {
const context = terms.sideKey()
const sessionID = session.currentSessionID() ?? activePendingId() ?? "new"
return `${context}:${sessionID}`
}
const forgetSessionFocus = (sessionID: string) => {
for (const key of focusMemory.keys()) if (key.endsWith(`:${sessionID}`)) focusMemory.delete(key)
}
const forgetContextFocus = (context: string) => {
for (const key of focusMemory.keys()) if (key.startsWith(`${context}:`)) focusMemory.delete(key)
}
const forgetTerminalFocus = (terminalID: string) => {
for (const [key, owner] of focusMemory) {
if (owner !== "prompt" && owner.terminal === terminalID) focusMemory.delete(key)
}
}
const rememberPromptFocus = (focused: boolean) => {
if (focused) focusMemory.set(focusKey(), "prompt")
}
const terminalVisible = () => sidePanel() === "terminal" && !history() && !reviewActive()
const focusOnDraftChange = () => {
const key = focusKey()
const owner = focusMemory.get(key)
if (!owner || owner === "prompt") return true
if (!terms.sidesForContext(terms.sideKey()).some((term) => term.id === owner.terminal)) {
focusMemory.delete(key)
return true
}
return terminalVisible() ? false : true
}
const restoreFocus = () => {
const key = focusKey()
const owner = focusMemory.get(key)
if (owner && owner !== "prompt") {
const context = terms.sideKey()
const terminal = terms.sidesForContext(context).find((term) => term.id === owner.terminal)
if (terminal && terminalVisible()) {
terms.setSideActive(context, terminal.id)
terms.requestFocus(terminal.id)
return
}
if (!terminal) focusMemory.delete(key)
}
requestChatFocus()
}
createEffect(
on(
() => terms.focusedId(),
(id) => {
if (!id) return
const key = terms.contextFor(id)
if (!key || !terms.sidesForContext(key).some((term) => term.id === id)) return
focusMemory.set(focusKey(), { terminal: id })
},
{ defer: true },
),
)
createEffect(
on(
focusKey,
(_key, previous) => {
if (previous !== undefined) queueMicrotask(restoreFocus)
},
{ defer: true },
),
)
// Ambient setup reveal restores the panel after success unless the user engaged.
const ambientSetup = createAmbientSetup({
terms,
@@ -777,39 +868,7 @@ const AgentManagerContent: Component = () => {
const isSessionBusy = (id: string): boolean => isAnySessionBusy([id])
/** Worktrees sorted so that grouped items are always adjacent, respecting custom order if set. */
const sortedWorktrees = createMemo(() => {
const ordered = applyTabOrder(worktrees(), sidebarWorktreeOrder())
if (ordered.length === 0) return []
// Collect grouped worktrees by groupId
const grouped = new Map<string, WorktreeState[]>()
for (const wt of ordered) {
if (!wt.groupId) continue
const list = grouped.get(wt.groupId) ?? []
list.push(wt)
grouped.set(wt.groupId, list)
}
// Build output: interleave groups at the position of their earliest member
const result: WorktreeState[] = []
const placed = new Set<string>()
for (const wt of ordered) {
if (placed.has(wt.id)) continue
if (wt.groupId) {
if (placed.has(wt.groupId)) continue
placed.add(wt.groupId)
const group = grouped.get(wt.groupId) ?? []
for (const g of group) {
result.push(g)
placed.add(g.id)
}
} else {
result.push(wt)
placed.add(wt.id)
}
}
return result
})
const sortedWorktrees = createMemo(() => sortWorktrees(worktrees(), sidebarWorktreeOrder()))
const worktreesInSection = (id: string) => sortedWorktrees().filter((wt) => wt.sectionId === id)
const ungrouped = createMemo(() => sortedWorktrees().filter((wt) => !wt.sectionId))
@@ -845,12 +904,14 @@ const AgentManagerContent: Component = () => {
setSelection(null)
setReviewActive(false)
session.selectSession(id)
requestChatFocus(true)
}
const focusSidebarItem = (item: { type: string; id: string }) => {
if (item.type === "local") selectLocal()
else if (item.type === "wt") selectWorktree(item.id)
else selectUnassigned(item.id)
requestChatFocus(true)
const el = document.querySelector(`[data-sidebar-id="${item.id}"]`)
if (el instanceof HTMLElement) scrollIntoView(el)
}
@@ -883,6 +944,7 @@ const AgentManagerContent: Component = () => {
const next = direction === "left" ? idx - 1 : idx + 1
if (next < 0 || next >= ids.length) return
focusTab(ids[next]!)
requestChatFocus(true)
}
const selectionDeps = {
@@ -903,10 +965,15 @@ const AgentManagerContent: Component = () => {
remembered === REVIEW_TAB_ID && reviewOpenByContext()[sel] === true,
}
const selectLocal = () => selectLocalAction(selectionDeps, localSessions())
const selectLocal = () => {
selectLocalAction(selectionDeps, localSessions())
requestChatFocus()
}
const selectWorktree = (worktreeId: string) =>
const selectWorktree = (worktreeId: string) => {
selectWorktreeAction(selectionDeps, worktreeId, sessionsForWorktree(worktreeId))
requestChatFocus()
}
const addSessionToCurrentWorktree = (sid: string) => {
const sel = selection()
@@ -926,6 +993,7 @@ const AgentManagerContent: Component = () => {
selectWorktree(worktreeId)
setHistory(false)
session.selectSession(sid)
requestChatFocus()
return true
}
@@ -1045,6 +1113,7 @@ const AgentManagerContent: Component = () => {
setSelection,
setActivePendingId,
})
requestChatFocus()
}
// Recover sidebar collapsed state and mark hydrated so transitions enable
sidebar.hydrate(state.sidebarCollapsed)
@@ -1109,7 +1178,7 @@ const AgentManagerContent: Component = () => {
else if (msg.action === "advancedWorktree") showNewWorktreeDialog()
else if (msg.action === "closeWorktree") closeSelectedWorktree()
else if (msg.action === "showShortcuts") handleShowKeyboardShortcuts()
else if (msg.action === "focusInput") window.dispatchEvent(new Event("focusPrompt"))
else if (msg.action === "focusInput") requestChatFocus(true)
else if (msg.action === "focusSearch")
focusChatSearch({ history: setHistory, review: setReviewActive, terminal: () => terms.setActiveId(undefined) })
else if (msg.action === "newTerminal") termHandlers.requestNew()
@@ -1192,7 +1261,7 @@ const AgentManagerContent: Component = () => {
const onWindowFocus = () => {
document.body.style.pointerEvents = ""
document.body.style.overflow = ""
window.dispatchEvent(new Event("focusPrompt"))
restoreFocus()
}
window.addEventListener("focus", onWindowFocus)
@@ -1238,7 +1307,9 @@ const AgentManagerContent: Component = () => {
// Mark sessions loaded as soon as the session context receives data (even if empty)
const unsubSessions = vscode.onMessage((msg) => {
if (msg.type === "sessionsLoaded" && !sessionsLoaded()) setSessionsLoaded(true)
if (msg.type === "agentManager.sessionClosed") handleCloseTab(msg.sessionId, false)
if (msg.type === "agentManager.sessionClosed") {
handleCloseTab(msg.sessionId, false)
}
})
const unsubRun = vscode.onMessage((msg) =>
applyRunStatus(msg, { ensure: (id) => registry.ensure(id), active: () => registry.active() }),
@@ -1255,13 +1326,14 @@ const AgentManagerContent: Component = () => {
showToast({ variant: "error", title: t("agentManager.terminal.errorTitle"), description: message }),
postMessage: (message) => vscode.postMessage(message as never),
onCreated: (contextKey, terminalId) => appendToTabOrder(contextKey, terminalId),
onSideCreated: (contextKey, terminalId) => {
onSideCreated: (contextKey, terminalId, focus) => {
// Focus only when the user is still looking at this panel —
// a slow create landing after a mode switch must not steal it.
if (sidePanel() === "terminal" && !history() && !reviewActive() && terms.sideKey() === contextKey) {
if (focus && sidePanel() === "terminal" && !history() && !reviewActive() && terms.sideKey() === contextKey) {
terms.requestFocus(terminalId)
}
},
onSideClosed: (_contextKey, terminalId) => forgetTerminalFocus(terminalId),
onScriptRunning: (contextKey, terminalId) => {
if (terms.sideKey() !== contextKey) return
// Setup output is informational: reveal without stealing focus, and
@@ -1311,6 +1383,7 @@ const AgentManagerContent: Component = () => {
const ms = managedSessions().find((s) => s.id === ev.sessionId)
if (ms?.worktreeId) setSelection(ms.worktreeId)
evictLocal(ev.sessionId)
requestChatFocus(true)
}
} else {
// Track this worktree as setting up and auto-select it in the sidebar
@@ -1339,6 +1412,7 @@ const AgentManagerContent: Component = () => {
evictLocal(ev.sessionId)
drafts.apply(ev.worktreeId, ev.sessionId)
session.selectSession(ev.sessionId)
requestChatFocus(true)
}
if (msg.type === "agentManager.sessionForked") {
@@ -1358,6 +1432,7 @@ const AgentManagerContent: Component = () => {
evictLocal(ev.sessionId)
}
session.selectSession(ev.sessionId)
requestChatFocus(true)
}
if (msg.type === "agentManager.keybindings") {
@@ -1806,6 +1881,7 @@ const AgentManagerContent: Component = () => {
// Second press/click: execute the delete
if (pendingDelete() === worktreeId) {
cancelPendingDelete()
forgetContextFocus(nsKey(worktreeId))
setBusyWorktrees((prev) => new Map([...prev, [wt.id, { reason: "deleting" as const }]]))
vscode.postMessage({ type: "agentManager.deleteWorktree", worktreeId: wt.id })
if (selection() === wt.id) {
@@ -1897,6 +1973,7 @@ const AgentManagerContent: Component = () => {
setSelection(LOCAL)
setReviewActive(false)
session.selectSession(sid)
requestChatFocus()
vscode.postMessage({ type: "agentManager.openLocally", sessionId: sid })
}
@@ -1945,6 +2022,7 @@ const AgentManagerContent: Component = () => {
session.clearCurrentSession()
}
}
forgetSessionFocus(sessionId)
if (pending || localSet().has(sessionId)) {
setLocalSessionIDs((prev) => prev.filter((id) => id !== sessionId))
}
@@ -2004,7 +2082,7 @@ const AgentManagerContent: Component = () => {
cancelAmbientSetup()
setSidePanel(null)
},
refocus: () => window.dispatchEvent(new Event("focusPrompt")),
refocus: requestChatFocus,
postMessage: (msg) => vscode.postMessage(msg as never),
track: (button, surface, properties) => metrics.track(button, surface, properties),
// Panel-local pick, immune to cross-window setting echoes (see side.ts).
@@ -2100,7 +2178,7 @@ const AgentManagerContent: Component = () => {
return activeTabs().find((s) => s.id === id)
})
const focusTab = (id: string) =>
const focusTab = (id: string) => {
focusCurrentTab({
id,
terms,
@@ -2116,6 +2194,7 @@ const AgentManagerContent: Component = () => {
selectSession: session.selectSession,
activateTerminal: termHandlers.activate,
})
}
const tabFocus = createTabFocus({ ids: () => tabIds(), select: focusTab })
// Close the currently active tab via keyboard shortcut.
@@ -2255,6 +2334,7 @@ const AgentManagerContent: Component = () => {
<ProjectList
projects={projectList()}
states={projectStates()}
store={(id) => registry.ensure(id)}
stats={projectLive.stats()}
local={projectLive.local()}
prs={projectLive.prs()}
@@ -2425,6 +2505,7 @@ const AgentManagerContent: Component = () => {
saveTabMemory()
session.selectSession(id)
setSelection(LOCAL)
requestChatFocus(true)
return
}
const ms = worktreeSessionIds().has(id) ? managedSessions().find((s) => s.id === id) : undefined
@@ -2432,6 +2513,7 @@ const AgentManagerContent: Component = () => {
selectWorktree(ms.worktreeId)
session.selectSession(id)
setReviewActive(false)
requestChatFocus()
return
}
openLocally(id)
@@ -2486,6 +2568,7 @@ const AgentManagerContent: Component = () => {
if (localSessionIDs().includes(id)) {
session.selectSession(id)
if (selection() === null) setSelection(LOCAL)
requestChatFocus()
return
}
// Navigate to owning worktree instead of forcing into local mode
@@ -2495,6 +2578,7 @@ const AgentManagerContent: Component = () => {
selectWorktree(ms.worktreeId)
session.selectSession(id)
setReviewActive(false)
requestChatFocus()
return
}
}
@@ -2506,7 +2590,10 @@ const AgentManagerContent: Component = () => {
readonly={readOnly()}
continueInWorktree={selection() === LOCAL}
promptBoxId={`agent-manager:${selection() ?? "unassigned"}`}
deferFocusToQuestion={hasQuestionOption}
pendingSessionID={selection() === LOCAL ? activePendingId() : undefined}
focusOnDraftChange={focusOnDraftChange}
onFocusChange={rememberPromptFocus}
/>
<Show when={readOnly()}>
<div class="am-readonly-banner">
@@ -40,6 +40,7 @@ import { useLanguage } from "../src/context/language"
import { useImageAttachments, type ImageAttachment } from "../src/hooks/useImageAttachments"
import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText"
import { useSpeechToTextModels } from "../src/context/speech-to-text-models"
import { createSpeechShortcut } from "../src/components/speech-to-text/shortcut"
import { convertToMentionPath } from "../src/utils/path-mentions"
import { insertSpacedText } from "../src/components/chat/prompt-input-utils"
import { WandSparkles } from "@kilocode/kilo-ui/lucide"
@@ -382,6 +383,12 @@ export const NewWorktreeDialog: Component<{
}
const onKey = (e: KeyboardEvent) => {
if (shortcut.down(e)) {
e.preventDefault()
e.stopPropagation()
return
}
// Shift+Tab cycles reasoning effort variants (setting: chat.shiftTabCyclesVariant).
// When disabled or no variants exist, fall through to default focus navigation.
if (e.key === "Tab" && e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
@@ -431,6 +438,19 @@ export const NewWorktreeDialog: Component<{
speech.start({ model: speechModel(), insert: insertSpeechText })
}
const shortcut = createSpeechShortcut({
speech,
disabled: () => !canUseSpeech() || starting(),
start: startSpeech,
finish: (submit) => speech.stop(submit ? { done: handleSubmit } : undefined),
})
const speechUp = (e: KeyboardEvent) => {
if (!shortcut.up(e)) return
e.preventDefault()
e.stopPropagation()
}
onCleanup(shortcut.reset)
const canEnhance = () => !starting() && !enhancing() && !speech.active() && server.isConnected()
const handleEnhance = () => {
@@ -608,6 +628,7 @@ export const NewWorktreeDialog: Component<{
adjustHeight()
}}
onKeyDown={onKey}
onKeyUp={speechUp}
onPaste={(e) => imageAttach.handlePaste(e)}
rows={3}
dir="auto"
@@ -19,11 +19,13 @@ import type { SidebarSearchItem } from "./sidebar-search"
import { LOCAL } from "./navigate"
import { NewWorktreeDialog } from "./NewWorktreeDialog"
import { ProjectBranchDialog } from "./ProjectBranchDialog"
import type { ProjectStore } from "./project/store"
import type { ModeRouter } from "./mode-router"
interface Props {
projects: AgentProjectSnapshot[]
states: Record<string, AgentManagerStateMessage>
store?: (projectId: string) => ProjectStore
stats: Record<string, Record<string, WorktreeGitStats>>
local: Record<string, LocalGitStats>
prs: Record<string, Record<string, PRStatus | null>>
@@ -208,6 +210,7 @@ export const ProjectList: Component<Props> = (props) => {
<ProjectSidebarBody
project={project}
state={props.states[project.id]}
store={props.store?.(project.id)}
stats={props.stats[project.id]}
local={props.local[project.id]}
prs={props.prs[project.id]}
@@ -1,26 +1,40 @@
import { For, Show, createMemo, createSignal, onCleanup, type Component } from "solid-js"
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { DragDropProvider, DragDropSensors } from "@thisbeyond/solid-dnd"
import {
DragDropProvider,
DragDropSensors,
DragOverlay,
SortableProvider,
createSortable,
type DragEvent,
} from "@thisbeyond/solid-dnd"
import type {
AgentManagerStateMessage,
AgentProjectSnapshot,
LocalGitStats,
PRStatus,
ProjectSessionInfo,
WorktreeState,
WorktreeGitStats,
} from "../src/types/messages"
import type { LanguageContextValue } from "../src/context/language"
import { useVSCode } from "../src/context/vscode"
import { formatRelativeDate } from "../src/utils/date"
import SectionHeader from "./SectionHeader"
import { WorktreeItem } from "./WorktreeItem"
import { UnassignedSessionsSection } from "./UnassignedSessionsSection"
import { ProjectActions } from "./ProjectActions"
import { applyTabOrder, firstOrderedTitle, reorderTabs } from "./tab-order"
import { buildTopLevelItems, sortWorktrees, isGroupEnd, isGroupStart, isGrouped } from "./section-helpers"
import { sectionAwareDetector } from "./section-dnd"
import { ConstrainDragXAxis } from "./constrain-drag-x"
import { createProjectStore, type ProjectStore } from "./project/store"
import { randomColor } from "./section-colors"
interface Props {
project: AgentProjectSnapshot
state?: AgentManagerStateMessage
store?: ProjectStore
busy?: (id: string) => boolean
stats?: Record<string, WorktreeGitStats>
local?: LocalGitStats
@@ -40,8 +54,21 @@ interface Props {
/** Permanent real sidebar body for one expanded project. */
export const ProjectSidebarBody: Component<Props> = (props) => {
const vscode = useVSCode()
const store = props.store ?? createProjectStore(props.project.id)
if (!props.store) {
createEffect(() => {
const state = props.state
if (state) store.applyState(state)
})
}
const [pending, setPending] = createSignal<string>()
const [renaming, setRenaming] = createSignal<string>()
const [renamingSection, setRenamingSection] = createSignal<string>()
const [pendingSection, setPendingSection] = createSignal<
{ ids: Set<string>; state?: AgentManagerStateMessage } | undefined
>()
const [dragging, setDragging] = createSignal<string>()
const [dragOrigin, setDragOrigin] = createSignal<string[]>()
const [name, setName] = createSignal("")
let pendingTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => clearTimeout(pendingTimer))
@@ -61,14 +88,105 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
const sessions = (worktreeId: string | null) =>
(props.sessions ?? []).filter((item) => item.worktreeId === worktreeId)
const active = () => props.selectedProject === props.project.id
const runs = createMemo(() => Object.fromEntries((state()?.runStatuses ?? []).map((run) => [run.worktreeId, run])))
const sections = () => state()?.sections ?? []
const runs = () => store.runStatuses()
const sections = () => store.sections()
const worktrees = () => store.worktrees()
const order = () => store.worktreeOrder()
const localSessions = () => sessions(null)
const ungrouped = () => state()?.worktrees.filter((wt) => !wt.sectionId) ?? []
const members = (sectionId: string) => state()?.worktrees.filter((wt) => wt.sectionId === sectionId) ?? []
const sorted = createMemo(() => sortWorktrees(worktrees(), order()))
const members = (sectionId: string) => sorted().filter((wt) => wt.sectionId === sectionId)
const ungrouped = createMemo(() => sorted().filter((wt) => !wt.sectionId))
const top = createMemo(() => buildTopLevelItems(sections(), ungrouped(), sorted(), order()))
const post = (message: Record<string, unknown>) =>
vscode.postMessage({ ...message, projectId: props.project.id } as never)
const scope = (kind: "section" | "worktree", id: string) => `${props.project.id}:${kind}:${id}`
const parse = (kind: "section" | "worktree", value: unknown) => {
if (typeof value !== "string") return
const prefix = `${props.project.id}:${kind}:`
return value.startsWith(prefix) ? value.slice(prefix.length) : undefined
}
const createSection = (worktreeIds?: string[]) => {
setPendingSection({ ids: new Set(sections().map((section) => section.id)), state: state() })
post({
type: "agentManager.createSection",
name: props.t("agentManager.section.defaultName"),
color: randomColor(),
worktreeIds,
})
}
createEffect(() => {
const previous = pendingSection()
if (!previous) return
const current = state()
if (current === previous.state) return
const created = (current?.sections ?? []).find((section) => !previous.ids.has(section.id))
setPendingSection(undefined)
if (!created) return
setRenamingSection(created.id)
})
const worktreeIds = createMemo(() => new Set(worktrees().map((wt) => wt.id)))
const sectionIds = createMemo(() => new Set(sections().map((section) => scope("section", section.id))))
const home = createMemo(
() =>
new Map(
worktrees().map(
(wt) => [scope("worktree", wt.id), wt.sectionId ? scope("section", wt.sectionId) : undefined] as const,
),
),
)
const detector = sectionAwareDetector(sectionIds, home)
const dragIds = createMemo(() => sorted().map((wt) => scope("worktree", wt.id)))
const onDragStart = (event: DragEvent) => {
const id = parse("worktree", event.draggable?.id)
if (!id || !worktreeIds().has(id)) return
setDragging(id)
setDragOrigin(order())
document.body.classList.add("am-wt-dragging-active")
}
const onDragOver = (event: DragEvent) => {
const from = parse("worktree", event.draggable?.id)
const to = parse("worktree", event.droppable?.id)
if (!from || !to || !worktreeIds().has(from) || !worktreeIds().has(to)) return
store.setWorktreeOrder((previous) => {
const current = applyTabOrder(
sorted().map((wt) => ({ id: wt.id })),
previous,
).map((item) => item.id)
return reorderTabs(current, from, to) ?? previous
})
}
const onDragEnd = (event: DragEvent) => {
const from = parse("worktree", event.draggable?.id)
const section = parse("section", event.droppable?.id)
const to = parse("worktree", event.droppable?.id)
setDragging(undefined)
const origin = dragOrigin()
setDragOrigin(undefined)
document.body.classList.remove("am-wt-dragging-active")
if (!from || !worktreeIds().has(from)) {
if (origin) store.setWorktreeOrder(origin)
return
}
if (section && sections().some((item) => item.id === section)) {
post({ type: "agentManager.moveToSection", worktreeIds: [from], sectionId: section })
return
}
if (!to || !worktreeIds().has(to)) {
if (origin) store.setWorktreeOrder(origin)
return
}
post({ type: "agentManager.setWorktreeOrder", order: order() })
}
onCleanup(() => document.body.classList.remove("am-wt-dragging-active"))
// Escape unmounts the focused rename input, which fires a synchronous blur
// that would re-commit the cancelled value; this flag swallows that blur.
let cancelled = false
@@ -86,62 +204,64 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
setRenaming(undefined)
}
const renderWorktree = (worktree: NonNullable<Props["state"]>["worktrees"][number]) => (
<WorktreeItem
worktree={worktree}
sidebarId={`${props.project.id}:${worktree.id}`}
label={worktree.label || worktree.branch}
subtitle={worktree.label && worktree.label !== worktree.branch ? worktree.branch : undefined}
active={active() && props.selection === worktree.id}
pendingDelete={pending() === worktree.id}
busy={props.busy?.(worktree.id) ?? false}
working={runs()[worktree.id]?.state === "running"}
stale={state()?.staleWorktreeIds?.includes(worktree.id) === true}
stats={props.stats?.[worktree.id]}
sessions={sessions(worktree.id).length}
grouped={false}
groupStart={false}
groupEnd={false}
groupSize={0}
renaming={renaming() === worktree.id}
renameValue={name()}
closeKeybind=""
openKeybind=""
pr={props.prs?.[worktree.id] ?? undefined}
runStatus={runs()[worktree.id]}
sections={sections()}
currentSectionId={worktree.sectionId}
onMoveToSection={(sectionId) =>
post({ type: "agentManager.moveToSection", worktreeIds: [worktree.id], sectionId })
}
onMoveToNewSection={() =>
post({
type: "agentManager.createSection",
name: props.t("agentManager.worktree.newSection"),
worktreeIds: [worktree.id],
})
}
onClick={() => {
if (pending() === worktree.id) return confirmDelete(worktree.id)
props.onSelectWorktree(props.project.id, worktree.id)
}}
onDelete={(event) => {
event.stopPropagation()
confirmDelete(worktree.id)
}}
onStartRename={(value) => {
setName(value)
setRenaming(worktree.id)
}}
onRenameInput={setName}
onCommitRename={() => commitRename(worktree.id)}
onCancelRename={cancelRename}
onRemoveStale={() => post({ type: "agentManager.removeStaleWorktree", worktreeId: worktree.id })}
onCopyPath={() => navigator.clipboard.writeText(worktree.path)}
onOpen={() => post({ type: "agentManager.openWorktree", worktreeId: worktree.id })}
onOpenPR={() => post({ type: "agentManager.openPR", worktreeId: worktree.id })}
/>
)
const renderWorktree = (worktree: WorktreeState, idx: () => number, list: WorktreeState[]) => {
const label = () => firstOrderedTitle(sessions(worktree.id), store.tabOrder()[worktree.id], worktree.branch)
const subtitle = () => (label() !== worktree.branch ? worktree.branch : undefined)
const sortable = createSortable(scope("worktree", worktree.id))
void sortable
return (
<div use:sortable class={`am-wt-sortable ${sortable.isActiveDraggable ? "am-wt-dragging" : ""}`}>
<WorktreeItem
worktree={worktree}
sidebarId={`${props.project.id}:${worktree.id}`}
label={worktree.label || label()}
subtitle={worktree.label ? (worktree.label !== worktree.branch ? worktree.branch : undefined) : subtitle()}
active={active() && props.selection === worktree.id}
pendingDelete={pending() === worktree.id}
busy={props.busy?.(worktree.id) ?? false}
working={runs()[worktree.id]?.state === "running"}
stale={state()?.staleWorktreeIds?.includes(worktree.id) === true}
stats={props.stats?.[worktree.id]}
sessions={sessions(worktree.id).length}
grouped={isGrouped(worktree)}
groupStart={isGroupStart(worktree, idx(), list)}
groupEnd={isGroupEnd(worktree, idx(), list)}
groupSize={worktree.groupId ? sorted().filter((item) => item.groupId === worktree.groupId).length : 0}
renaming={renaming() === worktree.id}
renameValue={name()}
closeKeybind=""
openKeybind=""
pr={props.prs?.[worktree.id] ?? undefined}
runStatus={runs()[worktree.id]}
sections={sections()}
currentSectionId={worktree.sectionId}
onMoveToSection={(sectionId) =>
post({ type: "agentManager.moveToSection", worktreeIds: [worktree.id], sectionId })
}
onMoveToNewSection={() => createSection([worktree.id])}
onClick={() => {
if (pending() === worktree.id) return confirmDelete(worktree.id)
props.onSelectWorktree(props.project.id, worktree.id)
}}
onDelete={(event) => {
event.stopPropagation()
confirmDelete(worktree.id)
}}
onStartRename={(value) => {
setName(value)
setRenaming(worktree.id)
}}
onRenameInput={setName}
onCommitRename={() => commitRename(worktree.id)}
onCancelRename={cancelRename}
onRemoveStale={() => post({ type: "agentManager.removeStaleWorktree", worktreeId: worktree.id })}
onCopyPath={() => navigator.clipboard.writeText(worktree.path)}
onOpen={() => post({ type: "agentManager.openWorktree", worktreeId: worktree.id })}
onOpenPR={() => post({ type: "agentManager.openPR", worktreeId: worktree.id })}
/>
</div>
)
}
return (
<div class="am-project-body" data-project-body={props.project.id}>
@@ -202,52 +322,73 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
t={props.t}
onCreate={() => post({ type: "agentManager.createWorktree" })}
onNew={() => props.onNewWorktree(props.project.id)}
onSection={() =>
post({
type: "agentManager.createSection",
name: props.t("agentManager.section.defaultName"),
})
}
onSection={() => createSection()}
onSetup={() => post({ type: "agentManager.configureSetupScript" })}
onBranch={() => props.onDefaultBranch(props.project.id, state()?.defaultBaseBranch, props.local?.branch)}
/>
</div>
<div class="am-worktree-list">
{/*
SectionHeader registers a drop target via solid-dnd, which throws
without a DragDropProvider ancestor and kills the whole render.
Multi-project has no drag-and-drop yet, so this provider is a
no-op context until DnD lands here.
*/}
<DragDropProvider onDragStart={() => {}} onDragEnd={() => {}}>
<DragDropProvider
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
collisionDetector={detector}
>
<DragDropSensors />
<For each={sections()}>
{(section, index) => (
<SectionHeader
section={section}
count={members(section.id).length}
onToggle={() => post({ type: "agentManager.toggleSectionCollapsed", sectionId: section.id })}
onRename={(value: string) =>
post({ type: "agentManager.renameSection", sectionId: section.id, name: value })
<ConstrainDragXAxis />
<SortableProvider ids={dragIds()}>
<For each={top()}>
{(item, index) => {
if (item.kind === "worktree") {
const list = ungrouped()
return renderWorktree(item.wt, () => list.indexOf(item.wt), list)
}
onDelete={() => post({ type: "agentManager.deleteSection", sectionId: section.id })}
onSetColor={(color: string | null) =>
post({ type: "agentManager.setSectionColor", sectionId: section.id, color })
}
isFirst={index() === 0}
isLast={index() === sections().length - 1}
onMoveUp={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: -1 })}
onMoveDown={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: 1 })}
>
<Show when={!section.collapsed}>
<div class="am-section-group-body">
<For each={members(section.id)}>{renderWorktree}</For>
</div>
</Show>
</SectionHeader>
)}
</For>
<For each={ungrouped()}>{renderWorktree}</For>
const section = item.section
const list = members(section.id)
return (
<SectionHeader
section={section}
dropId={scope("section", section.id)}
count={list.length}
autoRename={renamingSection() === section.id}
onRenameEnd={() => {
if (renamingSection() === section.id) setRenamingSection(undefined)
}}
onToggle={() => post({ type: "agentManager.toggleSectionCollapsed", sectionId: section.id })}
onRename={(value: string) =>
post({ type: "agentManager.renameSection", sectionId: section.id, name: value })
}
onDelete={() => post({ type: "agentManager.deleteSection", sectionId: section.id })}
onSetColor={(color: string | null) =>
post({ type: "agentManager.setSectionColor", sectionId: section.id, color })
}
isFirst={index() === 0}
isLast={index() === top().length - 1}
onMoveUp={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: -1 })}
onMoveDown={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: 1 })}
>
<Show when={!section.collapsed}>
<div class="am-section-group-body">
<For each={list}>{(wt, wtIndex) => renderWorktree(wt, wtIndex, list)}</For>
</div>
</Show>
</SectionHeader>
)
}}
</For>
</SortableProvider>
<DragOverlay>
{(() => {
const wt = sorted().find((item) => item.id === dragging())
if (!wt) return null
return (
<div class="am-wt-overlay">
<Icon name="branch" size="small" />
<span>{wt.label || firstOrderedTitle(sessions(wt.id), store.tabOrder()[wt.id], wt.branch)}</span>
</div>
)
})()}
</DragOverlay>
</DragDropProvider>
</div>
</div>
@@ -12,6 +12,8 @@ interface Props {
children?: JSX.Element
/** When true, auto-enter rename mode (e.g. after creation). */
autoRename?: boolean
/** Scoped drop id used when multiple project DnD providers are mounted. */
dropId?: string
onToggle: () => void
onRename: (name: string) => void
onDelete: () => void
@@ -59,7 +61,7 @@ const SectionHeader: Component<Props> = (props) => {
props.onToggle()
}
const droppable = createDroppable(props.section.id)
const droppable = createDroppable(props.dropId ?? props.section.id)
return (
<div
@@ -0,0 +1,48 @@
const OPTION = '[data-component="question-dock"] button[data-slot="question-option"]'
export function createChatFocus(deps: {
term: () => string | undefined
history: () => boolean
review: () => boolean
}) {
const focus = (force: boolean) => {
if ((!force && !document.hasFocus()) || deps.term() || deps.history() || deps.review()) return
if (!force && document.activeElement?.matches('[role="tab"]')) return
if (!force && document.activeElement?.closest('[data-component="question-dock"]')) return
if (focusQuestionOption()) return
const defer = hasQuestionOption()
window.dispatchEvent(
new CustomEvent("focusPrompt", {
detail: { restore: !defer, deferFocusToQuestion: defer },
}),
)
}
return (force = false) => {
queueMicrotask(() => focus(force))
requestAnimationFrame(() => {
focus(force)
requestAnimationFrame(() => {
focus(force)
requestAnimationFrame(() => focus(force))
})
})
}
}
/** Return whether the visible question dock has an enabled option to focus. */
export function hasQuestionOption(root: ParentNode = document): boolean {
for (const option of root.querySelectorAll<HTMLButtonElement>(OPTION)) {
if (!option.disabled && !option.closest("[inert]")) return true
}
return false
}
/** Focus the first enabled option in the visible question dock, if one exists. */
export function focusQuestionOption(root: ParentNode = document): boolean {
for (const option of root.querySelectorAll<HTMLButtonElement>(OPTION)) {
if (option.disabled || option.closest("[inert]")) continue
option.focus({ preventScroll: true })
return true
}
return false
}

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