diff --git a/.changeset/agent-manager-diff-scope-selector.md b/.changeset/agent-manager-diff-scope-selector.md new file mode 100644 index 0000000000..341e5ee768 --- /dev/null +++ b/.changeset/agent-manager-diff-scope-selector.md @@ -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. diff --git a/.changeset/fuzzy-pandas-listen.md b/.changeset/fuzzy-pandas-listen.md new file mode 100644 index 0000000000..c2d8334aec --- /dev/null +++ b/.changeset/fuzzy-pandas-listen.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Persist MCP server enable and disable changes from VS Code settings across window reloads. diff --git a/.changeset/headless-run-honest-exit.md b/.changeset/headless-run-honest-exit.md new file mode 100644 index 0000000000..37d00a8493 --- /dev/null +++ b/.changeset/headless-run-honest-exit.md @@ -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. diff --git a/.changeset/long-session-prompt-navigation.md b/.changeset/long-session-prompt-navigation.md new file mode 100644 index 0000000000..967dde901f --- /dev/null +++ b/.changeset/long-session-prompt-navigation.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Navigate long conversations from a compact prompt rail that loads earlier history as you scroll. diff --git a/.github/docs-sync/edit.mjs b/.github/docs-sync/edit.mjs index 777c3a2929..167656eda3 100644 --- a/.github/docs-sync/edit.mjs +++ b/.github/docs-sync/edit.mjs @@ -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}`, diff --git a/.github/docs-sync/lib.mjs b/.github/docs-sync/lib.mjs index 67c13354d8..6aaf9dc6c3 100644 --- a/.github/docs-sync/lib.mjs +++ b/.github/docs-sync/lib.mjs @@ -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-.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 } } diff --git a/.github/docs-sync/redact-stream.mjs b/.github/docs-sync/redact-stream.mjs new file mode 100644 index 0000000000..341aea34ad --- /dev/null +++ b/.github/docs-sync/redact-stream.mjs @@ -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)) +}) diff --git a/.github/docs-sync/selftest.mjs b/.github/docs-sync/selftest.mjs index fe2cb1faec..b720ba67cb 100644 --- a/.github/docs-sync/selftest.mjs +++ b/.github/docs-sync/selftest.mjs @@ -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") 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, diff --git a/.github/docs-sync/triage.mjs b/.github/docs-sync/triage.mjs index eb075980b5..a01eef0a99 100644 --- a/.github/docs-sync/triage.mjs +++ b/.github/docs-sync/triage.mjs @@ -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}`, diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml index 5a32e87698..147cde8a47 100644 --- a/.github/workflows/docs-sync.yml +++ b/.github/workflows/docs-sync.yml @@ -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 diff --git a/packages/kilo-docs/pages/ai-providers/cerebras.md b/packages/kilo-docs/pages/ai-providers/cerebras.md index 69487b2ee7..bdf7166a51 100644 --- a/packages/kilo-docs/pages/ai-providers/cerebras.md +++ b/packages/kilo-docs/pages/ai-providers/cerebras.md @@ -53,7 +53,7 @@ Then set your default model: ```jsonc { - "model": "cerebras/llama-4-scout-17b-16e-instruct", + "model": "cerebras/gpt-oss-120b", } ``` diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index 9651c14b2b..8710f462fa 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -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 diff --git a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md index 599917b6d2..7978ddd868 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md @@ -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" %} diff --git a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md index 1ed19e42de..1ef3526d95 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md @@ -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. diff --git a/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md b/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md index d8a2d98158..2e36fa2bea 100644 --- a/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md +++ b/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md @@ -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: diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/jetbrains.md b/packages/kilo-docs/pages/code-with-ai/platforms/jetbrains.md index a94b3cfc49..ca9f785b64 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/jetbrains.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/jetbrains.md @@ -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. diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md b/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md index 44b2b74d41..b91950b5ec 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md @@ -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. diff --git a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md index 096b2d1b3d..070fc59df5 100644 --- a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md +++ b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md @@ -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. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png index ac6b5490c0..49c95de8d9 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a7c800d169ca92674fc9f7c7d83032ca17e7d7937bdcbea5c72cb921a7fdb89e -size 51975 +oid sha256:2a74c7f53a5d9afa743ee49d298ce8fb7e357c5fcc46cad45de353694a7540dc +size 49476 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png index df4e9c7068..2ed225e9ec 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:51adb9e31ce0bc82981b0f20f895ce4ede3f92828546115f929942ef93bf0812 -size 11204 +oid sha256:d7fc23fcb7adf483c0b771ef601b23cb365dc7f40033b00fce703b35910aa4fc +size 27159 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png index 94024986cd..623df73220 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c5658ed9e5266311c4239b7cd470d2f772d1cc0b3fd6c6b00337d4f3141a3a4d -size 11950 +oid sha256:d11f4004ed3170647d385c14df077d235f5bb9bd6e5dec07557ee2014d553233 +size 27302 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png index 07e3e945ef..c2410b066a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:095ed97dc46498be6fd75483b24357ac0740b6f2c1544f6c103a1dac360e1a7e -size 11202 +oid sha256:f8bd56ba87d0c2bbef8a2325ab2e9f0e956c89e2602ff8ca7415d5fb7c9fa1f3 +size 29709 diff --git a/packages/kilo-jetbrains/frontend/build.gradle.kts b/packages/kilo-jetbrains/frontend/build.gradle.kts index 0c141225c6..a42f67e199 100644 --- a/packages/kilo-jetbrains/frontend/build.gradle.kts +++ b/packages/kilo-jetbrains/frontend/build.gradle.kts @@ -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("generateProviderIcons") { diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index 15b48416fe..182e9b77c4 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -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" } diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index e8e668b4b5..886465032f 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -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() private toolRequests = new Set() @@ -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 | 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 { + 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 | 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() diff --git a/packages/kilo-vscode/src/agent-manager/diff-scope.ts b/packages/kilo-vscode/src/agent-manager/diff-scope.ts new file mode 100644 index 0000000000..21d2e00793 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/diff-scope.ts @@ -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" | "" + * 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" +} diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index d386e36acc..d12b481120 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -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 diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index 75d538cc35..dff233ca5c 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -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 | 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 - /** 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 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() 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 { @@ -144,38 +147,38 @@ export class WorktreeDiffController { } } - public async revert(sessionId: string, file: string): Promise { + public async revert(id: string, file: string): Promise { 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 { - if (this.controller.currentId !== sessionId) { - await this.activate(sessionId, false, true) + public async request(id: string): Promise { + 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 { + public async requestFile(id: string, file: string): Promise { 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 { + /** + * 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 { + 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 { + 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 { 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 { - 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 { - 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 { - 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 { - 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", diff --git a/packages/kilo-vscode/src/diff/sources/catalog.ts b/packages/kilo-vscode/src/diff/sources/catalog.ts index fa36aaecf2..8eb2d0ffdf 100644 --- a/packages/kilo-vscode/src/diff/sources/catalog.ts +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -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 { - const root = getWorkspaceRoot() + async listWorkspaceBranches( + override: string | undefined, + dir?: string, + ): Promise { + const root = dir ?? getWorkspaceRoot() if (!root) return undefined const git = this.ensureBranchGit() diff --git a/packages/kilo-vscode/src/diff/sources/staged.ts b/packages/kilo-vscode/src/diff/sources/staged.ts index d21a508902..8cd250743c 100644 --- a/packages/kilo-vscode/src/diff/sources/staged.ts +++ b/packages/kilo-vscode/src/diff/sources/staged.ts @@ -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 => { 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() }, } } diff --git a/packages/kilo-vscode/src/diff/sources/unstaged.ts b/packages/kilo-vscode/src/diff/sources/unstaged.ts index b4d0c99616..5fcb03793d 100644 --- a/packages/kilo-vscode/src/diff/sources/unstaged.ts +++ b/packages/kilo-vscode/src/diff/sources/unstaged.ts @@ -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 => { 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() }, } } diff --git a/packages/kilo-vscode/src/diff/sources/worktree.ts b/packages/kilo-vscode/src/diff/sources/worktree.ts index 1f3d8ee25a..89e750a1c8 100644 --- a/packages/kilo-vscode/src/diff/sources/worktree.ts +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -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 => { 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 }, } diff --git a/packages/kilo-vscode/src/diff/types.ts b/packages/kilo-vscode/src/diff/types.ts index 913d4560a3..3ff72904e1 100644 --- a/packages/kilo-vscode/src/diff/types.ts +++ b/packages/kilo-vscode/src/diff/types.ts @@ -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" diff --git a/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts index 209d5c4e42..4b5ef9d5e9 100644 --- a/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts +++ b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts @@ -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), } diff --git a/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts b/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts index 0820dc818e..796a2d0eeb 100644 --- a/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts @@ -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() diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index ef69f9f317..36c1c1c766 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -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") }) diff --git a/packages/kilo-vscode/tests/unit/diff-scope.test.ts b/packages/kilo-vscode/tests/unit/diff-scope.test.ts new file mode 100644 index 0000000000..7714ae4fa2 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-scope.test.ts @@ -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") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/prompt-rail.test.ts b/packages/kilo-vscode/tests/unit/prompt-rail.test.ts index d9854b5226..18c800e1bb 100644 --- a/packages/kilo-vscode/tests/unit/prompt-rail.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-rail.test.ts @@ -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([]) }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 8397077e5d..a17c947bd9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -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) => ( + + ) + + // 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")} - + + } + > +
+ + {(opt, index) => ( + <> + {(text) =>
{text()}
}
+ + + )} +
+
+ + ) +} + +/** 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) => ( + +) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index d2daa6389b..e5aa67b906 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -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 = (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 = (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(`[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(`[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 = (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() - 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 = (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 = (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 = (props) => { 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())} /> diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx index 555cc337a0..857e4fa634 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx @@ -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 items: Accessor /** Row key of the item whose turn is currently at the top of the transcript. */ active: Accessor 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 + hasOlder: Accessor + loadingOlder: Accessor + prepending: Accessor + seeking: Accessor } 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() - const [anchor, setAnchor] = createSignal<{ top: number; left: number }>() + const [hover, setHover] = createSignal() + const [focused, setFocused] = createSignal() + 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 | 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(`[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) => ( + + ) + return ( - = 2}> + = 2}>