mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:32:08 +08:00
Merge origin/main into feat-stt-model-discovery
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep config-defined subagents routable when an installed primary agent uses the same name.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Speed up local session recall searches across large conversation histories.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep Kilo's persona out of generated conversation titles and Agent Manager branch names.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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() {
|
||||
|
||||
+1633
-4
File diff suppressed because it is too large
Load Diff
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-console",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,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 1–10 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:
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a7d7d047f14440d3dafc8a202131af6e755f80875012fd934a56580de21d36d5
|
||||
size 52887
|
||||
oid sha256:c3863da7e15cc40467319046521fbbfed0ee6ceab9f1f3ad03adb8941f454666
|
||||
size 52895
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bcb4fc19e832e52e4eda969d66be90cd4c6df26544bea0d117f9d3412dfcf9d9
|
||||
size 33866
|
||||
oid sha256:94227c822d88aa83f286136c05d92c8209b2cb08eaf6870cf79172aa004e8402
|
||||
size 39173
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3b1eb6cf15d5c2dc66c769a91fcf09bcf25bc2e8d17696ad0e9ab4181ed3038c
|
||||
size 658647
|
||||
oid sha256:fd794db4d2c038984f9e753ba70b8b0473d97a16713a9ff7255f0d2cf505d41a
|
||||
size 758306
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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,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,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,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(() => {})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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,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
Reference in New Issue
Block a user