Merge remote-tracking branch 'origin/main' into feat/execute-cmds-in-skill-context

This commit is contained in:
Bruno Agatao
2026-07-30 12:31:30 +02:00
108 changed files with 2792 additions and 382 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add a scope selector and base branch picker to the Agent Manager diff review. The side panel and full-screen review now let you switch between Branch, Staged, Unstaged, and Session scopes for the selected worktree, and the Branch scope's base branch can be overridden from a picker next to it. Branch stays the default, so existing review behavior is unchanged.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Persist MCP server enable and disable changes from VS Code settings across window reloads.
+13
View File
@@ -0,0 +1,13 @@
---
"@kilocode/cli": patch
---
Non-interactive `kilo run` no longer reports success for runs that did not complete. A plain
headless run (neither `--auto` nor `--dangerously-skip-permissions`) in which the CLI
auto-rejected at least one permission ask now exits 1 with a stderr diagnostic naming the cause,
and a run whose session errors mid-stream now prints that diagnostic to stderr under
`--format json` as well (previously the JSON branch swallowed it). Runs that complete their turn
with no auto-rejected permission still exit 0. Under `--format json` the auto-reject path adds a
new `error` event to the stream; existing event shapes are unchanged. The same exit-1 rule applies
to a plain non-interactive `--attach` run that auto-rejects an ask (that run was equally crippled);
interactive mode is untouched.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Navigate long conversations from a compact prompt rail that loads earlier history as you scroll.
+6 -1
View File
@@ -82,8 +82,13 @@ Batch specifics for this run: the PRs to handle are in the attached ${batchFile}
break
}
// Headless `kilo run` auto-rejects every permission ask; without --auto the
// agent cannot run shell commands. SECURITY: --auto grants unrestricted bash
// to an agent steered by external PR content. Hardening deferred: a scoped
// 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", 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}`,
+62 -4
View File
@@ -167,13 +167,53 @@ export function sleepSync(ms) {
const STDERR_TAIL_LINES = 20
const STDERR_TAIL_CHARS = 4_000
// CSI sequences (colour, cursor moves, erases). kilo renders its TUI to stderr,
// so an unstripped tail lands in the rolling PR's pending table as
// "^[[0m→ ^[[0mRead packages/..." and the cause is unreadable. Stripped before
// the line/char slice so escapes do not eat the budget. The persisted
// docs-sync-out/kilo-stderr-*.log stays raw — that is the debugging record.
// eslint-disable-next-line no-control-regex
const ANSI_CSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g
function tailText(text, { lines = STDERR_TAIL_LINES, chars = STDERR_TAIL_CHARS } = {}) {
const s = String(text ?? "").trim()
const s = String(text ?? "")
.replace(ANSI_CSI, "")
.trim()
if (!s) return ""
const lastLines = s.split("\n").slice(-lines).join("\n")
return lastLines.length > chars ? lastLines.slice(-chars) : lastLines
}
/**
* Artifact files are raw: GitHub masks secret values in log streams only, and the runner
* env holds long-lived secrets (KILO_API_KEY), so exact values of secret-looking env vars
* are redacted before stdout/stderr is persisted or printed.
* Matching is exact-substring and case-sensitive on values — JSON-escaped, base64'd, or
* line-wrapped renderings and values shorter than 8 chars survive (same limitation as
* GitHub's own log masking); this is defense-in-depth, not a guarantee the logs are clean.
*/
export function redactEnvSecrets(text) {
let out = String(text ?? "")
// Also match CREDENTIAL/PASSWORD/ORG_ID/_PAT (e.g. KILO_ORG_ID, GH_PAT) beyond KEY|TOKEN|SECRET.
const nameRe = /KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|ORG_ID|_PAT$/i
const candidates = []
for (const [name, value] of Object.entries(process.env)) {
if (!nameRe.test(name)) continue
if (typeof value !== "string" || value.length < 8) continue
candidates.push(value)
}
// Longer values first so a shorter secret that is a prefix of a longer one cannot leave a remainder.
candidates.sort((a, b) => b.length - a.length)
for (const value of candidates) {
if (!out.includes(value)) continue
out = out.split(value).join("***")
}
return out
}
/** Max bytes of child stderr persisted to docs-sync-out/ (full buffer, not the console tail). */
const STDERR_LOG_MAX_CHARS = 8 * 1024 * 1024
/**
* Run `kilo` via spawnSync so stderr is always recoverable — including when
* the child exits 0 after writing a diagnostic (execFileSync cannot return
@@ -181,6 +221,10 @@ function tailText(text, { lines = STDERR_TAIL_LINES, chars = STDERR_TAIL_CHARS }
*
* streamStdout:true → inherit fd 1 (edit live log); false → capture stdout
* (triage parses it). stderr is always buffered.
*
* Always writes the full captured stderr to
* docs-sync-out/kilo-stderr-<sanitized-label>.log (unconditional — success and
* failure). The console return value still uses the short tailText.
*/
export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo" }) {
const result = spawnSync("kilo", args, {
@@ -193,16 +237,30 @@ export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo"
const timedOut = Boolean(result.error && result.error.code === "ETIMEDOUT")
const exitCode =
typeof result.status === "number" ? result.status : timedOut ? null : result.status === null ? null : result.status
const stderrTail = tailText(result.stderr)
const stdout = streamStdout ? "" : String(result.stdout ?? "")
const stderrRaw = String(result.stderr ?? "")
const stderrSafe = redactEnvSecrets(stderrRaw)
const stderrTail = tailText(stderrSafe)
const stdoutSafe = streamStdout ? "" : redactEnvSecrets(String(result.stdout ?? ""))
// ok is "process finished without OS-level failure". Callers still treat a
// missing summary / unparseable output as failure even when ok is true —
// exit 0 is not success for the docs-sync bot.
const ok = !result.error && result.status === 0
// Persist full stderr on every call (not gated on ok/exitCode/summary). Cap is
// generous (megabytes) so long batch dumps keep auto-rejecting lines; console
// still uses the short tail above.
try {
fs.mkdirSync("docs-sync-out", { recursive: true })
const safe = label.replace(/[^A-Za-z0-9._-]/g, "-")
const body = stderrSafe.length > STDERR_LOG_MAX_CHARS ? stderrSafe.slice(-STDERR_LOG_MAX_CHARS) : stderrSafe
fs.writeFileSync(`docs-sync-out/kilo-stderr-${safe}.log`, body)
} catch (err) {
console.warn(`${label}: failed to write kilo-stderr log: ${err.message}`)
}
if (result.error && !timedOut) {
console.warn(`${label}: spawn error: ${result.error.message}`)
}
return { ok, stdout, stderrTail, exitCode, timedOut }
return { ok, stdout: stdoutSafe, stderrTail, exitCode, timedOut }
}
+25
View File
@@ -0,0 +1,25 @@
// kilocode_change - new file
/**
* Line-wise stdin→stdout filter that redacts secret-looking env values.
* Used in the docs-sync workflow so kilo stdout piped to edit-log.txt is safe.
* Env values contain no newlines, so line-wise processing never splits a value.
*/
import { redactEnvSecrets } from "./lib.mjs"
let carry = ""
process.stdin.setEncoding("utf8")
process.stdin.on("data", (chunk) => {
carry += chunk
let idx
while ((idx = carry.indexOf("\n")) !== -1) {
const line = carry.slice(0, idx + 1)
carry = carry.slice(idx + 1)
process.stdout.write(redactEnvSecrets(line))
}
})
process.stdin.on("end", () => {
if (carry.length > 0) process.stdout.write(redactEnvSecrets(carry))
})
+501 -17
View File
@@ -55,7 +55,7 @@ function writeExecutable(filePath, body) {
function makeStubKiloDir({ mode, callLog, stderrText = "event stream disconnected" }) {
const dir = mktemp("docs-sync-kilo-")
const kiloPath = path.join(dir, "kilo")
// mode: "stderr-exit0" | "record" | "partial-triage" | "mixed-triage"
// mode: "stderr-exit0" | "record" | "partial-triage" | "mixed-triage" | "write-edit-summary"
const script = `#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
@@ -81,6 +81,22 @@ let chunk = [];
if (fileArg && fs.existsSync(fileArg)) {
try { chunk = JSON.parse(fs.readFileSync(fileArg, "utf8")); } catch { chunk = []; }
}
if (mode === "write-edit-summary") {
// Success path: write the batch summary so edit.mjs returns true, while still
// emitting stderr so selftest can assert runKilo persisted it unconditionally.
process.stderr.write(stderrText + "\\n");
const m = fileArg && String(fileArg).match(/edit-batch-(\\d+)\\.json/);
const index = m ? m[1] : "0";
const summary = chunk.map((d) => ({
pr: d.number,
url: d.url,
action: "skipped",
reason: "selftest stub",
}));
fs.mkdirSync("docs-sync-out", { recursive: true });
fs.writeFileSync("docs-sync-out/edit-summary-" + index + ".json", JSON.stringify(summary));
process.exit(0);
}
if (mode === "partial-triage") {
// Classify only a proper subset (first URL) of the chunk.
const owned = chunk.slice(0, Math.max(0, chunk.length - 1));
@@ -116,6 +132,21 @@ if (mode === "mixed-triage") {
process.stdout.write(JSON.stringify(entries) + "\\n");
process.exit(0);
}
if (mode === "triage-embed-env-secret") {
// Valid triage JSON with a secret env value embedded in a string field
// (stdout is persisted to triage-raw-*.txt; must be redacted at capture).
const secret = process.env.KILO_API_KEY || "missing-secret";
const entries = chunk.map((d) => ({
pr: d.number,
url: d.url,
docs_worthy: true,
reason: "needs docs; diagnostic=" + secret,
target_sections: ["overview"],
priority: "high",
}));
process.stdout.write(JSON.stringify(entries) + "\\n");
process.exit(0);
}
process.stderr.write("unknown stub mode\\n");
process.exit(1);
`
@@ -129,7 +160,9 @@ function gitIn(cwd, args, env = {}) {
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
}).toString().trim()
})
.toString()
.trim()
}
function makeGitRunner(cwd, env = {}) {
@@ -247,14 +280,19 @@ function case1_mergeOrFallback() {
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
}).toString().trim()
})
.toString()
.trim()
assert.throws(() => mergeOrFallback({ branch: DEFAULT_BRANCH, git }), (err) => {
// Must throw the original merge error, not a merge --abort failure
const msg = String(err?.stderr ?? err?.message ?? err)
assert.ok(!/no merge to abort/i.test(msg), `should not reach merge --abort: ${msg}`)
return true
})
assert.throws(
() => mergeOrFallback({ branch: DEFAULT_BRANCH, git }),
(err) => {
// Must throw the original merge error, not a merge --abort failure
const msg = String(err?.stderr ?? err?.message ?? err)
assert.ok(!/no merge to abort/i.test(msg), `should not reach merge --abort: ${msg}`)
return true
},
)
}
}
@@ -372,6 +410,450 @@ function case2_defectB() {
}
}
// ---------------------------------------------------------------------------
// Case 2b — AC4a: every docs-sync kilo run argv carries --auto
// ---------------------------------------------------------------------------
/** Slice `args: [` … matching `]` from source (newlines allowed inside). */
function extractArgsArraySlice(source) {
const start = source.indexOf("args: [")
assert.ok(start >= 0, "args: [ not found in source")
let i = start + "args: ".length
assert.equal(source[i], "[")
let depth = 0
for (; i < source.length; i++) {
const ch = source[i]
if (ch === "[") depth++
else if (ch === "]") {
depth--
if (depth === 0) return source.slice(start, i + 1)
}
}
throw new assert.AssertionError({ message: "unclosed args: [ array in source" })
}
/** Label → kilo-stderr filename rule (must match lib.mjs runKilo). */
function kiloStderrLogName(label) {
return `kilo-stderr-${String(label).replace(/[^A-Za-z0-9._-]/g, "-")}.log`
}
function case2b_autoFlag() {
console.log("case 2b: AC4a (--auto on every docs-sync kilo run)")
// (i) region-scoped static check on triage.mjs / edit.mjs argv arrays
for (const name of ["triage.mjs", "edit.mjs"]) {
const src = fs.readFileSync(path.join(HERE, name), "utf8")
const slice = extractArgsArraySlice(src)
assert.ok(slice.includes('"--auto"'), `${name} args array must contain "--auto"; got:\n${slice}`)
}
// (ii) Fix verify failures step: join the run: | block and require --auto on kilo run
{
const yml = fs.readFileSync(path.join(HERE, "..", "workflows", "docs-sync.yml"), "utf8")
const stepIdx = yml.indexOf("Fix verify failures")
assert.ok(stepIdx >= 0, "Fix verify failures step missing")
const afterStep = yml.slice(stepIdx)
const runIdx = afterStep.indexOf("run: |")
assert.ok(runIdx >= 0, "run: | missing after Fix verify failures")
const blockStart = stepIdx + runIdx + "run: |".length
const rest = yml.slice(blockStart)
// Block ends at next unindented step key or EOF — collect indented lines
const lines = []
for (const line of rest.split("\n")) {
if (line === "") {
lines.push(line)
continue
}
// stop at next top-level list item under steps (two-space + "- ")
if (/^ {0,6}- name:/.test(line) || (/^\S/.test(line) && lines.length > 0)) break
lines.push(line)
}
// Join continuation backslashes then collapse whitespace for the kilo run line
const joined = lines
.map((l) => l.replace(/^\s+/, ""))
.join("\n")
.replace(/\\\n/g, " ")
.replace(/\s+/g, " ")
assert.match(joined, /kilo run\b/, `expected kilo run in Fix verify block:\n${joined}`)
const kiloCmd = joined.match(/kilo run\b[^|]*/)?.[0] ?? ""
assert.ok(
/\s--auto\b/.test(kiloCmd) || /kilo run\s+--auto\b/.test(kiloCmd),
`Fix verify kilo run must contain --auto; got: ${kiloCmd}`,
)
// The step runs under `set -o pipefail` + the default `bash -e`, so an
// unguarded kilo pipeline aborts the block before verify2.log is written
// once the CLI exits nonzero on a mid-stream error. The rebuild must decide
// this step's outcome, not the agent's exit code.
// Window is the end of the kilo pipeline → the rebuild, so a comment
// elsewhere in the block cannot satisfy the guard assertion.
const teeIdx = joined.indexOf("tee -a docs-sync-out/edit-log.txt")
assert.ok(teeIdx >= 0, `expected the kilo pipeline to tee edit-log.txt:\n${joined}`)
const kiloPipeline = joined.slice(teeIdx, joined.indexOf("bun run", teeIdx))
assert.match(
kiloPipeline,
/\|\|\s*(echo|true)\b/,
`Fix verify kilo pipeline must be guarded (|| echo/true) so bash -e cannot skip the rebuild; got: ${kiloPipeline}`,
)
assert.match(joined, /verify2\.log/, "Fix verify block must still write verify2.log")
}
// (iii) authoritative: real stub invocations with callLog — every argv has --auto
{
const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n))
const worthy = prs
const triage = prs.map((p) => ({
pr: p.number,
url: p.url,
docs_worthy: true,
reason: "needs docs",
target_sections: ["overview"],
priority: "high",
}))
const cwd = setupEditCwd(worthy, triage)
const callLog = path.join(cwd, "kilo-calls.log")
const stderrText = "event stream disconnected DIAG-AUTO"
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText, callLog })
const result = runNodeScript(EDIT_SCRIPT, {
cwd,
kiloDir,
env: {
EDIT_MODEL: "test/model",
DOCS_SYNC_BACKOFF_MS: "0",
EDIT_BUDGET_MINUTES: "5",
EDIT_BATCH_TIMEOUT_MINUTES: "1",
},
})
assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`)
assert.ok(fs.existsSync(callLog), "callLog must be written (stub was invoked)")
const lines = fs.readFileSync(callLog, "utf8").trim().split("\n").filter(Boolean)
assert.ok(lines.length > 0, "callCount > 0 required (vacuous empty log forbidden)")
for (const line of lines) {
const { argv } = JSON.parse(line)
assert.ok(
Array.isArray(argv) && argv.includes("--auto"),
`every kilo argv must include --auto; got ${JSON.stringify(argv)}`,
)
}
}
}
// ---------------------------------------------------------------------------
// Case 2c — full child stderr always written (success and failure paths)
// ---------------------------------------------------------------------------
function case2c_stderrLogAlways() {
console.log("case 2c: unconditional kilo-stderr-*.log")
const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n))
const worthy = prs
const triage = prs.map((p) => ({
pr: p.number,
url: p.url,
docs_worthy: true,
reason: "needs docs",
target_sections: ["overview"],
priority: "high",
}))
// Failure path: stub exits 0 without summary (same mode as case 2)
{
const cwd = setupEditCwd(worthy, triage)
const stderrText = "FAILPATH-STDERR-MARKER"
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText })
const result = runNodeScript(EDIT_SCRIPT, {
cwd,
kiloDir,
env: {
EDIT_MODEL: "test/model",
DOCS_SYNC_BACKOFF_MS: "0",
EDIT_BUDGET_MINUTES: "5",
EDIT_BATCH_TIMEOUT_MINUTES: "1",
},
})
assert.equal(result.status, 0, result.output)
const logName = kiloStderrLogName("edit batch 0 attempt 1")
const logPath = path.join(cwd, "docs-sync-out", logName)
assert.equal(logName, "kilo-stderr-edit-batch-0-attempt-1.log")
assert.ok(fs.existsSync(logPath), `expected ${logPath} on failure path`)
assert.match(fs.readFileSync(logPath, "utf8"), /FAILPATH-STDERR-MARKER/)
}
// Success path: stub writes summary (today's path that discarded stderr)
{
const cwd = setupEditCwd(worthy, triage)
const stderrText = "SUCCESSPATH-STDERR-MARKER"
const kiloDir = makeStubKiloDir({ mode: "write-edit-summary", stderrText })
const result = runNodeScript(EDIT_SCRIPT, {
cwd,
kiloDir,
env: {
EDIT_MODEL: "test/model",
DOCS_SYNC_BACKOFF_MS: "0",
EDIT_BUDGET_MINUTES: "5",
EDIT_BATCH_TIMEOUT_MINUTES: "1",
},
})
assert.equal(result.status, 0, result.output)
assert.ok(
fs.existsSync(path.join(cwd, "docs-sync-out", "edit-summary-0.json")),
"stub must write summary (success path)",
)
const logName = kiloStderrLogName("edit batch 0 attempt 1")
const logPath = path.join(cwd, "docs-sync-out", logName)
assert.ok(fs.existsSync(logPath), `expected ${logPath} on success path`)
assert.match(fs.readFileSync(logPath, "utf8"), /SUCCESSPATH-STDERR-MARKER/)
}
}
// ---------------------------------------------------------------------------
// Case 2d — redact secret env values from captured kilo stderr (artifact-safe)
// ---------------------------------------------------------------------------
function case2d_redactEnvSecrets() {
console.log("case 2d: redact env secrets from kilo stderr capture")
const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n))
const worthy = prs
const triage = prs.map((p) => ({
pr: p.number,
url: p.url,
docs_worthy: true,
reason: "needs docs",
target_sections: ["overview"],
priority: "high",
}))
const cwd = setupEditCwd(worthy, triage)
const secret = "selftest-secret-value-12345"
const stderrText = `leak before ${secret} after`
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText })
const result = runNodeScript(EDIT_SCRIPT, {
cwd,
kiloDir,
env: {
EDIT_MODEL: "test/model",
DOCS_SYNC_BACKOFF_MS: "0",
EDIT_BUDGET_MINUTES: "5",
EDIT_BATCH_TIMEOUT_MINUTES: "1",
KILO_API_KEY: secret,
},
})
assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`)
const logName = kiloStderrLogName("edit batch 0 attempt 1")
const logPath = path.join(cwd, "docs-sync-out", logName)
assert.ok(fs.existsSync(logPath), `expected ${logPath}`)
const logBody = fs.readFileSync(logPath, "utf8")
assert.ok(!logBody.includes(secret), `persisted stderr must not contain secret; got: ${logBody}`)
assert.ok(logBody.includes("leak before *** after"), `persisted stderr must redact to exact line; got: ${logBody}`)
// Console stderr-tail region must also be redacted (not only the artifact file).
const tailIdx = result.output.indexOf("stderr tail:")
assert.ok(tailIdx >= 0, `expected stderr tail: in output; got: ${result.output}`)
const tailRegion = result.output.slice(tailIdx)
assert.ok(!tailRegion.includes(secret), `console stderr tail must not contain secret; got: ${tailRegion}`)
}
// ---------------------------------------------------------------------------
// Case 2e — longer secret first when a shorter env value is a prefix
// ---------------------------------------------------------------------------
function case2e_prefixSecretOrdering() {
console.log("case 2e: prefix-secret ordering (longer value redacted first)")
const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n))
const worthy = prs
const triage = prs.map((p) => ({
pr: p.number,
url: p.url,
docs_worthy: true,
reason: "needs docs",
target_sections: ["overview"],
priority: "high",
}))
const cwd = setupEditCwd(worthy, triage)
const shortSecret = "abcdefgh"
const longSecret = "abcdefghIJKL-tail"
const stderrText = `leak: ${longSecret} end`
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText })
const result = runNodeScript(EDIT_SCRIPT, {
cwd,
kiloDir,
env: {
EDIT_MODEL: "test/model",
DOCS_SYNC_BACKOFF_MS: "0",
EDIT_BUDGET_MINUTES: "5",
EDIT_BATCH_TIMEOUT_MINUTES: "1",
A_KEY: shortSecret,
B_TOKEN: longSecret,
},
})
assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`)
const logName = kiloStderrLogName("edit batch 0 attempt 1")
const logPath = path.join(cwd, "docs-sync-out", logName)
assert.ok(fs.existsSync(logPath), `expected ${logPath}`)
const logBody = fs.readFileSync(logPath, "utf8")
assert.ok(!logBody.includes("IJKL-tail"), `must not leak prefix remainder; got: ${logBody}`)
assert.ok(logBody.includes("leak: *** end"), `expected full long secret redacted; got: ${logBody}`)
}
// ---------------------------------------------------------------------------
// Case 2f — redact secret values from captured kilo stdout (triage-raw artifact)
// ---------------------------------------------------------------------------
function case2f_redactStdout() {
console.log("case 2f: redact env secrets from kilo stdout (triage-raw)")
const digest = [samplePr(501), samplePr(502)]
const cwd = setupTriageCwd(digest)
const secret = "selftest-stdout-secret-99999"
const kiloDir = makeStubKiloDir({ mode: "triage-embed-env-secret" })
const summaryFile = path.join(cwd, "step-summary.md")
fs.writeFileSync(summaryFile, "")
const result = runNodeScript(TRIAGE_SCRIPT, {
cwd,
kiloDir,
env: {
TRIAGE_MODEL: "test/model",
DOCS_SYNC_BACKOFF_MS: "0",
TRIAGE_BUDGET_MINUTES: "30",
GITHUB_STEP_SUMMARY: summaryFile,
KILO_API_KEY: secret,
},
})
assert.equal(result.status, 0, `triage.mjs exit: ${result.output}`)
const rawFiles = fs.readdirSync(path.join(cwd, "docs-sync-out")).filter((f) => f.startsWith("triage-raw-"))
assert.ok(rawFiles.length > 0, "expected triage-raw-*.txt artifact")
for (const f of rawFiles) {
const body = fs.readFileSync(path.join(cwd, "docs-sync-out", f), "utf8")
assert.ok(!body.includes(secret), `triage-raw must not contain secret; ${f}: ${body}`)
}
const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8"))
assert.ok(triage.length >= 1, "triage must still parse after redaction")
assert.ok(
triage.some((e) => e.docs_worthy === true || e.pending === true || e.docs_worthy === false),
"triage entries must be structured",
)
}
// ---------------------------------------------------------------------------
// Case 2g — redact-stream.mjs line-wise filter (including partial last line)
// ---------------------------------------------------------------------------
function case2g_redactStream() {
console.log("case 2g: redact-stream.mjs stdin filter")
const secret = "stream-secret-value-xyz"
const filterPath = path.join(HERE, "redact-stream.mjs")
assert.ok(fs.existsSync(filterPath), `expected ${filterPath}`)
const input = `leak ${secret} after\npartial-${secret}`
const result = spawnSync(process.execPath, [filterPath], {
env: { ...process.env, KILO_API_KEY: secret },
input,
encoding: "utf8",
timeout: 10_000,
})
assert.equal(result.status, 0, `redact-stream exit: ${result.stderr || result.error}`)
assert.equal(result.stdout, "leak *** after\npartial-***")
}
// ---------------------------------------------------------------------------
// Case 2h — pending causes reach the rolling PR free of ANSI escapes
// ---------------------------------------------------------------------------
function case2h_pendingCauseIsReadable() {
console.log("case 2h: pending cause has no ANSI escapes")
const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n))
const worthy = prs
const triage = prs.map((p) => ({
pr: p.number,
url: p.url,
docs_worthy: true,
reason: "needs docs",
target_sections: ["overview"],
priority: "high",
}))
const cwd = setupEditCwd(worthy, triage)
// Verbatim shape of a real kilo TUI stderr line (see PR #12521's pending table).
const ESC = "\u001b"
const stderrText = `${ESC}[0m→ ${ESC}[0mRead packages/kilo-docs/AGENTS.md${ESC}[2K${ESC}[1G done`
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText })
const result = runNodeScript(EDIT_SCRIPT, {
cwd,
kiloDir,
env: {
EDIT_MODEL: "test/model",
DOCS_SYNC_BACKOFF_MS: "0",
EDIT_BUDGET_MINUTES: "5",
EDIT_BATCH_TIMEOUT_MINUTES: "1",
},
})
assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`)
const summary = JSON.parse(fs.readFileSync(path.join(cwd, ".docs-sync-summary.json"), "utf8"))
assert.equal(summary.length, 5)
for (const e of summary) {
assert.equal(e.action, "pending", `expected pending, got ${JSON.stringify(e)}`)
assert.ok(!e.reason.includes(ESC), `pending reason must not contain ANSI escapes: ${JSON.stringify(e.reason)}`)
// Non-vacuous: the diagnostic text itself must survive the strip.
assert.match(e.reason, /Read packages\/kilo-docs\/AGENTS\.md/)
}
// The raw artifact log keeps the escapes — it is the debugging record.
const rawLog = fs.readFileSync(path.join(cwd, "docs-sync-out", "kilo-stderr-edit-batch-0-attempt-1.log"), "utf8")
assert.ok(rawLog.includes(ESC), "persisted stderr log must stay raw")
}
// ---------------------------------------------------------------------------
// Case 2i — wall-clock budgets can actually fit work
// ---------------------------------------------------------------------------
/**
* The pre-unit gates in triage.mjs/edit.mjs refuse to start a chunk/batch unless
* a whole per-unit timeout remains, so a budget below that timeout silently runs
* ZERO units and defers every PR. Run 30306629290 hit the weaker form of this:
* 8 of 11 chunks and 4 of 11 batches ran, the rest deferred untried. Assert the
* workflow sets both budgets and that each fits at least two units.
*/
function case2i_budgetsFitWork() {
console.log("case 2i: triage/edit budgets fit at least two units")
const yml = fs.readFileSync(path.join(HERE, "..", "workflows", "docs-sync.yml"), "utf8")
const readEnvNumber = (key) => {
const m = yml.match(new RegExp(`^\\s*${key}:\\s*"?(\\d+)"?\\s*$`, "m"))
assert.ok(m, `${key} must be set in docs-sync.yml (default is too small to drain a backlog)`)
return Number(m[1])
}
// Per-unit timeouts are script constants, not workflow env; read them from source.
const triageSrc = fs.readFileSync(path.join(HERE, "triage.mjs"), "utf8")
const chunkMin = Number(triageSrc.match(/CHUNK_TIMEOUT_MS = (\d+) \* 60 \* 1000/)?.[1])
assert.ok(Number.isFinite(chunkMin), "could not read CHUNK_TIMEOUT_MS from triage.mjs")
const editSrc = fs.readFileSync(path.join(HERE, "edit.mjs"), "utf8")
const batchMin = Number(editSrc.match(/EDIT_BATCH_TIMEOUT_MINUTES\) \|\| (\d+)/)?.[1])
assert.ok(Number.isFinite(batchMin), "could not read EDIT_BATCH_TIMEOUT_MINUTES default from edit.mjs")
const triageBudget = readEnvNumber("TRIAGE_BUDGET_MINUTES")
const editBudget = readEnvNumber("EDIT_BUDGET_MINUTES")
assert.ok(
triageBudget >= 2 * chunkMin,
`TRIAGE_BUDGET_MINUTES=${triageBudget} must be >= 2x chunk timeout (${chunkMin}m)`,
)
assert.ok(editBudget >= 2 * batchMin, `EDIT_BUDGET_MINUTES=${editBudget} must be >= 2x batch timeout (${batchMin}m)`)
// The job timeout must outlast both budgets plus the non-LLM steps.
const jobTimeout = Number(yml.match(/^\s*timeout-minutes:\s*(\d+)\s*$/m)?.[1])
assert.ok(Number.isFinite(jobTimeout), "could not read job timeout-minutes")
assert.ok(
jobTimeout > triageBudget + editBudget,
`job timeout-minutes=${jobTimeout} must exceed triage+edit budgets (${triageBudget}+${editBudget})`,
)
}
// ---------------------------------------------------------------------------
// Case 3 — watermark invariant
// ---------------------------------------------------------------------------
@@ -611,14 +1093,8 @@ function case4_routing() {
triage: [],
uncovered: [],
})
assert.ok(
!forgedRows.skippedRows[0].includes("<!--"),
"clean() must strip <!-- from reasons",
)
assert.ok(
!forgedRows.skippedRows[0].includes("-->"),
"clean() must strip --> from reasons",
)
assert.ok(!forgedRows.skippedRows[0].includes("<!--"), "clean() must strip <!-- from reasons")
assert.ok(!forgedRows.skippedRows[0].includes("-->"), "clean() must strip --> from reasons")
const forgedBody = renderBody({
date: "2026-07-27",
since: "s",
@@ -934,6 +1410,14 @@ function main() {
const cases = [
case1_mergeOrFallback,
case2_defectB,
case2b_autoFlag,
case2c_stderrLogAlways,
case2d_redactEnvSecrets,
case2e_prefixSecretOrdering,
case2f_redactStdout,
case2g_redactStream,
case2h_pendingCauseIsReadable,
case2i_budgetsFitWork,
case3_watermark,
case4_routing,
case5_recollection,
+6 -1
View File
@@ -71,8 +71,13 @@ function triageChunk(chunk, index, budgetDeadline) {
break
}
// Headless `kilo run` auto-rejects every permission ask; without --auto the
// agent cannot run shell commands. SECURITY: --auto grants unrestricted bash
// to an agent steered by external PR content. Hardening deferred: a scoped
// 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", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile],
args: ["run", "--auto", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile],
timeoutMs: Math.min(CHUNK_TIMEOUT_MS, left),
streamStdout: false,
label: `triage chunk ${index} attempt ${attempt}`,
+27 -4
View File
@@ -66,8 +66,13 @@ jobs:
sync:
if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request'
runs-on: blacksmith-4vcpu-ubuntu-2404
# Budget: 3 setup/collect + 35 triage + 50 edit + 2 verify + 10 fix + 2 upsert = 102 min, 18-minute reserve.
timeout-minutes: 120
# Budget: 4 setup/collect + 90 triage + 120 edit + 2 verify + 10 fix + 2 upsert = 228 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
env:
# Both are required: without KILO_ORG_ID the gateway bills the key
# owner's personal balance (402 "Add credits") instead of the org.
@@ -116,6 +121,9 @@ jobs:
if: steps.collect.outputs.count != '0'
env:
SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }}
# Default 35 fit only 8 of 11 chunks on a 254-PR window. Headroom for
# --auto making chunks slower now that the agent really runs commands.
TRIAGE_BUDGET_MINUTES: "90"
run: node .github/docs-sync/triage.mjs
- name: Filter docs-worthy PRs
@@ -153,6 +161,11 @@ jobs:
- name: Update docs (Kilo CLI, batched)
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
continue-on-error: true
env:
# Default 50 fit only 4 of 11 batches. A healthy --auto batch is ~8 min,
# and edit.mjs will not start a batch without EDIT_BATCH_TIMEOUT_MINUTES
# (15) left, so 120 covers 14 batches = 70 PRs against ~5 worthy/day.
EDIT_BUDGET_MINUTES: "120"
run: node .github/docs-sync/edit.mjs
- name: Verify docs build and tests
@@ -174,9 +187,19 @@ jobs:
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }}
run: |
set -o pipefail
kilo run "The docs build or tests failed. Read the attached docs-sync-out/verify.log and fix the packages/kilo-docs changes so they pass. Do not revert doc edits; fix them. Do not modify anything outside packages/kilo-docs." \
# Headless kilo run auto-rejects every permission ask; the runner has no
# user config granting bash, so without --auto the agent cannot run ordinary
# shell commands against the repository.
kilo run --auto "The docs build or tests failed. Read the attached docs-sync-out/verify.log and fix the packages/kilo-docs changes so they pass. Do not revert doc edits; fix them. Do not modify anything outside packages/kilo-docs." \
-m "$EDIT_MODEL" --dir "$GITHUB_WORKSPACE" -f docs-sync-out/verify.log \
| tee -a docs-sync-out/edit-log.txt
| node .github/docs-sync/redact-stream.mjs \
| tee -a docs-sync-out/edit-log.txt \
|| echo "::warning::kilo fix pass exited nonzero; re-verifying anyway"
# The rebuild below decides this step's outcome, not the agent's exit code.
# Without the guard above, `set -o pipefail` + the default `bash -e` would
# abort here once the CLI half of this PR ships: a mid-stream session error
# (or an auto-rejected ask) exits 1, verify2.log is never written, and
# `Re-verify status` reports VERIFIED=false even when the docs build fine.
{ bun run --filter @kilocode/kilo-docs build && bun run --filter @kilocode/kilo-docs test; } 2>&1 | tee docs-sync-out/verify2.log
- name: Re-verify status
@@ -53,7 +53,7 @@ Then set your default model:
```jsonc
{
"model": "cerebras/llama-4-scout-17b-16e-instruct",
"model": "cerebras/gpt-oss-120b",
}
```
@@ -159,7 +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 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.
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 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.
## Sections
@@ -25,9 +25,16 @@ Type `@` in the chat input to get autocomplete suggestions. You can mention:
| **File** | Attach a file's contents to your message | `@src/utils.ts` |
| **Terminal** | Include your active VS Code terminal output | `@terminal` |
| **Git Changes** | Attach uncommitted working-tree diffs and new files | `@git-changes` |
| **Past chats** | Attach a previous session's transcript as context | `@`**Past chats** → pick a session |
Selecting a suggestion inserts the mention and highlights it in the input. File contents, terminal output, and git changes are attached as context when you send the message.
### Referencing Past Chats
Choosing **Past chats** from the `@` menu opens a searchable picker of your previous sessions in the current workspace, ordered by recency. Selecting a session inserts a highlighted mention token; when you send the message, that session's current transcript is attached as context so the agent can build on the earlier conversation. Clicking the mention token opens the referenced session.
Very long transcripts are truncated, keeping the beginning and end, so a single mention cannot fill the context window.
### Drag and Drop
You can also add file mentions by dragging and dropping:
@@ -70,6 +77,7 @@ This means the agent can explore your entire project as needed, rather than bein
| **Mention files when helpful** | If you know the exact file, mention its path to save the agent a search step |
| **Keep editor tabs relevant** | Open tabs are passed as context, so keep relevant files open |
| **Trust the agent's tools** | The agent can search, read, and explore your codebase — let it do the discovery work |
| **Reference a past chat** | Type `@` and choose **Past chats** to open a searchable picker of previous sessions in the current workspace. Selecting a session attaches its current transcript as context when you send the message. |
{% /tab %}
{% tab label="CLI" %}
@@ -26,6 +26,7 @@ While the specifics change constantly, some principles stay consistent:
- Use the **model selector** in the chat prompt area to pick a model for the current session. You can also type `/models` to open the model picker.
- When the selected model supports variants, type `/variant` to open the reasoning effort selector.
- Press `Shift+Tab` in the prompt input to cycle to the next reasoning effort variant, wrapping after the last one. This works in the sidebar chat, the Agent Manager prompt, and the New Worktree dialog, and the variant selector tooltip shows the shortcut on hover. To keep `Shift+Tab` for keyboard focus navigation instead, disable the `kilo-code.new.chat.shiftTabCyclesVariant` setting (also available under **Settings → Display**).
- Set per-agent defaults and a global default in the **Settings** panel (Models tab), or directly in the `kilo.jsonc` config file.
- **Model precedence:** Session override → Last picked per agent → Per-agent config → Global config → [Auto Free](/docs/code-with-ai/agents/auto-model#tiers) (note: Auto Free may route to providers that log prompts — see the Auto Model page for details).
- The model selector remembers the last model you picked for each agent — switching agents restores your previous choice. A manual pick always beats config settings; use the **reset button** (visible when your active model differs from config) to go back to the config default.
@@ -100,6 +100,10 @@ Clicking **Revert to here** does two things:
The button is only active when the agent is idle. While the agent is running, the button is disabled to prevent reverting mid-operation.
{% callout type="note" %}
Workspace restoration requires snapshots. If snapshots are disabled or the reverted range has no stored checkpoint, only the conversation is rewound — the agent's file changes remain on disk, and the revert banner warns you (see below).
{% /callout %}
### The Revert Banner
After reverting, a **Revert Banner** appears at the bottom of the chat. The banner shows:
@@ -113,6 +117,11 @@ The banner provides two actions:
- **Redo** — Steps forward one message at a time, re-applying changes from the next reverted message
- **Redo All** — Restores the workspace to the latest state and un-hides all messages (only shown when more than one message is reverted)
If a revert could not restore your workspace files, the banner warns you that only the conversation was rewound:
- **Snapshots disabled** — the banner explains that file changes were not restored because snapshots are disabled, and offers an **Enable snapshots** button that opens **Settings → Checkpoints**.
- **No checkpoint available** — the banner explains that no file checkpoint was available, so workspace changes remain on disk (for example, when reverting a range that predates checkpoints).
### Making a Revert Permanent
While in a reverted state, you have two choices:
@@ -8,3 +8,11 @@ description: "Using Kilo Code in JetBrains IDEs"
## Installation
{% partial file="install-jetbrains.md" /%}
## Settings
Open **Settings → Tools → Kilo Code** to configure the plugin. The JetBrains plugin reads and writes the same shared `kilo.jsonc` config files as the CLI and the VS Code extension, so changes apply across clients. See [Settings](/docs/getting-started/settings) for config file locations and precedence.
- **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.
@@ -20,6 +20,10 @@ The mobile app lets you:
- Spawn Cloud Agents and code directly from the app.
- 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.
- 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.
## Kilo Pass and Billing
@@ -39,6 +43,43 @@ For Kilo Pass pricing, billing, and account management details, use the [Kilo Pa
{% image src="/docs/img/mobile-apps/session-filters.webp" alt="Kilo Code mobile session filter panel for Cloud Extension CLI Slack and other platforms" caption="Filter sessions by platform and project, including Cloud, Extension, CLI, Slack, and other sessions." /%}
{% /imageGallery %}
## Choosing where a session runs
The new-session screen includes a **Run on** picker that chooses where your session runs:
- **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.
## Queueing follow-up messages
The composer stays editable while the agent is working, so you don't have to wait for a session to finish before sending your next message. Type your follow-up and press **Send** to add it to the session's queue; queued messages are processed in order. While a session is streaming, **Stop** appears only when the composer is empty — with text entered, Send takes its place.
A queued message shows a subtle **Queued** badge on its bubble. The badge clears when the message starts processing or when the queue drains or is cancelled. Queueing works for Cloud Agent sessions and for remote sessions on a connected `kilo remote` CLI instance.
## Reviewing GitHub pull requests
Open a pull request from a PR link to review it without leaving the app:
- **Overview** — PR state and CI checks at a glance.
- **Files** — syntax-highlighted diffs with line-level comments and a file navigator.
- **Discussion** — review threads with replies, resolve/unresolve, and reactions.
Comments you leave are collected into a pending review on your device and submitted to GitHub as a single review. When the PR is ready, you can merge it (merge, squash, or rebase), enable or disable auto-merge, or update the branch — all from the app.
PR review uses your connected GitHub account; the app asks you to connect GitHub if you have not already.
## Session cost and model details
The app shows what each session cost and which models did the work:
- **Session list** — a finished session with a recorded cost shows it in the row's meta line (for example, `$0.12 · 5m ago`). Sessions that are still running or have no cost show no cost.
- **Cost breakdown** — open a session's Context usage sheet to see a Token usage section (input, output, reasoning, cache read, and cache write tokens, plus the cache hit rate) and a collapsible Models section with each model's name, provider, step count, and cost. A Subagents row covers any remaining spend, so the per-model costs always add up to the session total.
- **Per-message model label** — assistant messages show a dimmed model label on the first assistant reply and whenever the model changes during the session. Turns routed by [Auto Model](/docs/code-with-ai/agents/auto-model) show the concrete model that handled the turn.
Cost is recorded when a session closes; sessions that closed before this feature shipped do not show a cost.
## Android App
The Android app is available now on Google Play.
@@ -9,7 +9,7 @@ description: "Configure automatic approval settings for Kilo Code operations"
**Security Warning:** Auto-approve settings bypass confirmation prompts, giving Kilo Code direct access to your system. This can result in data loss, file corruption, or worse. Command line access is particularly dangerous, as it can potentially execute harmful operations that could damage your system or compromise security. Only enable auto-approval for actions you fully trust.
{% /callout %}
Auto-approve settings speed up your workflow by eliminating repetitive confirmation prompts, but they significantly increase security risks. The VS Code extension and CLI share the same permission model; choose the tab that matches how you configure Kilo Code.
Auto-approve settings speed up your workflow by eliminating repetitive confirmation prompts, but they significantly increase security risks. The VS Code extension, JetBrains plugin, and CLI share the same permission model; choose the tab that matches how you configure Kilo Code. In the JetBrains plugin, the same rules are configured under **Settings → Tools → Kilo Code → Auto-Approve**.
{% callout type="note" %}
**Editing project config while a session is running:** Kilo caches project-level `kilo.jsonc` / `kilo.json` (in `.kilo/`) when it first loads a workspace, and does not re-read it on every prompt. If you add, change, or remove a project permission rule while the backend is already running, reload the VS Code window (or start a fresh CLI session) for the change to take effect. Until then, Kilo keeps using the previously loaded rules — so an auto-approved call may still cite a project rule you just edited. Global config (`~/.config/kilo/`) is reloaded automatically.
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a7c800d169ca92674fc9f7c7d83032ca17e7d7937bdcbea5c72cb921a7fdb89e
size 51975
oid sha256:2a74c7f53a5d9afa743ee49d298ce8fb7e357c5fcc46cad45de353694a7540dc
size 49476
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:51adb9e31ce0bc82981b0f20f895ce4ede3f92828546115f929942ef93bf0812
size 11204
oid sha256:d7fc23fcb7adf483c0b771ef601b23cb365dc7f40033b00fce703b35910aa4fc
size 27159
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c5658ed9e5266311c4239b7cd470d2f772d1cc0b3fd6c6b00337d4f3141a3a4d
size 11950
oid sha256:d11f4004ed3170647d385c14df077d235f5bb9bd6e5dec07557ee2014d553233
size 27302
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:095ed97dc46498be6fd75483b24357ac0740b6f2c1544f6c103a1dac360e1a7e
size 11202
oid sha256:f8bd56ba87d0c2bbef8a2325ab2e9f0e956c89e2602ff8ca7415d5fb7c9fa1f3
size 29709
@@ -28,8 +28,8 @@ dependencies {
implementation(libs.zxing.core)
testImplementation(kotlin("test"))
testImplementation("junit:junit:4.13.2")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.11.4")
testImplementation(libs.junit)
testRuntimeOnly(libs.junit.vintage.engine)
}
val providerIcons = tasks.register<Copy>("generateProviderIcons") {
@@ -5,6 +5,9 @@ intellij-rpc-plugin = "2.3.20-RC2-0.1"
kotlin-jvm-plugin = "2.3.20"
kotlin-serialization-plugin = "2.3.20"
kotlin-serialization = "1.11.0"
kotlinx-coroutines = "1.10.2"
junit = "4.13.2"
junit-vintage = "5.11.4"
okhttp = "4.12.0"
openapi-generator = "7.21.0"
detekt = "1.23.8"
@@ -22,7 +25,9 @@ okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" }
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.10.2" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
junit = { module = "junit:junit", version.ref = "junit" }
junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit-vintage" }
zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "commons-compress" }
@@ -4,10 +4,12 @@ import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
import type { KiloConnectionService } from "../services/cli-backend"
import { getErrorMessage } from "../kilo-provider-utils"
import { resolveLocalDiffTarget } from "../diff/shared/target"
import { DiffSourceCatalog } from "../diff/sources/catalog"
import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings"
import { isAbsolutePath } from "../path-utils"
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
import { remoteRef, WorktreeStateManager, type Worktree } from "./WorktreeStateManager"
import { composeDiffId, normalizeScope } from "./diff-scope"
import { handleSection } from "./section-handler"
import { normalizeBaseBranch } from "./base-branch"
import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller"
@@ -71,6 +73,7 @@ export class AgentManagerProvider implements Disposable {
private orchestration: AgentManagerOrchestrationBridge
private gitOps: GitOps
private diffs: WorktreeDiffController
private diffCatalog: DiffSourceCatalog
private naming: BranchNamingController
private staleWorktreeIds = new Set<string>()
private toolRequests = new Set<string>()
@@ -149,12 +152,13 @@ export class AgentManagerProvider implements Disposable {
log: (msg) => this.log(msg),
})
const local = createLocalDiff(this.gitOps, (...args) => this.log(...args))
this.diffCatalog = new DiffSourceCatalog(this.connectionService)
this.diffs = new WorktreeDiffController({
getState: () => this.getStateManager(),
getRoot: () => this.getRoot(),
getStateReady: () => this.stateReady,
catalog: this.diffCatalog,
git: this.gitOps,
localDiff: local.summary,
localDiffFile: local.file,
post: (msg) => this.postToWebview(msg),
log: (...args) => this.log(...args),
@@ -684,11 +688,11 @@ export class AgentManagerProvider implements Disposable {
private onDiffMessage(m: AgentManagerInMessage): Record<string, unknown> | null | undefined {
if (m.type === "agentManager.requestWorktreeDiff") {
void this.diffs.request(m.sessionId)
void this.diffs.request(composeDiffId(m.sessionId, normalizeScope(m.scope)))
return null
}
if (m.type === "agentManager.requestWorktreeDiffFile") {
void this.diffs.requestFile(m.sessionId, m.file)
void this.diffs.requestFile(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.file)
return null
}
if (m.type === "agentManager.applyWorktreeDiff") {
@@ -696,23 +700,52 @@ export class AgentManagerProvider implements Disposable {
return null
}
if (m.type === "agentManager.revertWorktreeFile") {
void this.diffs.revert(m.sessionId, m.file)
void this.diffs.revert(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.file)
return null
}
if (m.type === "agentManager.startDiffWatch") {
this.diffs.start(m.sessionId)
this.diffs.start(composeDiffId(m.sessionId, normalizeScope(m.scope)))
return null
}
if (m.type === "agentManager.stopDiffWatch") {
this.diffs.stop()
return null
}
if (m.type === "agentManager.requestDiffBranches") {
void this.sendDiffBranches(m.sessionId, m.scope)
return null
}
if (m.type === "agentManager.setDiffBaseBranch") {
void this.diffs.setBase(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.branch).then(() => {
void this.sendDiffBranches(m.sessionId, m.scope)
})
return null
}
if (m.type === "agentManager.openFile") {
this.openWorktreeFile(m.sessionId, m.filePath, m.line, m.column)
return null
}
}
private async sendDiffBranches(sessionId: string, scope?: string): Promise<void> {
const id = composeDiffId(sessionId, normalizeScope(scope))
const result = await this.diffs.branches(id).catch((err) => {
this.log("Failed to list diff branches:", err instanceof Error ? err.message : String(err))
return undefined
})
if (!result) return
this.postToWebview({
type: "agentManager.diffBranches",
sessionId: id,
branches: result.branches,
defaultBranch: result.defaultBranch,
autoBase: result.autoBase,
currentBase: result.currentBase,
isAuto: result.isAuto,
currentBranch: result.currentBranch,
})
}
private onBridgeMessage(m: AgentManagerInMessage): Record<string, unknown> | null | undefined {
if (m.type !== "openFile") return undefined
@@ -1927,6 +1960,7 @@ export class AgentManagerProvider implements Disposable {
this.orchestration.dispose()
this.visiblePresence.clear()
this.diffs.stop()
this.diffCatalog.dispose()
this.naming.dispose()
this.statsPoller.stop()
this.gitOps.dispose()
@@ -0,0 +1,57 @@
/**
* Composite diff-source keying for Agent Manager.
*
* Agent Manager keys diff sources by *context* (a session id, or the `local`
* workspace pseudo-context) while the standalone Changes viewer keys by
* *scope* (branch / staged / unstaged / session). To expose scopes in Agent
* Manager we compose the two into a single id the SourceController can build.
*
* ctx = "local" | "<sessionId>"
* scope = "branch" | "staged" | "unstaged" | "session"
* id = `${ctx}#${scope}`
*
* `ctx#branch` is the default and reproduces the pre-scope behavior exactly.
*/
export type DiffScope = "branch" | "staged" | "unstaged" | "session"
export const DEFAULT_DIFF_SCOPE: DiffScope = "branch"
const SEP = "#"
export function composeDiffId(ctx: string, scope: DiffScope): string {
return `${ctx}${SEP}${scope}`
}
/**
* Split a composite id back into context and scope. Tolerates a bare context
* id (no separator) by assuming the default branch scope, which keeps the
* pre-scope messages working unchanged.
*/
export function parseDiffId(id: string): { ctx: string; scope: DiffScope } {
const idx = id.lastIndexOf(SEP)
if (idx === -1) return { ctx: id, scope: DEFAULT_DIFF_SCOPE }
const scope = id.slice(idx + SEP.length)
if (isDiffScope(scope)) return { ctx: id.slice(0, idx), scope }
return { ctx: id, scope: DEFAULT_DIFF_SCOPE }
}
export function isDiffScope(value: string): value is DiffScope {
return value === "branch" || value === "staged" || value === "unstaged" || value === "session"
}
export function normalizeScope(value: unknown): DiffScope {
return typeof value === "string" && isDiffScope(value) ? value : DEFAULT_DIFF_SCOPE
}
/**
* Map a scope to the underlying standalone-viewer source id the catalog knows
* how to build. `branch` maps to the workspace source; `session` is handled
* separately because it needs the session id embedded in the source id.
*/
export function scopeToSourceId(scope: DiffScope, ctx: string): string {
if (scope === "staged") return "staged"
if (scope === "unstaged") return "unstaged"
if (scope === "session") return `session:${ctx}`
return "workspace"
}
@@ -286,6 +286,18 @@ interface RevertWorktreeFileResultMessage {
message: string
}
/** Branch picker data for a context's diff directory. */
interface DiffBranchesMessage {
type: "agentManager.diffBranches"
sessionId: string
branches: BranchListItem[]
defaultBranch: string
autoBase?: string
currentBase?: string
isAuto: boolean
currentBranch?: string
}
interface PRStatusOutMessage {
type: "agentManager.prStatus"
worktreeId: string
@@ -324,6 +336,7 @@ export type AgentManagerOutMessage =
| WorktreeDiffMessage
| WorktreeDiffFileMessage
| RevertWorktreeFileResultMessage
| DiffBranchesMessage
| PRStatusOutMessage
| ActionOutMessage
| RunStatusMessage
@@ -517,6 +530,7 @@ interface ImportFromPRIn {
interface RequestWorktreeDiffIn {
type: "agentManager.requestWorktreeDiff"
sessionId: string
scope?: string
}
interface ApplyWorktreeDiffIn {
@@ -529,11 +543,13 @@ interface RequestWorktreeDiffFileIn {
type: "agentManager.requestWorktreeDiffFile"
sessionId: string
file: string
scope?: string
}
interface StartDiffWatchIn {
type: "agentManager.startDiffWatch"
sessionId: string
scope?: string
}
interface StopDiffWatchIn {
@@ -544,6 +560,20 @@ interface RevertWorktreeFileIn {
type: "agentManager.revertWorktreeFile"
sessionId: string
file: string
scope?: string
}
interface RequestDiffBranchesIn {
type: "agentManager.requestDiffBranches"
sessionId: string
scope?: string
}
interface SetDiffBaseBranchIn {
type: "agentManager.setDiffBaseBranch"
sessionId: string
scope?: string
branch?: string
}
interface RefreshPRIn {
@@ -809,6 +839,8 @@ export type AgentManagerInMessage =
| StartDiffWatchIn
| StopDiffWatchIn
| RevertWorktreeFileIn
| RequestDiffBranchesIn
| SetDiffBaseBranchIn
| RefreshPRIn
| OpenPRIn
| OpenSessionsIn
@@ -1,12 +1,13 @@
import { SourceController } from "../diff/SourceController"
import { resolveLocalDiffTarget } from "../diff/shared/target"
import { WorktreeDiffReverter, type StatusResolver } from "../diff/shared/reverter"
import type { DiffFile } from "../diff/types"
import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "../diff/sources/types"
import type { DiffFile, PanelContext } from "../diff/types"
import type { DiffSource } from "../diff/sources/types"
import type { DiffSourceCatalog } from "../diff/sources/catalog"
import type { ApplyConflict, GitOps } from "./GitOps"
import { shouldStopDiffPolling } from "./delete-worktree"
import { Semaphore } from "./semaphore"
import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager"
import { parseDiffId, scopeToSourceId } from "./diff-scope"
import type { AgentManagerOutMessage, WorktreeDiffEntry } from "./types"
const LOCAL_DIFF_ID = "local" as const
@@ -19,14 +20,11 @@ export interface WorktreeDiffControllerContext {
getState: () => WorktreeStateManager | undefined
getRoot: () => string | undefined
getStateReady: () => Promise<void> | undefined
/**
* In-process diff paths deliberately bypass the SDK client to keep git spawns
* out of the Bun `kilo serve` process (see oven-sh/bun#18265).
*/
/** Builds the underlying per-scope diff sources (workspace/staged/unstaged/session). */
catalog: DiffSourceCatalog
/** Shared git ops, injected into sources so they don't spawn their own channels. */
git: GitOps
/** In-process diff summary (replaces client.worktree.diffSummary). */
localDiff: (dir: string, base: string) => Promise<WorktreeDiffEntry[]>
/** In-process single-file diff (replaces client.worktree.diffFile). */
/** In-process single-file diff (replaces client.worktree.diffFile). Used by revert. */
localDiffFile: (dir: string, base: string, file: string) => Promise<WorktreeDiffEntry | null>
post: (msg: AgentManagerOutMessage) => void
log: (...args: unknown[]) => void
@@ -34,13 +32,14 @@ export interface WorktreeDiffControllerContext {
export class WorktreeDiffController {
private readonly controller: SourceController
private readonly details = new Semaphore(3)
private target: Target | undefined
private applying: string | undefined
/** Ephemeral per-context base override, keyed by context id. */
private baseOverrides = new Map<string, string>()
constructor(private readonly ctx: WorktreeDiffControllerContext) {
this.controller = new SourceController(
(id) => this.source(id),
(id, ctx) => this.source(id, ctx),
() => [],
(msg) => this.ctx.post(msg as AgentManagerOutMessage),
{
@@ -80,7 +79,11 @@ export class WorktreeDiffController {
}
public shouldStopForWorktree(path: string, sessions: ManagedSession[]): boolean {
return shouldStopDiffPolling(path, sessions, this.target, this.controller.currentId)
// Pass the parsed context id, not the composite id, so the orphaned-session
// check matches real session ids.
const current = this.controller.currentId
const ctxId = current ? parseDiffId(current).ctx : undefined
return shouldStopDiffPolling(path, sessions, this.target, ctxId)
}
public async apply(worktreeId: string, value?: unknown): Promise<void> {
@@ -144,38 +147,38 @@ export class WorktreeDiffController {
}
}
public async revert(sessionId: string, file: string): Promise<void> {
public async revert(id: string, file: string): Promise<void> {
if (!file) return
if (this.controller.currentId !== sessionId) {
const result = await this.revertFile(sessionId, file)
this.postRevertResult(sessionId, file, result)
if (this.controller.currentId !== id) {
const result = await this.revertFile(id, file)
this.postRevertResult(id, file, result)
return
}
await this.controller.revertFile(file)
}
public async request(sessionId: string): Promise<void> {
if (this.controller.currentId !== sessionId) {
await this.activate(sessionId, false, true)
public async request(id: string): Promise<void> {
if (this.controller.currentId !== id) {
await this.activate(id, false, true)
return
}
this.target = undefined
await this.controller.refresh()
}
public async requestFile(sessionId: string, file: string): Promise<void> {
public async requestFile(id: string, file: string): Promise<void> {
if (!file) return
if (this.controller.currentId !== sessionId) {
this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: null })
if (this.controller.currentId !== id) {
this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId: id, file, diff: null })
return
}
await this.controller.requestFile(file)
}
public start(sessionId: string): void {
if (this.controller.isPolling && this.controller.currentId === sessionId) return
this.ctx.log(`Starting diff polling for session ${sessionId}`)
void this.activate(sessionId, true, true)
public start(id: string): void {
if (this.controller.isPolling && this.controller.currentId === id) return
this.ctx.log(`Starting diff polling for ${id}`)
void this.activate(id, true, true)
}
public stop(): void {
@@ -183,92 +186,113 @@ export class WorktreeDiffController {
this.target = undefined
}
private async activate(sessionId: string, poll: boolean, fetch: boolean): Promise<void> {
/**
* Set or clear an ephemeral base override for a context (worktree or local),
* then re-activate the current source so it refetches against the new base.
* Passing undefined clears the override and falls back to the recorded parent.
*/
public async setBase(id: string, branch: string | undefined): Promise<void> {
const { ctx } = parseDiffId(id)
if (branch) this.baseOverrides.set(ctx, branch)
else this.baseOverrides.delete(ctx)
this.target = undefined
this.controller.setContext({ workspaceRoot: this.ctx.getRoot() })
await this.controller.activate(sessionId, { poll, fetch })
await this.controller.reactivate()
}
private async resolve(sessionId: string): Promise<{ directory: string; baseBranch: string } | undefined> {
if (sessionId === LOCAL_DIFF_ID) return await this.resolveLocal()
/** Branch picker data for a context's directory, using any active override. */
public async branches(id: string) {
await this.ready("stateReady rejected, continuing diff branches resolve:")
const { ctx } = parseDiffId(id)
const target = await this.resolve(ctx)
if (!target) return undefined
return await this.ctx.catalog.listWorkspaceBranches(this.baseOverrides.get(ctx), target.directory)
}
private async activate(id: string, poll: boolean, fetch: boolean): Promise<void> {
this.target = undefined
await this.ready("stateReady rejected, continuing diff activate:")
const { ctx } = parseDiffId(id)
const resolved = await this.resolve(ctx)
this.target = resolved ? { sessionId: id, ...resolved } : undefined
this.controller.setContext({
workspaceRoot: this.ctx.getRoot(),
dir: resolved?.directory,
// The resolved base already bakes in any ephemeral override (see
// resolve()), so pass it as the explicit base and leave
// baseBranchOverride unset to avoid double resolution.
baseBranch: resolved?.baseBranch,
// Agent Manager always knows its intended directory (LOCAL resolves to
// the root). Never fall back to the workspace root for an unresolvable
// worktree context — return an empty diff instead.
strictDir: true,
git: this.ctx.git,
log: (...args) => this.ctx.log(...args),
})
await this.controller.activate(id, { poll, fetch })
}
private async resolve(ctxId: string): Promise<{ directory: string; baseBranch: string } | undefined> {
if (ctxId === LOCAL_DIFF_ID) return await this.resolveLocal()
const state = this.ctx.getState()
if (!state) {
this.ctx.log(`resolveDiffTarget: no state manager for session ${sessionId}`)
this.ctx.log(`resolveDiffTarget: no state manager for context ${ctxId}`)
return undefined
}
const session = state.getSession(sessionId)
const session = state.getSession(ctxId)
if (!session) {
this.ctx.log(
`resolveDiffTarget: session ${sessionId} not found in state (${state.getSessions().length} total sessions)`,
`resolveDiffTarget: session ${ctxId} not found in state (${state.getSessions().length} total sessions)`,
)
return undefined
}
if (!session.worktreeId) {
this.ctx.log(`resolveDiffTarget: session ${sessionId} has no worktreeId (local session)`)
this.ctx.log(`resolveDiffTarget: session ${ctxId} has no worktreeId (local session)`)
return undefined
}
const worktree = state.getWorktree(session.worktreeId)
if (!worktree) {
this.ctx.log(`resolveDiffTarget: worktree ${session.worktreeId} not found for session ${sessionId}`)
this.ctx.log(`resolveDiffTarget: worktree ${session.worktreeId} not found for session ${ctxId}`)
return undefined
}
return { directory: worktree.path, baseBranch: remoteRef(worktree) }
const base = this.baseOverrides.get(ctxId) ?? remoteRef(worktree)
return { directory: worktree.path, baseBranch: base }
}
private async resolveLocal(): Promise<{ directory: string; baseBranch: string } | undefined> {
return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), this.ctx.getRoot())
const root = this.ctx.getRoot()
if (!root) return undefined
const override = this.baseOverrides.get(LOCAL_DIFF_ID)
if (override) {
return { directory: root, baseBranch: override }
}
return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), root)
}
private async ready(msg: string): Promise<void> {
await this.ctx.getStateReady()?.catch((err) => this.ctx.log(msg, err))
}
private source(sessionId: string): DiffSource {
const descriptor: DiffSourceDescriptor = {
id: sessionId,
type: "workspace",
group: "Git",
capabilities: { revert: true, comments: true },
}
/**
* Build the active source for a composite id by delegating to the catalog.
* The composite id (ctx#scope) is preserved as the descriptor id so the
* webview keys diff data by context+scope. Context resolution (dir/base)
* already happened in activate() and is carried by the PanelContext.
*/
private source(id: string, panelCtx: PanelContext): DiffSource {
const { ctx, scope } = parseDiffId(id)
const built = this.ctx.catalog.build(scopeToSourceId(scope, ctx), panelCtx)
return {
descriptor,
fetch: () => this.fetch(sessionId),
fetchFile: (file) => this.fetchFile(sessionId, file),
revert: (file) => this.revertFile(sessionId, file),
...built,
descriptor: { ...built.descriptor, id },
}
}
private async fetch(sessionId: string): Promise<DiffSourceFetch> {
await this.ready("stateReady rejected, continuing diff resolve:")
const target = await this.ensureTarget(sessionId)
if (!target) return { diffs: [], stopPolling: true }
const files = await this.ctx.localDiff(target.directory, target.baseBranch)
this.ctx.log(`Worktree diff returned ${files.length} file(s) for session ${sessionId}`)
return { diffs: files as AgentManagerDiffFile[] }
}
private async fetchFile(sessionId: string, file: string): Promise<DiffFile | null> {
await this.ready("stateReady rejected, continuing diff detail resolve:")
return this.details.run(async () => {
const target = await this.ensureTarget(sessionId)
if (!target) return null
try {
return (await this.ctx.localDiffFile(target.directory, target.baseBranch, file)) as AgentManagerDiffFile | null
} catch (error) {
this.ctx.log("Failed to fetch worktree diff file:", error)
return null
}
})
}
private async revertFile(sessionId: string, file: string): Promise<{ ok: boolean; message: string }> {
private async revertFile(id: string, file: string): Promise<{ ok: boolean; message: string }> {
await this.ready("stateReady rejected, continuing revert resolve:")
const target = await this.resolveTarget(sessionId)
const { ctx } = parseDiffId(id)
const target = await this.resolve(ctx)
if (!target) return { ok: false, message: "Could not resolve diff target" }
try {
@@ -285,19 +309,6 @@ export class WorktreeDiffController {
}
}
private async ensureTarget(sessionId: string): Promise<Target | undefined> {
if (this.controller.currentId !== sessionId) return undefined
if (this.target?.sessionId === sessionId) return this.target
return await this.resolveTarget(sessionId)
}
private async resolveTarget(sessionId: string): Promise<Target | undefined> {
const target = await this.resolve(sessionId)
if (!target) return undefined
this.target = { sessionId, ...target }
return this.target
}
private postRevertResult(sessionId: string, file: string, result: { ok: boolean; message: string }): void {
this.ctx.post({
type: "agentManager.revertWorktreeFileResult",
@@ -90,12 +90,17 @@ export class DiffSourceCatalog implements vscode.Disposable {
}
build(id: string, ctx: PanelContext): DiffSource {
const opts = { dir: () => ctx.dir, strictDir: ctx.strictDir, git: ctx.git, log: ctx.log }
if (id === WORKSPACE_SOURCE_ID) {
return createWorktreeDiffSource({ baseBranchOverride: ctx.baseBranchOverride })
return createWorktreeDiffSource({
...opts,
baseBranchOverride: ctx.baseBranchOverride,
baseBranch: ctx.baseBranch,
})
}
if (id === STAGED_SOURCE_ID) return createStagedDiffSource()
if (id === UNSTAGED_SOURCE_ID) return createUnstagedDiffSource()
if (id === STAGED_SOURCE_ID) return createStagedDiffSource(opts)
if (id === UNSTAGED_SOURCE_ID) return createUnstagedDiffSource(opts)
if (id.startsWith(TURN_PREFIX)) {
const [sessionId, messageId] = id.slice(TURN_PREFIX.length).split(":")
@@ -108,14 +113,22 @@ export class DiffSourceCatalog implements vscode.Disposable {
if (id.startsWith(SESSION_PREFIX)) {
const sessionId = id.slice(SESSION_PREFIX.length)
if (!sessionId) throw new Error(`DiffSourceCatalog.build: empty session id in "${id}"`)
return createSessionDiffSource(sessionId, this.sessionFetch, ctx.workspaceRoot, this.checkSnapshotsEnabled)
return createSessionDiffSource(
sessionId,
this.sessionFetch,
ctx.dir ?? ctx.workspaceRoot,
this.checkSnapshotsEnabled,
)
}
throw new Error(`DiffSourceCatalog.build: unknown source id "${id}"`)
}
async listWorkspaceBranches(override: string | undefined): Promise<WorkspaceBranchesResult | undefined> {
const root = getWorkspaceRoot()
async listWorkspaceBranches(
override: string | undefined,
dir?: string,
): Promise<WorkspaceBranchesResult | undefined> {
const root = dir ?? getWorkspaceRoot()
if (!root) return undefined
const git = this.ensureBranchGit()
@@ -34,17 +34,39 @@ function stamp(entry: FileEntry, before: string, after: string): FileEntry {
return { ...entry, stamp: `${entry.status}:${before}:${after}` }
}
export interface StagedDiffSourceOptions {
/**
* Resolve the directory to diff. Defaults to the VS Code workspace root.
* Agent Manager passes a worktree path so the source diffs inside the
* worktree rather than the main checkout.
*/
dir?: () => string | undefined
/**
* When true, a `dir` that resolves to undefined yields an empty diff rather
* than falling back to the workspace root.
*/
strictDir?: boolean
/** Shared GitOps / log so sources don't each spawn their own channel. */
git?: GitOps
log?: (...args: unknown[]) => void
}
/**
* Diff between the git index and HEAD — what `git diff --cached` would show.
* Polls on the standard interval; revert isn't supported (use `git reset` from
* a real git client). Read-only view.
*/
export function createStagedDiffSource(): DiffSource {
const output = vscode.window.createOutputChannel("Kilo Diff: Staged")
const log = (...args: unknown[]) => appendOutput(output, "StagedDiffSource", ...args)
const git = new GitOps({ log })
export function createStagedDiffSource(opts: StagedDiffSourceOptions = {}): DiffSource {
const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Staged")
const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "StagedDiffSource", ...args))
const git = opts.git ?? new GitOps({ log })
const root = (): string | undefined => getWorkspaceRoot()
const root = (): string | undefined => {
const dir = opts.dir?.()
if (dir) return dir
if (opts.strictDir) return undefined
return getWorkspaceRoot()
}
const listEntries = async (dir: string): Promise<FileEntry[]> => {
const [nameStatus, numstat, raw] = await Promise.all([
@@ -150,8 +172,10 @@ export function createStagedDiffSource(): DiffSource {
},
dispose(): void {
git.dispose()
output.dispose()
// Only dispose resources we own (created here). Injected git/log are
// owned by the caller.
if (!opts.git) git.dispose()
output?.dispose()
},
}
}
@@ -40,17 +40,39 @@ function stamp(entry: FileEntry, before: string, after: string): FileEntry {
return { ...entry, stamp: `${entry.status}:${before}:${after}` }
}
export interface UnstagedDiffSourceOptions {
/**
* Resolve the directory to diff. Defaults to the VS Code workspace root.
* Agent Manager passes a worktree path so the source diffs inside the
* worktree rather than the main checkout.
*/
dir?: () => string | undefined
/**
* When true, a `dir` that resolves to undefined yields an empty diff rather
* than falling back to the workspace root.
*/
strictDir?: boolean
/** Shared GitOps / log so sources don't each spawn their own channel. */
git?: GitOps
log?: (...args: unknown[]) => void
}
/**
* Diff between the working tree and the index — what `git diff` shows for
* tracked files, plus untracked files (treated as fully-added). Read-only;
* polls on the standard interval.
*/
export function createUnstagedDiffSource(): DiffSource {
const output = vscode.window.createOutputChannel("Kilo Diff: Unstaged")
const log = (...args: unknown[]) => appendOutput(output, "UnstagedDiffSource", ...args)
const git = new GitOps({ log })
export function createUnstagedDiffSource(opts: UnstagedDiffSourceOptions = {}): DiffSource {
const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Unstaged")
const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "UnstagedDiffSource", ...args))
const git = opts.git ?? new GitOps({ log })
const root = (): string | undefined => getWorkspaceRoot()
const root = (): string | undefined => {
const dir = opts.dir?.()
if (dir) return dir
if (opts.strictDir) return undefined
return getWorkspaceRoot()
}
const listTracked = async (dir: string): Promise<FileEntry[]> => {
const [nameStatus, numstat, raw] = await Promise.all([
@@ -192,8 +214,10 @@ export function createUnstagedDiffSource(): DiffSource {
},
dispose(): void {
git.dispose()
output.dispose()
// Only dispose resources we own (created here). Injected git/log are
// owned by the caller.
if (!opts.git) git.dispose()
output?.dispose()
},
}
}
@@ -23,6 +23,28 @@ export interface WorktreeDiffSourceOptions {
* the current branch — only the comparison target changes. Reset on dispose.
*/
baseBranchOverride?: string
/**
* Resolve the directory to diff. Defaults to the VS Code workspace root.
* Agent Manager passes a worktree path so the source diffs inside the
* worktree rather than the main checkout.
*/
dir?: () => string | undefined
/**
* When true, a `dir` that resolves to undefined yields an empty diff rather
* than falling back to the workspace root. Prevents an unresolvable
* worktree context from silently diffing the main checkout.
*/
strictDir?: boolean
/**
* Explicit base branch to diff against. When set, the source skips
* auto-resolution (tracking → default) and diffs against this ref directly.
* Agent Manager passes the worktree's recorded parent so a worktree always
* compares against its own base even when the workspace default differs.
*/
baseBranch?: string
/** Shared GitOps / log so sources don't each spawn their own channel. */
git?: GitOps
log?: (...args: unknown[]) => void
}
/**
@@ -32,9 +54,16 @@ export interface WorktreeDiffSourceOptions {
* extension host — no `kilo serve` round-trip.
*/
export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): DiffSource {
const output = vscode.window.createOutputChannel("Kilo Diff: Workspace")
const log = (...args: unknown[]) => appendOutput(output, "WorktreeDiffSource", ...args)
const git = new GitOps({ log })
const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Workspace")
const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "WorktreeDiffSource", ...args))
const git = opts.git ?? new GitOps({ log })
const root = (): string | undefined => {
const dir = opts.dir?.()
if (dir) return dir
if (opts.strictDir) return undefined
return getWorkspaceRoot()
}
// Cached between fetches so repeated polling doesn't re-resolve the base
// branch every tick. Reset only on dispose (when the source is swapped out).
@@ -42,22 +71,32 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}):
const resolveTarget = async (): Promise<DiffTarget | undefined> => {
if (target) return target
if (opts.baseBranch) {
const dir = root()
if (!dir) {
log("Local diff: no directory (explicit base mode)")
return
}
target = { directory: dir, baseBranch: opts.baseBranch }
log(`Local diff: using explicit base=${opts.baseBranch} dir=${dir}`)
return target
}
if (opts.baseBranchOverride) {
const root = getWorkspaceRoot()
if (!root) {
const dir = root()
if (!dir) {
log("Local diff: no workspace root (override mode)")
return
}
const resolved = await resolveOverrideRef(git, root, opts.baseBranchOverride, log)
const resolved = await resolveOverrideRef(git, dir, opts.baseBranchOverride, log)
if (!resolved) {
log(`Local diff: override base="${opts.baseBranchOverride}" could not be resolved, falling back to auto`)
} else {
target = { directory: root, baseBranch: resolved }
target = { directory: dir, baseBranch: resolved }
log(`Local diff: using override base=${resolved}`)
return target
}
}
target = await resolveLocalDiffTarget(git, log, getWorkspaceRoot())
target = await resolveLocalDiffTarget(git, log, root())
return target
}
@@ -109,8 +148,10 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}):
},
dispose(): void {
git.dispose()
output.dispose()
// Only dispose resources we own (created here). Injected git/log are
// owned by the caller.
if (!opts.git) git.dispose()
output?.dispose()
target = undefined
},
}
+21
View File
@@ -10,6 +10,27 @@ export interface PanelContext {
hidePicker?: boolean
/** User-picked base branch for the workspace source. Undefined = auto. */
baseBranchOverride?: string
/**
* Explicit directory to diff inside, overriding the workspace root lookup.
* Agent Manager passes a worktree path so its sources operate in the
* worktree rather than the main checkout.
*/
dir?: string
/**
* When true, a source whose `dir` resolves to undefined returns an empty
* diff instead of falling back to the workspace root. Agent Manager sets
* this so an unresolvable worktree context never silently diffs the main
* checkout.
*/
strictDir?: boolean
/**
* Explicit base ref for the workspace source, skipping auto-resolution.
* Agent Manager passes the worktree's recorded parent ref.
*/
baseBranch?: string
/** Shared GitOps / log injected by Agent Manager to avoid per-source channels. */
git?: import("../agent-manager/GitOps").GitOps
log?: (...args: unknown[]) => void
}
export type DiffImageError = "too-large" | "unreadable"
@@ -14,6 +14,7 @@ export async function fetchSnapshot(client: Client, dir: string, settings: () =>
config,
globalConfig: global,
projectConfig: overlay?.project,
collections: overlay?.collections,
settings: settings(),
features: configFeatures(config),
}
@@ -1,11 +1,52 @@
import { describe, expect, it } from "bun:test"
import {
mcpConfigScope,
mcpEnabledPatch,
selectedAgentNumberOverrideValue,
selectedAgentTextOverrideValue,
selectedDefaultAgentValue,
shouldClearDefaultAgentWhenAgentBecomesUnavailable,
} from "../../webview-ui/src/components/settings/agent-behaviour-patches"
describe("mcpEnabledPatch", () => {
it("returns only the enabled-state patch", () => {
expect(mcpEnabledPatch("docs", false)).toEqual({
mcp: {
docs: {
enabled: false,
},
},
})
})
it("routes project-defined servers to project config", () => {
expect(
mcpConfigScope("docs", {
mcp: [{ key: "docs", source: "project" }],
}),
).toBe("project")
})
it("routes global servers to global config", () => {
const collections = {
mcp: [{ key: "docs", source: "global" as const }],
}
expect(mcpConfigScope("docs", collections)).toBe("global")
})
it("keeps system, default, and unknown servers runtime-only", () => {
const collections = {
mcp: [
{ key: "legacy", source: "system" as const },
{ key: "builtin", source: "default" as const },
],
}
expect(mcpConfigScope("legacy", collections)).toBeUndefined()
expect(mcpConfigScope("builtin", collections)).toBeUndefined()
expect(mcpConfigScope("unknown", collections)).toBeUndefined()
})
})
describe("selectedAgentTextOverrideValue", () => {
it("maps an empty text field value to a null delete sentinel", () => {
expect(selectedAgentTextOverrideValue("")).toBeNull()
@@ -572,7 +572,9 @@ describe("Agent Manager Provider — onMessage routing", () => {
expect(text).toContain("class WorktreeDiffController")
expect(text).toContain("buildWorktreePatch")
expect(text).toContain("revertFile")
expect(text).toContain("diffSummary")
// Summary/detail diff data comes from the shared DiffSourceCatalog sources
// (workspace/staged/unstaged/session), not a bespoke in-controller pipeline.
expect(text).toContain("catalog.build")
expect(text).toContain("shouldStopDiffPolling")
expect(providerText).toContain("this.diffs")
})
@@ -0,0 +1,52 @@
import { describe, it, expect } from "bun:test"
import {
composeDiffId,
parseDiffId,
isDiffScope,
normalizeScope,
scopeToSourceId,
DEFAULT_DIFF_SCOPE,
} from "../../src/agent-manager/diff-scope"
describe("diff-scope composite ids", () => {
it("round-trips context and scope", () => {
expect(parseDiffId(composeDiffId("local", "branch"))).toEqual({ ctx: "local", scope: "branch" })
expect(parseDiffId(composeDiffId("ses_abc", "staged"))).toEqual({ ctx: "ses_abc", scope: "staged" })
expect(parseDiffId(composeDiffId("ses_abc", "unstaged"))).toEqual({ ctx: "ses_abc", scope: "unstaged" })
expect(parseDiffId(composeDiffId("ses_abc", "session"))).toEqual({ ctx: "ses_abc", scope: "session" })
})
it("parses session ids containing no separator as default branch scope", () => {
expect(parseDiffId("ses_abc")).toEqual({ ctx: "ses_abc", scope: DEFAULT_DIFF_SCOPE })
})
it("treats an unknown trailing segment as part of the context, not a scope", () => {
// A session id that happens to contain '#' but not a valid scope keeps the
// full id as context and falls back to branch.
expect(parseDiffId("ses_a#bogus")).toEqual({ ctx: "ses_a#bogus", scope: DEFAULT_DIFF_SCOPE })
})
it("isDiffScope guards the closed enum", () => {
expect(isDiffScope("branch")).toBe(true)
expect(isDiffScope("staged")).toBe(true)
expect(isDiffScope("unstaged")).toBe(true)
expect(isDiffScope("session")).toBe(true)
expect(isDiffScope("turn")).toBe(false)
expect(isDiffScope("")).toBe(false)
})
it("normalizeScope falls back to branch for unknown input", () => {
expect(normalizeScope("staged")).toBe("staged")
expect(normalizeScope("nope")).toBe("branch")
expect(normalizeScope(undefined)).toBe("branch")
expect(normalizeScope(42)).toBe("branch")
})
it("maps scopes to catalog source ids", () => {
expect(scopeToSourceId("branch", "ses_abc")).toBe("workspace")
expect(scopeToSourceId("staged", "ses_abc")).toBe("staged")
expect(scopeToSourceId("unstaged", "ses_abc")).toBe("unstaged")
expect(scopeToSourceId("session", "ses_abc")).toBe("session:ses_abc")
expect(scopeToSourceId("branch", "local")).toBe("workspace")
})
})
@@ -2,7 +2,13 @@ import { describe, expect, it } from "bun:test"
import { messageTurns } from "../../webview-ui/src/context/session-queue"
import { transcriptRows } from "../../webview-ui/src/context/transcript-rows"
import type { Message, Part, TextPart } from "../../webview-ui/src/types/messages"
import { capacity, previewText, promptItems, railItems } from "../../webview-ui/src/components/chat/prompt-rail"
import {
capacity,
historyAction,
previewText,
promptItems,
railEntries,
} from "../../webview-ui/src/components/chat/prompt-rail"
const base = {
sessionID: "session",
@@ -157,18 +163,38 @@ describe("promptItems", () => {
})
describe("capacity", () => {
it("counts how many worst-case rows fit the transcript height", () => {
expect(capacity(24 + 76 * 5)).toBe(5)
expect(capacity(100)).toBe(1)
it("counts how many ticks fit the transcript height", () => {
expect(capacity(24 + 7 * 5)).toBe(5)
expect(capacity(31)).toBe(1)
})
it("fits far more ticks than the navigator lists rows", () => {
// A tick is a hairline, so a sidebar-height transcript holds a whole
// session's prompts rather than the handful of card rows that fit.
expect(capacity(724)).toBe(100)
})
it("returns nothing usable for unmeasured or tiny transcripts", () => {
expect(capacity(0)).toBeLessThan(1)
expect(capacity(99)).toBeLessThan(1)
expect(capacity(30)).toBeLessThan(1)
})
})
describe("railItems", () => {
describe("historyAction", () => {
it("loads the next page only after the previous page made progress", () => {
expect(historyAction(80, 160, true)).toBe("load")
})
it("jumps after the final page", () => {
expect(historyAction(160, 200, false)).toBe("jump")
})
it("stops instead of retrying a page that made no progress", () => {
expect(historyAction(160, 160, true)).toBe("stop")
})
})
describe("railEntries", () => {
const items = Array.from({ length: 5 }, (_, i) => ({
key: `k${i}`,
turn: `t${i}`,
@@ -178,15 +204,36 @@ describe("railItems", () => {
}))
it("passes through when everything fits", () => {
expect(railItems(items, 5)).toEqual(items)
expect(railItems(items, 10)).toEqual(items)
expect(railEntries(items, 5)).toEqual(items.map((item, index) => ({ type: "prompt", item, index })))
expect(railEntries(items, 10)).toEqual(items.map((item, index) => ({ type: "prompt", item, index })))
})
it("keeps the newest items when capacity is smaller", () => {
expect(railItems(items, 2)).toEqual(items.slice(-2))
it("keeps the first and latest prompts at minimal capacity", () => {
expect(railEntries(items, 2)).toEqual([
{ type: "prompt", item: items[0], index: 0 },
{ type: "prompt", item: items[4], index: 4 },
])
})
it("summarizes hidden loaded prompts between the first and recent prompts", () => {
expect(railEntries(items, 4)).toEqual([
{ type: "prompt", item: items[0], index: 0 },
{ type: "overflow", count: 2, index: 1 },
{ type: "prompt", item: items[3], index: 3 },
{ type: "prompt", item: items[4], index: 4 },
])
})
it("reserves the first entry for unloaded history", () => {
expect(railEntries(items, 4, true)).toEqual([
{ type: "history" },
{ type: "overflow", count: 3, index: 0 },
{ type: "prompt", item: items[3], index: 3 },
{ type: "prompt", item: items[4], index: 4 },
])
})
it("returns nothing at zero capacity", () => {
expect(railItems(items, 0)).toEqual([])
expect(railEntries(items, 0)).toEqual([])
})
})
@@ -23,6 +23,7 @@ import type {
AgentManagerWorktreeDiffMessage,
AgentManagerWorktreeDiffFileMessage,
AgentManagerWorktreeDiffLoadingMessage,
AgentManagerDiffBranchesMessage,
AgentManagerApplyWorktreeDiffResultMessage,
AgentManagerWorktreeStatsMessage,
AgentManagerLocalStatsMessage,
@@ -136,6 +137,9 @@ import {
} from "./section-helpers"
import { sectionAwareDetector } from "./section-dnd"
import { ConstrainDragXAxis } from "./constrain-drag-x"
import { DiffScopeControls } from "../diff-viewer/DiffScopeControls"
import { scopeCapabilities } from "./diff-scope-state"
import { createDiffReviewScope } from "./diff-review-scope"
import { initialMessage, seedInitialVariant } from "./initial-message"
import { createMarkdownRender } from "./review-preferences"
import { createSidebarCollapse } from "./sidebar-collapse"
@@ -1308,6 +1312,10 @@ const AgentManagerContent: Component = () => {
diffs.onWorktreeDiffLoading(msg as AgentManagerWorktreeDiffLoadingMessage)
}
if (msg.type === "agentManager.diffBranches") {
review.onBranches(msg as AgentManagerDiffBranchesMessage)
}
if (msg.type === "agentManager.applyWorktreeDiffResult") {
apply.onApplyResult(msg as AgentManagerApplyWorktreeDiffResultMessage)
}
@@ -1379,15 +1387,47 @@ const AgentManagerContent: Component = () => {
const currentDiffSessionId = createMemo(selectedDiffSessionId)
// Start/stop diff watch when panel opens/closes, review tab opens, or session changes
// Diff scope + base branch state, shared by the side panel and review tab.
const review = createDiffReviewScope({
ctx: currentDiffSessionId,
panelOpen: diffOpen,
reviewActive,
local: LOCAL,
vscode,
})
// The composite id (ctx#scope) the extension keys diff data by.
const diffScopeId = review.id
// Shared scope + base-picker controls for the side panel and review tab.
const diffScopeControls = (compact: boolean) => (
<DiffScopeControls
descriptors={review.descriptors()}
currentId={review.id()}
onSelectScope={review.select}
showBase={review.isBranch()}
branches={review.branches()}
branchesLoading={review.loading()}
defaultBranch={review.defaultBranch()}
autoBase={review.autoBase()}
currentBase={review.currentBase()}
isAuto={review.isAuto()}
currentBranch={review.currentBranch()}
onSelectBase={review.selectBase}
compact={compact}
/>
)
// Start/stop diff watch when panel opens/closes, review tab opens, scope
// changes, or session changes.
createEffect(() => {
const panel = diffOpen()
const review = reviewActive()
const active = reviewActive()
const scope = review.scope()
if (panel || review) {
if (panel || active) {
const id = currentDiffSessionId()
if (id) {
vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id })
vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id, scope })
return
}
vscode.postMessage({ type: "agentManager.stopDiffWatch" })
@@ -1432,33 +1472,17 @@ const AgentManagerContent: Component = () => {
tabFocus.restore()
}
// Data for the review tab: use local diff data for local context,
// current session for selected worktree context, or first available in that worktree.
// Data for the review tab / side panel: keyed by the composite diff id
// (ctx#scope) the extension pushes, so each scope keeps its own file set and
// switching back to a fetched scope is instant.
const reviewDiffs = createMemo(() => {
const data = diffDatas()
const sel = selection()
const id = session.currentSessionID()
if (sel === LOCAL) return data[LOCAL] ?? []
if (id && data[id]) {
const current = managedSessions().find((s) => s.id === id)
if (sel && current?.worktreeId === sel) return data[id]!
}
if (!sel) return []
const ids = managedSessions()
.filter((s) => s.worktreeId === sel)
.map((s) => s.id)
for (const sid of ids) {
if (data[sid]) return data[sid]!
}
return []
const key = diffScopeId()
if (!key) return []
return data[key] ?? []
})
const diffSessionKey = createMemo(() => {
const sel = selection()
if (sel === LOCAL) return `local:${LOCAL}`
if (sel === null) return `session:${session.currentSessionID() ?? ""}`
return `worktree:${sel}`
})
const diffSessionKey = createMemo(() => diffScopeId() ?? "")
const setSharedDiffStyle = (style: "unified" | "split") => {
if (reviewDiffStyle() === style) return
@@ -1467,14 +1491,14 @@ const AgentManagerContent: Component = () => {
}
const requestDiffFile = (file: string) => {
const sessionId = currentDiffSessionId()
if (!sessionId) return
diffs.requestDiffFile(sessionId, file)
const id = diffScopeId()
if (!id) return
diffs.requestDiffFile(id, file)
}
const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(currentDiffSessionId))
const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(diffScopeId))
const revertCtl = createRevertFile(currentDiffSessionId, vscode, showToast, t)
const revertCtl = createRevertFile(diffScopeId, currentDiffSessionId, () => review.scope(), vscode, showToast, t)
const handleConfigureSetupScript = () => {
vscode.postMessage({ type: "agentManager.configureSetupScript" })
@@ -2475,12 +2499,19 @@ const AgentManagerContent: Component = () => {
{t("agentManager.open.button")}
</Button>
</Tooltip>
<Tooltip value={t("agentManager.apply.tooltip")} placement="bottom">
<Tooltip
value={
review.scope() === "branch"
? t("agentManager.apply.tooltip")
: t("agentManager.diff.applyBranchOnly")
}
placement="bottom"
>
<Button
size="small"
variant="ghost"
onClick={openApplyDialog}
disabled={!hasChanges() || applyBusy()}
disabled={!hasChanges() || applyBusy() || review.scope() !== "branch"}
>
<Show when={applyBusy()}>
<Spinner class="am-apply-spinner" />
@@ -2782,6 +2813,8 @@ const AgentManagerContent: Component = () => {
loadingFiles={diffFileLoadingForCurrent()}
sessionId={currentDiffSessionId()}
sessionKey={diffSessionKey()}
lead={diffScopeControls(true)}
canRevert={scopeCapabilities(review.scope()).revert}
diffStyle={reviewDiffStyle()}
onDiffStyleChange={setSharedDiffStyle}
markdownRender={markdown.render()}
@@ -2829,6 +2862,9 @@ const AgentManagerContent: Component = () => {
loadingFiles={diffFileLoadingForCurrent()}
sessionId={currentDiffSessionId()}
sessionKey={diffSessionKey()}
lead={diffScopeControls(false)}
canRevert={scopeCapabilities(review.scope()).revert}
canComment={scopeCapabilities(review.scope()).comments}
comments={reviewComments()}
onCommentsChange={setReviewCommentsForSelection}
composer={reviewComposer}
@@ -1,4 +1,4 @@
import { type Component, createSignal, createMemo, Show, createEffect, on } from "solid-js"
import { type Component, createSignal, createMemo, Show, createEffect, on, type JSXElement } from "solid-js"
import type { VirtualizerHandle } from "virtua/solid"
import { Diff } from "@kilocode/kilo-ui/diff"
import { Accordion } from "@kilocode/kilo-ui/accordion"
@@ -7,7 +7,6 @@ import { FileIcon } from "@kilocode/kilo-ui/file-icon"
import { DiffChanges } from "@kilocode/kilo-ui/diff-changes"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Button } from "@kilocode/kilo-ui/button"
import { RadioGroup } from "@kilocode/kilo-ui/radio-group"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
@@ -15,6 +14,7 @@ import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pie
import type { WorktreeFileDiff } from "../src/types/messages"
import { KILO_FILE_PATH_MIME } from "../src/utils/path-mentions"
import { useLanguage } from "../src/context/language"
import { DiffStyleSelect } from "../diff-viewer/InlineSelect"
import { useVSCode } from "../src/context/vscode"
import { useServer } from "../src/context/server"
import { useProvider } from "../src/context/provider"
@@ -86,6 +86,10 @@ interface DiffPanelProps {
onRevertFile?: (file: string) => void
revertingFiles?: Set<string>
activeTerminalId?: string
/** Optional leading row rendered under the header (e.g. the scope selector). */
lead?: JSXElement
/** Defaults to true. Hides the per-file Revert action when false. */
canRevert?: boolean
}
export const DiffPanel: Component<DiffPanelProps> = (props) => {
@@ -475,21 +479,18 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
<div class="am-diff-panel" onKeyDown={handleKeyDown} onMouseDown={handleRootMouseDown} tabIndex={-1} ref={rootRef}>
<div class="am-diff-header">
<div class="am-diff-header-main">
<span class="am-diff-header-title">{t("session.review.change.other")}</span>
{/* Scope + base picker replace the static "Changes" title: it names
what you're looking at and is the primary control. Always shown,
so an empty scope can still be switched away from. */}
<Show when={props.lead}>{props.lead}</Show>
<Show when={props.diffs.length > 0}>
<>
<RadioGroup
options={["unified", "split"] as const}
current={props.diffStyle ?? "unified"}
size="small"
value={(style) => style}
label={(style) =>
style === "unified" ? t("ui.sessionReview.diffStyle.unified") : t("ui.sessionReview.diffStyle.split")
}
onSelect={(style) => {
if (!style) return
props.onDiffStyleChange?.(style)
}}
<DiffStyleSelect
value={props.diffStyle ?? "unified"}
onSelect={(style) => props.onDiffStyleChange?.(style)}
unifiedLabel={t("ui.sessionReview.diffStyle.unified")}
splitLabel={t("ui.sessionReview.diffStyle.split")}
title={t("ui.sessionReview.diffStyle.unified")}
/>
<span class="am-diff-header-stats">
<span>{t("session.review.filesChanged", { count: totals().files })}</span>
@@ -636,7 +637,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
/>
</Tooltip>
</Show>
<Show when={props.onRevertFile}>
<Show when={props.onRevertFile && props.canRevert !== false}>
<Tooltip value={t("agentManager.diff.revertFile")} placement="top">
<IconButton
icon="discard"
@@ -43,12 +43,20 @@
.am-review-toolbar-left {
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
flex: 1;
min-width: 0;
overflow: hidden;
}
/* Keep the radio group from being the tallest thing in the row so it matches
the 22px selector chips and the small ghost buttons. The inline scope/base
controls are styled in banners.css (non-am- prefixed, shared with the
standalone diff viewer). */
.am-review-toolbar [data-component="radio-group"] {
font-size: var(--font-size-small);
}
.am-review-toolbar-right {
display: flex;
align-items: center;
@@ -77,7 +85,7 @@
.am-review-toolbar-stats {
display: flex;
align-items: center;
flex: 1 1 auto;
flex: 0 100 auto;
gap: 8px;
font-size: var(--font-size-small);
color: var(--text-weak);
@@ -1717,35 +1717,58 @@ body.am-wt-dragging-active * {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 4px 4px 12px;
gap: 6px;
padding: 4px 4px 4px 8px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weak-base);
position: relative;
z-index: 20;
background: var(--surface-base);
}
.am-diff-header-title {
font-size: var(--font-size-small);
font-weight: 500;
color: var(--text-weak);
/* Query container for the narrow-panel rules below and in banners.css. The
panel width is user-draggable, so the header adapts to its own width
rather than the viewport. */
container-type: inline-size;
container-name: am-diff-header;
}
.am-diff-header-main {
display: flex;
align-items: center;
gap: 10px;
gap: 6px;
flex: 1;
min-width: 0;
overflow: hidden;
}
.am-diff-header-stats {
display: flex;
align-items: center;
gap: 8px;
flex: 0 1 auto;
min-width: 0;
font-size: var(--font-size-small);
color: var(--text-weak);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Progressive disclosure, least important first. Thresholds are derived from the
measured control widths: scope 76px, base <=191px, diff style 76px, 6px gaps,
plus ~90px of action buttons and 12px padding. Each breakpoint fires while the
remaining set still fits, so nothing is ever clipped under the buttons.
The collapsed-files hint goes before the totals; both are also shown on the
worktree row and the diff toggle button, so no information is lost. */
@container am-diff-header (max-width: 560px) {
.am-diff-header-collapsed {
display: none;
}
}
@container am-diff-header (max-width: 520px) {
.am-diff-header-stats {
display: none;
}
}
.am-diff-header-adds {
@@ -0,0 +1,118 @@
/**
* Diff scope + base branch state for the Agent Manager review surfaces.
*
* Owns the per-context scope selection, the branch picker data for the active
* context, and the message senders that drive both. Extracted from
* AgentManagerApp to keep that file under its line cap; both the side panel
* and the full-screen review tab consume the single instance returned here.
*/
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import type { BranchInfo } from "../src/types/messages"
import { createDiffScope, isDiffScope, scopeDescriptors, type DiffScope } from "./diff-scope-state"
interface VsCode {
postMessage(msg: unknown): void
}
export interface DiffReviewScopeOptions {
/** Current diff context (worktree session id or the LOCAL pseudo-id). */
ctx: Accessor<string | undefined>
/** Whether the diff side panel is open. */
panelOpen: Accessor<boolean>
/** Whether the full-screen review tab is active. */
reviewActive: Accessor<boolean>
/** The id that marks the local pseudo-context (omits the Session scope). */
local: string
vscode: VsCode
}
export function createDiffReviewScope(opts: DiffReviewScopeOptions) {
const scope = createDiffScope(opts.ctx)
// The composite id (ctx#scope) the extension keys diff data by.
const id = createMemo(() => scope.id())
// Branch picker state for the active context (Branch scope only).
const [branches, setBranches] = createSignal<BranchInfo[]>([])
const [loading, setLoading] = createSignal(false)
const [defaultBranch, setDefaultBranch] = createSignal("")
const [autoBase, setAutoBase] = createSignal<string | undefined>(undefined)
const [currentBase, setCurrentBase] = createSignal<string | undefined>(undefined)
const [isAuto, setIsAuto] = createSignal(true)
const [currentBranch, setCurrentBranch] = createSignal<string | undefined>(undefined)
// Scope descriptors for the current context. The `local` pseudo-context and
// contexts without a real session omit the Session scope.
const descriptors = createMemo(() => {
const ctx = opts.ctx()
if (!ctx) return []
return scopeDescriptors(ctx, ctx !== opts.local)
})
const isBranch = () => scope.scope() === "branch"
const select = (next: string) => {
const ctx = opts.ctx()
if (!ctx) return
const value = next.slice(ctx.length + 1)
scope.setScope(isDiffScope(value) ? value : "branch")
}
const selectBase = (branch: string | undefined) => {
const ctx = opts.ctx()
if (!ctx) return
// Optimistic update; the extension echoes authoritative state back.
setCurrentBase(branch ?? autoBase())
setIsAuto(branch === undefined)
opts.vscode.postMessage({ type: "agentManager.setDiffBaseBranch", sessionId: ctx, scope: scope.scope(), branch })
}
// Fetch branch picker data whenever the Branch scope becomes active for the
// current context. The extension owns override state, so ask each time.
createEffect(() => {
if (scope.scope() !== "branch") return
const ctx = opts.ctx()
if (!ctx) return
if (!opts.panelOpen() && !opts.reviewActive()) return
setLoading(true)
opts.vscode.postMessage({ type: "agentManager.requestDiffBranches", sessionId: ctx, scope: scope.scope() })
})
/** Handle the extension's diffBranches push, ignoring stale contexts. */
const onBranches = (ev: {
sessionId: string
branches: BranchInfo[]
defaultBranch: string
autoBase?: string
currentBase?: string
isAuto: boolean
currentBranch?: string
}) => {
if (ev.sessionId === id()) {
setBranches(ev.branches)
setDefaultBranch(ev.defaultBranch)
setAutoBase(ev.autoBase)
setCurrentBase(ev.currentBase)
setIsAuto(ev.isAuto)
setCurrentBranch(ev.currentBranch)
}
setLoading(false)
}
return {
scope: scope.scope,
id,
descriptors,
isBranch,
select,
selectBase,
onBranches,
branches,
loading,
defaultBranch,
autoBase,
currentBase,
isAuto,
currentBranch,
}
}
@@ -0,0 +1,103 @@
/**
* Webview-side diff scope state for Agent Manager.
*
* Mirrors the extension's composite diff id (`ctx#scope`, see
* `src/agent-manager/diff-scope.ts`) and builds the fixed scope descriptor
* list shown in the scope selector. Agent Manager always offers the same four
* scopes per context, so the descriptors are computed client-side rather than
* pushed from the extension.
*/
import { createMemo, createSignal, type Accessor } from "solid-js"
import type { DiffSourceDescriptor } from "../../src/diff/sources/types"
export type DiffScope = "branch" | "staged" | "unstaged" | "session"
export const DEFAULT_DIFF_SCOPE: DiffScope = "branch"
const SEP = "#"
export function composeDiffId(ctx: string, scope: DiffScope): string {
return `${ctx}${SEP}${scope}`
}
export function parseDiffId(id: string): { ctx: string; scope: DiffScope } {
const idx = id.lastIndexOf(SEP)
const scope = id.slice(idx + SEP.length)
if (idx !== -1 && isDiffScope(scope)) return { ctx: id.slice(0, idx), scope }
return { ctx: id, scope: DEFAULT_DIFF_SCOPE }
}
export function isDiffScope(value: string): value is DiffScope {
return value === "branch" || value === "staged" || value === "unstaged" || value === "session"
}
/**
* The fixed scope descriptors for a context. `workspace` maps to the Branch
* scope to reuse the existing i18n keys (`diffViewer.source.workspace.*`).
* Session scope is only meaningful for a real session context, so it is
* omitted for the `local` pseudo-context and for contexts without a session.
*/
export function scopeDescriptors(ctx: string, hasSession: boolean): DiffSourceDescriptor[] {
const out: DiffSourceDescriptor[] = [
{
id: composeDiffId(ctx, "branch"),
type: "workspace",
group: "Git",
capabilities: { revert: true, comments: true },
},
{ id: composeDiffId(ctx, "staged"), type: "staged", group: "Git", capabilities: { revert: false, comments: true } },
{
id: composeDiffId(ctx, "unstaged"),
type: "unstaged",
group: "Git",
capabilities: { revert: false, comments: true },
},
]
if (hasSession) {
out.push({
id: composeDiffId(ctx, "session"),
type: "session",
group: "Session",
capabilities: { revert: false, comments: true },
})
}
return out
}
/**
* Whether the Branch scope supports revert. Staged/unstaged/session are
* read-only; only the Branch scope can revert files back to the merge base.
*/
export function scopeCapabilities(scope: DiffScope): { revert: boolean; comments: boolean } {
return { revert: scope === "branch", comments: true }
}
/**
* Per-context scope selection. Keeps the last-picked scope per context id so
* switching between worktrees restores each worktree's scope, while a brand
* new context defaults to Branch.
*/
export function createDiffScope(currentCtx: Accessor<string | undefined>) {
const [scopes, setScopes] = createSignal<Record<string, DiffScope>>({})
const scope = createMemo((): DiffScope => {
const ctx = currentCtx()
if (!ctx) return DEFAULT_DIFF_SCOPE
return scopes()[ctx] ?? DEFAULT_DIFF_SCOPE
})
const id = createMemo(() => {
const ctx = currentCtx()
if (!ctx) return undefined
return composeDiffId(ctx, scope())
})
const setScope = (next: DiffScope) => {
const ctx = currentCtx()
if (!ctx) return
setScopes((prev) => ({ ...prev, [ctx]: next }))
}
return { scope, id, setScope }
}
@@ -136,6 +136,8 @@ export const dict = {
"agentManager.diff.revertFile": "استعادة الملف",
"agentManager.diff.revertSuccess": "تم استعادة الملف",
"agentManager.diff.revertError": "فشل الاستعادة",
"agentManager.diff.applyBranchOnly":
"لا يعمل تطبيق التغييرات إلا على فرق الفرع الكامل. انتقل إلى نطاق Branch لتطبيقها.",
"agentManager.open.button": "فتح",
"agentManager.open.tooltip": "فتح Worktree هذا في VS Code",
"agentManager.apply.globalButton": "تطبيق",
@@ -139,6 +139,8 @@ export const dict = {
"agentManager.diff.revertFile": "Reverter arquivo",
"agentManager.diff.revertSuccess": "Arquivo revertido",
"agentManager.diff.revertError": "Falha ao reverter",
"agentManager.diff.applyBranchOnly":
"Aplicar funciona apenas no diff completo da branch. Mude para o escopo Branch para aplicar.",
"agentManager.open.button": "Abrir",
"agentManager.open.tooltip": "Abrir este Worktree no VS Code",
"agentManager.apply.globalButton": "Aplicar",
@@ -139,6 +139,8 @@ export const dict = {
"agentManager.diff.revertFile": "Vrati datoteku",
"agentManager.diff.revertSuccess": "Datoteka vraćena",
"agentManager.diff.revertError": "Vraćanje neuspješno",
"agentManager.diff.applyBranchOnly":
"Primijeni radi samo s kompletnim diffom grane. Prebacite se na opseg Branch da biste primijenili.",
"agentManager.open.button": "Otvori",
"agentManager.open.tooltip": "Otvori ovaj worktree u VS Code-u",
"agentManager.apply.globalButton": "Primijeni",
@@ -140,6 +140,8 @@ export const dict = {
"agentManager.diff.revertFile": "Gendan fil",
"agentManager.diff.revertSuccess": "Fil gendannet",
"agentManager.diff.revertError": "Gendannelse fejlede",
"agentManager.diff.applyBranchOnly":
"Anvend virker kun på hele Branch-diffen. Skift til Branch-området for at anvende.",
"agentManager.open.button": "Åbn",
"agentManager.open.tooltip": "Åbn dette Worktree i VS Code",
"agentManager.apply.globalButton": "Anvend",
@@ -140,6 +140,8 @@ export const dict = {
"agentManager.diff.revertFile": "Datei zurücksetzen",
"agentManager.diff.revertSuccess": "Datei zurückgesetzt",
"agentManager.diff.revertError": "Zurücksetzen fehlgeschlagen",
"agentManager.diff.applyBranchOnly":
"Anwenden funktioniert nur für den vollständigen Branch-Diff. Wechsle zum Bereich Branch, um anzuwenden.",
"agentManager.open.button": "Öffnen",
"agentManager.open.tooltip": "Dieses Worktree in VS Code öffnen",
"agentManager.apply.globalButton": "Anwenden",
@@ -143,6 +143,7 @@ export const dict = {
"agentManager.diff.revertFile": "Revert file",
"agentManager.diff.revertSuccess": "File reverted",
"agentManager.diff.revertError": "Revert failed",
"agentManager.diff.applyBranchOnly": "Apply works on the full branch diff. Switch to the Branch scope to apply.",
"agentManager.open.button": "Open",
"agentManager.open.tooltip": "Open this worktree in VS Code",
"agentManager.apply.globalButton": "Apply",
@@ -139,6 +139,8 @@ export const dict = {
"agentManager.diff.revertFile": "Revertir archivo",
"agentManager.diff.revertSuccess": "Archivo revertido",
"agentManager.diff.revertError": "Error al revertir",
"agentManager.diff.applyBranchOnly":
"Aplicar solo funciona con el diff completo de la rama. Cambia al ámbito Branch para aplicar.",
"agentManager.open.button": "Abrir",
"agentManager.open.tooltip": "Abrir este Worktree en VS Code",
"agentManager.apply.globalButton": "Aplicar",
@@ -143,6 +143,8 @@ export const dict = {
"agentManager.diff.revertFile": "بازگردانی فایل",
"agentManager.diff.revertSuccess": "فایل بازگردانی شد",
"agentManager.diff.revertError": "بازگردانی ناموفق بود",
"agentManager.diff.applyBranchOnly":
"اعمال تغییرات روی اختلاف کامل شاخه انجام می‌شود. برای اعمال، به محدوده Branch بروید.",
"agentManager.open.button": "باز کردن",
"agentManager.open.tooltip": "باز کردن این Worktree در VS Code",
"agentManager.apply.globalButton": "اعمال",
@@ -139,6 +139,8 @@ export const dict = {
"agentManager.diff.revertFile": "Rétablir le fichier",
"agentManager.diff.revertSuccess": "Fichier rétabli",
"agentManager.diff.revertError": "Échec du rétablissement",
"agentManager.diff.applyBranchOnly":
"Appliquer ne fonctionne que sur le diff complet de la branche. Passez à la portée Branch pour appliquer.",
"agentManager.open.button": "Ouvrir",
"agentManager.open.tooltip": "Ouvrir ce worktree dans VS Code",
"agentManager.apply.globalButton": "Appliquer",
@@ -145,6 +145,8 @@ export const dict = {
"agentManager.diff.revertFile": "Ripristina file",
"agentManager.diff.revertSuccess": "File ripristinato",
"agentManager.diff.revertError": "Ripristino non riuscito",
"agentManager.diff.applyBranchOnly":
"Applica funziona solo sul diff completo del branch. Passa all'ambito Branch per applicare.",
"agentManager.open.button": "Apri",
"agentManager.open.tooltip": "Apri questo worktree in VS Code",
"agentManager.apply.globalButton": "Applica",
@@ -138,6 +138,8 @@ export const dict = {
"agentManager.diff.revertFile": "ファイルを元に戻す",
"agentManager.diff.revertSuccess": "ファイルを元に戻しました",
"agentManager.diff.revertError": "元に戻せませんでした",
"agentManager.diff.applyBranchOnly":
"適用はブランチ全体の差分に対してのみ利用できます。適用するにはスコープを Branch に切り替えてください。",
"agentManager.open.button": "開く",
"agentManager.open.tooltip": "このWorktreeをVS Codeで開く",
"agentManager.apply.globalButton": "適用",
@@ -137,6 +137,8 @@ export const dict = {
"agentManager.diff.revertFile": "파일 되돌리기",
"agentManager.diff.revertSuccess": "파일이 되돌려졌습니다",
"agentManager.diff.revertError": "되돌리기 실패",
"agentManager.diff.applyBranchOnly":
"적용은 전체 브랜치 diff에서만 작동합니다. 적용하려면 범위를 Branch로 전환하세요.",
"agentManager.open.button": "열기",
"agentManager.open.tooltip": "이 Worktree를 VS Code에서 열기",
"agentManager.apply.globalButton": "적용",
@@ -144,6 +144,8 @@ export const dict = {
"agentManager.diff.revertFile": "Bestand terugzetten",
"agentManager.diff.revertSuccess": "Bestand teruggezet",
"agentManager.diff.revertError": "Terugzetten mislukt",
"agentManager.diff.applyBranchOnly":
"Toepassen werkt alleen op de volledige branch-diff. Schakel naar het bereik Branch om toe te passen.",
"agentManager.open.button": "Openen",
"agentManager.open.tooltip": "Open deze worktree in VS Code",
"agentManager.apply.globalButton": "Toepassen",
@@ -138,6 +138,7 @@ export const dict = {
"agentManager.diff.revertFile": "Tilbakestill fil",
"agentManager.diff.revertSuccess": "Fil tilbakestilt",
"agentManager.diff.revertError": "Tilbakestilling feilet",
"agentManager.diff.applyBranchOnly": "Bruk fungerer kun på hele Branch-diffen. Bytt til Branch-omfanget for å bruke.",
"agentManager.open.button": "Åpne",
"agentManager.open.tooltip": "Åpne dette Worktree-et i VS Code",
"agentManager.apply.globalButton": "Bruk",
@@ -138,6 +138,8 @@ export const dict = {
"agentManager.diff.revertFile": "Cofnij plik",
"agentManager.diff.revertSuccess": "Plik cofnięty",
"agentManager.diff.revertError": "Cofanie nie powiodło się",
"agentManager.diff.applyBranchOnly":
"Funkcja Zastosuj działa tylko z pełnym diffem brancha. Przełącz się na zakres Branch, aby zastosować.",
"agentManager.open.button": "Otwórz",
"agentManager.open.tooltip": "Otwórz ten Worktree w VS Code",
"agentManager.apply.globalButton": "Zastosuj",
@@ -139,6 +139,8 @@ export const dict = {
"agentManager.diff.revertFile": "Откатить файл",
"agentManager.diff.revertSuccess": "Файл откатан",
"agentManager.diff.revertError": "Ошибка отката",
"agentManager.diff.applyBranchOnly":
"Применение работает только с полным diff ветки. Чтобы применить изменения, переключитесь на область Branch.",
"agentManager.open.button": "Открыть",
"agentManager.open.tooltip": "Открыть этот Worktree в VS Code",
"agentManager.apply.globalButton": "Применить",
@@ -134,6 +134,8 @@ export const dict = {
"agentManager.diff.revertFile": "ย้อนกลับไฟล์",
"agentManager.diff.revertSuccess": "ย้อนกลับไฟล์แล้ว",
"agentManager.diff.revertError": "ย้อนกลับล้มเหลว",
"agentManager.diff.applyBranchOnly":
"นำไปใช้ได้เฉพาะกับ diff ของ Branch ทั้งหมดเท่านั้น สลับไปที่ขอบเขต Branch เพื่อใช้งาน",
"agentManager.open.button": "เปิด",
"agentManager.open.tooltip": "เปิด Worktree นี้ใน VS Code",
"agentManager.apply.globalButton": "นำไปใช้",
@@ -145,6 +145,8 @@ export const dict = {
"agentManager.diff.revertFile": "Dosyayı geri al",
"agentManager.diff.revertSuccess": "Dosya geri alındı",
"agentManager.diff.revertError": "Geri alma başarısız",
"agentManager.diff.applyBranchOnly":
"Uygula yalnızca tam Branch diff'inde çalışır. Uygulamak için Branch kapsamına geçin.",
"agentManager.open.button": "Aç",
"agentManager.open.tooltip": "Bu worktree'yi VS Code'da aç",
"agentManager.apply.globalButton": "Uygula",
@@ -146,6 +146,8 @@ export const dict = {
"agentManager.diff.revertFile": "Скасувати зміни файлу",
"agentManager.diff.revertSuccess": "Файл відновлено",
"agentManager.diff.revertError": "Не вдалося відновити",
"agentManager.diff.applyBranchOnly":
"Застосування працює лише з повним diff гілки. Щоб застосувати зміни, перемкніться на область Branch.",
"agentManager.open.button": "Відкрити",
"agentManager.open.tooltip": "Відкрити це робоче дерево у VS Code",
"agentManager.apply.globalButton": "Застосувати",
@@ -133,6 +133,7 @@ export const dict = {
"agentManager.diff.revertFile": "还原文件",
"agentManager.diff.revertSuccess": "文件已还原",
"agentManager.diff.revertError": "还原失败",
"agentManager.diff.applyBranchOnly": "应用仅适用于完整的分支差异。请切换到 Branch 范围后再应用。",
"agentManager.open.button": "打开",
"agentManager.open.tooltip": "在 VS Code 中打开此 Worktree",
"agentManager.apply.globalButton": "应用",
@@ -133,6 +133,7 @@ export const dict = {
"agentManager.diff.revertFile": "還原檔案",
"agentManager.diff.revertSuccess": "檔案已還原",
"agentManager.diff.revertError": "還原失敗",
"agentManager.diff.applyBranchOnly": "套用僅適用於完整的分支差異。請切換至 Branch 範圍後再套用。",
"agentManager.open.button": "開啟",
"agentManager.open.tooltip": "在 VS Code 中開啟此 Worktree",
"agentManager.apply.globalButton": "套用",
@@ -12,7 +12,9 @@ interface Toast {
}
export function createRevertFile(
diffScopeId: Accessor<string | undefined>,
currentDiffSessionId: Accessor<string | undefined>,
scope: Accessor<string>,
vscode: VsCode,
showToast: (t: Toast) => void,
t: (key: string) => string,
@@ -20,20 +22,21 @@ export function createRevertFile(
const [files, setFiles] = createSignal<Record<string, Set<string>>>({})
const reverting = createMemo(() => {
const sessionId = currentDiffSessionId()
if (!sessionId) return new Set<string>()
return files()[sessionId] ?? new Set<string>()
const id = diffScopeId()
if (!id) return new Set<string>()
return files()[id] ?? new Set<string>()
})
function revert(file: string) {
const id = diffScopeId()
const sessionId = currentDiffSessionId()
if (!sessionId) return
if (!id || !sessionId) return
setFiles((prev) => {
const set = new Set(prev[sessionId] ?? [])
const set = new Set(prev[id] ?? [])
set.add(file)
return { ...prev, [sessionId]: set }
return { ...prev, [id]: set }
})
vscode.postMessage({ type: "agentManager.revertWorktreeFile", sessionId, file })
vscode.postMessage({ type: "agentManager.revertWorktreeFile", sessionId, file, scope: scope() })
}
function onResult(ev: AgentManagerRevertWorktreeFileResultMessage) {
@@ -9,6 +9,7 @@
import { createSignal, type Accessor } from "solid-js"
import { mergeWorktreeDiffs } from "../diff-viewer/diff-state"
import { parseDiffId } from "./diff-scope-state"
import type { useVSCode } from "../src/context/vscode"
import type {
AgentManagerWorktreeDiffFileMessage,
@@ -17,6 +18,16 @@ import type {
WorktreeFileDiff,
} from "../src/types/messages"
/**
* Decompose a composite diff id (`ctx#scope`) into the wire fields the
* extension expects. Bare ids (no scope separator) parse to the default
* branch scope.
*/
function wire(id: string) {
const { ctx, scope } = parseDiffId(id)
return { sessionId: ctx, scope }
}
export function createWorktreeDiffs(vscode: ReturnType<typeof useVSCode>) {
const [diffDatas, setDiffDatas] = createSignal<Record<string, WorktreeFileDiff[]>>({})
const [diffLoading, setDiffLoading] = createSignal(false)
@@ -48,20 +59,20 @@ export function createWorktreeDiffs(vscode: ReturnType<typeof useVSCode>) {
})
}
/** Lazily load a single file's full diff for the current session. */
const requestDiffFile = (sessionId: string, file: string) => {
if (diffFileLoading()[sessionId]?.[file]) return
setDiffFilePending(sessionId, file, true)
vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", sessionId, file })
/** Lazily load a single file's full diff for the given composite diff id. */
const requestDiffFile = (id: string, file: string) => {
if (diffFileLoading()[id]?.[file]) return
setDiffFilePending(id, file, true)
vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", file, ...wire(id) })
}
/** Files the backend flagged as stale in a merged update need a fresh fetch. */
const refreshStaleDiffs = (sessionId: string, files: Set<string>) => {
const loading = diffFileLoading()[sessionId] ?? {}
const refreshStaleDiffs = (id: string, files: Set<string>) => {
const loading = diffFileLoading()[id] ?? {}
for (const file of files) {
if (loading[file]) continue
setDiffFilePending(sessionId, file, true)
vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", sessionId, file })
setDiffFilePending(id, file, true)
vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", file, ...wire(id) })
}
}
@@ -96,7 +96,7 @@ export const BaseBranchPicker: Component<BaseBranchPickerProps> = (props) => {
placement="bottom-start"
flip
trigger={
<button class="am-selector-trigger diff-base-trigger" type="button">
<button class="am-selector-trigger diff-inline-trigger" type="button" title={triggerLabel()}>
<span class="am-selector-left">
<Show when={!props.currentBranch}>
<Icon name="branch" size="small" />
@@ -0,0 +1,80 @@
import { Show, type Component } from "solid-js"
import type { DiffSourceDescriptor } from "../../src/diff/sources/types"
import type { BranchInfo } from "../src/types/messages"
import { useLanguage } from "../src/context/language"
import { InlineSelect, type InlineOption } from "./InlineSelect"
import { BaseBranchPicker } from "./BaseBranchPicker"
interface DiffScopeControlsProps {
descriptors: DiffSourceDescriptor[]
currentId: string | undefined
onSelectScope: (id: string) => void
/** Show the base branch picker (only when the Branch scope is active). */
showBase: boolean
branches: BranchInfo[]
branchesLoading: boolean
defaultBranch: string
autoBase: string | undefined
currentBase: string | undefined
isAuto: boolean
currentBranch: string | undefined
onSelectBase: (branch: string | undefined) => void
/**
* Compact mode for the narrow side panel: drops the `current →` prefix and
* tightens the label caps so the row survives a user-shrunk panel.
*/
compact?: boolean
}
/**
* Scope selector plus base branch picker, sized to sit inline in a diff
* toolbar row. Shared by the Agent Manager side panel and review tab.
*
* Note this deliberately avoids `DiffPickerHeader`: that component is a
* full-width header band (`margin: 8px 12px`) and using it inside a toolbar
* inflates the row height and misaligns against the neighboring buttons.
*/
export const DiffScopeControls: Component<DiffScopeControlsProps> = (props) => {
const { t } = useLanguage()
const options = (): InlineOption<string>[] =>
props.descriptors.map((desc) => ({
value: desc.id,
label: t(`diffViewer.source.${desc.type}.label`),
group: t(desc.group === "Session" ? "diffViewer.group.session" : "diffViewer.group.git"),
}))
// The trigger's tooltip explains what the active scope actually shows,
// reusing the per-scope descriptions the standalone picker already has.
const title = () => {
const active = props.descriptors.find((desc) => desc.id === props.currentId)
if (!active) return ""
return t(`diffViewer.source.${active.type}.tooltip`)
}
return (
<span class="diff-scope-controls" classList={{ "diff-scope-controls-compact": props.compact }}>
<Show when={props.descriptors.length > 0}>
<InlineSelect
options={options()}
value={props.currentId}
onSelect={props.onSelectScope}
title={title()}
compact={props.compact}
/>
</Show>
<Show when={props.showBase}>
<BaseBranchPicker
branches={props.branches}
loading={props.branchesLoading}
defaultBranch={props.defaultBranch}
autoBase={props.autoBase}
currentBase={props.currentBase}
isAuto={props.isAuto}
currentBranch={props.compact ? undefined : props.currentBranch}
onSelect={props.onSelectBase}
/>
</Show>
</span>
)
}
@@ -1,4 +1,4 @@
import { type Component, createSignal, createMemo, createEffect, on, onCleanup, Show } from "solid-js"
import { type Component, createSignal, createMemo, createEffect, on, onCleanup, Show, type JSXElement } from "solid-js"
import type { VirtualizerHandle } from "virtua/solid"
// Styles are imported by the component so every consumer (sidebar diff viewer,
// agent manager, storybook) picks them up automatically. Keep these imports here —
@@ -89,6 +89,8 @@ interface FullScreenDiffViewProps {
canRevert?: boolean
/** Defaults to true. Disables comment creation and "Send all" when false. */
canComment?: boolean
/** Optional leading content rendered first in the toolbar's left group. */
lead?: JSXElement
onClose: () => void
}
@@ -541,6 +543,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
{/* Toolbar */}
<div class="am-review-toolbar">
<div class="am-review-toolbar-left">
<Show when={props.lead}>{props.lead}</Show>
<RadioGroup
options={["unified", "split"] as const}
current={props.diffStyle}
@@ -0,0 +1,127 @@
import { type Component, For, Show, createSignal } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import { DeferredPopover } from "../src/components/shared/DeferredPopover"
export interface InlineOption<T extends string> {
value: T
label: string
/** Secondary text shown right-aligned in the menu row. */
hint?: string
/** Optional group heading; consecutive options sharing a group are grouped. */
group?: string
}
interface InlineSelectProps<T extends string> {
options: InlineOption<T>[]
value: T | undefined
onSelect: (value: T) => void
/** Trigger icon. Omit to keep the trigger as narrow as possible. */
icon?: string
/** Accessible name / tooltip text for the trigger. */
title: string
/** Caps the trigger label width so long values ellipsize instead of pushing. */
compact?: boolean
/** Extra class on the trigger, so narrow-width rules can target one control. */
class?: string
}
/**
* Compact dropdown sized for a diff toolbar row.
*
* Deliberately not kilo-ui's `Select`: that renders an input-sized control
* (32px, base font) which dwarfs the ghost buttons and radio group it sits
* next to. This mirrors the `am-selector-trigger` markup the branch pickers
* use, shrunk via `.diff-inline-trigger`, so every control in the row shares
* one height and font size.
*/
export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
const [open, setOpen] = createSignal(false)
const current = () => props.options.find((opt) => opt.value === props.value)
const label = () => current()?.label ?? ""
const choose = (value: T) => {
props.onSelect(value)
setOpen(false)
}
// Group heading renders only when it differs from the previous option's, so
// callers just order their options by group.
const heading = (index: number) => {
const group = props.options[index]?.group
if (!group) return undefined
if (index === 0) return group
return props.options[index - 1]?.group === group ? undefined : group
}
return (
<DeferredPopover
open={open()}
onOpenChange={setOpen}
placement="bottom-start"
flip
portal={false}
deferDismiss
class="am-dropdown diff-inline-menu"
trigger={
<button
class={`am-selector-trigger diff-inline-trigger${props.class ? ` ${props.class}` : ""}`}
type="button"
title={props.title}
>
<span class="am-selector-left">
<Show when={props.icon}>{(name) => <Icon name={name()} size="small" />}</Show>
<span class="am-selector-value">{label()}</span>
</span>
<span class="am-selector-right">
<Icon name="selector" size="small" />
</span>
</button>
}
>
<div class="am-dropdown-list">
<For each={props.options}>
{(opt, index) => (
<>
<Show when={heading(index())}>{(text) => <div class="diff-inline-group">{text()}</div>}</Show>
<button
class="am-branch-item"
classList={{ "am-branch-item-active": opt.value === props.value }}
type="button"
onClick={() => choose(opt.value)}
>
<span class="am-branch-item-left">
<span class="am-branch-item-name">{opt.label}</span>
</span>
<Show when={opt.hint}>
<span class="am-branch-hint">{opt.hint}</span>
</Show>
</button>
</>
)}
</For>
</div>
</DeferredPopover>
)
}
/** Compact unified/split picker. Replaces the wide radio group in tight rows. */
export const DiffStyleSelect: Component<{
value: "unified" | "split"
onSelect: (value: "unified" | "split") => void
unifiedLabel: string
splitLabel: string
title: string
}> = (props) => (
<InlineSelect
options={[
{ value: "unified", label: props.unifiedLabel },
{ value: "split", label: props.splitLabel },
]}
value={props.value}
onSelect={props.onSelect}
title={props.title}
class="diff-style-select"
compact
/>
)
@@ -71,7 +71,7 @@ import {
type TranscriptRow,
} from "../../context/transcript-rows"
import { PromptRail } from "./PromptRail"
import { capacity, promptItems, railItems, type PromptRailItem } from "./prompt-rail"
import { capacity, historyAction, promptItems, railEntries, type PromptRailItem } from "./prompt-rail"
import { onTimelineHighlight, type TimelineHighlight } from "../../utils/timeline/highlight"
import { useTranscriptSearch, type SearchMatch } from "../../context/transcript-search"
import { applyTranscriptHighlights, clearTranscriptHighlights } from "./transcript-search-highlight"
@@ -909,8 +909,8 @@ export const MessageList: Component<MessageListProps> = (props) => {
// entirely to the precise per-occurrence check in paintHighlights,
// which only scrolls when the exact match actually needs it.
if (!mounted) {
const index = keys().indexOf(match.key)
if (index >= 0) {
const index = indexes().get(match.key)
if (index !== undefined) {
virtualizer()?.scrollToIndex(index, { align: "center" })
}
}
@@ -946,23 +946,65 @@ export const MessageList: Component<MessageListProps> = (props) => {
const tail = createMemo(() => partition().direct.map((row) => row.key))
const lookup = createMemo(() => new Map(partition().direct.map((row) => [row.key, row])))
const keys = createMemo(() => partition().virtual.map((row) => row.key))
const indexes = createMemo(() => new Map(keys().map((key, index) => [key, index])))
const fingerprint = createMemo(() => rowFingerprint(keys()))
const [pending, setPending] = createSignal<{ sid: string; key: string }>()
// Scrolls the transcript to a row by key. Virtualized rows jump through
// the virtualizer; direct/live/queued rows are mounted, so they use
// scrollIntoView. Pauses auto-follow first so the jump isn't snapped back.
const jump = (key: string) => {
autoScroll.pause()
const index = keys().indexOf(key)
if (index >= 0) {
virtualizer()?.scrollToIndex(index, { align: "start" })
const index = indexes().get(key)
if (index !== undefined) {
const handle = virtualizer()
if (handle) {
setPending(undefined)
handle.scrollToIndex(index, { align: "start" })
return
}
const sid = session.currentSessionID()
if (sid) setPending({ sid, key })
return
}
const el = scrollEl()
const target = el?.querySelector<HTMLElement>(`[data-row-key="${CSS.escape(key)}"]`)
target?.scrollIntoView({ block: "start" })
if (target) {
setPending(undefined)
target.scrollIntoView({ block: "start" })
return
}
const sid = session.currentSessionID()
if (sid) setPending({ sid, key })
}
// Keep unresolved targets by stable row key. Virtual rows resolve once
// Virtua installs its handle; direct/live rows resolve once Solid mounts
// their DOM node.
createEffect(() => {
const target = pending()
if (!target) return
if (target.sid !== session.currentSessionID()) {
setPending(undefined)
return
}
const index = indexes().get(target.key)
const handle = virtualizer()
if (index !== undefined && handle) {
setPending(undefined)
autoScroll.pause()
handle.scrollToIndex(index, { align: "start" })
return
}
const el = scrollEl()
const row = el?.querySelector<HTMLElement>(`[data-row-key="${CSS.escape(target.key)}"]`)
if (!row) return
setPending(undefined)
autoScroll.pause()
row.scrollIntoView({ block: "start" })
})
// Clicking a bar in the task timeline scrolls the transcript to that message.
// Jumps land instantly (no smooth animation): while pinned at the bottom, a
// smooth scroll's initial frames sit within createAutoScroll's near-bottom
@@ -985,12 +1027,67 @@ export const MessageList: Component<MessageListProps> = (props) => {
const items = createMemo(() => promptItems(rows()))
// Until the transcript is measured there is no height to cap against, and
// rendering every prompt would spill ticks past the rail on long sessions.
const shown = createMemo(() => railItems(items(), capacity(height())))
const entries = createMemo(() => railEntries(items(), capacity(height()), session.hasOlderMessages()))
const [activeTurn, setActiveTurn] = createSignal<string>()
const railActiveKey = createMemo(() => shown().find((item) => item.turn === activeTurn())?.key)
const railActiveKey = createMemo(() => items().find((item) => item.turn === activeTurn())?.key)
const [seek, setSeek] = createSignal<{ sid: string; count: number }>()
let paging = false
const first = () => {
const item = items()[0]
if (!session.hasOlderMessages()) {
if (item) jump(item.key)
return
}
const sid = session.currentSessionID()
if (!sid || session.loadingOlderMessages()) return
setSeek({ sid, count: session.messages().length })
if (!session.loadOlderMessages()) setSeek(undefined)
}
// Loading the first prompt is deliberate and progressive: each completed
// prepend advances the existing page cursor, while hover/open remains free
// of network and full-history work. Stop if a request makes no progress so
// backend failures cannot turn into a retry loop.
createEffect(() => {
const loading = session.loadingOlderMessages()
const target = seek()
if (!target) {
paging = loading
return
}
if (target.sid !== session.currentSessionID()) {
paging = false
setSeek(undefined)
return
}
if (loading) {
paging = true
return
}
if (!paging) return
paging = false
const count = session.messages().length
const action = historyAction(target.count, count, session.hasOlderMessages())
if (action === "stop") {
const item = items()[0]
setSeek(undefined)
if (item) jump(item.key)
return
}
if (action === "load") {
setSeek({ sid: target.sid, count })
if (!session.loadOlderMessages()) setSeek(undefined)
return
}
const item = items()[0]
setSeek(undefined)
if (item) jump(item.key)
})
const trackActive = () => {
const list = shown()
const list = items()
if (list.length === 0) return setActiveTurn(undefined)
const handle = virtualizer()
const offset = handle?.scrollOffset
@@ -998,6 +1095,11 @@ export const MessageList: Component<MessageListProps> = (props) => {
const row = partition().virtual[handle.findItemIndex(offset)]
if (row) return setActiveTurn(row.turn)
}
const el = scrollEl()
if (handle && el && el.scrollHeight > el.clientHeight + 1) {
const row = partition().virtual[0]
if (row) return setActiveTurn(row.turn)
}
setActiveTurn(list.at(-1)?.turn)
}
let activeFrame: number | undefined
@@ -1014,7 +1116,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
// Re-derive the active turn whenever the transcript changes so the rail
// reflects a newly started turn even before any scrolling happens.
createEffect(() => {
shown()
items()
partition()
scheduleActive()
})
@@ -1263,14 +1365,25 @@ export const MessageList: Component<MessageListProps> = (props) => {
</div>
<PromptRail
items={shown}
entries={entries}
items={items}
active={() => railActiveKey()}
onSelect={(item: PromptRailItem) => jump(item.key)}
onFirst={first}
onLatest={() => {
const item = items().at(-1)
if (item) jump(item.key)
}}
onLoadOlder={() => session.loadOlderMessages()}
onWheel={(deltaY: number) => {
const el = scrollEl()
if (el) el.scrollTop += deltaY
}}
height={height}
hasOlder={session.hasOlderMessages}
loadingOlder={session.loadingOlderMessages}
prepending={() => session.messageMutation() === "prepend"}
seeking={() => Boolean(seek())}
/>
<Show when={autoScroll.userScrolled()}>
@@ -2,50 +2,84 @@
/**
* PromptRail component
* Thin vertical tick rail on the left edge of the transcript, one tick per
* user prompt. Hovering/focusing the rail opens a floating card listing the
* prompts with a short answer preview each; clicking jumps the transcript.
* Thin vertical summary rail on the left edge of the transcript. Hovering or
* focusing opens a bounded navigator for every loaded prompt; clicking jumps
* the virtualized transcript without mounting the intervening rows.
*/
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
import { Portal } from "solid-js/web"
import { VList, type VListHandle } from "virtua/solid"
import { useLanguage } from "../../context/language"
import { RAIL_INSET, ROW_HEIGHT, type PromptRailItem } from "./prompt-rail"
import { RAIL_INSET, ROW_HEIGHT, TICK_MIN, TICK_STEP, type PromptRailEntry, type PromptRailItem } from "./prompt-rail"
interface PromptRailProps {
entries: Accessor<PromptRailEntry[]>
items: Accessor<PromptRailItem[]>
/** Row key of the item whose turn is currently at the top of the transcript. */
active: Accessor<string | undefined>
onSelect: (item: PromptRailItem) => void
onFirst: () => void
onLatest: () => void
onLoadOlder: () => void
/** Forwards wheel events so scrolling over a tick scrolls the transcript. */
onWheel: (deltaY: number) => void
/** Transcript height, used to spread the ticks. */
height: Accessor<number>
hasOlder: Accessor<boolean>
loadingOlder: Accessor<boolean>
prepending: Accessor<boolean>
seeking: Accessor<boolean>
}
const CLOSE_DELAY = 120
const TICK_STEP = 14
const EDGE = 12
const GAP = 8
const VIRTUAL_LIMIT = 30
const CARD_CHROME = 44
const NEAR_TOP = 200
export function PromptRail(props: PromptRailProps) {
const language = useLanguage()
const [open, setOpen] = createSignal(false)
const [hover, setHover] = createSignal<number>()
const [anchor, setAnchor] = createSignal<{ top: number; left: number }>()
const [hover, setHover] = createSignal<string>()
const [focused, setFocused] = createSignal<number>()
const [anchor, setAnchor] = createSignal<{ top: number; left: number; height: number }>()
let rail: HTMLElement | undefined
let card: HTMLDivElement | undefined
let list: VListHandle | undefined
let timer: ReturnType<typeof setTimeout> | undefined
let frame: number | undefined
let revealing = false
const items = createMemo(() => props.items())
const entries = createMemo(() => props.entries())
const virtualized = createMemo(() => items().length > VIRTUAL_LIMIT)
// Ticks are spread over the available height, tightening as prompts pile up
// but never growing past their natural step.
// but never growing past their natural step nor packing tighter than a tick
// can still be aimed at.
const step = createMemo(() => {
const count = items().length
const count = entries().length
if (count === 0) return TICK_STEP
return Math.min(TICK_STEP, Math.floor((props.height() - RAIL_INSET) / count))
return Math.max(TICK_MIN, Math.min(TICK_STEP, Math.floor((props.height() - RAIL_INSET) / count)))
})
// Reaching the top of the navigator pages older history in, the same way the
// transcript itself loads earlier messages when scrolled near its top. Opening
// the card scrolls the hovered prompt into view, which would otherwise look
// like a scroll to the top and fetch on hover, so programmatic reveals are
// excluded and only scrolling the user drove pages.
const offset = () => (virtualized() ? (list?.scrollOffset ?? 0) : (card?.scrollTop ?? 0))
const page = (value: number) => {
if (revealing || value > NEAR_TOP) return
if (!props.hasOlder() || props.loadingOlder() || props.seeking()) return
props.onLoadOlder()
}
// Centers the card on the tick group so each row sits beside its own tick,
// then keeps it inside the transcript and the viewport. The rail spans the
// transcript exactly (top/bottom 0), so its own rect doubles as those bounds
@@ -55,13 +89,17 @@ export function PromptRail(props: PromptRailProps) {
const place = () => {
if (!rail) return
const rect = rail.getBoundingClientRect()
const height = card?.offsetHeight ?? Math.min(items().length * ROW_HEIGHT + EDGE, rect.height)
const limit = Math.max(0, Math.min(window.innerHeight - EDGE * 2, rect.height - 8))
if (limit === 0) return
const estimate = Math.min(items().length * ROW_HEIGHT + CARD_CHROME, limit)
const height = virtualized() ? limit : (card?.offsetHeight ?? estimate)
const min = Math.max(EDGE, rect.top + 4)
const max = Math.min(window.innerHeight - EDGE, rect.bottom - 4) - height
const center = rect.top + rect.height / 2 - height / 2
setAnchor({
top: max < min ? min : Math.min(Math.max(center, min), max),
left: rect.right + GAP,
height: limit,
})
}
@@ -70,11 +108,35 @@ export function PromptRail(props: PromptRailProps) {
timer = undefined
}
const reveal = (index: number) => {
if (frame !== undefined) cancelAnimationFrame(frame)
revealing = true
frame = requestAnimationFrame(() => {
frame = undefined
if (virtualized()) {
list?.scrollToIndex(index, { align: "center" })
return
}
const row = card?.querySelector<HTMLElement>(`[data-prompt-index="${index}"]`)
if (!row || !card) return
card.scrollTop = Math.max(0, row.offsetTop - card.clientHeight / 2 + row.offsetHeight / 2)
})
}
const entryItem = (entry: PromptRailEntry) => {
if (entry.type === "prompt") return entry.item
return items()[entry.type === "overflow" ? entry.index : 0]
}
const openCard = (index: number) => {
cancelClose()
setHover(index)
const entry = entries()[index]
const item = entry && entryItem(entry)
setFocused(index)
setHover(item?.key)
place()
setOpen(true)
if (item) reveal(items().findIndex((candidate) => candidate.key === item.key))
}
const closeCard = () => {
@@ -86,6 +148,9 @@ export function PromptRail(props: PromptRailProps) {
}
onCleanup(cancelClose)
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
})
// Resizing the panel moves the rail out from under an open card.
createEffect(() => {
@@ -103,9 +168,20 @@ export function PromptRail(props: PromptRailProps) {
onCleanup(() => cancelAnimationFrame(frame))
})
let seeking = false
createEffect(() => {
const next = props.seeking()
if (seeking && !next && !props.hasOlder()) {
const item = items()[0]
setHover(item?.key)
if (item) reveal(0)
}
seeking = next
})
const onKeyDown = (event: KeyboardEvent) => {
const list = items()
const current = hover() ?? 0
const values = entries()
const current = focused() ?? 0
if (event.key === "Escape") {
event.preventDefault()
cancelClose()
@@ -115,19 +191,22 @@ export function PromptRail(props: PromptRailProps) {
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
const item = list[current]
if (item) props.onSelect(item)
const entry = values[current]
if (!entry) return
if (entry.type === "prompt") props.onSelect(entry.item)
if (entry.type === "history") props.onFirst()
if (entry.type === "overflow") openCard(current)
return
}
const next =
event.key === "ArrowDown"
? Math.min(list.length - 1, current + 1)
? Math.min(values.length - 1, current + 1)
: event.key === "ArrowUp"
? Math.max(0, current - 1)
: event.key === "Home"
? 0
: event.key === "End"
? list.length - 1
? values.length - 1
: undefined
if (next === undefined) return
event.preventDefault()
@@ -139,8 +218,58 @@ export function PromptRail(props: PromptRailProps) {
const label = (item: PromptRailItem, index: number) =>
language.t("session.prompts.tick", { index: index + 1, total: items().length, prompt: item.prompt })
const entryLabel = (entry: PromptRailEntry) => {
if (entry.type === "prompt") return label(entry.item, entry.index)
if (entry.type === "history") return language.t("session.prompts.first")
return language.t("session.prompts.overflow", { count: entry.count })
}
const entryActive = (entry: PromptRailEntry) => {
if (entry.type === "prompt") return entry.item.key === props.active()
if (entry.type === "history") return false
const index = items().findIndex((item) => item.key === props.active())
return index >= entry.index && index < entry.index + entry.count
}
const selectFirst = () => {
const item = items()[0]
setHover(item?.key)
if (item) reveal(0)
props.onFirst()
}
const selectLatest = () => {
const index = items().length - 1
const item = items()[index]
setHover(item?.key)
if (item) reveal(index)
props.onLatest()
}
const row = (item: PromptRailItem, index: Accessor<number>) => (
<button
type="button"
class="prompt-rail-row"
classList={{ "prompt-rail-row--hover": item.key === hover() }}
data-prompt-index={index()}
aria-label={label(item, index())}
onMouseEnter={() => setHover(item.key)}
onClick={() => props.onSelect(item)}
>
<span class="prompt-rail-row-prompt" data-queued={item.queued || undefined}>
<Show when={item.queued}>
<span class="prompt-rail-row-status">{language.t("session.prompts.queued")} · </span>
</Show>
{item.prompt}
</span>
<Show when={item.answer || !item.prompt}>
<span class="prompt-rail-row-answer">{item.answer || language.t("session.prompts.noAnswer")}</span>
</Show>
</button>
)
return (
<Show when={items().length >= 2}>
<Show when={entries().length >= 2}>
<nav
ref={rail}
class="prompt-rail"
@@ -157,21 +286,26 @@ export function PromptRail(props: PromptRailProps) {
props.onWheel(event.deltaY)
}}
>
<For each={items()}>
{(item, index) => (
<For each={entries()}>
{(entry, index) => (
<button
type="button"
class="prompt-rail-tick"
classList={{
"prompt-rail-tick--active": item.key === props.active(),
"prompt-rail-tick--open": open() && index() === hover(),
"prompt-rail-tick--active": entryActive(entry),
"prompt-rail-tick--open": open() && index() === focused(),
"prompt-rail-tick--overflow": entry.type !== "prompt",
}}
data-queued={item.queued || undefined}
aria-label={label(item, index())}
tabIndex={index() === (hover() ?? 0) ? 0 : -1}
data-queued={(entry.type === "prompt" && entry.item.queued) || undefined}
aria-label={entryLabel(entry)}
tabIndex={index() === (focused() ?? 0) ? 0 : -1}
onMouseEnter={() => openCard(index())}
onFocus={() => openCard(index())}
onClick={() => props.onSelect(item)}
onClick={() => {
if (entry.type === "prompt") props.onSelect(entry.item)
if (entry.type === "history") selectFirst()
if (entry.type === "overflow") openCard(index())
}}
>
<span class="prompt-rail-tick-line" />
</button>
@@ -185,35 +319,84 @@ export function PromptRail(props: PromptRailProps) {
<div
ref={card}
class="prompt-rail-card"
data-virtualized={virtualized() || undefined}
role="dialog"
aria-label={language.t("session.prompts.navLabel")}
style={{ top: `${position().top}px`, left: `${position().left}px` }}
style={{
top: `${position().top}px`,
left: `${position().left}px`,
"--prompt-rail-card-height": `${position().height}px`,
}}
onMouseEnter={cancelClose}
onMouseLeave={closeCard}
onWheel={(event) => {
// A reveal placed the list here, so any wheel from now on is the
// user's. Scrolling up at the very top emits no scroll event, so
// the intent to go further back has to be read from the wheel.
revealing = false
if (event.deltaY < 0) page(offset())
}}
onScroll={() => {
if (card && !virtualized()) page(card.scrollTop)
}}
>
<For each={items()}>
{(item, index) => (
<button
type="button"
class="prompt-rail-row"
classList={{ "prompt-rail-row--hover": index() === hover() }}
onMouseEnter={() => setHover(index())}
onClick={() => props.onSelect(item)}
>
<span class="prompt-rail-row-prompt" data-queued={item.queued || undefined}>
<Show when={item.queued}>
<span class="prompt-rail-row-status">{language.t("session.prompts.queued")} · </span>
</Show>
{item.prompt}
</span>
<Show when={item.answer || !item.prompt}>
<span class="prompt-rail-row-answer">
{item.answer || language.t("session.prompts.noAnswer")}
</span>
</Show>
</button>
)}
</For>
<div class="prompt-rail-card-header">
<span class="prompt-rail-card-title">{language.t("session.prompts.navLabel")}</span>
<div class="prompt-rail-card-actions">
<Tooltip value={language.t("session.prompts.first")} placement="top">
<IconButton
icon="arrow-up"
label={language.t("session.prompts.first")}
aria-label={language.t("session.prompts.first")}
variant="ghost"
size="small"
disabled={props.seeking() || props.loadingOlder()}
onClick={selectFirst}
/>
</Tooltip>
<Tooltip value={language.t("session.prompts.latest")} placement="top">
<IconButton
icon="arrow-down-to-line"
label={language.t("session.prompts.latest")}
aria-label={language.t("session.prompts.latest")}
variant="ghost"
size="small"
onClick={selectLatest}
/>
</Tooltip>
</div>
</div>
<Show when={props.loadingOlder() || props.seeking()}>
<div class="prompt-rail-loading" role="status">
<Spinner />
<span>{language.t("session.messages.loadingEarlier")}</span>
</div>
</Show>
<Show
when={virtualized()}
fallback={
<div class="prompt-rail-list-static">
<For each={items()}>{row}</For>
</div>
}
>
<VList
ref={(handle) => {
list = handle
}}
class="prompt-rail-list"
data={items()}
itemSize={ROW_HEIGHT}
bufferSize={ROW_HEIGHT * 3}
shift={props.prepending()}
onScroll={page}
onScrollEnd={() => {
revealing = false
}}
>
{row}
</VList>
</Show>
</div>
</Portal>
)}
@@ -9,25 +9,40 @@ export interface PromptRailItem {
answer: string
}
export type PromptRailEntry =
| { type: "prompt"; item: PromptRailItem; index: number }
| { type: "overflow"; count: number; index: number }
| { type: "history" }
const PROMPT_LIMIT = 160
const ANSWER_LIMIT = 220
/**
* Height of the tallest card row (padding + a one-line prompt + a two-line
* answer), and the unit the fit cap is measured in. Deliberately the worst
* case rather than an average: "only show what fits" should stay true for a
* card whose rows all wrap, not just for a lucky mix of short ones.
* answer). Sizes the navigator's virtualized rows; the rail's own fit cap is
* measured in tick spacing instead, since a tick is only a hairline.
*/
export const ROW_HEIGHT = 76
/** Vertical padding reserved at the top and bottom of the rail. */
export const RAIL_INSET = 24
/** Natural spacing between ticks, and the tightest they are allowed to pack. */
export const TICK_STEP = 14
export const TICK_MIN = 7
/**
* How many prompts fit the available transcript height. The card and the rail
* always render the same set, so this one number drives both.
* How many ticks fit the available transcript height. Measured in tick
* spacing, not card row height: a tick is a 1.5px line, so the rail holds
* several times more prompts than the navigator can list at once, and
* summarizing at the card's row count would hide prompts that have room to
* show. The complete prompt list lives in the bounded navigator.
*/
export function capacity(height: number): number {
return Math.floor((height - RAIL_INSET) / ROW_HEIGHT)
return Math.floor((height - RAIL_INSET) / TICK_MIN)
}
export function historyAction(before: number, after: number, more: boolean): "stop" | "load" | "jump" {
if (after <= before) return "stop"
return more ? "load" : "jump"
}
// The card never renders markdown — user message text shows literally, and
@@ -92,7 +107,38 @@ export function promptItems(rows: TranscriptRow[]): PromptRailItem[] {
return items
}
export function railItems(items: PromptRailItem[], capacity: number): PromptRailItem[] {
export function railEntries(items: PromptRailItem[], capacity: number, history = false): PromptRailEntry[] {
if (capacity < 1) return []
return items.slice(-capacity)
if (!history && items.length <= capacity) {
return items.map((item, index) => ({ type: "prompt", item, index }))
}
if (capacity === 1) {
if (history) return [{ type: "history" }]
const index = items.length - 1
const item = items[index]
return item ? [{ type: "prompt", item, index }] : []
}
if (capacity === 2) {
const item = items.at(-1)
const latest = item ? [{ type: "prompt" as const, item, index: items.length - 1 }] : []
if (history) return [{ type: "history" }, ...latest]
const first = items[0]
return first ? [{ type: "prompt", item: first, index: 0 }, ...latest] : latest
}
const count = Math.min(items.length, capacity - 2)
const start = items.length - count
const recent = items.slice(start).map((item, offset) => ({
type: "prompt" as const,
item,
index: start + offset,
}))
const prefix: PromptRailEntry[] = history
? [{ type: "history" }]
: items[0]
? [{ type: "prompt", item: items[0], index: 0 }]
: []
const hidden = start - (history ? 0 : 1)
if (hidden < 1) return [...prefix, ...recent]
return [...prefix, { type: "overflow", count: hidden, index: history ? 0 : 1 }, ...recent]
}
@@ -17,7 +17,7 @@ import ModeEditView from "./ModeEditView"
import ModeCreateView from "./ModeCreateView"
import McpEditView from "./McpEditView"
import WorkflowsTab from "./agent-behaviour/WorkflowsTab"
import { selectedDefaultAgentValue } from "./agent-behaviour-patches"
import { mcpConfigScope, mcpEnabledPatch, selectedDefaultAgentValue } from "./agent-behaviour-patches"
import { parseImport, MAX_IMPORT_SIZE } from "./mode-io"
import type { ImportError } from "./mode-io"
@@ -50,7 +50,7 @@ type AgentView = "list" | "create" | "edit"
const AgentBehaviourTab: Component = () => {
const language = useLanguage()
const { config, updateConfig } = useConfig()
const { config, collections, updateConfig, updateGlobalConfig, updateProjectConfig } = useConfig()
const session = useSession()
const dialog = useDialog()
const vscode = useVSCode()
@@ -675,12 +675,17 @@ const AgentBehaviourTab: Component = () => {
<Switch
checked={isConnected(name)}
disabled={session.mcpLoading() === name}
onChange={() => {
if (isConnected(name)) {
session.disconnectMcp(name)
} else {
session.connectMcp(name)
onChange={(enabled: boolean) => {
const scope = mcpConfigScope(name, collections())
if (scope) {
const update = scope === "project" ? updateProjectConfig : updateGlobalConfig
update(mcpEnabledPatch(name, enabled))
}
if (!enabled) {
session.disconnectMcp(name)
return
}
session.connectMcp(name)
}}
hideLabel
>
@@ -1,3 +1,20 @@
import type { Config, ConfigCollections } from "../../types/messages"
export function mcpEnabledPatch(name: string, enabled: boolean): Partial<Config> {
return {
mcp: {
[name]: {
enabled,
},
},
}
}
export function mcpConfigScope(name: string, collections: ConfigCollections): "global" | "project" | undefined {
const source = collections.mcp?.find((entry) => entry.key === name)?.source
return source === "project" || source === "global" ? source : undefined
}
export function selectedDefaultAgentValue(value: string): string | null {
return value || null
}
@@ -11,7 +11,7 @@
import { createContext, useContext, createSignal, createMemo, onCleanup } from "solid-js"
import type { ParentComponent, Accessor } from "solid-js"
import { useVSCode } from "./vscode"
import type { Config, ExtensionMessage, FeatureFlags } from "../types/messages"
import type { Config, ConfigCollections, ExtensionMessage, FeatureFlags } from "../types/messages"
import {
configUnsetPaths,
deepMerge,
@@ -35,6 +35,7 @@ interface ConfigContextValue {
config: Accessor<Config>
globalConfig: Accessor<Config>
projectConfig: Accessor<Config>
collections: Accessor<ConfigCollections>
settings: Accessor<Record<string, unknown>>
features: Accessor<FeatureFlags>
loading: Accessor<boolean>
@@ -57,6 +58,7 @@ export const ConfigProvider: ParentComponent = (props) => {
const [config, setConfig] = createSignal<Config>({})
const [globalConfig, setGlobalConfig] = createSignal<Config>({})
const [projectConfig, setProjectConfig] = createSignal<Config>({})
const [collections, setCollections] = createSignal<ConfigCollections>({})
const [settings, setSettings] = createSignal<Record<string, unknown>>({})
const [features, setFeatures] = createSignal<FeatureFlags>({ indexing: false, sandboxControls: false })
const [loading, setLoading] = createSignal(true)
@@ -82,6 +84,9 @@ export const ConfigProvider: ParentComponent = (props) => {
// Error from the most recent saveConfig() attempt, or null if no error.
// Cleared when the user edits the draft again or starts a new save.
const [saveError, setSaveError] = createSignal<SaveError | null>(null)
const updateCollections = (next: ConfigCollections | undefined) => {
if (next !== undefined) setCollections(next)
}
// Register handler immediately (not in onMount) so we never miss
// a configLoaded message that arrives before the DOM mount.
@@ -136,6 +141,7 @@ export const ConfigProvider: ParentComponent = (props) => {
setProjectConfig(mergeScopedConfig(message.projectConfig, projectDraft()))
setSavedProject(message.projectConfig)
}
updateCollections(message.collections)
setLoading(false)
return
}
@@ -163,6 +169,7 @@ export const ConfigProvider: ParentComponent = (props) => {
setProjectConfig(message.projectConfig)
setSavedProject(message.projectConfig)
}
updateCollections(message.collections)
setFeatures(message.features)
} else {
// configUpdated from a different source (e.g. PermissionDock save).
@@ -176,6 +183,7 @@ export const ConfigProvider: ParentComponent = (props) => {
setProjectConfig(mergeScopedConfig(message.projectConfig, projectDraft()))
setSavedProject(message.projectConfig)
}
updateCollections(message.collections)
setFeatures(message.features)
}
if (message.settings) mergeSettings(message.settings)
@@ -313,6 +321,7 @@ export const ConfigProvider: ParentComponent = (props) => {
config,
globalConfig,
projectConfig,
collections,
settings,
features,
loading,
@@ -291,7 +291,7 @@ interface SessionContextValue {
createSession: () => void
clearCurrentSession: () => void
loadSessions: () => void
loadOlderMessages: () => void
loadOlderMessages: () => boolean
selectSession: (id: string) => void
deleteSession: (id: string) => void
renameSession: (id: string, title: string) => void
@@ -2560,9 +2560,9 @@ export const SessionProvider: ParentComponent = (props) => {
function loadOlderMessages() {
const id = currentSessionID()
if (!id || !server.isConnected()) return
if (!id || !server.isConnected()) return false
const page = pages[id] ?? emptyPageState
if (!page.hasMore || page.loadingOlder || page.loadingInitial || !page.before) return
if (!page.hasMore || page.loadingOlder || page.loadingInitial || !page.before) return false
patchPage(id, { loadingOlder: true })
vscode.postMessage({
type: "loadMessages",
@@ -2571,6 +2571,7 @@ export const SessionProvider: ParentComponent = (props) => {
before: page.before,
limit: MESSAGE_PAGE_LIMIT,
})
return true
}
// Session whose message fetch was deferred because the backend was offline at
+3 -1
View File
@@ -296,7 +296,6 @@ export const dict = {
"session.tab.review": "مراجعة",
"session.review.filesChanged": "تم تغيير {{count}} ملفات",
"session.review.change.other": "تغييرات",
"session.review.loadingChanges": "جارٍ تحميل التغييرات...",
"session.review.noChanges": "لا توجد تغييرات",
"session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...",
@@ -709,6 +708,9 @@ export const dict = {
"session.prompts.tick": "المطالبة {{index}} من {{total}}: {{prompt}}",
"session.prompts.noAnswer": "لا توجد استجابة بعد",
"session.prompts.queued": "في قائمة الانتظار",
"session.prompts.first": "أول مطالبة",
"session.prompts.latest": "أحدث مطالبة",
"session.prompts.overflow": "{{count}} مطالبات سابقة",
"session.status.writingResponse": "...جارٍ كتابة الرد",
"session.status.retry": "جارٍ إعادة المحاولة…",
"session.status.working": "...جارٍ العمل",
+3 -1
View File
@@ -306,7 +306,6 @@ export const dict = {
"session.tab.review": "Revisão",
"session.review.filesChanged": "{{count}} Arquivos Alterados",
"session.review.change.other": "Alterações",
"session.review.loadingChanges": "Carregando alterações...",
"session.review.noChanges": "Sem alterações",
"session.messages.loadingEarlier": "Carregando mensagens anteriores...",
@@ -727,6 +726,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} de {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Sem resposta ainda",
"session.prompts.queued": "Na fila",
"session.prompts.first": "Primeiro prompt",
"session.prompts.latest": "Prompt mais recente",
"session.prompts.overflow": "{{count}} prompts anteriores",
"session.status.writingResponse": "Escrevendo resposta…",
"session.status.retry": "Tentando novamente…",
"session.status.working": "Trabalhando…",
+3 -1
View File
@@ -304,7 +304,6 @@ export const dict = {
"session.tab.review": "Pregled",
"session.review.filesChanged": "Izmijenjeno {{count}} datoteka",
"session.review.change.other": "Izmjene",
"session.review.loadingChanges": "Učitavanje izmjena...",
"session.review.noChanges": "Nema izmjena",
@@ -727,6 +726,9 @@ export const dict = {
"session.prompts.tick": "Upit {{index}} od {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Još nema odgovora",
"session.prompts.queued": "Na čekanju",
"session.prompts.first": "Prvi upit",
"session.prompts.latest": "Najnoviji upit",
"session.prompts.overflow": "{{count}} ranijih upita",
"session.status.writingResponse": "Pisanje odgovora…",
"session.status.retry": "Ponovni pokušaj…",
"session.status.working": "Radim…",
+3 -1
View File
@@ -303,7 +303,6 @@ export const dict = {
"session.tab.review": "Gennemgang",
"session.review.filesChanged": "{{count}} Filer ændret",
"session.review.change.other": "Ændringer",
"session.review.loadingChanges": "Indlæser ændringer...",
"session.review.noChanges": "Ingen ændringer",
"session.messages.loadingEarlier": "Indlæser tidligere beskeder...",
@@ -725,6 +724,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} af {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Intet svar endnu",
"session.prompts.queued": "I kø",
"session.prompts.first": "Første prompt",
"session.prompts.latest": "Seneste prompt",
"session.prompts.overflow": "{{count}} tidligere prompter",
"session.status.writingResponse": "Skriver svar…",
"session.status.retry": "Prøver igen…",
"session.status.working": "Arbejder…",
@@ -312,7 +312,6 @@ export const dict = {
"session.tab.review": "Überprüfung",
"session.review.filesChanged": "{{count}} Dateien geändert",
"session.review.change.other": "Änderungen",
"session.review.loadingChanges": "Lade Änderungen...",
"session.review.noChanges": "Keine Änderungen",
"session.messages.loadingEarlier": "Lade frühere Nachrichten...",
@@ -738,6 +737,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} von {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Noch keine Antwort",
"session.prompts.queued": "In Warteschlange",
"session.prompts.first": "Erster Prompt",
"session.prompts.latest": "Neuester Prompt",
"session.prompts.overflow": "{{count}} frühere Prompts",
"session.status.writingResponse": "Antwort wird geschrieben…",
"session.status.retry": "Erneuter Versuch…",
"session.status.working": "Wird bearbeitet…",
@@ -301,7 +301,6 @@ export const dict = {
"session.tab.review": "Review",
"session.review.filesChanged": "{{count}} Files Changed",
"session.review.change.other": "Changes",
"session.review.loadingChanges": "Loading changes...",
"session.review.noChanges": "No changes",
@@ -679,6 +678,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} of {{total}}: {{prompt}}",
"session.prompts.noAnswer": "No response yet",
"session.prompts.queued": "Queued",
"session.prompts.first": "First prompt",
"session.prompts.latest": "Latest prompt",
"session.prompts.overflow": "{{count}} earlier prompts",
"session.status.writingResponse": "Writing response...",
"session.status.retry": "Retrying…",
"session.status.working": "Working...",
+3 -1
View File
@@ -307,7 +307,6 @@ export const dict = {
"session.tab.review": "Revisión",
"session.review.filesChanged": "{{count}} Archivos Cambiados",
"session.review.change.other": "Cambios",
"session.review.loadingChanges": "Cargando cambios...",
"session.review.noChanges": "Sin cambios",
"session.messages.loadingEarlier": "Cargando mensajes anteriores...",
@@ -732,6 +731,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} de {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Aún no hay respuesta",
"session.prompts.queued": "En cola",
"session.prompts.first": "Primera solicitud",
"session.prompts.latest": "Última solicitud",
"session.prompts.overflow": "{{count}} solicitudes anteriores",
"session.status.writingResponse": "Escribiendo respuesta…",
"session.status.retry": "Reintentando…",
"session.status.working": "Trabajando…",
+3
View File
@@ -683,6 +683,9 @@ export const dict = {
"session.prompts.tick": "پرامپت {{index}} از {{total}}: {{prompt}}",
"session.prompts.noAnswer": "هنوز پاسخی وجود ندارد",
"session.prompts.queued": "در صف انتظار",
"session.prompts.first": "اولین پرامپت",
"session.prompts.latest": "آخرین پرامپت",
"session.prompts.overflow": "{{count}} پرامپت قبلی",
"session.status.writingResponse": "در حال نوشتن پاسخ...",
"session.status.retry": "در حال تلاش مجدد…",
"session.status.working": "در حال پردازش...",
+3 -1
View File
@@ -306,7 +306,6 @@ export const dict = {
"session.tab.review": "Revue",
"session.review.filesChanged": "{{count}} fichiers modifiés",
"session.review.change.other": "Modifications",
"session.review.loadingChanges": "Chargement des modifications...",
"session.review.noChanges": "Aucune modification",
"session.messages.loadingEarlier": "Chargement des messages précédents...",
@@ -738,6 +737,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} sur {{total}} : {{prompt}}",
"session.prompts.noAnswer": "Pas encore de réponse",
"session.prompts.queued": "En attente",
"session.prompts.first": "Premier prompt",
"session.prompts.latest": "Dernier prompt",
"session.prompts.overflow": "{{count}} prompts précédents",
"session.status.writingResponse": "Rédaction de la réponse…",
"session.status.retry": "Nouvelle tentative…",
"session.status.working": "En cours…",
+3 -1
View File
@@ -219,7 +219,6 @@ export const dict = {
"ui.approval.source.default": "per impostazione predefinita",
"session.tab.review": "Revisione",
"session.review.filesChanged": "{{count}} file modificati",
"session.review.change.other": "Modifiche",
"session.review.loadingChanges": "Caricamento modifiche...",
"session.review.noChanges": "Nessuna modifica",
"session.messages.loadingEarlier": "Caricamento messaggi precedenti...",
@@ -578,6 +577,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} di {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Nessuna risposta ancora",
"session.prompts.queued": "In coda",
"session.prompts.first": "Primo prompt",
"session.prompts.latest": "Ultimo prompt",
"session.prompts.overflow": "{{count}} prompt precedenti",
"session.status.writingResponse": "Scrittura risposta...",
"session.status.retry": "Riprovo...",
"session.status.working": "Al lavoro...",
+3 -1
View File
@@ -303,7 +303,6 @@ export const dict = {
"session.tab.review": "レビュー",
"session.review.filesChanged": "{{count}} ファイル変更",
"session.review.change.other": "変更",
"session.review.loadingChanges": "変更を読み込み中...",
"session.review.noChanges": "変更なし",
"session.messages.loadingEarlier": "以前のメッセージを読み込み中...",
@@ -719,6 +718,9 @@ export const dict = {
"session.prompts.tick": "プロンプト {{index}}/{{total}}: {{prompt}}",
"session.prompts.noAnswer": "まだ応答がありません",
"session.prompts.queued": "キューに追加済み",
"session.prompts.first": "最初のプロンプト",
"session.prompts.latest": "最新のプロンプト",
"session.prompts.overflow": "{{count}} 件前のプロンプト",
"session.status.writingResponse": "応答を作成中…",
"session.status.retry": "再試行中…",
"session.status.working": "作業中…",
+3 -1
View File
@@ -304,7 +304,6 @@ export const dict = {
"session.tab.review": "검토",
"session.review.filesChanged": "{{count}}개 파일 변경됨",
"session.review.change.other": "변경",
"session.review.loadingChanges": "변경 사항 로드 중...",
"session.review.noChanges": "변경 없음",
"session.messages.loadingEarlier": "이전 메시지 로드 중...",
@@ -720,6 +719,9 @@ export const dict = {
"session.prompts.tick": "프롬프트 {{index}}/{{total}}: {{prompt}}",
"session.prompts.noAnswer": "아직 응답이 없습니다",
"session.prompts.queued": "대기 중",
"session.prompts.first": "첫 번째 프롬프트",
"session.prompts.latest": "최신 프롬프트",
"session.prompts.overflow": "{{count}}개 이전 프롬프트",
"session.status.writingResponse": "응답 작성 중...",
"session.status.retry": "재시도 중…",
"session.status.working": "작업 중...",
+3 -1
View File
@@ -307,7 +307,6 @@ export const dict = {
"session.tab.review": "Beoordelen",
"session.review.filesChanged": "{{count}} bestanden gewijzigd",
"session.review.change.other": "Wijzigingen",
"session.review.loadingChanges": "Wijzigingen laden...",
"session.review.noChanges": "Geen wijzigingen",
@@ -717,6 +716,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} van {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Nog geen reactie",
"session.prompts.queued": "In wachtrij",
"session.prompts.first": "Eerste prompt",
"session.prompts.latest": "Meest recente prompt",
"session.prompts.overflow": "{{count}} eerdere prompts",
"session.status.writingResponse": "Antwoord schrijven...",
"session.status.retry": "Opnieuw proberen...",
"session.status.working": "Bezig...",
+3 -1
View File
@@ -310,7 +310,6 @@ export const dict = {
"session.tab.review": "Gjennomgang",
"session.review.filesChanged": "{{count}} filer endret",
"session.review.change.other": "Endringer",
"session.review.loadingChanges": "Laster endringer...",
"session.review.noChanges": "Ingen endringer",
"session.messages.loadingEarlier": "Laster inn tidligere meldinger...",
@@ -687,6 +686,9 @@ export const dict = {
"session.prompts.tick": "Ledetekst {{index}} av {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Ingen svar ennå",
"session.prompts.queued": "I kø",
"session.prompts.first": "Første ledetekst",
"session.prompts.latest": "Siste ledetekst",
"session.prompts.overflow": "{{count}} tidligere ledetekster",
"session.status.writingResponse": "Skriver svar…",
"session.status.retry": "Prøver på nytt…",
"session.status.working": "Arbeider…",
+3 -1
View File
@@ -304,7 +304,6 @@ export const dict = {
"session.tab.review": "Przegląd",
"session.review.filesChanged": "Zmieniono {{count}} plików",
"session.review.change.other": "Zmiany",
"session.review.loadingChanges": "Ładowanie zmian...",
"session.review.noChanges": "Brak zmian",
"session.messages.loadingEarlier": "Ładowanie wcześniejszych wiadomości...",
@@ -683,6 +682,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} z {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Jeszcze brak odpowiedzi",
"session.prompts.queued": "W kolejce",
"session.prompts.first": "Pierwszy prompt",
"session.prompts.latest": "Ostatni prompt",
"session.prompts.overflow": "{{count}} wcześniejszych promptów",
"session.status.writingResponse": "Pisanie odpowiedzi…",
"session.status.retry": "Ponawianie…",
"session.status.working": "Pracuję…",
+3 -1
View File
@@ -302,7 +302,6 @@ export const dict = {
"session.tab.review": "Обзор",
"session.review.filesChanged": "{{count}} файлов изменено",
"session.review.change.other": "Изменения",
"session.review.loadingChanges": "Загрузка изменений...",
"session.review.noChanges": "Нет изменений",
"session.messages.loadingEarlier": "Загрузка предыдущих сообщений...",
@@ -724,6 +723,9 @@ export const dict = {
"session.prompts.tick": "Промпт {{index}} из {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Ответа пока нет",
"session.prompts.queued": "В очереди",
"session.prompts.first": "Первый запрос",
"session.prompts.latest": "Последний запрос",
"session.prompts.overflow": "{{count}} предыдущих запросов",
"session.status.writingResponse": "Пишу ответ…",
"session.status.retry": "Повторная попытка…",
"session.status.working": "Работаю…",
+3 -1
View File
@@ -301,7 +301,6 @@ export const dict = {
"session.tab.review": "ตรวจสอบ",
"session.review.filesChanged": "{{count}} ไฟล์ที่เปลี่ยนแปลง",
"session.review.change.other": "การเปลี่ยนแปลง",
"session.review.loadingChanges": "กำลังโหลดการเปลี่ยนแปลง...",
"session.review.noChanges": "ไม่มีการเปลี่ยนแปลง",
@@ -716,6 +715,9 @@ export const dict = {
"session.prompts.tick": "พรอมต์ {{index}} จาก {{total}}: {{prompt}}",
"session.prompts.noAnswer": "ยังไม่มีการตอบกลับ",
"session.prompts.queued": "อยู่ในคิว",
"session.prompts.first": "พรอมต์แรก",
"session.prompts.latest": "พรอมต์ล่าสุด",
"session.prompts.overflow": "พรอมต์ก่อนหน้า {{count}} รายการ",
"session.status.writingResponse": "กำลังเขียนคำตอบ...",
"session.status.retry": "กำลังลองใหม่…",
"session.status.working": "กำลังทำงาน...",
+3 -1
View File
@@ -302,7 +302,6 @@ export const dict = {
"session.tab.review": "İnceleme",
"session.review.filesChanged": "{{count}} Dosya Değişti",
"session.review.change.other": "Değişiklik",
"session.review.loadingChanges": "Değişiklikler yükleniyor...",
"session.review.noChanges": "Değişiklik yok",
@@ -711,6 +710,9 @@ export const dict = {
"session.prompts.tick": "Komut {{index}} / {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Henüz yanıt yok",
"session.prompts.queued": "Sırada",
"session.prompts.first": "İlk istem",
"session.prompts.latest": "En son istem",
"session.prompts.overflow": "{{count}} önceki istem",
"session.status.writingResponse": "Yanıt yazılıyor...",
"session.status.retry": "Yeniden deneniyor…",
"session.status.working": "Çalışıyor...",
+3 -1
View File
@@ -306,7 +306,6 @@ export const dict = {
"session.tab.review": "Огляд",
"session.review.filesChanged": "{{count}} файлів змінено",
"session.review.change.other": "Змін",
"session.review.loadingChanges": "Завантаження змін...",
"session.review.noChanges": "Змін немає",
@@ -713,6 +712,9 @@ export const dict = {
"session.prompts.tick": "Запит {{index}} з {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Відповіді ще немає",
"session.prompts.queued": "У черзі",
"session.prompts.first": "Перший запит",
"session.prompts.latest": "Останній запит",
"session.prompts.overflow": "{{count}} попередніх запитів",
"session.status.writingResponse": "Пишу відповідь...",
"session.status.retry": "Повторна спроба…",
"session.status.working": "Працює...",
+3 -1
View File
@@ -292,7 +292,6 @@ export const dict = {
"session.tab.review": "审查",
"session.review.filesChanged": "{{count}} 个文件变更",
"session.review.change.other": "更改",
"session.review.loadingChanges": "正在加载更改...",
"session.review.noChanges": "无更改",
"session.messages.loadingEarlier": "正在加载更早的消息...",
@@ -700,6 +699,9 @@ export const dict = {
"session.prompts.tick": "提示词 {{index}}/{{total}}{{prompt}}",
"session.prompts.noAnswer": "暂无响应",
"session.prompts.queued": "已排队",
"session.prompts.first": "首个提示",
"session.prompts.latest": "最新提示",
"session.prompts.overflow": "{{count}} 个更早的提示",
"session.status.writingResponse": "正在撰写回复…",
"session.status.retry": "正在重试…",
"session.status.working": "处理中…",
+3 -1
View File
@@ -290,7 +290,6 @@ export const dict = {
"session.tab.review": "審查",
"session.review.filesChanged": "{{count}} 個檔案變更",
"session.review.change.other": "變更",
"session.review.loadingChanges": "正在載入變更...",
"session.review.noChanges": "沒有變更",
"session.messages.loadingEarlier": "正在載入更早的訊息...",
@@ -660,6 +659,9 @@ export const dict = {
"session.prompts.tick": "提示詞 {{index}}/{{total}}{{prompt}}",
"session.prompts.noAnswer": "尚無回應",
"session.prompts.queued": "已排入佇列",
"session.prompts.first": "第一個提示",
"session.prompts.latest": "最新提示",
"session.prompts.overflow": "{{count}} 個較早的提示",
"session.status.writingResponse": "正在撰寫回覆…",
"session.status.retry": "正在重試…",
"session.status.working": "處理中…",
@@ -262,7 +262,7 @@ export function mockSessionValue(overrides?: {
createSession: noop,
clearCurrentSession: noop,
loadSessions: noop,
loadOlderMessages: noop,
loadOlderMessages: () => false,
selectSession: noop,
deleteSession: noop,
renameSession: noop,
@@ -335,6 +335,7 @@ const ConfigWrapper: ParentComponent<{
config: createMemo(() => cfg()),
globalConfig: createMemo(() => (scoped ? global() : cfg())),
projectConfig: createMemo(() => (scoped ? project() : cfg())),
collections: () => ({}),
settings,
features,
loading: () => false,

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