diff --git a/.changeset/adaptive-opus-five.md b/.changeset/adaptive-opus-five.md new file mode 100644 index 00000000000..9d8cf6c1b31 --- /dev/null +++ b/.changeset/adaptive-opus-five.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Support adaptive thinking levels for Claude Opus and Sonnet 5 and later. diff --git a/.changeset/agent-manager-side-terminal.md b/.changeset/agent-manager-side-terminal.md new file mode 100644 index 00000000000..533da3233cb --- /dev/null +++ b/.changeset/agent-manager-side-terminal.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Let users open Agent Manager terminals in the VS Code terminal or an embedded side panel. The terminal button's dropdown picks the destination; the side panel shares the right-hand inspector with the diff view and keeps running in the background when hidden. diff --git a/.changeset/atomic-session-revert.md b/.changeset/atomic-session-revert.md new file mode 100644 index 00000000000..ed922d1a6e2 --- /dev/null +++ b/.changeset/atomic-session-revert.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Keep conversations and workspace files unchanged when a checkpoint cannot be fully restored. diff --git a/.changeset/console-headless-credentials.md b/.changeset/console-headless-credentials.md new file mode 100644 index 00000000000..6c6102f4674 --- /dev/null +++ b/.changeset/console-headless-credentials.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Include basic-auth credentials in the Local and Network Console URLs printed by `kilo console`, so users on headless hosts (no `DISPLAY`/`WAYLAND_DISPLAY`, SSH sessions, CI runners) can open the URL in a browser on another machine and reach the Console. \ No newline at end of file diff --git a/.changeset/fix-nix-bun-pin.md b/.changeset/fix-nix-bun-pin.md new file mode 100644 index 00000000000..5facef0667f --- /dev/null +++ b/.changeset/fix-nix-bun-pin.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep Nix builds on the Bun version required by the repository. diff --git a/.changeset/fix-scoped-mode-cycling.md b/.changeset/fix-scoped-mode-cycling.md new file mode 100644 index 00000000000..d71009746d3 --- /dev/null +++ b/.changeset/fix-scoped-mode-cycling.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs. diff --git a/.changeset/fix-vscode-settings-save.md b/.changeset/fix-vscode-settings-save.md new file mode 100644 index 00000000000..c7ed178ad9b --- /dev/null +++ b/.changeset/fix-vscode-settings-save.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix settings changes sometimes failing to save and apply in VS Code. diff --git a/.changeset/fuzzy-tildes-smile.md b/.changeset/fuzzy-tildes-smile.md new file mode 100644 index 00000000000..39c76209f86 --- /dev/null +++ b/.changeset/fuzzy-tildes-smile.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve parenthesized tilde expressions as literal text in rendered chat messages. diff --git a/.changeset/ingest-shutdown-flush.md b/.changeset/ingest-shutdown-flush.md new file mode 100644 index 00000000000..94ff86a5b1c --- /dev/null +++ b/.changeset/ingest-shutdown-flush.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix session transcripts losing their final messages when the CLI exits — pending uploads are now flushed on shutdown and as soon as a session closes. diff --git a/.changeset/instant-prompt-tooltips.md b/.changeset/instant-prompt-tooltips.md new file mode 100644 index 00000000000..d34f46dc044 --- /dev/null +++ b/.changeset/instant-prompt-tooltips.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show prompt input toggle tooltips instantly on hover instead of after a delay. diff --git a/.changeset/jetbrains-bundled-cli.md b/.changeset/jetbrains-bundled-cli.md new file mode 100644 index 00000000000..d0239ff1765 --- /dev/null +++ b/.changeset/jetbrains-bundled-cli.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation. diff --git a/.changeset/jetbrains-queued-prompts.md b/.changeset/jetbrains-queued-prompts.md new file mode 100644 index 00000000000..b22d8c8353c --- /dev/null +++ b/.changeset/jetbrains-queued-prompts.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Allow sending prompts while a session is busy and show queued prompts with a remove action. diff --git a/.changeset/opencode-v1-17-5-to-v1-17-9.md b/.changeset/opencode-v1-17-5-to-v1-17-9.md new file mode 100644 index 00000000000..5a019637c3e --- /dev/null +++ b/.changeset/opencode-v1-17-5-to-v1-17-9.md @@ -0,0 +1,30 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Changes from opencode v1.17.5 to v1.17.9 upstream: + +- Core Bugfixes: Improved MCP server compatibility by declaring Kilo's supported client capabilities. +- Core Bugfixes: Plugin client requests now reuse the active server instead of assuming the default local port. +- Core Bugfixes: ACP shell tool calls now show the command and working directory from the start. +- Core Bugfixes: Plugin-provided shell environment variables now apply to PTY sessions. +- Core Bugfixes: OpenAI-compatible providers now accept MCP tool schemas that previously failed validation. (@jquense) +- Core Bugfixes: Cloudflare AI Gateway now receives the configured API key correctly. (@keefetang) +- Core Bugfixes: MCP tools without declared schema properties now work with providers that expect object properties. +- Core Bugfixes: Long-running MCP tools now keep their timeout alive when they report progress. (@Nomadcxx) +- Core Bugfixes: The MCP OAuth callback server now shuts down once authorization finishes or is cancelled. +- Core Bugfixes: MCP tool failures now surface the server's error text instead of a generic failure. +- Core Bugfixes: MCP OAuth error pages now escape provider error text correctly. +- Core Bugfixes: Honor configured agent step limits by forcing a final text response instead of failing mid-run. +- Core Bugfixes: Queue steering prompts before dismissing pending questions so the previous turn cannot resume first. +- Core Bugfixes: Prevent local server credentials from leaking into spawned PTY processes. +- Core Bugfixes: Fix Devstral model detection when provider IDs use different casing. (@Robin1987China) +- Core Bugfixes: Pass configured custom headers to Copilot model requests. +- Core Improvements: MCP servers can now receive the current workspace as a client root. +- Core Improvements: Session timelines load much faster and avoid flicker or scroll jumps. +- Core Improvements: Add `high` and `max` thinking variants for GLM-5.2 across supported providers. (@imranshaiedi-byte) +- Core Improvements: Stop wrapping follow-up user messages in a steering reminder so prompt caching stays effective. +- TUI Bugfixes: MCP debug now uses the SDK's latest protocol version. +- TUI Bugfixes: Only show the background subagent shortcut when the server supports it. +- UI Bugfixes: Render completed Mermaid blocks from diagram source instead of fenced Markdown. diff --git a/.changeset/pwsh-permission-fail-closed.md b/.changeset/pwsh-permission-fail-closed.md new file mode 100644 index 00000000000..cb140f33cdf --- /dev/null +++ b/.changeset/pwsh-permission-fail-closed.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix bash permission rules being bypassed on PowerShell for commands containing a bare `--` such as `git checkout -- `. Commands the shell parser cannot parse now get checked against their raw command text instead of executing without a permission check. diff --git a/.changeset/quiet-json-events.md b/.changeset/quiet-json-events.md new file mode 100644 index 00000000000..bc7f513506e --- /dev/null +++ b/.changeset/quiet-json-events.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Emit each agent event once from `kilo run --format json`. diff --git a/.changeset/quiet-vscode-watchers.md b/.changeset/quiet-vscode-watchers.md new file mode 100644 index 00000000000..0f44b7cfda8 --- /dev/null +++ b/.changeset/quiet-vscode-watchers.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Prevent the VS Code backend from eagerly starting native file watchers for every Agent Manager worktree. diff --git a/.changeset/reliable-vscode-message-copy.md b/.changeset/reliable-vscode-message-copy.md new file mode 100644 index 00000000000..566918cf939 --- /dev/null +++ b/.changeset/reliable-vscode-message-copy.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep message and response copy buttons working after switching focus away from VS Code. diff --git a/.changeset/safe-windows-snapshot-diffs.md b/.changeset/safe-windows-snapshot-diffs.md new file mode 100644 index 00000000000..55dd9e61bc6 --- /dev/null +++ b/.changeset/safe-windows-snapshot-diffs.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Keep Windows snapshot diffs parseable and preserve valid files when a stored patch is malformed. diff --git a/.changeset/stalled-provider-first-byte.md b/.changeset/stalled-provider-first-byte.md new file mode 100644 index 00000000000..2d11921fd62 --- /dev/null +++ b/.changeset/stalled-provider-first-byte.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Bound the wait for a provider's first response byte by the request timeout. A provider that accepts a request and returns headers but never sends body data now fails and retries instead of leaving the turn hanging after a tool call completes. The same `timeout` value now covers both the connection phase and the wait for the first byte as a single deadline; streaming responses that have already produced data are unaffected. diff --git a/.changeset/steady-cli-subprocess-tests.md b/.changeset/steady-cli-subprocess-tests.md new file mode 100644 index 00000000000..e95a9d4c734 --- /dev/null +++ b/.changeset/steady-cli-subprocess-tests.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Stabilize cross-platform CLI subprocess tests under constrained CI runners diff --git a/.changeset/steady-editor-tabs.md b/.changeset/steady-editor-tabs.md new file mode 100644 index 00000000000..84e5f2b0b56 --- /dev/null +++ b/.changeset/steady-editor-tabs.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Open Kilo chats, settings, and files as tabs in the selected editor pane without creating, locking, or resizing editor panes. diff --git a/.changeset/tidy-am-i18n-keys.md b/.changeset/tidy-am-i18n-keys.md new file mode 100644 index 00000000000..85c3ca15810 --- /dev/null +++ b/.changeset/tidy-am-i18n-keys.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Remove unused translation keys from the Agent Manager, sidebar webview, shared kilo-i18n, and autocomplete dictionaries across all locales, and add a conservative lint test for unreferenced, unprotected dictionary keys. diff --git a/.changeset/tui-variant-shortcut-hint.md b/.changeset/tui-variant-shortcut-hint.md new file mode 100644 index 00000000000..8891876bdb9 --- /dev/null +++ b/.changeset/tui-variant-shortcut-hint.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show the `Ctrl+T` variant cycling shortcut in the TUI prompt hint row whenever the active model exposes reasoning variants, as the first hint before the agent and command palette hints diff --git a/.github/docs-sync/edit.mjs b/.github/docs-sync/edit.mjs index 52943de31ab..777c3a2929c 100644 --- a/.github/docs-sync/edit.mjs +++ b/.github/docs-sync/edit.mjs @@ -6,20 +6,22 @@ * Batching bounds each `kilo run` context (a replay window can yield dozens * of docs-worthy PRs with large diffs). Each batch gets its own CLI session * and writes its own summary file; results are merged into - * docs-sync-out/edit-summary.json. A batch that fails is skipped with a - * warning — its PRs show up in the rolling PR body as skipped, so nothing - * fails silently. + * docs-sync-out/edit-summary.json. A batch that fails or is deferred by the + * wall-clock budget is recorded as action "pending" so the watermark holds + * back and the next run re-collects those PRs. * - * Env: EDIT_MODEL (provider/model), KILO_API_KEY (set by workflow; read natively by the kilo provider). + * Env: EDIT_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (set by workflow; read natively by the kilo provider). + * Budgets: EDIT_BUDGET_MINUTES (default 50), EDIT_BATCH_TIMEOUT_MINUTES (default 15). + * Test hook: DOCS_SYNC_BACKOFF_MS replaces every retry wait when set. */ -import { execFileSync } from "node:child_process" import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" +import { backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs" const BATCH_SIZE = 5 -const ATTEMPTS = 2 +const ATTEMPTS = 3 const OUT_DIR = "docs-sync-out" export const SUMMARY_FILE = ".docs-sync-summary.json" @@ -28,6 +30,10 @@ const basePrompt = fs.readFileSync(path.join(HERE, "edit-prompt.md"), "utf8") const model = process.env.EDIT_MODEL if (!model) throw new Error("EDIT_MODEL is required") +const EDIT_BUDGET_MINUTES = Number(process.env.EDIT_BUDGET_MINUTES) || 50 +const EDIT_BATCH_TIMEOUT_MINUTES = Number(process.env.EDIT_BATCH_TIMEOUT_MINUTES) || 15 +const BATCH_TIMEOUT_MS = EDIT_BATCH_TIMEOUT_MINUTES * 60 * 1000 + const worthy = JSON.parse(fs.readFileSync(`${OUT_DIR}/worthy.json`, "utf8")) const triage = JSON.parse(fs.readFileSync(`${OUT_DIR}/triage.json`, "utf8")) const priority = new Map(triage.map((e) => [e.url, e])) @@ -36,7 +42,18 @@ const ordered = [...worthy].sort((a, b) => { return (rank[priority.get(a.url)?.priority] ?? 1) - (rank[priority.get(b.url)?.priority] ?? 1) }) -function editBatch(batch, index) { +/** @type {Map} url → pending cause for failed/deferred batches */ +const pendingCauses = new Map() + +function formatCause(result) { + const bits = [] + if (result.timedOut) bits.push("timed out") + if (result.exitCode !== null && result.exitCode !== undefined) bits.push(`exit ${result.exitCode}`) + if (result.stderrTail) bits.push(result.stderrTail.replaceAll("\n", " ").slice(0, 200)) + return bits.join("; ") || "no diagnostic" +} + +function editBatch(batch, index, budgetDeadline) { const batchFile = `${OUT_DIR}/edit-batch-${index}.json` const triageFile = `${OUT_DIR}/edit-batch-triage-${index}.json` const summaryFile = `${OUT_DIR}/edit-summary-${index}.json` @@ -54,31 +71,59 @@ function editBatch(batch, index) { Batch specifics for this run: the PRs to handle are in the attached ${batchFile} (full details) and ${triageFile} (triage verdicts). Handle ONLY the PRs in these batch files. When finished, write your per-PR results in the summary JSON format described above to the file \`${summaryFile}\` (path relative to the repository root).` + let lastCause = "edit pass failed" for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { - try { - // Message positional first: --file is multi-value and would otherwise - // consume a trailing message as a file path ("File not found"). - execFileSync( - "kilo", - ["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile], - // stdout streams live to the Actions log; stderr is piped so failure - // warnings can include the tail of the actual CLI error. - { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 25 * 60 * 1000, stdio: ["ignore", "inherit", "pipe"] }, + const left = remainingMs(budgetDeadline) + if (left < BATCH_TIMEOUT_MS) { + lastCause = `edit budget exhausted before batch ${index} attempt ${attempt} (${Math.ceil(left / 1000)}s left, need ${EDIT_BATCH_TIMEOUT_MINUTES}m)` + console.warn( + `batch ${index}: stopping retries — remaining budget cannot fit another ${EDIT_BATCH_TIMEOUT_MINUTES}m attempt`, ) - if (fs.existsSync(summaryFile)) return true - // Tolerate the agent dropping the docs-sync-out/ prefix. - const alt = path.basename(summaryFile) - if (fs.existsSync(alt)) { - fs.renameSync(alt, summaryFile) - return true + break + } + + const result = runKilo({ + args: ["run", 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}`, + }) + + if (fs.existsSync(summaryFile)) return true + // Tolerate the agent dropping the docs-sync-out/ prefix. + const alt = path.basename(summaryFile) + if (fs.existsSync(alt)) { + fs.renameSync(alt, summaryFile) + return true + } + + // Exit 0 is not success: missing summary is a failure logged WITH the + // captured stderrTail and exit code on every attempt. + const cause = formatCause(result) + lastCause = `edit batch ${index}: ${cause}` + console.warn( + `batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced` + + ` (exit ${result.exitCode}${result.timedOut ? ", timed out" : ""})` + + (result.stderrTail ? `\nstderr tail:\n${result.stderrTail}` : "\nstderr tail: (empty)"), + ) + + if (attempt < ATTEMPTS) { + const wait = backoffMsForAttempt(attempt) + // Skip the wait when the remaining budget cannot fit another attempt. + const afterWait = remainingMs(budgetDeadline) - wait + if (wait > 0 && afterWait >= BATCH_TIMEOUT_MS) { + console.warn(`batch ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`) + sleepSync(wait) + } else if (wait > 0) { + console.warn( + `batch ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`, + ) } - console.warn(`batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced`) - } catch (err) { - const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n") - console.warn(`batch ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`) } } - console.warn(`::warning::edit batch ${index} failed after ${ATTEMPTS} attempts; ${batch.length} PRs skipped`) + + console.warn(`::warning::edit batch ${index} failed after up to ${ATTEMPTS} attempts; ${batch.length} PRs pending`) + for (const d of batch) pendingCauses.set(d.url, lastCause) return false } @@ -88,12 +133,34 @@ for (let i = 0; i < ordered.length; i += BATCH_SIZE) { } console.log(`editing docs for ${ordered.length} PRs in ${batches.length} batches of up to ${BATCH_SIZE}`) +const budgetDeadline = deadline(EDIT_BUDGET_MINUTES) +let deferredFrom = -1 + for (let i = 0; i < batches.length; i++) { - editBatch(batches[i], i) + const left = remainingMs(budgetDeadline) + if (left < BATCH_TIMEOUT_MS) { + deferredFrom = i + const deferredPrs = batches.slice(i).reduce((n, b) => n + b.length, 0) + console.warn( + `stopping edit pass before batch ${i}: remaining budget (${Math.ceil(left / 1000)}s) cannot fit a ${EDIT_BATCH_TIMEOUT_MINUTES}m batch; deferring ${deferredPrs} PRs`, + ) + const cause = `edit budget exhausted before batch ${i} (${Math.ceil(left / 1000)}s left)` + for (let j = i; j < batches.length; j++) { + for (const d of batches[j]) pendingCauses.set(d.url, cause) + } + break + } + editBatch(batches[i], i, budgetDeadline) +} + +if (deferredFrom >= 0) { + console.warn( + `edit pass deferred ${batches.slice(deferredFrom).reduce((n, b) => n + b.length, 0)} PRs due to wall-clock budget`, + ) } // Merge batch summaries. Coverage: every worthy PR gets an entry so the PR -// body accounts for it; failed batches show up as skipped. +// body accounts for it; failed/deferred batches show up as pending (not skipped). const merged = [] const seen = new Set() for (let i = 0; i < batches.length; i++) { @@ -108,15 +175,24 @@ for (let i = 0; i < batches.length; i++) { const url = String(e?.url ?? "") if (!url.startsWith("http") || seen.has(url)) continue seen.add(url) - merged.push({ pr: Number(e.pr) || 0, url, action: String(e.action ?? "skipped"), reason: String(e.reason ?? "") }) + merged.push({ + pr: Number(e.pr) || 0, + url, + action: String(e.action ?? "skipped"), + reason: String(e.reason ?? ""), + }) } } for (const d of ordered) { if (seen.has(d.url)) continue - merged.push({ pr: d.number, url: d.url, action: "skipped", reason: "edit pass failed or timed out for this PR" }) + const cause = pendingCauses.get(d.url) || "edit pass failed or timed out for this PR" + merged.push({ pr: d.number, url: d.url, action: "pending", reason: cause }) } // upsert-pr.mjs consumes the merged summary from the repo root; the file is // removed there before committing so it never lands in the docs PR. fs.writeFileSync(SUMMARY_FILE, JSON.stringify(merged, null, 2)) -console.log(`edit pass complete: ${merged.filter((e) => e.action !== "skipped").length} changed, ${merged.filter((e) => e.action === "skipped").length} skipped`) +const changed = merged.filter((e) => e.action !== "skipped" && e.action !== "pending").length +const skipped = merged.filter((e) => e.action === "skipped").length +const pending = merged.filter((e) => e.action === "pending").length +console.log(`edit pass complete: ${changed} changed, ${skipped} skipped, ${pending} pending`) diff --git a/.github/docs-sync/lib.mjs b/.github/docs-sync/lib.mjs index 56dfb7b9226..67c13354d88 100644 --- a/.github/docs-sync/lib.mjs +++ b/.github/docs-sync/lib.mjs @@ -6,6 +6,7 @@ * gh CLI. */ +import { spawnSync } from "node:child_process" import fs from "node:fs" const API = "https://api.github.com" @@ -111,3 +112,97 @@ export function appendSummary(markdown) { const summary = process.env.GITHUB_STEP_SUMMARY if (summary) fs.appendFileSync(summary, markdown + "\n") } + +/** + * Absolute deadline timestamp (ms since epoch) for a wall-clock budget. + * Used by triage/edit to stop before the job timeout rather than silently + * truncating. + */ +export function deadline(minutes) { + return Date.now() + Number(minutes) * 60 * 1000 +} + +/** Remaining milliseconds until a deadline; never negative. */ +export function remainingMs(deadlineMs) { + return Math.max(0, Number(deadlineMs) - Date.now()) +} + +/** + * Backoff schedule between kilo-run attempts. Production waits 60s then 300s + * (observed outage lasted ~11 min; batch 8 recovered on attempt 2). When + * DOCS_SYNC_BACKOFF_MS is set it replaces EVERY wait (`0` disables waiting); + * the workflow never sets it — only selftests do. + */ +export function backoffMsForAttempt(attempt) { + // attempt is 1-based; wait happens after attempt N before attempt N+1. + const override = process.env.DOCS_SYNC_BACKOFF_MS + if (override !== undefined && override !== "") { + const n = Number(override) + return Number.isFinite(n) && n >= 0 ? n : 0 + } + // After attempt 1 → 60s; after attempt 2 → 300s; nothing after the last. + if (attempt === 1) return 60_000 + if (attempt === 2) return 300_000 + return 0 +} + +/** + * Blocking sleep used between kilo-run retries. Prefer this over async sleep + * so edit/triage stay synchronous around spawnSync. + */ +export function sleepSync(ms) { + const n = Number(ms) + if (!Number.isFinite(n) || n <= 0) return + const end = Date.now() + n + // Atomics.wait is the portable Node sync sleep (no busy loop). + const sab = new SharedArrayBuffer(4) + const view = new Int32Array(sab) + while (Date.now() < end) { + const left = end - Date.now() + if (left <= 0) break + Atomics.wait(view, 0, 0, Math.min(left, 2_147_483_647)) + } +} + +const STDERR_TAIL_LINES = 20 +const STDERR_TAIL_CHARS = 4_000 + +function tailText(text, { lines = STDERR_TAIL_LINES, chars = STDERR_TAIL_CHARS } = {}) { + const s = String(text ?? "").trim() + if (!s) return "" + const lastLines = s.split("\n").slice(-lines).join("\n") + return lastLines.length > chars ? lastLines.slice(-chars) : lastLines +} + +/** + * Run `kilo` via spawnSync so stderr is always recoverable — including when + * the child exits 0 after writing a diagnostic (execFileSync cannot return + * piped stderr on exit 0; that path lost every diagnostic on run 30122603016). + * + * streamStdout:true → inherit fd 1 (edit live log); false → capture stdout + * (triage parses it). stderr is always buffered. + */ +export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo" }) { + const result = spawnSync("kilo", args, { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + timeout: timeoutMs, + stdio: ["ignore", streamStdout ? "inherit" : "pipe", "pipe"], + }) + + 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 ?? "") + // 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 + + if (result.error && !timedOut) { + console.warn(`${label}: spawn error: ${result.error.message}`) + } + + return { ok, stdout, stderrTail, exitCode, timedOut } +} diff --git a/.github/docs-sync/prepare-branch.mjs b/.github/docs-sync/prepare-branch.mjs index 2b57932c442..2710832ccf3 100644 --- a/.github/docs-sync/prepare-branch.mjs +++ b/.github/docs-sync/prepare-branch.mjs @@ -6,56 +6,100 @@ * origin/main (preserves any human commits on the branch) * - otherwise -> fresh branch from origin/main (bot force-pushes later) * - * Outputs: branch, mode (update|fresh), pr_number (empty when fresh). + * Outputs: branch, mode (update|fresh|conflict), pr_number (empty when fresh). */ import { execFileSync } from "node:child_process" +import { pathToFileURL } from "node:url" import { api, appendOutput, repo, searchIssues } from "./lib.mjs" export const DEFAULT_BRANCH = "docs/auto-sync" -const git = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim() +const defaultGit = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim() -const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 }) - -let mode = "fresh" -let prNumber = "" -let branch = DEFAULT_BRANCH - -if (prs.length > 0) { - const pr = await api(`/repos/${repo()}/pulls/${prs[0].number}`) - branch = pr.head?.ref ?? DEFAULT_BRANCH - prNumber = String(pr.number) - git(["fetch", "origin", "main", branch]) - git(["checkout", branch]) +/** + * Merge origin/main into the current branch. On a genuine conflict, abort the + * merge, switch to a dated fallback branch from origin/main, and return + * mode=conflict so human commits on the rolling branch stay untouched. Any + * other merge failure (missing identity, corrupt ref, fetch issues) is + * rethrown so the job fails loudly. + */ +export function mergeOrFallback({ branch, git = defaultGit }) { try { git(["merge", "origin/main", "--no-edit"]) - mode = "update" - } catch { + return { branch, mode: "update" } + } catch (err) { + // Conflict ⇔ unmerged index entries (or MERGE_HEAD still present). + // Identity failures and similar abort before a merge is started, so + // merge --abort would itself fail — those must rethrow. + let unmerged = "" + try { + unmerged = git(["ls-files", "--unmerged"]) + } catch { + // ls-files itself failing is not a conflict signal + } + let mergeInProgress = false + try { + git(["rev-parse", "-q", "--verify", "MERGE_HEAD"]) + mergeInProgress = true + } catch { + mergeInProgress = false + } + const isConflict = unmerged.length > 0 || mergeInProgress + if (!isConflict) throw err + console.warn(`merge of origin/main into ${branch} conflicted.`) - console.warn("Leaving the conflicted branch untouched so human commits are preserved; continuing on a fresh dated branch.") + console.warn( + "Leaving the conflicted branch untouched so human commits are preserved; continuing on a fresh dated branch.", + ) git(["merge", "--abort"]) - branch = `${DEFAULT_BRANCH}-${new Date().toISOString().slice(0, 10)}` + const fallback = `${DEFAULT_BRANCH}-${new Date().toISOString().slice(0, 10)}` + try { + git(["fetch", "origin", `+refs/heads/${fallback}:refs/remotes/origin/${fallback}`]) + } catch { + console.log(`dated branch ${fallback} does not exist on origin yet; will create it on push`) + } + git(["checkout", "-B", fallback, "origin/main"]) + return { branch: fallback, mode: "conflict" } + } +} + +async function main() { + const git = defaultGit + const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 }) + + let mode = "fresh" + let prNumber = "" + let branch = DEFAULT_BRANCH + + if (prs.length > 0) { + const pr = await api(`/repos/${repo()}/pulls/${prs[0].number}`) + branch = pr.head?.ref ?? DEFAULT_BRANCH + prNumber = String(pr.number) + git(["fetch", "origin", "main", branch]) + git(["checkout", branch]) + ;({ branch, mode } = mergeOrFallback({ branch, git })) + } else { + // Keep the remote-tracking ref current so the later --force-with-lease + // push (stale branch left over from a merged/closed PR) is safe. try { git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]) } catch { - console.log(`dated branch ${branch} does not exist on origin yet; will create it on push`) + console.log(`branch ${branch} does not exist on origin yet; will create it on push`) } git(["checkout", "-B", branch, "origin/main"]) - mode = "conflict" } -} else { - // Keep the remote-tracking ref current so the later --force-with-lease - // push (stale branch left over from a merged/closed PR) is safe. - try { - git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]) - } catch { - console.log(`branch ${branch} does not exist on origin yet; will create it on push`) - } - git(["checkout", "-B", branch, "origin/main"]) + + appendOutput("branch", branch) + appendOutput("mode", mode) + appendOutput("pr_number", prNumber) + console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`) } -appendOutput("branch", branch) -appendOutput("mode", mode) -appendOutput("pr_number", prNumber) -console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`) +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (isMain) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/.github/docs-sync/selftest.mjs b/.github/docs-sync/selftest.mjs new file mode 100644 index 00000000000..fe2cb1faec3 --- /dev/null +++ b/.github/docs-sync/selftest.mjs @@ -0,0 +1,964 @@ +// kilocode_change - new file + +/** + * Offline self-check for the docs-sync failure paths (S4). + * Plain node:assert, no network, no LLM, no new dependency. + * Run: node .github/docs-sync/selftest.mjs + */ + +import assert from "node:assert/strict" +import { execFileSync, spawnSync } from "node:child_process" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" + +import { mergeOrFallback, DEFAULT_BRANCH } from "./prepare-branch.mjs" +import { applyCap } from "./watermark.mjs" +import { + computeUncovered, + computeProcessedThrough, + routeRows, + dropLegacySkipped, + noDiffReport, + renderBody, + extractSectionRows, +} from "./upsert-pr.mjs" + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const EDIT_SCRIPT = path.join(HERE, "edit.mjs") +const TRIAGE_SCRIPT = path.join(HERE, "triage.mjs") +const COLLECT_SCRIPT = path.join(HERE, "collect.mjs") + +const temps = [] + +function mktemp(prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)) + temps.push(dir) + return dir +} + +function cleanup() { + for (const dir of temps.splice(0)) { + try { + fs.rmSync(dir, { recursive: true, force: true }) + } catch { + // best-effort + } + } +} + +function writeExecutable(filePath, body) { + fs.writeFileSync(filePath, body, { mode: 0o755 }) +} + +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" + const script = `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const mode = ${JSON.stringify(mode)}; +const callLog = ${JSON.stringify(callLog ?? "")}; +const stderrText = ${JSON.stringify(stderrText)}; +if (callLog) { + fs.appendFileSync(callLog, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd() }) + "\\n"); +} +if (mode === "stderr-exit0") { + process.stderr.write(stderrText + "\\n"); + process.exit(0); +} +if (mode === "record") { + process.stderr.write("recorded\\n"); + process.exit(0); +} +// Parse -f chunk/batch file from args for triage stubs +const args = process.argv.slice(2); +const fIdx = args.indexOf("-f"); +const fileArg = fIdx >= 0 ? args[fIdx + 1] : null; +let chunk = []; +if (fileArg && fs.existsSync(fileArg)) { + try { chunk = JSON.parse(fs.readFileSync(fileArg, "utf8")); } catch { chunk = []; } +} +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)); + const entries = owned.map((d) => ({ + pr: d.number, + url: d.url, + docs_worthy: false, + reason: "genuine not worthy", + target_sections: [], + priority: "medium", + })); + if (entries.length === 0 && chunk.length > 0) { + // single-PR chunk: still leave one missing by emitting empty-ish foreign-only + process.stdout.write("[]\\n"); + } else { + process.stdout.write(JSON.stringify(entries) + "\\n"); + } + process.exit(0); +} +if (mode === "mixed-triage") { + // Half docs_worthy true, half fail (no output for second half — but we return + // only some entries so backfill marks the rest pending). Actually: return + // docs_worthy:true for first half of chunk URLs so worthy > 0. + const half = Math.ceil(chunk.length / 2); + const entries = chunk.slice(0, half).map((d) => ({ + pr: d.number, + url: d.url, + docs_worthy: true, + reason: "needs docs", + 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); +` + writeExecutable(kiloPath, script) + return dir +} + +function gitIn(cwd, args, env = {}) { + return execFileSync("git", args, { + cwd, + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }).toString().trim() +} + +function makeGitRunner(cwd, env = {}) { + return (args) => gitIn(cwd, args, env) +} + +function initRepoWithIdentity(dir) { + gitIn(dir, ["init", "-b", "main"]) + gitIn(dir, ["config", "user.name", "docs-sync-selftest"]) + gitIn(dir, ["config", "user.email", "docs-sync-selftest@example.com"]) + gitIn(dir, ["config", "commit.gpgsign", "false"]) +} + +// --------------------------------------------------------------------------- +// Case 1 — Defect A: mergeOrFallback +// --------------------------------------------------------------------------- +function case1_mergeOrFallback() { + console.log("case 1: Defect A (mergeOrFallback)") + + // 1a — identity configured + clean merge → mode=update + { + const dir = mktemp("docs-sync-merge-clean-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "a.txt"), "base\n") + gitIn(dir, ["add", "a.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH]) + fs.writeFileSync(path.join(dir, "b.txt"), "on branch\n") + gitIn(dir, ["add", "b.txt"]) + gitIn(dir, ["commit", "-m", "branch commit"]) + // Advance main without conflict + gitIn(dir, ["checkout", "main"]) + fs.writeFileSync(path.join(dir, "c.txt"), "on main\n") + gitIn(dir, ["add", "c.txt"]) + gitIn(dir, ["commit", "-m", "main advance"]) + gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"]) + gitIn(dir, ["checkout", DEFAULT_BRANCH]) + + const result = mergeOrFallback({ branch: DEFAULT_BRANCH, git: makeGitRunner(dir) }) + assert.equal(result.mode, "update") + assert.equal(result.branch, DEFAULT_BRANCH) + // merge brought c.txt in + assert.ok(fs.existsSync(path.join(dir, "c.txt"))) + } + + // 1b — genuine conflict → mode=conflict, abort succeeds, original branch untouched + { + const dir = mktemp("docs-sync-merge-conflict-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "conflict.txt"), "base\n") + gitIn(dir, ["add", "conflict.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + const baseSha = gitIn(dir, ["rev-parse", "HEAD"]) + + gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH]) + fs.writeFileSync(path.join(dir, "conflict.txt"), "branch side\n") + gitIn(dir, ["add", "conflict.txt"]) + gitIn(dir, ["commit", "-m", "branch edit"]) + const branchShaBefore = gitIn(dir, ["rev-parse", "HEAD"]) + + gitIn(dir, ["checkout", "main"]) + fs.writeFileSync(path.join(dir, "conflict.txt"), "main side\n") + gitIn(dir, ["add", "conflict.txt"]) + gitIn(dir, ["commit", "-m", "main edit"]) + gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"]) + gitIn(dir, ["checkout", DEFAULT_BRANCH]) + + const result = mergeOrFallback({ branch: DEFAULT_BRANCH, git: makeGitRunner(dir) }) + assert.equal(result.mode, "conflict") + assert.ok(result.branch.startsWith(`${DEFAULT_BRANCH}-`)) + // Original rolling branch tip unchanged + const branchShaAfter = gitIn(dir, ["rev-parse", DEFAULT_BRANCH]) + assert.equal(branchShaAfter, branchShaBefore) + // No merge in progress + let mergeHead = true + try { + gitIn(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]) + } catch { + mergeHead = false + } + assert.equal(mergeHead, false) + void baseSha + } + + // 1c — identity-less / non-conflict merge failure → throws (does not fake conflict) + { + const dir = mktemp("docs-sync-merge-noid-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "a.txt"), "base\n") + gitIn(dir, ["add", "a.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH]) + fs.writeFileSync(path.join(dir, "b.txt"), "branch\n") + gitIn(dir, ["add", "b.txt"]) + gitIn(dir, ["commit", "-m", "branch"]) + gitIn(dir, ["checkout", "main"]) + fs.writeFileSync(path.join(dir, "c.txt"), "main\n") + gitIn(dir, ["add", "c.txt"]) + gitIn(dir, ["commit", "-m", "main"]) + gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"]) + gitIn(dir, ["checkout", DEFAULT_BRANCH]) + + // Strip identity so merge cannot create a commit + gitIn(dir, ["config", "--unset", "user.name"]) + gitIn(dir, ["config", "--unset", "user.email"]) + + const env = { + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + } + const git = (args) => + execFileSync("git", ["-c", "user.useConfigOnly=true", ...args], { + cwd: dir, + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }).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 + }) + } +} + +// --------------------------------------------------------------------------- +// Helpers to run edit.mjs / triage.mjs as child processes +// --------------------------------------------------------------------------- +function setupEditCwd(worthy, triage) { + const cwd = mktemp("docs-sync-edit-") + fs.mkdirSync(path.join(cwd, "docs-sync-out"), { recursive: true }) + fs.writeFileSync(path.join(cwd, "docs-sync-out", "worthy.json"), JSON.stringify(worthy, null, 2)) + fs.writeFileSync(path.join(cwd, "docs-sync-out", "triage.json"), JSON.stringify(triage, null, 2)) + return cwd +} + +function setupTriageCwd(digest) { + const cwd = mktemp("docs-sync-triage-") + fs.mkdirSync(path.join(cwd, "docs-sync-out"), { recursive: true }) + fs.writeFileSync(path.join(cwd, "docs-sync-out", "digest.json"), JSON.stringify(digest, null, 2)) + return cwd +} + +function runNodeScript(scriptPath, { cwd, env = {}, kiloDir }) { + const pathEnv = [kiloDir, process.env.PATH].filter(Boolean).join(path.delimiter) + const result = spawnSync(process.execPath, [scriptPath], { + cwd, + env: { + ...process.env, + ...env, + PATH: pathEnv, + DOCS_SYNC_BACKOFF_MS: env.DOCS_SYNC_BACKOFF_MS ?? "0", + }, + encoding: "utf8", + timeout: 60_000, + }) + return { + status: result.status, + stdout: String(result.stdout ?? ""), + stderr: String(result.stderr ?? ""), + output: `${result.stdout ?? ""}${result.stderr ?? ""}`, + error: result.error, + } +} + +function samplePr(n, { merged_at, repo = "Kilo-Org/cloud" } = {}) { + return { + repo, + number: n, + title: `feat: sample ${n}`, + url: `https://github.com/${repo}/pull/${n}`, + author: "dev", + merged_at: merged_at ?? "2026-07-20T12:00:00.000Z", + labels: [], + body: "body", + files: [], + files_total: 1, + patch_excerpt: "", + } +} + +// --------------------------------------------------------------------------- +// Case 2 — Defect B: edit.mjs with stub kilo (exit 0 + stderr) +// --------------------------------------------------------------------------- +function case2_defectB() { + console.log("case 2: Defect B (edit.mjs stderr-on-exit-0)") + + 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 stderrText = "event stream disconnected DIAG-CASE2" + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText }) + + const started = Date.now() + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + // Enough budget for 3 attempts × tiny timeout + EDIT_BUDGET_MINUTES: "5", + EDIT_BATCH_TIMEOUT_MINUTES: "1", + }, + }) + const elapsed = Date.now() - started + + assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`) + // Backoff collapsed — 3 attempts without 60s+300s waits + assert.ok(elapsed < 15_000, `backoff should collapse with DOCS_SYNC_BACKOFF_MS=0; elapsed=${elapsed}ms`) + + assert.match(result.output, /stderr tail:/) + assert.match(result.output, /DIAG-CASE2|event stream disconnected/) + assert.match(result.output, /attempt 1/) + assert.match(result.output, /attempt 2/) + // 3 attempts + assert.match(result.output, /attempt 3|failed after up to 3 attempts/) + + 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)}`) + } + + const uncovered = computeUncovered({ worthy, summary, triage }) + assert.equal(uncovered.length, 5) + for (const u of uncovered) { + assert.ok(u.reason, "uncovered reason present") + } +} + +// --------------------------------------------------------------------------- +// Case 3 — watermark invariant +// --------------------------------------------------------------------------- +function case3_watermark() { + console.log("case 3: watermark invariant") + + const now = "2026-07-27T12:00:00.000Z" + const nowMs = Date.parse(now) + + const prA = samplePr(10, { merged_at: "2026-07-20T10:00:00.000Z" }) + const prB = samplePr(11, { merged_at: "2026-07-22T15:30:00.000Z" }) + const prC = samplePr(12, { merged_at: "2026-07-25T08:00:00.000Z" }) + const digest = [prA, prB, prC] + + // all covered → processed-through === now + { + const worthy = [prA, prB] + const summary = [ + { pr: 10, url: prA.url, action: "updated packages/kilo-docs/pages/x.md", reason: "" }, + { pr: 11, url: prB.url, action: "skipped", reason: "already documented" }, + ] + const triage = [ + { pr: 10, url: prA.url, docs_worthy: true, pending: false, reason: "ok" }, + { pr: 11, url: prB.url, docs_worthy: true, pending: false, reason: "ok" }, + ] + const uncovered = computeUncovered({ worthy, summary, triage }) + assert.equal(uncovered.length, 0) + const through = computeProcessedThrough({ uncovered, digest, now }) + assert.equal(through, now) + } + + // one uncovered → merged_at − 1 ms, strictly < now + { + const worthy = [prA, prB] + const summary = [ + { pr: 10, url: prA.url, action: "updated x", reason: "" }, + { pr: 11, url: prB.url, action: "pending", reason: "edit batch 0: exit 0" }, + ] + const uncovered = computeUncovered({ worthy, summary, triage: [] }) + assert.equal(uncovered.length, 1) + assert.equal(uncovered[0].url, prB.url) + const through = computeProcessedThrough({ uncovered, digest, now }) + const expected = new Date(Date.parse(prB.merged_at) - 1).toISOString() + assert.equal(through, expected) + assert.ok(Date.parse(through) < nowMs) + } + + // several uncovered → earliest merge time wins + { + const worthy = [prA, prB, prC] + const summary = [ + { pr: 10, url: prA.url, action: "pending", reason: "fail" }, + { pr: 12, url: prC.url, action: "pending", reason: "fail" }, + ] + // prB missing from summary entirely + const uncovered = computeUncovered({ worthy, summary, triage: [] }) + assert.ok(uncovered.length >= 2) + const through = computeProcessedThrough({ uncovered, digest, now }) + // earliest among A, B, C that are uncovered — A is earliest + const times = uncovered + .map((u) => digest.find((d) => d.url === u.url)?.merged_at) + .filter(Boolean) + .map((t) => Date.parse(t)) + const earliest = Math.min(...times) + assert.equal(through, new Date(earliest - 1).toISOString()) + } + + // summary missing/truncated while worthy non-empty → every worthy URL held back + { + const worthy = [prA, prB] + const uncovered = computeUncovered({ worthy, summary: [], triage: [] }) + assert.equal(uncovered.length, 2) + const through = computeProcessedThrough({ uncovered, digest, now }) + assert.equal(through, new Date(Date.parse(prA.merged_at) - 1).toISOString()) + } + + // noDiffReport three arms + { + const uncovered = [{ url: prA.url, reason: "edit batch failed" }] + const arm1 = noDiffReport({ uncovered, sinceOverride: true }) + assert.ok(arm1.summary.includes(prA.url)) + assert.ok(arm1.warning, "override + uncovered → warning present") + + const arm2 = noDiffReport({ uncovered: [], sinceOverride: true }) + assert.equal(arm2.warning, null, "override + empty uncovered → warning absent") + + const arm3 = noDiffReport({ uncovered, sinceOverride: false }) + assert.equal(arm3.warning, null, "scheduled + uncovered → warning absent") + } + + // triage pending:true backfill rows land in uncovered (consumption) + { + const triage = [ + { + pr: 99, + url: "https://github.com/Kilo-Org/cloud/pull/99", + docs_worthy: false, + pending: true, + reason: "not classified by triage", + }, + ] + const uncovered = computeUncovered({ worthy: [], summary: [], triage }) + assert.equal(uncovered.length, 1) + assert.equal(uncovered[0].url, triage[0].url) + const through = computeProcessedThrough({ + uncovered, + digest: [{ url: triage[0].url, merged_at: "2026-07-21T00:00:00.000Z" }], + now, + }) + assert.equal(through, new Date(Date.parse("2026-07-21T00:00:00.000Z") - 1).toISOString()) + } + + // fallback field (post-plan repair) + { + const uncovered = [{ url: "https://github.com/Kilo-Org/cloud/pull/50", reason: "missing" }] + const fallback = "2026-07-17T00:00:00.000Z" + // unresolved merged_at + parseable fallback → hold at fallback, warn + const prevWarn = console.warn + const warnings = [] + console.warn = (...a) => warnings.push(a.join(" ")) + try { + const through = computeProcessedThrough({ uncovered, digest: [], now, fallback }) + assert.equal(through, new Date(fallback).toISOString()) + assert.ok(Date.parse(through) < nowMs) + assert.ok(warnings.some((w) => w.includes("::warning::"))) + } finally { + console.warn = prevWarn + } + + // unresolved + unparseable/missing fallback → throws + assert.throws(() => computeProcessedThrough({ uncovered, digest: [], now }), /fallback|SINCE|refusing/i) + assert.throws( + () => computeProcessedThrough({ uncovered, digest: [], now, fallback: "not-a-date" }), + /fallback|SINCE|refusing/i, + ) + + // resolved merged_at ignores fallback + const throughResolved = computeProcessedThrough({ + uncovered: [{ url: prA.url, reason: "x" }], + digest: [prA], + now, + fallback: "2020-01-01T00:00:00.000Z", + }) + assert.equal(throughResolved, new Date(Date.parse(prA.merged_at) - 1).toISOString()) + + // empty uncovered ignores fallback + const throughEmpty = computeProcessedThrough({ + uncovered: [], + digest: [], + now, + fallback: "2020-01-01T00:00:00.000Z", + }) + assert.equal(throughEmpty, now) + } +} + +// --------------------------------------------------------------------------- +// Case 4 — routing and round trip +// --------------------------------------------------------------------------- +function case4_routing() { + console.log("case 4: routing and round trip") + + const summary = [ + { pr: 1, url: "https://github.com/Kilo-Org/cloud/pull/1", action: "updated pages/a.md", reason: "" }, + { pr: 2, url: "https://github.com/Kilo-Org/cloud/pull/2", action: "skipped", reason: "already documented" }, + { pr: 3, url: "https://github.com/Kilo-Org/cloud/pull/3", action: "pending", reason: "edit batch 1: exit 0" }, + ] + const triage = [ + { + pr: 4, + url: "https://github.com/Kilo-Org/cloud/pull/4", + docs_worthy: false, + pending: false, + reason: "chore only", + }, + { + pr: 5, + url: "https://github.com/Kilo-Org/cloud/pull/5", + docs_worthy: false, + pending: true, + reason: "triage failed to classify this PR", + }, + ] + const worthy = [ + { number: 1, url: summary[0].url }, + { number: 2, url: summary[1].url }, + { number: 3, url: summary[2].url }, + ] + const uncovered = computeUncovered({ worthy, summary, triage }) + const { changesRows, pendingRows, skippedRows } = routeRows({ summary, triage, uncovered }) + + // pending appears in neither Changes nor Considered + const changesText = changesRows.join("\n") + const skippedText = skippedRows.join("\n") + assert.ok(changesText.includes("pull/1"), "success in Changes") + assert.ok(!changesText.includes("pull/3"), "pending must not be in Changes") + assert.ok(!changesText.includes("pull/5"), "triage-pending must not be in Changes") + assert.ok(skippedText.includes("pull/2"), "genuine skipped in Considered") + assert.ok(skippedText.includes("pull/4"), "genuine not-worthy in Considered") + assert.ok(!skippedText.includes("pull/3"), "pending must not be in Considered") + assert.ok(!skippedText.includes("pull/5"), "triage-pending must not be in Considered") + assert.ok(pendingRows.some((r) => r.includes("pull/3"))) + assert.ok(pendingRows.some((r) => r.includes("pull/5"))) + + // round-trip renderBody → extractSectionRows + const through = "2026-07-20T09:59:59.999Z" + const body = renderBody({ + date: "2026-07-27", + since: "2026-07-17T00:00:00.000Z", + through, + changesRows, + pendingRows, + skippedRows, + verified: true, + draftReasons: [], + note: "", + }) + assert.ok(body.includes(``)) + const extChanges = extractSectionRows(body, "changes") + const extPending = extractSectionRows(body, "pending") + const extSkipped = extractSectionRows(body, "skipped") + assert.deepEqual(extChanges, changesRows) + assert.deepEqual(extPending, pendingRows) + assert.deepEqual(extSkipped, skippedRows) + + // clean() prevents marker forgery in agent-generated row strings + { + const forgedRows = routeRows({ + summary: [ + { + pr: 9, + url: "https://github.com/Kilo-Org/cloud/pull/9", + action: "skipped", + reason: "x injection", + }, + ], + triage: [], + uncovered: [], + }) + assert.ok( + !forgedRows.skippedRows[0].includes(""), + "clean() must strip --> from reasons", + ) + const forgedBody = renderBody({ + date: "2026-07-27", + since: "s", + through: "t", + changesRows: [], + pendingRows: [], + skippedRows: forgedRows.skippedRows, + verified: true, + draftReasons: [], + note: "", + }) + // Exactly one real section end marker — the forged sequences were stripped + assert.equal((forgedBody.match(//g) || []).length, 1) + const extracted = extractSectionRows(forgedBody, "skipped") + assert.equal(extracted.length, 1) + assert.ok(extracted[0].includes("injection")) + } + + const legacyRows = [ + "| [Kilo-Org/cloud#1](https://github.com/Kilo-Org/cloud/pull/1) | edit pass failed or timed out for this PR |", + "| [Kilo-Org/cloud#2](https://github.com/Kilo-Org/cloud/pull/2) | triage failed to classify this PR |", + "| [Kilo-Org/cloud#3](https://github.com/Kilo-Org/cloud/pull/3) | not classified by triage |", + "| [Kilo-Org/cloud#4](https://github.com/Kilo-Org/cloud/pull/4) | already covered by existing docs |", + ] + const kept = dropLegacySkipped(legacyRows) + assert.equal(kept.length, 1) + assert.ok(kept[0].includes("pull/4")) + assert.ok(!kept.some((r) => r.includes("edit pass failed"))) + assert.ok(!kept.some((r) => r.includes("triage failed to classify"))) + assert.ok(!kept.some((r) => r.includes("not classified by triage"))) +} + +// --------------------------------------------------------------------------- +// Case 5 — re-collection window +// --------------------------------------------------------------------------- +function case5_recollection() { + console.log("case 5: re-collection closes the loop") + + const collectSrc = fs.readFileSync(COLLECT_SCRIPT, "utf8") + // Query template must use merged:>= + assert.ok( + /merged:>=\$\{since\.toISOString\(\)\}/.test(collectSrc) || /merged:>=/.test(collectSrc), + "collect.mjs must search merged:>=since", + ) + assert.match(collectSrc, /merged:>=/) + + const mergedAt = "2026-07-22T15:30:00.000Z" + const uncovered = [{ url: "https://github.com/Kilo-Org/cloud/pull/11", reason: "pending" }] + const digest = [{ url: uncovered[0].url, merged_at: mergedAt }] + const now = "2026-07-27T12:00:00.000Z" + const since = computeProcessedThrough({ uncovered, digest, now }) + // held-back since is strictly before the uncovered PR's merged_at + assert.ok(Date.parse(since) < Date.parse(mergedAt), `since ${since} must be < merged_at ${mergedAt}`) + // And the query window merged:>=since therefore includes that PR + assert.ok(Date.parse(mergedAt) >= Date.parse(since)) +} + +// --------------------------------------------------------------------------- +// Case 6 — budgets +// --------------------------------------------------------------------------- +function case6_budgets() { + console.log("case 6: budgets") + + // --- edit budget --- + { + // 12 PRs = 3 batches of 5; budget too small for even one batch unit + const prs = Array.from({ length: 12 }, (_, i) => samplePr(100 + i)) + const worthy = prs + const triage = prs.map((p) => ({ + pr: p.number, + url: p.url, + docs_worthy: true, + reason: "needs docs", + target_sections: [], + priority: "medium", + })) + const cwd = setupEditCwd(worthy, triage) + const callLog = path.join(cwd, "kilo-calls.log") + const kiloDir = makeStubKiloDir({ mode: "record", callLog }) + + // EDIT_BUDGET_MINUTES must be positive (0 falls through to default 50). + // BATCH_TIMEOUT default would be 15m; set both tiny so left < BATCH_TIMEOUT immediately. + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + EDIT_BUDGET_MINUTES: "0.0001", + EDIT_BATCH_TIMEOUT_MINUTES: "15", + }, + }) + assert.equal(result.status, 0, result.output) + assert.match(result.output, /deferring \d+ PRs/) + assert.match(result.output, /deferred \d+ PRs due to wall-clock budget/) + + const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, "utf8").trim() : "" + const callCount = calls ? calls.split("\n").filter(Boolean).length : 0 + assert.equal(callCount, 0, `kilo must not be invoked for deferred edit batches; got ${callCount}`) + + const summary = JSON.parse(fs.readFileSync(path.join(cwd, ".docs-sync-summary.json"), "utf8")) + assert.ok(summary.every((e) => e.action === "pending")) + const uncovered = computeUncovered({ worthy, summary, triage }) + assert.equal(uncovered.length, 12) + assert.ok(summary.every((e) => e.action !== "skipped")) + } + + // --- triage budget --- + { + // CHUNK_SIZE=25; 30 PRs = 2 chunks; budget too small for a 10m chunk + const digest = Array.from({ length: 30 }, (_, i) => samplePr(200 + i)) + const cwd = setupTriageCwd(digest) + const callLog = path.join(cwd, "kilo-calls.log") + const kiloDir = makeStubKiloDir({ mode: "record", callLog }) + 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: "0.0001", + GITHUB_STEP_SUMMARY: summaryFile, + }, + }) + assert.equal(result.status, 0, result.output) + assert.match(result.output, /deferring \d+ PRs/) + + const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, "utf8").trim() : "" + const callCount = calls ? calls.split("\n").filter(Boolean).length : 0 + assert.equal(callCount, 0, `kilo must not be invoked for deferred triage chunks; got ${callCount}`) + + const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8")) + assert.equal(triage.length, 30) + assert.ok(triage.every((e) => e.pending === true)) + assert.ok(triage.every((e) => e.docs_worthy === false)) + const uncovered = computeUncovered({ worthy: [], summary: [], triage }) + assert.equal(uncovered.length, 30) + } +} + +// --------------------------------------------------------------------------- +// Case 7 — applyCap both arms +// --------------------------------------------------------------------------- +function case7_cap() { + console.log("case 7: applyCap") + + const now = new Date("2026-07-27T12:00:00.000Z") + const old = new Date("2026-06-01T00:00:00.000Z") + + const prevLog = console.log + const prevWarn = console.warn + const logs = [] + const warnings = [] + console.log = (...a) => logs.push(a.join(" ")) + console.warn = (...a) => warnings.push(a.join(" ")) + try { + // explicit:false + older than 14 days → clamped AND reported + const a = applyCap(old, now, { explicit: false }) + assert.equal(a.clamped, true) + assert.ok(a.since.getTime() > old.getTime()) + const cap = new Date(now.getTime() - 14 * 24 * 3600 * 1000) + assert.equal(a.since.toISOString(), cap.toISOString()) + assert.ok(warnings.some((w) => w.includes("::warning::") && w.includes("clamped"))) + + // explicit:true + older than 14 days → unchanged, skip reported + logs.length = 0 + warnings.length = 0 + const b = applyCap(old, now, { explicit: true }) + assert.equal(b.clamped, false) + assert.equal(b.since.toISOString(), old.toISOString()) + assert.ok(logs.some((l) => /cap skipped|INPUT_SINCE/i.test(l))) + } finally { + console.log = prevLog + console.warn = prevWarn + } +} + +// --------------------------------------------------------------------------- +// Case 8 — triage.mjs outputs +// --------------------------------------------------------------------------- +function case8_triage() { + console.log("case 8: triage pass outputs") + + // 8a Run A: SINCE_OVERRIDE=true + everything pending → warning present + { + const digest = [samplePr(301), samplePr(302), samplePr(303)] + const cwd = setupTriageCwd(digest) + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText: "stream end before idle" }) + 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", + SINCE_OVERRIDE: "true", + GITHUB_STEP_SUMMARY: summaryFile, + }, + }) + assert.equal(result.status, 0, result.output) + const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8")) + assert.equal(triage.length, 3) + assert.ok(triage.every((e) => e.pending === true)) + const summary = fs.readFileSync(summaryFile, "utf8") + assert.match(summary, /triage pending/) + for (const d of digest) { + assert.ok(summary.includes(d.url), `summary lists ${d.url}`) + } + assert.match(result.output, /::warning::.*since-override/) + } + + // 8a Run B: SINCE_OVERRIDE unset → warning absent + { + const digest = [samplePr(311), samplePr(312)] + const cwd = setupTriageCwd(digest) + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText: "stream end" }) + 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, + }, + }) + assert.equal(result.status, 0, result.output) + const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8")) + assert.ok(triage.every((e) => e.pending === true)) + assert.ok(fs.readFileSync(summaryFile, "utf8").includes("triage pending")) + assert.ok(!/::warning::.*since-override/.test(result.output), "override warning must be absent when unset") + } + + // 8a Run C: SINCE_OVERRIDE=true with MIXED stub (worthy > 0) → warning ABSENT + { + const digest = [samplePr(321), samplePr(322), samplePr(323), samplePr(324)] + const cwd = setupTriageCwd(digest) + const kiloDir = makeStubKiloDir({ mode: "mixed-triage" }) + 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", + SINCE_OVERRIDE: "true", + GITHUB_STEP_SUMMARY: summaryFile, + }, + }) + assert.equal(result.status, 0, result.output) + const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8")) + const worthy = triage.filter((e) => e.docs_worthy === true).length + const pending = triage.filter((e) => e.pending === true).length + assert.ok(worthy > 0, "mixed stub must produce worthy > 0") + assert.ok(pending > 0, "mixed stub must leave some pending") + assert.ok( + !/::warning::.*since-override/.test(result.output), + "override warning must be ABSENT when worthy > 0 (Upsert will run)", + ) + } + + // 8b — partial classification → missing URLs pending:true + computeUncovered + { + // One chunk of 4 PRs; stub classifies first 3 only + const digest = [samplePr(401), samplePr(402), samplePr(403), samplePr(404)] + const cwd = setupTriageCwd(digest) + const kiloDir = makeStubKiloDir({ mode: "partial-triage" }) + 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, + }, + }) + assert.equal(result.status, 0, result.output) + const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8")) + assert.equal(triage.length, 4) + const missing = triage.filter((e) => e.reason === "not classified by triage") + assert.ok(missing.length >= 1, "backfill must mark unclassified URLs") + assert.ok(missing.every((e) => e.pending === true)) + const uncovered = computeUncovered({ worthy: [], summary: [], triage }) + for (const m of missing) { + assert.ok( + uncovered.some((u) => u.url === m.url), + `${m.url} must appear in computeUncovered`, + ) + } + } +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- +function main() { + const cases = [ + case1_mergeOrFallback, + case2_defectB, + case3_watermark, + case4_routing, + case5_recollection, + case6_budgets, + case7_cap, + case8_triage, + ] + let failed = 0 + for (const fn of cases) { + try { + fn() + console.log(` ok: ${fn.name}`) + } catch (err) { + failed++ + console.error(` FAIL: ${fn.name}`) + console.error(err) + } finally { + cleanup() + } + } + if (failed > 0) { + console.error(`\nselftest: ${failed} case(s) failed`) + process.exit(1) + } + console.log("\nselftest: all cases passed") +} + +main() diff --git a/.github/docs-sync/triage.mjs b/.github/docs-sync/triage.mjs index 034a7ddcbda..eb075980b51 100644 --- a/.github/docs-sync/triage.mjs +++ b/.github/docs-sync/triage.mjs @@ -6,52 +6,82 @@ * A daily window holds ~30-50 PRs; a replay can hold several hundred. A * single triage call over that volume truncates its JSON output, so the * digest is split into chunks of CHUNK_SIZE and each chunk is triaged with - * its own `kilo run` call. A chunk that fails twice is degraded to - * "unclassified" entries (docs_worthy=false) instead of failing the run — - * the PR body then shows those PRs as skipped, visible to reviewers. + * its own `kilo run` call. A chunk that fails, is only partially classified, + * or is deferred by the wall-clock budget is marked pending:true (still + * docs_worthy:false so filter-worthy excludes it) so the watermark holds + * back and the next run re-collects those PRs. * - * Env: TRIAGE_MODEL (provider/model), KILO_API_KEY (gateway auth, set by the workflow; - * the kilo provider reads it natively). Reads the prompt from triage-prompt.md next to this script. + * Env: TRIAGE_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (gateway auth, set by + * the workflow; the kilo provider reads them natively). Reads the prompt from triage-prompt.md next to this script. + * Budget: TRIAGE_BUDGET_MINUTES (default 35). Test hook: DOCS_SYNC_BACKOFF_MS. */ -import { execFileSync } from "node:child_process" import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" import { parseTriageEntries } from "./extract-json.mjs" +import { appendSummary, backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs" const CHUNK_SIZE = 25 -const ATTEMPTS = 2 +const ATTEMPTS = 3 const OUT_DIR = "docs-sync-out" +const CHUNK_TIMEOUT_MS = 10 * 60 * 1000 const HERE = path.dirname(fileURLToPath(import.meta.url)) const prompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8") const model = process.env.TRIAGE_MODEL if (!model) throw new Error("TRIAGE_MODEL is required") +const TRIAGE_BUDGET_MINUTES = Number(process.env.TRIAGE_BUDGET_MINUTES) || 35 + const digest = JSON.parse(fs.readFileSync(`${OUT_DIR}/digest.json`, "utf8")) -function triageChunk(chunk, index) { +function formatCause(result) { + const bits = [] + if (result.timedOut) bits.push("timed out") + if (result.exitCode !== null && result.exitCode !== undefined) bits.push(`exit ${result.exitCode}`) + if (result.stderrTail) bits.push(result.stderrTail.replaceAll("\n", " ").slice(0, 200)) + return bits.join("; ") || "no diagnostic" +} + +function pendingEntry(d, reason) { + return { + pr: d.number, + url: d.url, + docs_worthy: false, + pending: true, + reason, + target_sections: [], + priority: "medium", + } +} + +function triageChunk(chunk, index, budgetDeadline) { const chunkFile = `${OUT_DIR}/triage-chunk-${index}.json` fs.writeFileSync(chunkFile, JSON.stringify(chunk, null, 2)) + let lastCause = "triage failed to classify this PR" for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { - let raw - try { - // Message positional first: --file is multi-value and would otherwise - // consume a trailing message as a file path ("File not found"). - raw = execFileSync( - "kilo", - ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile], - { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 10 * 60 * 1000, stdio: ["ignore", "pipe", "pipe"] }, + const left = remainingMs(budgetDeadline) + if (left < CHUNK_TIMEOUT_MS) { + lastCause = `triage budget exhausted before chunk ${index} attempt ${attempt}` + console.warn( + `chunk ${index}: stopping retries — remaining budget cannot fit another ${CHUNK_TIMEOUT_MS / 60000}m attempt`, ) - } catch (err) { - const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n") - console.warn(`chunk ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`) - continue + break } - fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw) - const entries = parseTriageEntries(raw) + + const result = runKilo({ + args: ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile], + timeoutMs: Math.min(CHUNK_TIMEOUT_MS, left), + streamStdout: false, + label: `triage chunk ${index} attempt ${attempt}`, + }) + + const raw = result.stdout + if (raw) fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw) + + const entries = raw ? parseTriageEntries(raw) : null if (entries) { // An entry for a PR outside this chunk must not win the shared dedupe // against the chunk that actually owns it — drop foreign entries. @@ -62,18 +92,35 @@ function triageChunk(chunk, index) { } if (owned.length > 0) return owned } - console.warn(`chunk ${index} attempt ${attempt}: no valid JSON in output`) + + // Exit 0 is not success: unparseable output is a failure logged WITH + // the captured stderrTail and exit code on every attempt. + const cause = formatCause(result) + lastCause = `triage chunk ${index}: ${cause}` + console.warn( + `chunk ${index} attempt ${attempt}: no valid JSON in output` + + ` (exit ${result.exitCode}${result.timedOut ? ", timed out" : ""})` + + (result.stderrTail ? `\nstderr tail:\n${result.stderrTail}` : "\nstderr tail: (empty)"), + ) + + if (attempt < ATTEMPTS) { + const wait = backoffMsForAttempt(attempt) + const afterWait = remainingMs(budgetDeadline) - wait + if (wait > 0 && afterWait >= CHUNK_TIMEOUT_MS) { + console.warn(`chunk ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`) + sleepSync(wait) + } else if (wait > 0) { + console.warn( + `chunk ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`, + ) + } + } } - console.warn(`::warning::chunk ${index} failed triage after ${ATTEMPTS} attempts; marking ${chunk.length} PRs unclassified`) - return chunk.map((d) => ({ - pr: d.number, - url: d.url, - docs_worthy: false, - reason: "triage failed to classify this PR", - target_sections: [], - priority: "medium", - })) + console.warn( + `::warning::chunk ${index} failed triage after up to ${ATTEMPTS} attempts; marking ${chunk.length} PRs pending`, + ) + return chunk.map((d) => pendingEntry(d, lastCause.includes("triage failed") ? lastCause : `triage failed to classify this PR (${lastCause})`)) } const chunks = [] @@ -82,30 +129,61 @@ for (let i = 0; i < digest.length; i += CHUNK_SIZE) { } console.log(`triaging ${digest.length} PRs in ${chunks.length} chunks of up to ${CHUNK_SIZE}`) +const budgetDeadline = deadline(TRIAGE_BUDGET_MINUTES) const merged = [] const seen = new Set() + for (let i = 0; i < chunks.length; i++) { - for (const e of triageChunk(chunks[i], i)) { + const left = remainingMs(budgetDeadline) + if (left < CHUNK_TIMEOUT_MS) { + const deferredPrs = chunks.slice(i).reduce((n, c) => n + c.length, 0) + console.warn( + `stopping triage before chunk ${i}: remaining budget (${Math.ceil(left / 1000)}s) cannot fit a ${CHUNK_TIMEOUT_MS / 60000}m chunk; deferring ${deferredPrs} PRs`, + ) + const cause = `triage budget exhausted before chunk ${i} (${Math.ceil(left / 1000)}s left)` + for (let j = i; j < chunks.length; j++) { + for (const d of chunks[j]) { + if (seen.has(d.url)) continue + seen.add(d.url) + merged.push(pendingEntry(d, cause)) + } + } + break + } + + for (const e of triageChunk(chunks[i], i, budgetDeadline)) { if (seen.has(e.url)) continue seen.add(e.url) merged.push(e) } } -// Coverage: every digest PR gets a triage entry so the PR body's skipped -// table is complete. Unclassified defaults to not-docs-worthy (conservative). +// Coverage: every digest PR gets a triage entry. Partial-chunk backfill and +// any other missing URL are pending:true — not a genuine "not worthy" verdict. for (const d of digest) { if (seen.has(d.url)) continue - merged.push({ - pr: d.number, - url: d.url, - docs_worthy: false, - reason: "not classified by triage", - target_sections: [], - priority: "medium", - }) + merged.push(pendingEntry(d, "not classified by triage")) } fs.writeFileSync(`${OUT_DIR}/triage.json`, JSON.stringify(merged, null, 2)) const worthy = merged.filter((e) => e.docs_worthy).length -console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy`) +const pending = merged.filter((e) => e.pending === true) +console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy, ${pending.length} pending`) + +// Upsert is gated off when worthy == 0, so triage emits its own Step Summary +// listing every PR it marked pending:true and why. +if (pending.length > 0) { + const lines = pending.map((e) => `- [${e.url}] ${e.reason}`) + appendSummary( + `### docs-sync: triage pending (will retry)\n\n${pending.length} PR(s) were not classified and will be re-collected on the next run:\n\n${lines.join("\n")}`, + ) +} + +// Replay warning (S2j): warn IFF since-override AND something pending AND +// docs-worthy count is 0 (Upsert is gated off, so noDiffReport never runs). +const sinceOverride = process.env.SINCE_OVERRIDE === "true" +if (sinceOverride && pending.length > 0 && worthy === 0) { + console.warn( + "::warning::docs-sync since-override replay left uncovered PRs and wrote no PR body (worthy=0); re-run the override — the watermark was not held back in the body", + ) +} diff --git a/.github/docs-sync/upsert-pr.mjs b/.github/docs-sync/upsert-pr.mjs index e39551f307c..1ef25b75964 100644 --- a/.github/docs-sync/upsert-pr.mjs +++ b/.github/docs-sync/upsert-pr.mjs @@ -8,6 +8,14 @@ * diff exceeds the file cap or verification failed. The PR body carries * marker-delimited sections so later runs can append rows, plus a * machine-readable processed-through watermark. + * + * Watermark invariant: processed-through never moves past a PR that has no + * terminal outcome. Terminal := action !== "pending" (a deliberate agent + * "skipped" IS terminal). Uncovered PRs hold the marker at earliest + * merged_at − 1 ms so collect's merged:>=since re-collects them next run. + * Three review rounds found four independent defects in a queue-based + * alternative (unreachable gate, empty-PR creation, cap-overflow loss, + * draft-state corruption); a held-back watermark has none of those modes. */ import { execFileSync } from "node:child_process" @@ -17,6 +25,7 @@ import { pathToFileURL } from "node:url" const BRANCH = process.env.BRANCH || "docs/auto-sync" const FILE_CAP = 15 const ROW_CAP = 150 +const PENDING_DISPLAY_CAP = 60 const SUMMARY_FILE = ".docs-sync-summary.json" const DOCS_PATH = "packages/kilo-docs" @@ -42,6 +51,11 @@ function skippedRow(e) { return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |` } +function pendingRow(e) { + const reason = clean(e.reason ?? e.cause ?? "").replaceAll("|", "\\|").replaceAll("\n", " ") + return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |` +} + export function extractSectionRows(body, name) { const m = String(body ?? "").match( new RegExp(`([\\s\\S]*?)`), @@ -50,7 +64,14 @@ export function extractSectionRows(body, name) { return m[1] .split("\n") .map((l) => l.trim()) - .filter((l) => l.startsWith("|") && !l.startsWith("| ---") && !/^\|\s*Docs change/.test(l) && !/^\|\s*PR\s*\|/.test(l)) + .filter( + (l) => + l.startsWith("|") && + !l.startsWith("| ---") && + !/^\|\s*Docs change/.test(l) && + !/^\|\s*PR\s*\|/.test(l) && + !/^\|\s*Why\s*\|/.test(l), + ) } function section(name, header, rows) { @@ -58,7 +79,12 @@ function section(name, header, rows) { return `\n${body}\n` } -export function renderBody({ date, since, through, changesRows, skippedRows, verified, draftReasons, note }) { +export function renderBody({ date, since, through, changesRows, pendingRows, skippedRows, verified, draftReasons, note }) { + const pendingDisplay = + pendingRows.length > PENDING_DISPLAY_CAP + ? [...pendingRows.slice(0, PENDING_DISPLAY_CAP), `| +${pendingRows.length - PENDING_DISPLAY_CAP} more | |`] + : pendingRows + return `## Automated docs sync — ${date} This PR keeps kilo.ai/docs in sync with features merged to [Kilo-Org/cloud](https://github.com/Kilo-Org/cloud) and [Kilo-Org/kilocode](https://github.com/Kilo-Org/kilocode). Every change below links to the merged PR it documents. @@ -70,6 +96,10 @@ ${note ? `- ${note}\n` : ""}${draftReasons.length > 0 ? `- Draft because: ${draf ${section("changes", "| Docs change | Source |", changesRows)} +### Pending — will retry + +${section("pending", "| PR | Why |", pendingDisplay)} + ### Considered, no docs change needed ${section("skipped", "| PR | Reason |", skippedRows)} @@ -100,32 +130,222 @@ function readJson(path, fallback) { } } +/** + * Uncovered = (worthy URLs with no summary row) ∪ (summary action "pending") + * ∪ (triage entries with pending: true). A worthy PR is covered iff it has a + * summary row whose action !== "pending" and carries no triage pending flag. + */ +export function computeUncovered({ worthy, summary, triage }) { + const worthyList = Array.isArray(worthy) ? worthy : [] + const summaryList = Array.isArray(summary) ? summary : [] + const triageList = Array.isArray(triage) ? triage : [] + + const summaryByUrl = new Map() + for (const e of summaryList) { + if (e?.url) summaryByUrl.set(e.url, e) + } + + const triagePendingByUrl = new Map() + for (const e of triageList) { + if (e?.url && e.pending === true) triagePendingByUrl.set(e.url, e) + } + + /** @type {Map} */ + const out = new Map() + + for (const w of worthyList) { + const url = w?.url + if (!url) continue + const row = summaryByUrl.get(url) + if (!row) { + out.set(url, { + url, + pr: w.number ?? w.pr, + reason: "no edit summary row (edit pass did not cover this PR)", + }) + continue + } + if (row.action === "pending") { + out.set(url, { + url, + pr: row.pr ?? w.number ?? w.pr, + reason: row.reason || "edit pass pending", + }) + } + } + + // Summary pending rows for URLs not in worthy (defensive). + for (const row of summaryList) { + if (row?.action === "pending" && row.url && !out.has(row.url)) { + out.set(row.url, { + url: row.url, + pr: row.pr, + reason: row.reason || "edit pass pending", + }) + } + } + + for (const [url, e] of triagePendingByUrl) { + if (out.has(url)) continue + out.set(url, { + url, + pr: e.pr, + reason: e.reason || "triage pending", + }) + } + + return [...out.values()] +} + +/** + * processed-through = now when uncovered is empty; otherwise earliest + * merged_at among uncovered PRs minus 1 ms (from digest-full.json). + * When uncovered is non-empty but no merged_at resolves, hold at + * `fallback` (the run's window start / SINCE): every uncovered PR was + * collected via merged:>=since, so holding there re-collects all of them. + * Never advance past unresolved uncovered PRs (Defect-B permanent-loss). + */ +export function computeProcessedThrough({ uncovered, digest, now, fallback }) { + const nowIso = typeof now === "string" ? now : new Date(now).toISOString() + if (!uncovered || uncovered.length === 0) return nowIso + + const digestList = Array.isArray(digest) ? digest : [] + const byUrl = new Map(digestList.filter((d) => d?.url).map((d) => [d.url, d])) + + let earliest = null + for (const u of uncovered) { + const d = byUrl.get(u.url) + const mergedAt = d?.merged_at + if (!mergedAt) continue + const t = Date.parse(mergedAt) + if (!Number.isFinite(t)) continue + if (earliest === null || t < earliest) earliest = t + } + + if (earliest === null) { + // digest-full missing/corrupt while uncovered is non-empty: hold at + // window start so collect's merged:>=since re-collects every PR. + // Never use now−1ms — that strands uncovered PRs permanently. + const fallbackMs = fallback == null ? NaN : Date.parse(fallback) + if (!Number.isFinite(fallbackMs)) { + throw new Error( + `docs-sync: cannot resolve merged_at for ${uncovered.length} uncovered PR(s) and fallback/SINCE is missing or unparseable; refusing to advance processed-through`, + ) + } + const fallbackIso = new Date(fallbackMs).toISOString() + console.warn( + `::warning::docs-sync: merge times for ${uncovered.length} uncovered PR(s) could not be resolved; holding watermark at window start ${fallbackIso}`, + ) + return fallbackIso + } + + return new Date(earliest - 1).toISOString() +} + +/** + * Route summary + triage into the three body sections. + * changesRows = action neither skipped nor pending + * pendingRows = uncovered from computeUncovered + * skippedRows = action === "skipped" ∪ triage docs_worthy false && !pending + */ +export function routeRows({ summary, triage, uncovered }) { + const summaryList = Array.isArray(summary) ? summary : [] + const triageList = Array.isArray(triage) ? triage : [] + const uncoveredList = Array.isArray(uncovered) ? uncovered : [] + + const changesEntries = summaryList.filter((e) => e.action !== "skipped" && e.action !== "pending") + const skippedEntries = [ + ...triageList.filter((e) => e.docs_worthy === false && e.pending !== true), + ...summaryList.filter((e) => e.action === "skipped"), + ] + + return { + changesRows: changesEntries.map(changeRow), + pendingRows: uncoveredList.map(pendingRow), + skippedRows: skippedEntries.map(skippedRow), + } +} + +/** + * Drop pre-existing Considered rows whose reason contains any of the three + * legacy failure literals (substring match — live rows carry longer strings). + * Genuine no-doc-needed rows are untouched. + */ +export function dropLegacySkipped(rows) { + const list = Array.isArray(rows) ? rows : [] + const needles = ["edit pass failed or timed out", "triage failed to classify", "not classified by triage"] + return list.filter((row) => { + const s = String(row ?? "") + return !needles.some((n) => s.includes(n)) + }) +} + +/** + * No-diff early-return report. Returns summary markdown and an optional + * replay warning. Warns IFF sinceOverride && uncovered non-empty (no commit + * happened — that is the caller's situation). + */ +export function noDiffReport({ uncovered, sinceOverride }) { + const list = Array.isArray(uncovered) ? uncovered : [] + const lines = + list.length === 0 + ? ["The agent found nothing worth documenting in this window."] + : [ + `No packages/kilo-docs diff was produced, but ${list.length} PR(s) remain uncovered and will be re-collected on the next scheduled run:`, + "", + ...list.map((u) => `- [${u.url}] ${u.reason || "uncovered"}`), + ] + + const summary = `### docs-sync: no docs changes\n\n${lines.join("\n")}` + + let warning = null + if (sinceOverride && list.length > 0) { + warning = + "docs-sync since-override replay left uncovered PRs and wrote no PR body (no docs commit); re-run the override — the watermark was not held back in the body" + } + + return { summary, warning } +} + async function main() { const { api, appendOutput, appendSummary, repo } = await import("./lib.mjs") - const through = process.env.PROCESSED_THROUGH ?? new Date().toISOString() + const now = process.env.PROCESSED_THROUGH ?? new Date().toISOString() const since = process.env.SINCE ?? "unknown" + const sinceOverride = process.env.SINCE_OVERRIDE === "true" const mode = ["update", "conflict"].includes(process.env.PREP_MODE) ? process.env.PREP_MODE : "fresh" const existingPr = process.env.PR_NUMBER || "" const verified = process.env.VERIFIED === "true" - const date = through.slice(0, 10) + const date = now.slice(0, 10) // The agent's run summary is consumed here and never committed. const agentSummary = readJson(SUMMARY_FILE, []) fs.rmSync(SUMMARY_FILE, { force: true }) const triage = readJson("docs-sync-out/triage.json", []) + const worthy = readJson("docs-sync-out/worthy.json", []) + const digest = readJson("docs-sync-out/digest-full.json", []) + + // Order matters: compute uncovered BEFORE the no-diff early return so + // noDiffReport can name every held-back PR. + const uncovered = computeUncovered({ worthy, summary: agentSummary, triage }) if (git(["status", "--porcelain", "--", DOCS_PATH]) === "") { console.log("no packages/kilo-docs changes produced; nothing to commit") - appendSummary("### docs-sync: no docs changes\n\nThe agent found nothing worth documenting in this window.") + const { summary, warning } = noDiffReport({ uncovered, sinceOverride }) + appendSummary(summary) + if (warning) console.warn(`::warning::${warning}`) return } - git(["config", "user.name", "github-actions[bot]"]) - git(["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"]) + // Git identity is configured once in docs-sync.yml (Configure git identity) + // before any commit-creating step, including prepare-branch's merge. git(["add", DOCS_PATH]) git(["commit", "-m", `docs: sync with merged PRs (${date})`]) + // Watermark: now when fully covered; else earliest uncovered merged_at − 1ms. + // Pass SINCE as fallback so missing digest-full cannot strand uncovered PRs. + const through = computeProcessedThrough({ uncovered, digest, now, fallback: since }) + // The draft cap bounds the cumulative PR diff, not just this run's commit. const changedFiles = git(["diff", "--name-only", "origin/main...HEAD", "--", DOCS_PATH]) .split("\n") @@ -151,25 +371,33 @@ async function main() { git(mode === "update" ? ["push", "origin", `HEAD:${BRANCH}`] : ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`]) - const changesNew = agentSummary.filter((e) => e.action !== "skipped").map(changeRow) - const skippedNew = [ - ...triage.filter((e) => e.docs_worthy === false), - ...agentSummary.filter((e) => e.action === "skipped"), - ].map(skippedRow) + const { changesRows: changesNew, pendingRows: pendingNew, skippedRows: skippedNew } = routeRows({ + summary: agentSummary, + triage, + uncovered, + }) let oldChanges = [] let oldSkipped = [] + let oldPending = [] if (mode === "update" && existingPr) { const pr = await api(`/repos/${repo()}/pulls/${existingPr}`) oldChanges = extractSectionRows(pr.body, "changes") - oldSkipped = extractSectionRows(pr.body, "skipped") + oldSkipped = dropLegacySkipped(extractSectionRows(pr.body, "skipped")) + oldPending = extractSectionRows(pr.body, "pending") } + // Pending is replaced each run (informational only); do not merge legacy + // pending rows — uncovered is recomputed fresh. oldPending is read only so + // extractSectionRows stays exercised; discarded deliberately. + void oldPending + const body = renderBody({ date, since, through, changesRows: mergeRows(oldChanges, changesNew), + pendingRows: pendingNew, skippedRows: mergeRows(oldSkipped, skippedNew), verified, draftReasons, @@ -228,8 +456,10 @@ async function main() { } appendOutput("pr_url", prUrl) - appendSummary(`### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n`) - console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length})`) + appendSummary( + `### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n- uncovered: ${uncovered.length}\n- processed-through: ${through}\n`, + ) + console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length}, uncovered=${uncovered.length}, through=${through})`) } const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href diff --git a/.github/docs-sync/watermark.mjs b/.github/docs-sync/watermark.mjs index cc4c9690168..32986fef67f 100644 --- a/.github/docs-sync/watermark.mjs +++ b/.github/docs-sync/watermark.mjs @@ -7,9 +7,10 @@ * * Priority: workflow_dispatch input `since` > latest open bot PR marker > * last merged bot PR marker > 72h ago. Hard cap: never look back more than - * 14 days. + * 14 days — unless the human explicitly requested a window via INPUT_SINCE. */ +import { pathToFileURL } from "node:url" import { appendOutput, appendSummary, repo, searchIssues } from "./lib.mjs" const FALLBACK_HOURS = 72 @@ -42,34 +43,65 @@ async function findWatermark() { return null } -const now = new Date() -let since - -const input = (process.env.INPUT_SINCE ?? "").trim() -if (input) { - since = new Date(input) - if (Number.isNaN(since.getTime())) { - throw new Error(`Invalid INPUT_SINCE: ${input}`) +/** + * Apply the 14-day lookback cap. When `explicit` is true (dispatch override), + * the cap is skipped so a human-requested recovery window is not silently + * shortened. Returns `{ since, clamped }`. + */ +export function applyCap(since, now, { explicit = false } = {}) { + if (explicit) { + console.log(`14-day cap skipped: INPUT_SINCE was set explicitly (${since.toISOString()})`) + return { since, clamped: false } } - console.log(`watermark from dispatch input: ${since.toISOString()}`) -} else { - since = - (await findWatermark()) ?? new Date(now.getTime() - FALLBACK_HOURS * 3600 * 1000) + const cap = new Date(now.getTime() - CAP_DAYS * 24 * 3600 * 1000) + if (since < cap) { + const from = since.toISOString() + const to = cap.toISOString() + console.warn( + `::warning::docs-sync watermark clamped from ${from} to ${to} (${CAP_DAYS}-day cap). ` + + `Anything still uncovered before ${to} is abandoned and needs a human.`, + ) + return { since: cap, clamped: true } + } + return { since, clamped: false } } -// A forged, edited, or malformed marker in the future would silently match -// nothing in the merged:>= search; clamp it loudly. -if (since > now) { - console.warn(`watermark ${since.toISOString()} is in the future, clamping to now`) - since = now +async function main() { + const now = new Date() + let since + let explicit = false + + const input = (process.env.INPUT_SINCE ?? "").trim() + if (input) { + since = new Date(input) + if (Number.isNaN(since.getTime())) { + throw new Error(`Invalid INPUT_SINCE: ${input}`) + } + explicit = true + console.log(`watermark from dispatch input: ${since.toISOString()}`) + } else { + since = (await findWatermark()) ?? new Date(now.getTime() - FALLBACK_HOURS * 3600 * 1000) + } + + // A forged, edited, or malformed marker in the future would silently match + // nothing in the merged:>= search; clamp it loudly. + if (since > now) { + console.warn(`watermark ${since.toISOString()} is in the future, clamping to now`) + since = now + } + + ;({ since } = applyCap(since, now, { explicit })) + + appendOutput("since", since.toISOString()) + appendOutput("now", now.toISOString()) + appendOutput("since_override", explicit ? "true" : "false") + appendSummary(`### docs-sync watermark\n\n- since: \`${since.toISOString()}\`\n- now: \`${now.toISOString()}\`\n`) } -const cap = new Date(now.getTime() - CAP_DAYS * 24 * 3600 * 1000) -if (since < cap) { - console.log(`watermark ${since.toISOString()} older than ${CAP_DAYS}d cap, clamping`) - since = cap +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (isMain) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) } - -appendOutput("since", since.toISOString()) -appendOutput("now", now.toISOString()) -appendSummary(`### docs-sync watermark\n\n- since: \`${since.toISOString()}\`\n- now: \`${now.toISOString()}\`\n`) diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml index 9eacf14e614..5a32e876986 100644 --- a/.github/workflows/docs-sync.yml +++ b/.github/workflows/docs-sync.yml @@ -5,9 +5,13 @@ name: docs-sync # triages them for docs relevance, runs Kilo CLI headless to update # packages/kilo-docs, and maintains one rolling PR for human review. # -# Security posture: scheduled/manual only, checks out main, never executes -# code from PR branches. State is derived from the bot's own PRs (watermark -# marker in the PR body), so missed or failed runs self-heal on the next run. +# Security posture: scheduled/manual runs check out the dispatched ref and may +# push/comment with write permissions and org secrets. PR runs (paths-limited to +# this workflow and .github/docs-sync/**) execute branch code only in a +# read-only, secretless `selftest` job that never pushes, comments, or calls an +# LLM. `pull_request` (not `pull_request_target`) keeps fork tokens read-only. +# State is derived from the bot's own PRs (watermark marker in the PR body), so +# missed or failed runs self-heal on the next run. on: schedule: @@ -22,6 +26,10 @@ on: description: "Collect + triage only, no edits, no PR" type: boolean default: false + pull_request: + paths: + - ".github/docs-sync/**" + - ".github/workflows/docs-sync.yml" permissions: contents: write # push the rolling branch, create the auto-docs label @@ -29,7 +37,7 @@ permissions: issues: write # comment on the rolling PR concurrency: - group: docs-sync + group: ${{ github.event_name == 'pull_request' && format('docs-sync-pr-{0}', github.event.pull_request.number) || 'docs-sync' }} cancel-in-progress: false env: @@ -37,15 +45,14 @@ env: EDIT_MODEL: ${{ vars.DOCS_SYNC_EDIT_MODEL || 'kilo/moonshotai/kimi-k3' }} jobs: - sync: + selftest: if: github.repository == 'Kilo-Org/kilocode' runs-on: blacksmith-4vcpu-ubuntu-2404 - timeout-minutes: 120 + permissions: + contents: read steps: - name: Checkout repository uses: actions/checkout@v6 - with: - fetch-depth: 0 # prepare-branch merges main into the rolling branch - name: Setup Node uses: actions/setup-node@v6 @@ -53,6 +60,39 @@ jobs: node-version: "24" package-manager-cache: false + - name: Run docs-sync selftest + run: node .github/docs-sync/selftest.mjs + + 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 + env: + # Both are required: without KILO_ORG_ID the gateway bills the key + # owner's personal balance (402 "Add credits") instead of the org. + KILO_API_KEY: ${{ secrets.KILO_API_KEY }} + KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 # prepare-branch merges main into the rolling branch + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: "24" + package-manager-cache: false + + - name: Run docs-sync selftest + run: node .github/docs-sync/selftest.mjs + - name: Install Kilo CLI run: | npm install -g @kilocode/cli @@ -75,7 +115,7 @@ jobs: id: triage if: steps.collect.outputs.count != '0' env: - KILO_API_KEY: ${{ secrets.KILO_API_KEY }} + SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }} run: node .github/docs-sync/triage.mjs - name: Filter docs-worthy PRs @@ -101,10 +141,18 @@ jobs: GH_TOKEN: ${{ github.token }} run: node .github/docs-sync/prepare-branch.mjs + # After prepare-branch checks out the rolling branch and merges main, the + # worktree holds main's scripts. Restore the dispatched ref's copies so a + # branch-dispatch AC9 run actually exercises the fixed code. git restore + # (not checkout) leaves them unstaged so upsert-pr's bare commit won't + # include them in the docs PR. + - name: Restore docs-sync scripts from the dispatched ref + if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true + run: git restore --source=${{ github.sha }} -- .github/docs-sync + - name: Update docs (Kilo CLI, batched) if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true - env: - KILO_API_KEY: ${{ secrets.KILO_API_KEY }} + continue-on-error: true run: node .github/docs-sync/edit.mjs - name: Verify docs build and tests @@ -121,8 +169,8 @@ jobs: id: fix if: steps.verify.outcome == 'failure' continue-on-error: true + timeout-minutes: 10 env: - KILO_API_KEY: ${{ secrets.KILO_API_KEY }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }} run: | set -o pipefail @@ -150,6 +198,7 @@ jobs: GH_TOKEN: ${{ github.token }} PROCESSED_THROUGH: ${{ steps.wm.outputs.now }} SINCE: ${{ steps.wm.outputs.since }} + SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }} BRANCH: ${{ steps.prep.outputs.branch }} PREP_MODE: ${{ steps.prep.outputs.mode }} PR_NUMBER: ${{ steps.prep.outputs.pr_number }} diff --git a/.github/workflows/publish-jetbrains-bundled.yml b/.github/workflows/publish-jetbrains-bundled.yml new file mode 100644 index 00000000000..5908b25e738 --- /dev/null +++ b/.github/workflows/publish-jetbrains-bundled.yml @@ -0,0 +1,341 @@ +# kilocode_change - new file +name: publish-jetbrains-bundled + +on: + workflow_dispatch: + inputs: + pr: + description: Merged JetBrains release PR number to bundle + required: true + type: string + merge_commit: + description: Merge commit SHA from the reviewed release PR + required: true + type: string + +concurrency: + group: publish-jetbrains-bundled-pr-${{ inputs.pr }} + cancel-in-progress: false + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + validate: + if: github.repository == 'Kilo-Org/kilocode' + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + pull-requests: read + outputs: + version: ${{ steps.release.outputs.version }} + kind: ${{ steps.release.outputs.kind }} + tag: ${{ steps.release.outputs.tag }} + channel: ${{ steps.release.outputs.marketplace_channel }} + steps: + - name: Checkout trusted validation scripts + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: main + + - name: Setup Bun for validation + uses: ./.github/actions/setup-bun + + - name: Checkout merged release PR for validation + uses: actions/checkout@v6 + with: + fetch-depth: 0 + path: release + persist-credentials: false + ref: ${{ inputs.merge_commit }} + + - name: Validate release PR and tag + id: release + working-directory: release + run: bun ../script/jetbrains-release-validate.ts --pr "$PR_NUMBER" + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pr }} + + bundle: + needs: validate + if: github.repository == 'Kilo-Org/kilocode' + runs-on: blacksmith-8vcpu-ubuntu-2404 + permissions: + actions: read + contents: write + outputs: + version: ${{ needs.validate.outputs.version }} + kind: ${{ needs.validate.outputs.kind }} + steps: + - name: Checkout merged release PR metadata + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ inputs.merge_commit }} + + - name: Save reviewed release metadata + run: | + cp packages/kilo-jetbrains/CHANGELOG.md "$RUNNER_TEMP/jetbrains-CHANGELOG.md" + cp packages/kilo-jetbrains/gradle.properties "$RUNNER_TEMP/jetbrains-gradle.properties" + + - name: Checkout release tag + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ needs.validate.outputs.tag }} + + - name: Restore reviewed release metadata + run: | + cp "$RUNNER_TEMP/jetbrains-CHANGELOG.md" packages/kilo-jetbrains/CHANGELOG.md + cp "$RUNNER_TEMP/jetbrains-gradle.properties" packages/kilo-jetbrains/gradle.properties + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Install dependencies + run: bun install + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Install build tools + run: | + sudo apt-get update + sudo apt-get install -y patchelf zip unzip + curl --fail --location \ + https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \ + --output "$RUNNER_TEMP/zig.tar.xz" + echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status + tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH" + + - name: Validate signing secrets + run: | + missing=0 + for name in JETBRAINS_CERTIFICATE_CHAIN JETBRAINS_PRIVATE_KEY JETBRAINS_PRIVATE_KEY_PASSWORD; do + if [[ -z "${!name}" ]]; then + echo "Missing required secret: $name" >&2 + missing=1 + fi + done + exit "$missing" + env: + JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }} + JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} + JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} + + - name: Write signing secrets to temp files + run: | + dir="$RUNNER_TEMP/jetbrains-signing" + mkdir -m 700 -p "$dir" + chain="$dir/certificate-chain.pem" + key="$dir/private-key.pem" + umask 077 + printf '%s' "$JETBRAINS_CERTIFICATE_CHAIN" > "$chain" + printf '%s' "$JETBRAINS_PRIVATE_KEY" > "$key" + chmod 600 "$chain" "$key" + echo "JETBRAINS_CERTIFICATE_CHAIN_FILE=$chain" >> "$GITHUB_ENV" + echo "JETBRAINS_PRIVATE_KEY_FILE=$key" >> "$GITHUB_ENV" + env: + JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }} + JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} + + - name: Build signed bundled plugin + working-directory: packages/kilo-jetbrains + run: | + args=( + -Pproduction=true + -Pkilo.version="$VERSION" + -Pkilo.channel="$CHANNEL" + -Pkilo.cli.bundled=true + ) + ./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin "${args[@]}" + env: + GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} + VERSION: ${{ needs.validate.outputs.version }} + CHANNEL: ${{ needs.validate.outputs.channel }} + JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} + + - name: Remove signing secret temp files + if: always() + run: rm -rf "$RUNNER_TEMP/jetbrains-signing" + + - name: Resolve bundled archive + id: archive + run: | + mapfile -t signed < <(compgen -G "packages/kilo-jetbrains/build/distributions/*-signed.zip") + if [[ "${#signed[@]}" -ne 1 ]]; then + echo "Expected exactly one signed bundled JetBrains plugin ZIP, found ${#signed[@]}." >&2 + printf '%s\n' "${signed[@]}" >&2 + exit 1 + fi + asset="kilo-code-${VERSION}-bundled.zip" + dest="packages/kilo-jetbrains/build/release/$asset" + mkdir -p "$(dirname "$dest")" + cp "${signed[0]}" "$dest" + echo "asset=$asset" >> "$GITHUB_OUTPUT" + echo "path=$dest" >> "$GITHUB_OUTPUT" + env: + VERSION: ${{ needs.validate.outputs.version }} + + - name: Upload bundled ZIP to GitHub Release + run: gh release upload "$TAG" "$ARCHIVE" --clobber --repo "$GITHUB_REPOSITORY" + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.validate.outputs.tag }} + ARCHIVE: ${{ steps.archive.outputs.path }} + + - name: Resolve bundled asset URL + id: asset + run: | + url="$(gh release view "$TAG" --json assets --jq '.assets[] | select(.name == env.ASSET) | .url' --repo "$GITHUB_REPOSITORY")" + if [[ -z "$url" ]]; then + echo "Could not resolve GitHub Release URL for $ASSET" >&2 + exit 1 + fi + echo "url=$url" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.validate.outputs.tag }} + ASSET: ${{ steps.archive.outputs.asset }} + + - name: Generate stable plugin repository XML + if: needs.validate.outputs.kind == 'stable' + run: | + mkdir -p pages/jetbrains + python3 <<'PY' + import html + import io + import os + import zipfile + import xml.etree.ElementTree as ET + + archive = os.environ["ARCHIVE"] + asset = os.environ["ASSET_URL"] + version = os.environ["VERSION"] + + def plugin_xml(path): + with zipfile.ZipFile(path) as zip: + for name in zip.namelist(): + if name.endswith("META-INF/plugin.xml"): + return zip.read(name) + for name in zip.namelist(): + if not name.endswith(".jar"): + continue + with zipfile.ZipFile(io.BytesIO(zip.read(name))) as jar: + for item in jar.namelist(): + if item.endswith("META-INF/plugin.xml"): + return jar.read(item) + raise SystemExit("bundled plugin ZIP did not contain META-INF/plugin.xml") + + root = ET.fromstring(plugin_xml(archive)) + + def text(name, default=""): + item = root.find(name) + return item.text.strip() if item is not None and item.text else default + + def cdata(value): + return "", "]]]]>") + "]]>" + + plugin = text("id", "ai.kilocode.jetbrains") + name = text("name", "Kilo Code") + vendor = text("vendor", "Kilo Code") + desc = text("description") + notes = text("change-notes") + idea = root.find("idea-version") + attrs = "" + if idea is not None: + since = idea.attrib.get("since-build") + until = idea.attrib.get("until-build") + if since: + attrs += f' since-build="{html.escape(since)}"' + if until: + attrs += f' until-build="{html.escape(until)}"' + + xml = [ + '', + '', + f' ', + f' {html.escape(name)}', + f' {html.escape(vendor)}', + f' ', + ] + if desc: + xml.append(f' {cdata(desc)}') + if notes: + xml.append(f' {cdata(notes)}') + xml.extend([' ', '', '']) + with open("pages/jetbrains/updatePlugins.xml", "w", encoding="utf-8") as file: + file.write("\n".join(xml)) + PY + env: + ARCHIVE: ${{ steps.archive.outputs.path }} + ASSET_URL: ${{ steps.asset.outputs.url }} + VERSION: ${{ needs.validate.outputs.version }} + + - name: Upload stable Pages source + if: needs.validate.outputs.kind == 'stable' + uses: actions/upload-artifact@v4 + with: + name: jetbrains-pages-${{ needs.validate.outputs.version }} + path: pages + if-no-files-found: error + + - name: Upload workflow artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: kilo-jetbrains-bundled-${{ needs.validate.outputs.version }} + path: | + packages/kilo-jetbrains/build/release/*.zip + pages/jetbrains/updatePlugins.xml + if-no-files-found: ignore + + pages: + needs: bundle + if: needs.bundle.outputs.kind == 'stable' + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + actions: read + id-token: write + pages: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Download stable Pages source + uses: actions/download-artifact@v4 + with: + name: jetbrains-pages-${{ needs.bundle.outputs.version }} + path: pages + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: pages + + - name: Deploy Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml index ba43fb4124a..cb976193acc 100644 --- a/.github/workflows/publish-jetbrains.yml +++ b/.github/workflows/publish-jetbrains.yml @@ -23,6 +23,7 @@ concurrency: cancel-in-progress: false permissions: + actions: write contents: write pull-requests: read @@ -199,6 +200,19 @@ jobs: ARCHIVE: ${{ steps.archive.outputs.path }} NOTES: packages/kilo-jetbrains/build/release-notes.md + - name: Dispatch bundled GitHub release build + continue-on-error: true + run: | + gh workflow run publish-jetbrains-bundled.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref main \ + -f pr="$PR_NUMBER" \ + -f merge_commit="$MERGE_COMMIT" + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr }} + MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha || inputs.merge_commit }} + - name: Upload workflow artifact if: always() uses: actions/upload-artifact@v4 diff --git a/.opencode-version b/.opencode-version index f0257c2afb2..6d4057fbe47 100644 --- a/.opencode-version +++ b/.opencode-version @@ -1 +1 @@ -v1.17.5 +v1.17.9 diff --git a/CONTEXT.md b/CONTEXT.md index bb0e319018f..c937cbaabec 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -52,6 +52,9 @@ _Avoid_: Request body, wire options **Generation Controls**: Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog. +**PTY Environment**: +The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory. + ## Relationships - A **System Context** is an opaque carrier composed from zero or more **Context Sources**. @@ -99,6 +102,8 @@ Provider-neutral sampling and output controls, partitioned from provider semanti - A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history. - **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding. - **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing. +- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `KILO_TERMINAL`. +- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. - When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply. - Ambient project instruction discovery honors `KILO_DISABLE_PROJECT_CONFIG`; global instructions remain eligible. diff --git a/bun.lock b/bun.lock index bd25540b5f3..fd599605c4d 100644 --- a/bun.lock +++ b/bun.lock @@ -798,6 +798,7 @@ "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", "@pierre/diffs": "catalog:", + "@shikijs/stream": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", "@solid-primitives/event-listener": "2.4.5", @@ -844,22 +845,22 @@ }, }, "trustedDependencies": [ - "esbuild", - "protobufjs", "web-tree-sitter", + "esbuild", "tree-sitter-bash", + "protobufjs", ], "patchedDependencies": { - "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", "virtua@0.49.1": "patches/virtua@0.49.1.patch", - "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", + "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", "pacote@21.5.1": "patches/pacote@21.5.1.patch", - "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", }, "overrides": { "@effect/platform-node-shared": "4.0.0-beta.74", @@ -898,13 +899,15 @@ "@opentui/core": "0.3.4", "@opentui/keymap": "0.3.4", "@opentui/solid": "0.3.4", - "@pierre/diffs": "1.1.22", + "@pierre/diffs": "1.2.10", "@playwright/test": "1.59.1", + "@shikijs/stream": "4.2.0", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@tailwindcss/vite": "4.1.11", + "@tanstack/solid-virtual": "3.13.28", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", "@types/bun": "1.3.14", @@ -931,7 +934,7 @@ "remeda": "2.26.0", "remend": "1.3.0", "semver": "7.7.4", - "shiki": "3.20.0", + "shiki": "4.2.0", "solid-js": "1.9.12", "solid-list": "0.3.0", "sst": "4.13.1", @@ -1956,9 +1959,11 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], - "@pierre/diffs": ["@pierre/diffs@1.1.22", "", { "dependencies": { "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-1Iv7kdl6OABFCd1n2HQbGUiRHouXPaHoIjcb7Lwg8zeJKY5ph+cESFcGEyIiwW0NCbKGtYS2bTnXmI+Eze5dwg=="], + "@pierre/diffs": ["@pierre/diffs@1.2.10", "", { "dependencies": { "@pierre/theme": "1.0.3", "@pierre/theming": "0.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-rPeAmDWarxFVTQpaf4y6wTxjZxU44xKJKoJti2zU21P06DVd9nRHZX+xSIObLB307Qjpaesyb1x/j0z94t7vLw=="], - "@pierre/theme": ["@pierre/theme@0.0.28", "", {}, "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw=="], + "@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="], + + "@pierre/theming": ["@pierre/theming@0.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A=="], "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], @@ -2076,15 +2081,19 @@ "@secretlint/types": ["@secretlint/types@10.2.2", "", {}, "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg=="], - "@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], + "@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="], - "@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="], + "@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="], - "@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="], + "@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="], + + "@shikijs/stream": ["@shikijs/stream@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0" }, "peerDependencies": { "react": "^19.0.0", "solid-js": "^1.9.0", "vue": "^3.2.0" }, "optionalPeers": ["react", "solid-js", "vue"] }, "sha512-OaMUUStdIZ+l1GJad9uVACR3Xvgwo4y+RmEuDMU62cgFMMg1IBCaIFmvzAR2HiCpGtwoc/qPfpNnP+ivgrPXZg=="], + + "@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="], "@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="], @@ -4306,7 +4315,7 @@ "shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="], - "shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], + "shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], @@ -5160,13 +5169,19 @@ "@qdrant/js-client-rest/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="], - "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], - "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], - "@shikijs/langs/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], - "@shikijs/themes/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/langs/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + + "@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + + "@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + + "@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], "@smithy/config-resolver/@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="], @@ -5594,9 +5609,7 @@ "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], - - "shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], diff --git a/bunfig.toml b/bunfig.toml index f36a00828ca..61bb6e0fdb3 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Keep Kilo's longer supply-chain quarantine while allowing packages that must track coordinated releases. minimumReleaseAge = 410520 # seconds (~4.75 days / ~114 hours) -minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] +minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] [test] root = "./do-not-run-tests-from-root" diff --git a/flake.nix b/flake.nix index 7383da725db..280d60035c5 100644 --- a/flake.nix +++ b/flake.nix @@ -21,59 +21,7 @@ devShells = forEachSystem (pkgs: { default = let - # Pin bun to the version declared in package.json (packageManager: "bun@1.3.14"). - # The locked nixpkgs revision ships 1.3.11, so we fetch the official release directly. - bun = - let - sources = { - "aarch64-linux" = { - name = "bun-linux-aarch64"; - hash = "sha256-on/7Y6gxA3WDbg1vZorhf6jY0YuIw3yCHGUzGXOhmjs="; - }; - "x86_64-linux" = { - name = "bun-linux-x64"; - hash = "sha256-lR7iruhV8IWVruxiJSJqKY0/6oOj3NZGXAnLzN9+hI8="; - }; - "aarch64-darwin" = { - name = "bun-darwin-aarch64"; - hash = "sha256-2LliIYKK1vl6x6wKt+lYcjQa92MAHogD6CZ2UsJlJiA="; - }; - "x86_64-darwin" = { - name = "bun-darwin-x64"; - hash = "sha256-QYPfM3RiPlurMVxUfPoJdFM81FfYa3O2OfeoeXTNZjM="; - }; - }; - source = - sources.${pkgs.stdenv.hostPlatform.system} - or (throw "Unsupported system for bun: ${pkgs.stdenv.hostPlatform.system}"); - in - pkgs.stdenv.mkDerivation rec { - pname = "bun"; - version = "1.3.14"; - src = pkgs.fetchurl { - url = "https://github.com/oven-sh/bun/releases/download/bun-v${version}/${source.name}.zip"; - inherit (source) hash; - }; - nativeBuildInputs = [ - pkgs.unzip - ] ++ pkgs.lib.optional pkgs.stdenv.isLinux pkgs.autoPatchelfHook; - buildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.stdenv.cc.cc.lib ]; - dontConfigure = true; - dontBuild = true; - installPhase = '' - runHook preInstall - install -Dm755 bun $out/bin/bun - ln -s $out/bin/bun $out/bin/bunx - runHook postInstall - ''; - meta = { - description = "Fast all-in-one JavaScript runtime"; - homepage = "https://bun.sh"; - license = pkgs.lib.licenses.mit; - mainProgram = "bun"; - platforms = builtins.attrNames sources; - }; - }; + bun = pkgs.callPackage ./nix/bun.nix { }; kilo-dev = pkgs.writeShellScriptBin "kilo-dev" '' set -euo pipefail @@ -279,11 +227,12 @@ packages = forEachSystem ( pkgs: let + bun = pkgs.callPackage ./nix/bun.nix { }; node_modules = pkgs.callPackage ./nix/node_modules.nix { - inherit rev; + inherit bun rev; }; kilo = pkgs.callPackage ./nix/kilo.nix { - inherit node_modules; + inherit bun node_modules; }; in { diff --git a/nix/bun.nix b/nix/bun.nix new file mode 100644 index 00000000000..a8808953494 --- /dev/null +++ b/nix/bun.nix @@ -0,0 +1,60 @@ +{ + lib, + stdenv, + fetchurl, + unzip, + autoPatchelfHook, +}: +let + package = lib.pipe ../package.json [ + builtins.readFile + builtins.fromJSON + ]; + version = lib.removePrefix "bun@" package.packageManager; + sources = { + "aarch64-linux" = { + name = "bun-linux-aarch64"; + hash = "sha256-on/7Y6gxA3WDbg1vZorhf6jY0YuIw3yCHGUzGXOhmjs="; + }; + "x86_64-linux" = { + name = "bun-linux-x64"; + hash = "sha256-lR7iruhV8IWVruxiJSJqKY0/6oOj3NZGXAnLzN9+hI8="; + }; + "aarch64-darwin" = { + name = "bun-darwin-aarch64"; + hash = "sha256-2LliIYKK1vl6x6wKt+lYcjQa92MAHogD6CZ2UsJlJiA="; + }; + "x86_64-darwin" = { + name = "bun-darwin-x64"; + hash = "sha256-QYPfM3RiPlurMVxUfPoJdFM81FfYa3O2OfeoeXTNZjM="; + }; + }; + source = + sources.${stdenv.hostPlatform.system} + or (throw "Unsupported system for bun: ${stdenv.hostPlatform.system}"); +in +stdenv.mkDerivation { + pname = "bun"; + inherit version; + src = fetchurl { + url = "https://github.com/oven-sh/bun/releases/download/bun-v${version}/${source.name}.zip"; + inherit (source) hash; + }; + nativeBuildInputs = [ unzip ] ++ lib.optional stdenv.isLinux autoPatchelfHook; + buildInputs = lib.optionals stdenv.isLinux [ stdenv.cc.cc.lib ]; + dontConfigure = true; + dontBuild = true; + installPhase = '' + runHook preInstall + install -Dm755 bun $out/bin/bun + ln -s $out/bin/bun $out/bin/bunx + runHook postInstall + ''; + meta = { + description = "Fast all-in-one JavaScript runtime"; + homepage = "https://bun.sh"; + license = lib.licenses.mit; + mainProgram = "bun"; + platforms = builtins.attrNames sources; + }; +} diff --git a/nix/hashes.json b/nix/hashes.json index c10bd672c36..8f0a833b461 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-UHxMHmx17Jex0yXgbpXCvIORubs9cFMsIXGacvs9+gA=", - "aarch64-linux": "sha256-tTnW84VaNEbfo46H24ETKZgFumZMwu6pgNDlY8KYkqo=", - "aarch64-darwin": "sha256-tBi4D1tfACA4ogYMdyjUK8sDb370rAGE2q8baeJjpdA=", - "x86_64-darwin": "sha256-MPIai9M+EQps81qp7RBmTsW/qPjcrWd7GGJQyeNCz/U=" + "x86_64-linux": "sha256-Y64ujq2R4SiyOavsXmaYsQkVNfdjhkzmPolOEV1RVyc=", + "aarch64-linux": "sha256-7S209ir2j9o9NXYmMUnEfo6m64cUIkexd8L95UeCMFE=", + "aarch64-darwin": "sha256-bE1zw7EK+4rgUgw80t8hdc2yojAF2f8ihjorh6B7jJE=", + "x86_64-darwin": "sha256-xukM9cEvZAKCXewun9xReGBKz09i63LACLN/eH54020=" } } diff --git a/package.json b/package.json index 543649f5030..2cbfccad00a 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@tsconfig/bun": "1.0.9", "@cloudflare/workers-types": "4.20251008.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@pierre/diffs": "1.1.22", + "@pierre/diffs": "1.2.10", "opentui-spinner": "0.0.7", "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", @@ -70,7 +70,7 @@ "@typescript/native-preview": "7.0.0-dev.20260316.1", "zod": "4.1.8", "remeda": "2.26.0", - "shiki": "3.20.0", + "shiki": "4.2.0", "solid-list": "0.3.0", "tailwindcss": "4.1.11", "virtua": "0.49.1", @@ -85,7 +85,9 @@ "@effect/sql-sqlite-bun": "4.0.0-beta.74", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", - "sst": "4.13.1" + "sst": "4.13.1", + "@tanstack/solid-virtual": "3.13.28", + "@shikijs/stream": "4.2.0" } }, "devDependencies": { @@ -157,11 +159,15 @@ "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", - "virtua@0.49.1": "patches/virtua@0.49.1.patch", + "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", + "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", - "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", + "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "virtua@0.49.1": "patches/virtua@0.49.1.patch", + "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", "pacote@21.5.1": "patches/pacote@21.5.1.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch" }, diff --git a/packages/core/package.json b/packages/core/package.json index d5f73a3983a..ddb4d33d35e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -10,8 +10,8 @@ "migration": "bun run script/migration.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", "test": "bun test --only-failures", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", - "typecheck": "tsgo --noEmit" + "typecheck": "tsgo --noEmit", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" }, "bin": { "opencode": "./bin/opencode" diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 82ab2b82136..4156db5c361 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -10,8 +10,7 @@ import { Location } from "./location" import { EventV2 } from "./event" import { Policy } from "./policy" import { State } from "./state" -import { Credential } from "./credential" -import { IntegrationSchema } from "./integration/schema" +import { Integration } from "./integration" export type ProviderRecord = { provider: ProviderV2.Info @@ -35,12 +34,7 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass) => { - const credential = active.get(IntegrationSchema.ID.make(provider.id)) - if (!credential) return provider - const body = { ...provider.request.body } - if (credential.value.type === "key") { - body.apiKey = credential.value.key - Object.assign(body, credential.value.metadata ?? {}) - } - // kilocode_change start - preserve Kilo organization routing from migrated OAuth credentials - if (credential.value.type === "oauth") { - body.apiKey = credential.value.access - if (credential.value.metadata?.accountID) body.kilocodeOrganizationId = credential.value.metadata.accountID - } - // kilocode_change end - return new ProviderV2.Info({ - ...provider, - enabled: { via: "credential", credentialID: credential.id }, - request: { ...provider.request, body }, - }) + const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => { + if (provider.disabled) return false + if (typeof provider.request.body.apiKey === "string") return true + if (connected) return true + return !integration } - const resolve = (model: ModelV2.Info, provider: ProviderV2.Info) => { + const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => { const api = model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0 ? { ...provider.api, id: model.api.id } @@ -208,18 +188,16 @@ export const layer = Layer.effect( }, finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) { if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid) - if (!policy.hasStatements()) return - for (const record of [...catalog.provider.list()]) { - if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { - catalog.provider.remove(record.provider.id) + if (policy.hasStatements()) { + for (const record of [...catalog.provider.list()]) { + if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { + catalog.provider.remove(record.provider.id) + } } } + yield* events.publish(Event.Updated, {}) }), }) - const active = Effect.fn("CatalogV2.active")(function* () { - return new Map((yield* credentials.all()).map((credential) => [credential.integrationID, credential])) - }) - yield* events.subscribe(PluginV2.Event.Added).pipe( // Plugin registries are location scoped even though the event bus is process scoped. Stream.filter( @@ -238,18 +216,23 @@ export const layer = Layer.effect( provider: { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { const record = yield* getRecord(providerID) - return project(record.provider, yield* active()) + return record.provider }), all: Effect.fn("CatalogV2.provider.all")(function* () { - const credentials = yield* active() - return Array.fromIterable(state.get().providers.values()).map((record) => - project(record.provider, credentials), - ) + return Array.fromIterable(state.get().providers.values()).map((record) => record.provider) }), available: Effect.fn("CatalogV2.provider.available")(function* () { - return (yield* result.provider.all()).filter((provider) => provider.enabled) + const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration])) + const connections = yield* integrations.connection.list() + return (yield* result.provider.all()).filter((provider) => + available( + provider, + active.get(Integration.ID.make(provider.id)), + connections.has(Integration.ID.make(provider.id)), + ), + ) }), }, @@ -258,33 +241,32 @@ export const layer = Layer.effect( const record = yield* getRecord(providerID) const model = record.models.get(modelID) if (!model) return yield* new ModelNotFoundError({ providerID, modelID }) - return resolve(model, project(record.provider, yield* active())) + return projectModel(model, record.provider) }), all: Effect.fn("CatalogV2.model.all")(function* () { - const credentials = yield* active() return pipe( Array.fromIterable(state.get().providers.values()), Array.flatMap((record) => { - const provider = project(record.provider, credentials) - return Array.fromIterable(record.models.values()).map((model) => resolve(model, provider)) + return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider)) }), Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), ) }), available: Effect.fn("CatalogV2.model.available")(function* () { - const providers = new Map((yield* result.provider.all()).map((provider) => [provider.id, provider])) - return (yield* result.model.all()).filter( - (model) => providers.get(model.providerID)?.enabled !== false && model.enabled, - ) + const providers = new Set((yield* result.provider.available()).map((provider) => provider.id)) + return (yield* result.model.all()).filter((model) => providers.has(model.providerID) && model.enabled) }), default: Effect.fn("CatalogV2.model.default")(function* () { const defaultModel = state.get().defaultModel if (defaultModel) { const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option) - if (Option.isSome(provider) && provider.value.enabled !== false) { + if ( + Option.isSome(provider) && + (yield* result.provider.available()).some((item) => item.id === provider.value.id) + ) { const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option) if (Option.isSome(model) && model.value.enabled) return model } @@ -300,11 +282,11 @@ export const layer = Layer.effect( small: Effect.fn("CatalogV2.model.small")(function* (providerID) { const record = state.get().providers.get(providerID) if (!record) return Option.none() - const provider = project(record.provider, yield* active()) + const provider = record.provider if (providerID === ProviderV2.ID.opencode) { const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano")) - if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano, provider)) + if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider)) } const candidates = pipe( @@ -332,7 +314,7 @@ export const layer = Layer.effect( return pipe( items, Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number), - Array.map((item) => resolve(item.model, provider)), + Array.map((item) => projectModel(item.model, provider)), Array.head, ) } @@ -353,6 +335,7 @@ export const layer = Layer.effect( const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ export const locationLayer = layer.pipe( + Layer.provideMerge(Integration.locationLayer), Layer.provideMerge(PluginV2.locationLayer), Layer.provideMerge(Policy.locationLayer), ) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 0d31b32d08f..47a3712e3ad 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -3,6 +3,7 @@ export * as ConfigProviderPlugin from "./provider" import { Effect } from "effect" import { Catalog } from "../../catalog" import { Config } from "../../config" +import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { ModelRequest } from "../../model-request" import { PluginV2 } from "../../plugin" @@ -13,9 +14,33 @@ export const Plugin = PluginV2.define({ effect: Effect.gen(function* () { const catalog = yield* Catalog.Service const config = yield* Config.Service + const integrations = yield* Integration.Service const transform = yield* catalog.transform() + const integrationTransform = yield* integrations.transform() const entries = yield* config.entries() const files = entries.filter((entry): entry is Config.Document => entry.type === "document") + const configuredIntegrations = new Set( + files.flatMap((file) => + Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])), + ), + ) + yield* integrationTransform((integrations) => { + for (const file of files) { + for (const [id, item] of Object.entries(file.info.providers ?? {})) { + const integrationID = Integration.ID.make(id) + if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue + integrations.update(integrationID, (integration) => { + integration.name = item.name ?? integration.name + }) + if (item.env !== undefined) { + integrations.method.update({ + integrationID, + method: { type: "env", names: [...item.env] }, + }) + } + } + } + }) yield* transform((catalog) => { const configuredDefault = Config.latest(entries, "model") @@ -28,8 +53,6 @@ export const Plugin = PluginV2.define({ const providerID = ProviderV2.ID.make(id) catalog.provider.update(providerID, (provider) => { if (item.name !== undefined) provider.name = item.name - if (item.env !== undefined) provider.env = [...item.env] - provider.enabled = { via: "custom", data: {} } if (item.api !== undefined) provider.api = { ...item.api } if (item.request !== undefined) { Object.assign(provider.request.headers, item.request.headers) diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index 11a987fac37..6dc4bae1ee8 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -102,6 +102,8 @@ export interface Interface { readonly all: () => Effect.Effect /** Returns stored credentials belonging to one integration. */ readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect + /** Returns one stored credential by ID. */ + readonly get: (id: ID) => Effect.Effect /** Replaces any credential for an integration and returns the new record. */ readonly create: (input: { readonly integrationID: IntegrationSchema.ID @@ -350,6 +352,11 @@ export const layer = Layer.effect( return credential ? [credential] : [] }) }), + get: Effect.fn("Credential.get")(function* (id) { + if (isolated) return find(id) // kilocode_change - injected workspace credentials are process-local + const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie) + return row ? stored(row) : undefined + }), create: Effect.fn("Credential.create")(function* (input) { const credential = new Stored({ id: ID.create(), diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 64053ee44ca..7e007aa8349 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -29,15 +29,16 @@ export const When = Schema.Struct({ }).annotate({ identifier: "Integration.When" }) export type When = typeof When.Type -export class TextPrompt extends Schema.Class("Integration.TextPrompt")({ +export const TextPrompt = Schema.Struct({ type: Schema.Literal("text"), key: Schema.String, message: Schema.String, placeholder: Schema.optional(Schema.String), when: Schema.optional(When), -}) {} +}).annotate({ identifier: "Integration.TextPrompt" }) +export type TextPrompt = typeof TextPrompt.Type -export class SelectPrompt extends Schema.Class("Integration.SelectPrompt")({ +export const SelectPrompt = Schema.Struct({ type: Schema.Literal("select"), key: Schema.String, message: Schema.String, @@ -49,27 +50,31 @@ export class SelectPrompt extends Schema.Class("Integration.Select }), ), when: Schema.optional(When), -}) {} +}).annotate({ identifier: "Integration.SelectPrompt" }) +export type SelectPrompt = typeof SelectPrompt.Type export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type")) export type Prompt = typeof Prompt.Type -export class OAuthMethod extends Schema.Class("Integration.OAuthMethod")({ +export const OAuthMethod = Schema.Struct({ id: MethodID, type: Schema.Literal("oauth"), label: Schema.String, prompts: Schema.optional(Schema.Array(Prompt)), -}) {} +}).annotate({ identifier: "Integration.OAuthMethod" }) +export type OAuthMethod = typeof OAuthMethod.Type -export class KeyMethod extends Schema.Class("Integration.KeyMethod")({ +export const KeyMethod = Schema.Struct({ type: Schema.Literal("key"), label: Schema.optional(Schema.String), -}) {} +}).annotate({ identifier: "Integration.KeyMethod" }) +export type KeyMethod = typeof KeyMethod.Type -export class EnvMethod extends Schema.Class("Integration.EnvMethod")({ +export const EnvMethod = Schema.Struct({ type: Schema.Literal("env"), names: Schema.Array(Schema.String), -}) {} +}).annotate({ identifier: "Integration.EnvMethod" }) +export type EnvMethod = typeof EnvMethod.Type export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type")) export type Method = typeof Method.Type @@ -197,7 +202,11 @@ export interface Interface { readonly get: (id: ID) => Effect.Effect /** Returns all integrations with their methods and current connections. */ readonly list: () => Effect.Effect - readonly connect: { + readonly connection: { + /** Returns active connections for every registered or credential-backed integration. */ + readonly list: () => Effect.Effect> + /** Returns the active connection for one integration. */ + readonly forIntegration: (id: ID) => Effect.Effect /** Runs a key method and stores the resulting credential. */ readonly key: (input: { /** Integration receiving the credential. */ @@ -218,6 +227,13 @@ export interface Interface { /** User-facing label for the credential created on completion. */ readonly label?: string }) => Effect.Effect + /** Updates a stored credential exposed as a connection. */ + readonly update: ( + credentialID: Credential.ID, + updates: Partial>, + ) => Effect.Effect + /** Removes a stored credential connection. */ + readonly remove: (credentialID: Credential.ID) => Effect.Effect } readonly attempt: { /** Returns the current state of an OAuth attempt. */ @@ -328,23 +344,32 @@ export const locationLayer = Layer.effect( }) const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => { - const connected = saved.map( - (credential) => - new IntegrationConnection.CredentialInfo({ type: "credential", id: credential.id, label: credential.label }), - ) + const connected = saved.map((credential) => ({ + type: "credential" as const, + id: credential.id, + label: credential.label, + })) const detected = entry.methods .filter((method) => method.type === "env") .flatMap((method) => method.names.filter((name) => process.env[name])) - .map( - (name, index) => - new IntegrationConnection.EnvInfo({ - type: "env", - name, - }), - ) + .map((name) => ({ type: "env" as const, name })) return [...connected, ...detected] } + const activeConnection = ( + entry: Entry | undefined, + saved: readonly Credential.Stored[], + ): IntegrationConnection.Info | undefined => { + const credential = saved.at(-1) + if (credential) return { type: "credential", id: credential.id, label: credential.label } + if (!entry) return + const name = entry.methods + .filter((method) => method.type === "env") + .flatMap((method) => method.names) + .find((name) => process.env[name]) + if (name) return { type: "env", name } + } + const project = (entry: Entry, saved: readonly Credential.Stored[]) => new Info({ id: entry.ref.id, @@ -412,6 +437,7 @@ export const locationLayer = Layer.effect( return [attempt, new Map(current).set(attemptID, terminal)] }) if (!result) return settled + if (Exit.isSuccess(settled)) yield* events.publish(Event.Updated, {}) yield* close(result.scope) return settled }), @@ -454,8 +480,21 @@ export const locationLayer = Layer.effect( }), )).toSorted((a, b) => a.name.localeCompare(b.name)) }), - connect: { - key: Effect.fn("Integration.connect.key")(function* (input) { + connection: { + list: Effect.fn("Integration.connection.list")(function* () { + const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID) + return new Map( + new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => { + const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? []) + return connection ? [[id, connection] as const] : [] + }), + ) + }), + forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) { + const entry = state.get().integrations.get(id) + return activeConnection(entry, yield* credentials.list(id)) + }), + key: Effect.fn("Integration.connection.key")(function* (input) { const method = state .get() .integrations.get(input.integrationID) @@ -466,8 +505,9 @@ export const locationLayer = Layer.effect( label: input.label, value: new Credential.Key({ type: "key", key: input.key }), }) + yield* events.publish(Event.Updated, {}) }), - oauth: Effect.fn("Integration.connect.oauth")(function* (input) { + oauth: Effect.fn("Integration.connection.oauth")(function* (input) { const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID) if (!method) { return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`) @@ -510,6 +550,14 @@ export const locationLayer = Layer.effect( time, }) }), + update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) { + yield* credentials.update(credentialID, updates) + yield* events.publish(Event.Updated, {}) + }), + remove: Effect.fn("Integration.connection.remove")(function* (credentialID) { + yield* credentials.remove(credentialID) + yield* events.publish(Event.Updated, {}) + }), }, attempt: { status: Effect.fn("Integration.attempt.status")(function* (attemptID) { diff --git a/packages/core/src/integration/connection.ts b/packages/core/src/integration/connection.ts index a190ebf78c0..200cf265800 100644 --- a/packages/core/src/integration/connection.ts +++ b/packages/core/src/integration/connection.ts @@ -3,16 +3,18 @@ export * as IntegrationConnection from "./connection" import { Schema } from "effect" import { Credential } from "../credential" -export class CredentialInfo extends Schema.Class("Connection.CredentialInfo")({ +export const CredentialInfo = Schema.Struct({ type: Schema.Literal("credential"), id: Credential.ID, label: Schema.String, -}) {} +}).annotate({ identifier: "Connection.CredentialInfo" }) +export type CredentialInfo = typeof CredentialInfo.Type -export class EnvInfo extends Schema.Class("Connection.EnvInfo")({ +export const EnvInfo = Schema.Struct({ type: Schema.Literal("env"), name: Schema.String, -}) {} +}).annotate({ identifier: "Connection.EnvInfo" }) +export type EnvInfo = typeof EnvInfo.Type export const Info = Schema.Union([CredentialInfo, EnvInfo]) .pipe(Schema.toTaggedUnion("type")) diff --git a/packages/core/src/kilocode/powershell.ts b/packages/core/src/kilocode/powershell.ts new file mode 100644 index 00000000000..866f215e043 --- /dev/null +++ b/packages/core/src/kilocode/powershell.ts @@ -0,0 +1,126 @@ +export function args(command: string) { + return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script(command)] +} + +const setup = `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false); +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); +$OutputEncoding = [Console]::OutputEncoding; +` + +function script(command: string) { + const pos = prologue(command) + const head = command.slice(0, pos) + const body = command.slice(pos) + const gap = head && !/[;\r\n]\s*$/.test(head) ? "\n" : "" + return `${head}${gap}${setup}${body}` +} + +function prologue(command: string) { + const pos = scan(command, 0) + const attr = attrs(command, pos) + const body = command.slice(attr) + const match = /^param\s*\(/i.exec(body) + if (!match) return pos + + const start = attr + match[0].lastIndexOf("(") + const end = block(command, start, "(", ")") + if (end === undefined) return pos + return end +} + +function attrs(command: string, start: number) { + let pos = start + while (pos < command.length) { + const next = scan(command, pos) + if (command[next] !== "[") return next + const end = block(command, next, "[", "]") + if (end === undefined) return start + pos = end + } + return pos +} + +function scan(command: string, start: number) { + let pos = start + while (pos < command.length) { + const next = trivia(command, pos) + if (next !== pos) { + pos = next + continue + } + const end = line(command, pos) + const value = command.slice(pos, end) + if (/^using\s+(?:assembly|module|namespace|type)\b/i.test(value)) { + pos = end + continue + } + return pos + } + return pos +} + +function trivia(command: string, start: number) { + let pos = start + while (pos < command.length) { + while (/\s/.test(command[pos] ?? "")) pos++ + if (command[pos] === "#") { + pos = line(command, pos) + continue + } + if (command.startsWith("<#", pos)) { + const end = command.indexOf("#>", pos + 2) + if (end === -1) return command.length + pos = end + 2 + continue + } + return pos + } + return pos +} + +function line(command: string, start: number) { + const index = command.indexOf("\n", start) + if (index === -1) return command.length + return index + 1 +} + +function block(command: string, start: number, open: string, close: string) { + let depth = 0 + let quote: string | undefined + for (let pos = start; pos < command.length; pos++) { + const char = command[pos] + if (quote) { + if (quote === "'" && char === "'" && command[pos + 1] === "'") { + pos++ + continue + } + if (quote === '"' && char === "`") { + pos++ + continue + } + if (char === quote) quote = undefined + continue + } + if (char === "'" || char === '"') { + quote = char + continue + } + if (command.startsWith("<#", pos)) { + const end = command.indexOf("#>", pos + 2) + if (end === -1) return + pos = end + 1 + continue + } + if (char === "#") { + pos = line(command, pos) - 1 + continue + } + if (char === open) depth++ + if (char === close) { + depth-- + if (depth === 0) return pos + 1 + } + } +} + +export const PowerShell = { args } diff --git a/packages/core/src/kilocode/pty-self-command.ts b/packages/core/src/kilocode/pty-self-command.ts new file mode 100644 index 00000000000..360e094bda7 --- /dev/null +++ b/packages/core/src/kilocode/pty-self-command.ts @@ -0,0 +1,61 @@ +import path from "path" + +type Input = { + command?: string + args?: string[] + cwd?: string +} + +type Command = { + command: string + args: string[] + cwd?: string +} + +const names = new Set(["kilo", "kilocode"]) +const self = command() + +function clean(input: string[]) { + return input.filter((arg, index) => { + if (arg === "--cwd") return false + if (input[index - 1] === "--cwd") return false + if (arg.startsWith("--cwd=")) return false + return true + }) +} + +function full(input: string, cwd: string) { + if (path.isAbsolute(input)) return input + return path.resolve(cwd, input) +} + +export function command( + proc = { argv: process.argv, execArgv: process.execArgv, execPath: process.execPath, cwd: process.cwd() }, +): Command { + const script = proc.argv[1] + const bundled = script?.startsWith("/$bunfs/") || (script ? /^[A-Za-z]:[\\/]~BUN[\\/]/.test(script) : false) + if (script && !bundled && /\.(ts|js|mjs|cjs)$/.test(script)) { + const file = full(script, proc.cwd) + const dir = path.dirname(file) + const root = path.basename(dir) === "src" ? path.dirname(dir) : proc.cwd + return { command: full(proc.execPath, proc.cwd), args: [...clean(proc.execArgv), file], cwd: root } + } + return { command: full(proc.execPath, proc.cwd), args: [] } +} + +export function resolve(input: Input, cmd = self): Input { + if (!input.command || !names.has(input.command)) return input + const args = input.args ?? [] + const project = cmd.cwd && args.length === 0 && input.cwd ? [input.cwd] : [] + return { + ...input, + command: cmd.command, + args: [...cmd.args, ...project, ...args], + cwd: cmd.cwd ?? input.cwd, + } +} + +export const KiloPtySelfCommand = { + command, + resolve, +} diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index ebfb096f130..9225e4a01a1 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -1,6 +1,6 @@ import { Context, Effect, Layer, Schema } from "effect" import { Project } from "./project" -import { AbsolutePath } from "./schema" +import { AbsolutePath, optionalOmitUndefined } from "./schema" import { WorkspaceV2 } from "./workspace" export * as Location from "./location" @@ -12,7 +12,7 @@ export class Ref extends Schema.Class("Location.Ref")({ export class Info extends Schema.Class("Location.Info")({ directory: AbsolutePath, - workspaceID: WorkspaceV2.ID.pipe(Schema.optional), + workspaceID: optionalOmitUndefined(WorkspaceV2.ID), project: Schema.Struct({ id: Project.ID, directory: AbsolutePath, diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 75782714098..694b0bc5649 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -21,7 +21,6 @@ import { PluginV2 } from "../plugin" import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" import { ConfigProviderPlugin } from "../config/plugin/provider" -import { EnvPlugin } from "./env" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" import { SkillV2 } from "../skill" @@ -98,7 +97,6 @@ export const layer = Layer.effect( }) const boot = Effect.gen(function* () { - yield* add(EnvPlugin) yield* add(AgentPlugin.Plugin) yield* add(CommandPlugin.Plugin) // kilocode_change - Kilo's CLI registry supplies `kilo-config`; do not register the redundant opencode skill. diff --git a/packages/core/src/plugin/env.ts b/packages/core/src/plugin/env.ts deleted file mode 100644 index 35e6981a40c..00000000000 --- a/packages/core/src/plugin/env.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Effect } from "effect" -import { PluginV2 } from "../plugin" - -export const EnvPlugin = PluginV2.define({ - id: PluginV2.ID.make("env"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - const key = item.provider.env.find((env) => process.env[env]) - if (!key) continue - evt.provider.update(item.provider.id, (provider) => { - provider.enabled = { - via: "env", - name: key, - } - }) - } - }), - } - }), -}) diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index e34c11f7889..a212d013ad1 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -70,16 +70,11 @@ export const ModelsDevPlugin = PluginV2.define({ integrations.update(integrationID, (integration) => (integration.name = item.name)) integrations.method.update({ integrationID, - method: new Integration.KeyMethod({ - type: "key", - }), + method: { type: "key" }, }) integrations.method.update({ integrationID, - method: new Integration.EnvMethod({ - type: "env", - names: [...item.env], - }), + method: { type: "env", names: [...item.env] }, }) } }) @@ -88,7 +83,6 @@ export const ModelsDevPlugin = PluginV2.define({ const providerID = ProviderV2.ID.make(item.id) catalog.provider.update(providerID, (provider) => { provider.name = item.name - provider.env = [...item.env] provider.api = item.npm ? { type: "aisdk", diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index afb3a183e50..ba7856b6357 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -24,7 +24,7 @@ export const CloudflareAIGatewayPlugin = PluginV2.define({ apiKey: config.apiKey, options: gatewayOptions(evt.options, metadata), } as any) - const unified = createUnified() + const unified = createUnified({ apiKey: config.apiKey }) evt.sdk = { languageModel(modelID: string) { return gateway(unified(modelID)) diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index 78fdda19954..bf6666c2521 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -27,9 +27,9 @@ export const KiloPlugin = PluginV2.define({ provider.request.headers["HTTP-Referer"] = "https://kilo.ai/" // kilocode_change start provider.request.headers["X-Title"] = "Kilo Code" - options.kilocodeToken = token ?? "anonymous" + options.apiKey = token ?? "anonymous" + options.kilocodeToken = options.apiKey if (org) options.kilocodeOrganizationId = org - if (!provider.enabled) provider.enabled = { via: "custom", data: { anonymous: true } } // kilocode_change end }) } diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index d8769aa980a..b416abd2848 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -1,20 +1,23 @@ import { Effect } from "effect" +import { Integration } from "../../integration" import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" // kilocode_change export const LLMGatewayPlugin = PluginV2.define({ id: PluginV2.ID.make("llmgateway"), effect: Effect.gen(function* () { + const integrations = yield* Integration.Service return { "catalog.transform": Effect.fn(function* (evt) { for (const item of evt.provider.list()) { - if (item.provider.enabled === false) continue + if (item.provider.disabled) continue + if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue if (item.provider.id !== ProviderV2.ID.make("llmgateway")) continue // kilocode_change evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["HTTP-Referer"] = "https://kilo.ai/" // kilocode_change + provider.request.headers["HTTP-Referer"] = "https://kilo.ai/" // kilocode_change start provider.request.headers["X-Title"] = "Kilo Code" provider.request.headers["X-Source"] = "kilo" diff --git a/packages/core/src/plugin/provider/openai-auth.ts b/packages/core/src/plugin/provider/openai-auth.ts index c28757ebdf8..fb9e8d300aa 100644 --- a/packages/core/src/plugin/provider/openai-auth.ts +++ b/packages/core/src/plugin/provider/openai-auth.ts @@ -32,11 +32,11 @@ const headlessMethodID = Integration.MethodID.make("chatgpt-headless") export const browser = { integrationID: Integration.ID.make("openai"), - method: new Integration.OAuthMethod({ + method: { id: browserMethodID, type: "oauth", label: "ChatGPT Pro/Plus (browser)", - }), + }, authorize: () => Effect.gen(function* () { const pkce = yield* Effect.promise(generatePKCE) @@ -95,11 +95,11 @@ export const browser = { export const headless = { integrationID: Integration.ID.make("openai"), - method: new Integration.OAuthMethod({ + method: { id: headlessMethodID, type: "oauth", label: "ChatGPT Pro/Plus (headless)", - }), + }, authorize: () => Effect.gen(function* () { const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>( @@ -258,6 +258,6 @@ function claim(token: string) { } const successPage = - "Kilo

Authorization successful

You can close this window.

" // kilocode_change + "Kilo

Authorization successful

You can close this window.

" const errorPage = (message: string) => - `Kilo

Authorization failed

${message.replace(/[&<>"']/g, "")}

` // kilocode_change + `Kilo

Authorization failed

${message.replace(/[&<>"']/g, "")}

` diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 67fd7816a3f..56e71f822dd 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -1,20 +1,20 @@ import { Effect } from "effect" +import { Integration } from "../../integration" import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" export const OpencodePlugin = PluginV2.define({ id: PluginV2.ID.make("opencode"), effect: Effect.gen(function* () { + const integrations = yield* Integration.Service let hasKey = false return { "catalog.transform": Effect.fn(function* (evt) { const item = evt.provider.get(ProviderV2.ID.opencode) if (!item) return + const integration = yield* integrations.get(Integration.ID.make(item.provider.id)) hasKey = Boolean( - process.env.OPENCODE_API_KEY || - item.provider.env.some((env) => process.env[env]) || - item.provider.request.body.apiKey || - (item.provider.enabled && item.provider.enabled.via === "credential"), + process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey, ) evt.provider.update(item.provider.id, (provider) => { if (!hasKey) provider.request.body.apiKey = "public" diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 7c89ac8e337..620fdc8b9ab 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -23,7 +23,7 @@ export const Plugin = PluginV2.define({ skill: new SkillV2.Info({ name: "customize-opencode", description: - "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.", + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.", location: AbsolutePath.make("/builtin/customize-opencode.md"), content: CustomizeOpencodeContent, }), diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md index 1c1cbdf3c29..f5235cc23f4 100644 --- a/packages/core/src/plugin/skill/customize-opencode.md +++ b/packages/core/src/plugin/skill/customize-opencode.md @@ -43,6 +43,8 @@ already-loaded config until then. | Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) | | Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | | Global agents | `~/.config/opencode/agent(s)/.md` | +| Project commands | `.opencode/command/.md` or `.opencode/commands/.md` | +| Global commands | `~/.config/opencode/command(s)/.md` | | Project skills | `.opencode/skill(s)//SKILL.md` | | Global skills | `~/.config/opencode/skill(s)//SKILL.md` | | External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` | @@ -96,7 +98,7 @@ Every field is optional. }, "command": { - "deploy": { "description": "...", "prompt": "..." } + "deploy": { "description": "...", "template": "..." } }, "provider": { @@ -151,6 +153,7 @@ Shape notes worth being explicit about: - `skills` is an object with `paths` and/or `urls`, not an array. - `references` is an object keyed by alias. Each value is a local path, Git repository, or string shorthand. - `agent` is an object keyed by agent name, not an array. +- `command` is an object keyed by command name, not an array. - `plugin` is an array of strings or `[name, options]` tuples, not an object. - `mcp[name].command` is an array of strings, never a single string. `type` is required. - `permission` is either a string action or an object keyed by tool name. @@ -277,6 +280,31 @@ opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agent `compaction`, `title`, `summary`. To override a built-in's fields, define the same key in `agent: { : { ... } }`. +## Commands + +opencode's command loader scans for `**/*.md` inside command directories. The +file is named after the command, and lives directly inside the `command` folder: + +``` +.opencode/command/deploy.md +``` + +Frontmatter: + +```markdown +--- +description: One sentence describing what the command does. +agent: build +model: anthropic/claude-sonnet-4-6 +--- + +(command body in markdown: the prompt opencode runs, with $ARGUMENTS for the user's input) +``` + +- `template` is the command body — everything below the frontmatter — and is required: it is the prompt opencode runs when the command is invoked. Do not also put a `template:` key in the frontmatter. +- `$ARGUMENTS` is replaced with everything the user typed after the command; `$1`, `$2`, … pull individual positional arguments. +- Optional: `description`, `agent`, `model`, `variant`, `subtask`. + ## Plugins `plugin:` is an array. Each entry is one of: @@ -300,7 +328,7 @@ function, not a plain object literal, and the function returns an object (return `{}` if there is nothing to register). ```ts -import type { Plugin } from "@opencode-ai/plugin" +import type { Plugin } from "@kilocode/plugin" export default (async ({ client, project, directory, $ }) => { return { @@ -397,16 +425,16 @@ the `plan` agent's permission ruleset (`edit: deny *`). When a user's config is broken and opencode won't start, these env vars help: -- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json` +- `KILO_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json` and start from globals only. Run from the project directory, opencode loads, the user edits the broken file, then they restart without the flag. -- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config. -- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`: +- `KILO_CONFIG=/path/to/file.json`: load an additional explicit config. +- `KILO_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`: inject inline JSON as a final local-scope merge. -- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins. -- `OPENCODE_PURE=1`: skip external plugins entirely. -- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`, - `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under +- `KILO_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins. +- `KILO_PURE=1`: skip external plugins entirely. +- `KILO_DISABLE_EXTERNAL_SKILLS=1`, + `KILO_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under `~/.claude/` and `~/.agents/`. ## When proposing edits @@ -415,8 +443,8 @@ When a user's config is broken and opencode won't start, these env vars help: exact shape, or the field is not covered in this skill, fetch `https://opencode.ai/config.json` and read the schema rather than guessing. - Preserve `$schema` and any existing fields the user did not ask to change. -- For agent, skill, and plugin definitions, prefer creating new files in the - correct location over inlining everything in `opencode.json`. +- For agent, command, skill, and plugin definitions, prefer creating new files + in the correct location over inlining everything in `opencode.json`. - If the user's existing config is malformed, point them at the env-var escape hatches above so they can edit from inside opencode without breaking their session. diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index 670b0d2dcba..0e3246b3b2a 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -245,6 +245,7 @@ export const layer = Layer.effect( (sourceDirectory) => Effect.forEach(strategies(), (strategy) => strategy.list(sourceDirectory).pipe( + Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])), Effect.map((items) => items.map((item) => ({ directory: item.directory, diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 58b5e9f813d..0d6084257b5 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -2,7 +2,6 @@ export * as ProviderV2 from "./provider" import { withStatics } from "./schema" import { Schema } from "effect" -import { Credential } from "./credential" export const ID = Schema.String.pipe( Schema.brand("ProviderV2.ID"), @@ -49,22 +48,7 @@ export type Request = typeof Request.Type export class Info extends Schema.Class("ProviderV2.Info")({ id: ID, name: Schema.String, - enabled: Schema.Union([ - Schema.Literal(false), - Schema.Struct({ - via: Schema.Literal("env"), - name: Schema.String, - }), - Schema.Struct({ - via: Schema.Literal("credential"), - credentialID: Credential.ID, - }), - Schema.Struct({ - via: Schema.Literal("custom"), - data: Schema.Record(Schema.String, Schema.Any), - }), - ]), - env: Schema.String.pipe(Schema.Array), + disabled: Schema.Boolean.pipe(Schema.optional), api: Api, request: Request, }) { @@ -72,8 +56,6 @@ export class Info extends Schema.Class("ProviderV2.Info")({ return new Info({ id: providerID, name: providerID, - enabled: false, - env: [], api: { type: "native", settings: {}, diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index 79ddc99b639..28e48f6b051 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -2,23 +2,29 @@ export * as Pty from "./pty" import type { Disp, Proc } from "#pty" import { Context, Effect, Layer, Schema, Types } from "effect" +import { Config } from "./config" import { EventV2 } from "./event" import { Location } from "./location" import { NonNegativeInt, PositiveInt } from "./schema" import { PtyID } from "./pty/schema" import { SessionSchema } from "./session/schema" // kilocode_change +import { Shell } from "./shell" import { lazy } from "./util/lazy" +import { KiloPtySelfCommand } from "./kilocode/pty-self-command" // kilocode_change const BUFFER_LIMIT = 1024 * 1024 * 2 -const BUFFER_CHUNK = 64 * 1024 -const encoder = new TextEncoder() +// Exited sessions stay observable (status, exit code, retained output) until removed explicitly. +// Cap retention so abandoned terminals do not accumulate unbounded buffers. +const EXITED_LIMIT = 25 const pty = lazy(() => import("#pty")) -type Socket = { - readyState: number - data?: unknown - send: (data: string | Uint8Array | ArrayBuffer) => void - close: (code?: number, reason?: string) => void +type Subscriber = { + readonly onData: (chunk: string) => void + readonly onEnd: (event: { exitCode?: number }) => void + active: boolean + detached: boolean + pending: string[] + end?: { exitCode?: number } } type Active = { @@ -27,22 +33,10 @@ type Active = { buffer: string bufferCursor: number cursor: number - subscribers: Map + subscribers: Map listeners: Disp[] } -const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws) - -// WebSocket control frame: 0x00 + UTF-8 JSON. -const meta = (cursor: number) => { - const json = JSON.stringify({ cursor }) - const bytes = encoder.encode(json) - const out = new Uint8Array(bytes.length + 1) - out[0] = 0 - out.set(bytes, 1) - return out -} - export const Info = Schema.Struct({ id: PtyID, title: Schema.String, @@ -52,6 +46,8 @@ export const Info = Schema.Struct({ status: Schema.Literals(["running", "exited"]), // Windows ConPTY assigns the child pid asynchronously, so 0 is valid at spawn time. pid: NonNegativeInt, + // Present once status is "exited". + exitCode: Schema.optional(NonNegativeInt), sessionID: Schema.optional(Schema.NullOr(SessionSchema.ID)), // kilocode_change }).annotate({ identifier: "Pty" }) @@ -67,14 +63,6 @@ export const CreateInput = Schema.Struct({ export type CreateInput = Types.DeepMutable -export type PreparedCreate = { - readonly command: string - readonly args: string[] - readonly cwd: string - readonly title?: string - readonly env: Record -} - export const UpdateInput = Schema.Struct({ title: Schema.optional(Schema.String), sessionID: Schema.optional(Schema.NullOr(SessionSchema.ID)), // kilocode_change @@ -88,10 +76,34 @@ export const UpdateInput = Schema.Struct({ export type UpdateInput = Types.DeepMutable +export type AttachInput = { + // Absolute output cursor to replay from. -1 tails from the current end; omitted replays the full retained buffer. + readonly cursor?: number + // Callbacks fire synchronously from the native PTY data path; keep them non-blocking. + readonly onData: (chunk: string) => void + // Fired once when the session stops producing output: process exit (exitCode set), removal, or service teardown. + readonly onEnd: (event: { exitCode?: number }) => void +} + +export type Attachment = { + // Retained output from the requested cursor to the current end. + readonly replay: string + // Absolute output cursor after replay. + readonly cursor: number + readonly write: (data: string) => void + // Starts live delivery after the caller has applied replay and cursor metadata. + readonly activate: () => void + readonly detach: () => void +} + export class NotFoundError extends Schema.TaggedErrorClass()("Pty.NotFoundError", { ptyID: PtyID, }) {} +export class ExitedError extends Schema.TaggedErrorClass()("Pty.ExitedError", { + ptyID: PtyID, +}) {} + export const Event = { Created: EventV2.define({ type: "pty.created", schema: { info: Info } }), Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }), @@ -102,19 +114,11 @@ export const Event = { export interface Interface { readonly list: () => Effect.Effect readonly get: (id: PtyID) => Effect.Effect - readonly create: (input: PreparedCreate) => Effect.Effect + readonly create: (input: CreateInput) => Effect.Effect readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect readonly remove: (id: PtyID) => Effect.Effect - readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect readonly write: (id: PtyID, data: string) => Effect.Effect - readonly connect: ( - id: PtyID, - ws: Socket, - cursor?: number, - ) => Effect.Effect< - { onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined, - NotFoundError - > + readonly attach: (id: PtyID, input: AttachInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Pty") {} @@ -124,28 +128,41 @@ export const layer = Layer.effect( Effect.gen(function* () { const events = yield* EventV2.Service const location = yield* Location.Service + const config = yield* Config.Service const context = yield* Effect.context() const runFork = Effect.runForkWith(context) const sessions = new Map() + const exitOrder: PtyID[] = [] + + function notifyEnd(session: Active, event: { exitCode?: number }) { + for (const subscriber of session.subscribers.values()) { + if (!subscriber.active) { + subscriber.end = event + continue + } + try { + subscriber.onEnd(event) + } catch {} + } + session.subscribers.clear() + } function teardown(session: Active) { for (const listener of session.listeners) listener.dispose() session.listeners.length = 0 - try { - session.process.kill() - } catch {} - for (const [sub, ws] of session.subscribers.entries()) { + if (session.info.status === "running") { try { - if (sock(ws) === sub) ws.close() + session.process.kill() } catch {} } - session.subscribers.clear() + notifyEnd(session, {}) } yield* Effect.addFinalizer(() => Effect.sync(() => { for (const session of sessions.values()) teardown(session) sessions.clear() + exitOrder.length = 0 }), ) @@ -157,12 +174,13 @@ export const layer = Layer.effect( const removeSession = Effect.fnUntraced(function* (id: PtyID) { const session = sessions.get(id) - if (!session) return false + if (!session) return sessions.delete(id) + const index = exitOrder.indexOf(id) + if (index !== -1) exitOrder.splice(index, 1) yield* Effect.logInfo("removing session", { id }) teardown(session) yield* events.publish(Event.Deleted, { id: session.info.id }) - return true }) const remove = Effect.fn("Pty.remove")(function* (id: PtyID) { @@ -178,29 +196,48 @@ export const layer = Layer.effect( return (yield* requireSession(id)).info }) - const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) { + const create = Effect.fn("Pty.create")(function* (input: CreateInput) { const id = PtyID.ascending() - yield* Effect.logInfo("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd }) + // kilocode_change start - resolve Kilo self-commands to the real binary, arguments, and project cwd + const resolved = KiloPtySelfCommand.resolve({ + command: input.command, + args: input.args ? [...input.args] : undefined, + cwd: input.cwd, + }) + const command = resolved.command || Shell.preferred(Config.latest(yield* config.entries(), "shell")) + const base = resolved.args ?? [] + const args = Shell.login(command) ? [...base, "-l"] : [...base] + const cwd = resolved.cwd || location.directory + // kilocode_change end + const env = { + ...process.env, + ...input.env, + TERM: "xterm-256color", + KILO_TERMINAL: "1", + KILO_PTY_ID: id, // kilocode_change - let nested Kilo processes identify their parent terminal + } as Record + // kilocode_change start - do not expose local server credentials to user terminals. + // node-pty inherits parent values for omitted keys, so empty tombstones are required. + env.KILO_SERVER_PASSWORD = "" + env.KILO_SERVER_USERNAME = "" + // kilocode_change end + if (process.platform === "win32") { + env.LC_ALL = "C.UTF-8" + env.LC_CTYPE = "C.UTF-8" + env.LANG = "C.UTF-8" + } + yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd }) const { spawn } = yield* Effect.promise(() => pty()) - // kilocode_change - expose the pty id to the spawned shell so a nested `kilo tui`/`kilo run` can - // detect it is running inside a kilo-spawned terminal (read via process.env.KILO_PTY_ID) - const env = { ...input.env, KILO_PTY_ID: id } - const proc = yield* Effect.sync(() => - spawn(input.command, input.args, { - name: "xterm-256color", - cwd: input.cwd, - env, - }), - ) - const info = { + const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env })) + const info: Info = { id, title: input.title || `Terminal ${id.slice(-4)}`, - command: input.command, - args: input.args, - cwd: input.cwd, + command, + args, + cwd, status: "running", pid: proc.pid, - } as const + } const session: Active = { info, process: proc, @@ -214,15 +251,15 @@ export const layer = Layer.effect( session.listeners.push( proc.onData((chunk) => { session.cursor += chunk.length - for (const [key, ws] of session.subscribers.entries()) { - if (ws.readyState !== 1 || sock(ws) !== key) { - session.subscribers.delete(key) + for (const [token, subscriber] of session.subscribers.entries()) { + if (!subscriber.active) { + subscriber.pending.push(chunk) continue } try { - ws.send(chunk) + subscriber.onData(chunk) } catch { - session.subscribers.delete(key) + session.subscribers.delete(token) } } session.buffer += chunk @@ -233,12 +270,19 @@ export const layer = Layer.effect( }), proc.onExit(({ exitCode }) => { if (session.info.status === "exited") return + session.info.status = "exited" + session.info.exitCode = exitCode + notifyEnd(session, { exitCode }) + exitOrder.push(id) runFork( Effect.gen(function* () { yield* Effect.logInfo("session exited", { id, exitCode }) - session.info.status = "exited" yield* events.publish(Event.Exited, { id, exitCode }) - yield* removeSession(id) + while (exitOrder.length > EXITED_LIMIT) { + const oldest = exitOrder[0] + if (!oldest) break + yield* removeSession(oldest) + } }), ) }), @@ -253,66 +297,71 @@ export const layer = Layer.effect( // kilocode_change start - associate nested Kilo TUI terminals with the viewed session if ("sessionID" in input) session.info.sessionID = input.sessionID ?? undefined // kilocode_change end - if (input.size) session.process.resize(input.size.cols, input.size.rows) + if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows) yield* events.publish(Event.Updated, { info: session.info }) return session.info }) - const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) { - const session = yield* requireSession(id) - if (session.info.status === "running") session.process.resize(cols, rows) - }) - const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) { const session = yield* requireSession(id) if (session.info.status === "running") session.process.write(data) }) - const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) { - const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close()))) - yield* Effect.logInfo("client connected to session", { id, directory: location.directory }) - const sub = sock(ws) - session.subscribers.delete(sub) - session.subscribers.set(sub, ws) - const cleanup = () => session.subscribers.delete(sub) + const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) { + const session = yield* requireSession(id) + if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id }) + yield* Effect.logInfo("client attached to session", { id, directory: location.directory }) + const token = {} + const subscriber: Subscriber = { + onData: input.onData, + onEnd: input.onEnd, + active: false, + detached: false, + pending: [], + } + session.subscribers.set(token, subscriber) const start = session.bufferCursor const end = session.cursor const from = - cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0 - const data = (() => { + input.cursor === -1 + ? end + : typeof input.cursor === "number" && Number.isSafeInteger(input.cursor) + ? Math.max(0, input.cursor) + : 0 + const replay = (() => { if (!session.buffer || from >= end) return "" const offset = Math.max(0, from - start) if (offset >= session.buffer.length) return "" return session.buffer.slice(offset) })() - if (data) { - try { - for (let i = 0; i < data.length; i += BUFFER_CHUNK) ws.send(data.slice(i, i + BUFFER_CHUNK)) - } catch { - cleanup() - ws.close() - return - } - } - try { - ws.send(meta(end)) - } catch { - cleanup() - ws.close() - return - } return { - onMessage: (message: string | ArrayBuffer) => { - session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message)) + replay, + cursor: end, + write: (data: string) => { + if (session.info.status === "running") session.process.write(data) }, - onClose: () => { - cleanup() + activate: () => { + if (subscriber.active || subscriber.detached) return + subscriber.active = true + try { + for (const chunk of subscriber.pending) subscriber.onData(chunk) + subscriber.pending.length = 0 + if (subscriber.end) subscriber.onEnd(subscriber.end) + } catch { + session.subscribers.delete(token) + } + }, + detach: () => { + subscriber.detached = true + subscriber.pending.length = 0 + subscriber.end = undefined + session.subscribers.delete(token) }, } }) - return Service.of({ list, get, create, update, remove, resize, write, connect }) + return Service.of({ list, get, create, update, remove, write, attach }) }), ) -export const locationLayer = layer +export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer)) diff --git a/packages/core/src/pty/input.ts b/packages/core/src/pty/input.ts deleted file mode 100644 index 0e4ea9a61af..00000000000 --- a/packages/core/src/pty/input.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Effect } from "effect" - -const inputDecoder = new TextDecoder("utf-8", { fatal: true }) - -export function handlePtyInput( - handler: { onMessage: (message: string | ArrayBuffer) => void }, - message: string | Uint8Array, -) { - if (typeof message === "string") { - handler.onMessage(message) - return Effect.void - } - return Effect.try({ - try: () => inputDecoder.decode(message), - catch: () => new Error("invalid PTY websocket input"), - }).pipe( - Effect.catch(() => Effect.succeed(undefined)), - Effect.flatMap((decoded) => { - if (decoded === undefined) return Effect.void - handler.onMessage(decoded) - return Effect.void - }), - ) -} diff --git a/packages/core/src/pty/protocol.ts b/packages/core/src/pty/protocol.ts new file mode 100644 index 00000000000..21c6a89f734 --- /dev/null +++ b/packages/core/src/pty/protocol.ts @@ -0,0 +1,37 @@ +export * as PtyProtocol from "./protocol" + +// Wire protocol for PTY websocket transports. The PTY domain service is transport-free; server +// routes adapt Pty.attach to websockets with these helpers so every surface speaks one protocol. +// +// Outbound frames are raw UTF-8 terminal chunks. One control frame — a 0x00 byte followed by +// UTF-8 JSON — carries the absolute output cursor after replay so clients can resume later. + +const encoder = new TextEncoder() +const decoder = new TextDecoder("utf-8", { fatal: true }) + +// Replay can be megabytes; send it in bounded frames. +export const REPLAY_CHUNK = 64 * 1024 + +export function metaFrame(cursor: number) { + const bytes = encoder.encode(JSON.stringify({ cursor })) + const out = new Uint8Array(bytes.length + 1) + out[0] = 0 + out.set(bytes, 1) + return out +} + +export function chunks(data: string) { + const out: string[] = [] + for (let i = 0; i < data.length; i += REPLAY_CHUNK) out.push(data.slice(i, i + REPLAY_CHUNK)) + return out +} + +// Inbound client frames are UTF-8 text or binary; invalid UTF-8 input is dropped. +export function decodeInput(message: string | Uint8Array | ArrayBuffer) { + if (typeof message === "string") return message + try { + return decoder.decode(message instanceof ArrayBuffer ? new Uint8Array(message) : message) + } catch { + return undefined + } +} diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 67a110413e7..4060cc6b044 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -1,7 +1,7 @@ export * as SessionRunner from "./index" import type { LLMError } from "@opencode-ai/llm" -import { Context, Effect, Schema } from "effect" +import { Context, Effect } from "effect" import { SessionSchema } from "../schema" import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error" import { SessionRunnerModel } from "./model" @@ -9,20 +9,11 @@ import type { SystemContext } from "../../system-context/index" import type { SessionContextEpoch } from "../context-epoch" import type { ToolOutputStore } from "../../tool-output-store" -export class StepLimitExceededError extends Schema.TaggedErrorClass()( - "SessionRunner.StepLimitExceededError", - { - sessionID: SessionSchema.ID, - limit: Schema.Int, - }, -) {} - export type RunError = | LLMError | SessionRunnerModel.Error | MessageDecodeError | ContextSnapshotDecodeError - | StepLimitExceededError | SystemContext.InitializationBlocked | SessionContextEpoch.AgentReplacementBlocked | ToolOutputStore.Error diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 02a1eb3fed2..233a4aa4d82 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -3,6 +3,7 @@ import { LLMClient, LLMError, LLMEvent, + Message, SystemPart, isContextOverflowFailure, type ProviderErrorEvent, @@ -29,10 +30,11 @@ import { SessionHistory } from "../history" import { SessionInput } from "../input" import { SessionSchema } from "../schema" import { SessionStore } from "../store" -import { type RunError, Service, StepLimitExceededError } from "./index" +import { type RunError, Service } from "./index" import { SessionRunnerModel } from "./model" import { createLLMEventPublisher } from "./publish-llm-event" import { toLLMMessages } from "./to-llm-message" +import { MAX_STEPS_PROMPT } from "./max-steps" /** * Runs one durable coding-agent Session until it settles. @@ -45,7 +47,7 @@ import { toLLMMessages } from "./to-llm-message" * - [ ] Replace local ownership with durable multi-node ownership when clustered. * - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably. * - [ ] Honor interruption and reject stale work after runtime attachment replacement. - * - [x] Bound model steps. + * - [x] Honor optional agent step limits. * - [ ] Bound provider retries and repeated identical tool calls. * * - Runtime context assembly @@ -80,13 +82,10 @@ import { toLLMMessages } from "./to-llm-message" * Durable activity recovery remains a separate future slice with an explicit retry policy. * * The current slice loads V2 history, translates it, resolves a model through a core service, and persists one - * provider turn. Registry definitions are advertised, local tool calls are settled durably, and a - * bounded explicit loop starts the next provider turn after local settlement. + * provider turn. Registry definitions are advertised, local tool calls are settled durably, and an + * explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop. */ -// QUESTION: Did this exist previously, or did we add this limit? Does it make sense? -const MAX_STEPS = 25 - export const layer = Layer.effect( Service, Effect.gen(function* () { @@ -175,6 +174,7 @@ export const layer = Layer.effect( const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* ( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, + step: number, recoverOverflow?: typeof compaction.compactAfterOverflow, ) { const session = yield* getSession(sessionID) @@ -214,7 +214,8 @@ export const layer = Layer.effect( const model = yield* models.resolve(session) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const context = entries.map((entry) => entry.message) - const toolMaterialization = yield* tools.materialize(agent.info?.permissions) + const isLastStep = agent.info?.steps !== undefined && step >= agent.info.steps + const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ model, @@ -222,8 +223,9 @@ export const layer = Layer.effect( system: [agent.info?.system, system.baseline] .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), - messages: toLLMMessages(context, model), - tools: toolMaterialization.definitions, + messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])], + tools: toolMaterialization?.definitions ?? [], + toolChoice: isLastStep ? "none" : undefined, }) if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) return yield* Effect.die(rebuildPreparedTurn()) @@ -254,6 +256,10 @@ export const layer = Layer.effect( } yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return + if (!toolMaterialization) { + yield* withPublication(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps")) + return + } needsContinuation = true const assistantMessageID = yield* publisher.assistantMessageID(event.id) yield* Effect.uninterruptibleMask((restore) => @@ -340,31 +346,32 @@ export const layer = Layer.effect( type RunTurn = ( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, + step: number, ) => Effect.Effect - const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) { - return yield* runTurnAttempt(sessionID, promotion).pipe( + const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) { + return yield* runTurnAttempt(sessionID, promotion, step).pipe( Effect.catchDefect( Effect.fnUntraced(function* (defect) { if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect) if (defect.transition._tag === "ContinueAfterOverflowCompaction") return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow") yield* Effect.yieldNow - return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion) + return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step) }), ), ) }) - const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) { - return yield* runTurnAttempt(sessionID, promotion, compaction.compactAfterOverflow).pipe( + const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) { + return yield* runTurnAttempt(sessionID, promotion, step, compaction.compactAfterOverflow).pipe( Effect.catchDefect( Effect.fnUntraced(function* (defect) { if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect) yield* Effect.yieldNow if (defect.transition._tag === "ContinueAfterOverflowCompaction") - return yield* runAfterOverflowCompaction(sessionID, undefined) - return yield* runTurn(sessionID, defect.transition.promotion) + return yield* runAfterOverflowCompaction(sessionID, undefined, step) + return yield* runTurn(sessionID, defect.transition.promotion, step) }), ), ) @@ -382,14 +389,11 @@ export const layer = Layer.effect( let openActivity = input.force === true || hasSteer || hasQueue while (openActivity) { let needsContinuation = true - for (let step = 0; step < MAX_STEPS; step++) { - needsContinuation = yield* runTurn(input.sessionID, promotion) + for (let step = 1; needsContinuation; step++) { + needsContinuation = yield* runTurn(input.sessionID, promotion, step) promotion = "steer" if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") - if (!needsContinuation) break } - if (needsContinuation) - return yield* new StepLimitExceededError({ sessionID: input.sessionID, limit: MAX_STEPS }) openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue") promotion = openActivity ? "queue" : undefined } diff --git a/packages/opencode/src/session/prompt/max-steps.txt b/packages/core/src/session/runner/max-steps.ts similarity index 90% rename from packages/opencode/src/session/prompt/max-steps.txt rename to packages/core/src/session/runner/max-steps.ts index 3aefa73779c..040584ab173 100644 --- a/packages/opencode/src/session/prompt/max-steps.txt +++ b/packages/core/src/session/runner/max-steps.ts @@ -1,4 +1,4 @@ -CRITICAL - MAXIMUM STEPS REACHED +export const MAX_STEPS_PROMPT = `CRITICAL - MAXIMUM STEPS REACHED The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only. @@ -13,4 +13,4 @@ Response must include: - List of any remaining tasks that were not completed - Recommendations for what should be done next -Any attempt to use tools is a critical violation. Respond with text ONLY. \ No newline at end of file +Any attempt to use tools is a critical violation. Respond with text ONLY.` diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 27bd15ec8d4..d067a7c6b93 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -8,6 +8,9 @@ import { Auth, type AnyRoute } from "@opencode-ai/llm/route" import { Context, Effect, Layer, Option, Schema } from "effect" import { produce } from "immer" import { Catalog } from "../../catalog" +import { Credential } from "../../credential" +import { Integration } from "../../integration" +import { IntegrationConnection } from "../../integration/connection" import { ModelV2 } from "../../model" import { ModelRequest } from "../../model-request" import { PluginBoot } from "../../plugin/boot" @@ -45,10 +48,12 @@ export class Service extends Context.Service()("@opencode/v2 /** Test or embedding seam for supplying a model resolver directly. */ export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) -const apiKey = (model: ModelV2.Info, provider?: ProviderV2.Info) => { +const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => { + if (credential?.value.type === "key") return Auth.value(credential.value.key) + if (credential?.value.type === "oauth") return Auth.value(credential.value.access) const value = model.request.body.apiKey ?? model.api.settings?.apiKey if (typeof value === "string") return Auth.value(value) - return provider?.enabled !== false && provider?.enabled.via === "env" ? Auth.config(provider.enabled.name) : undefined + return connection?.type === "env" ? Auth.config(connection.name) : undefined } const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { @@ -83,41 +88,54 @@ const apiName = (model: ModelV2.Info) => export const fromCatalogModel = ( model: ModelV2.Info, - provider?: ProviderV2.Info, + connection?: IntegrationConnection.Info, + credential?: Credential.Stored, ): Effect.Effect => { - const key = apiKey(model, provider) - if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai") { + const resolved = + credential?.value.metadata === undefined + ? model + : produce(model, (draft) => { + Object.assign(draft.request.body, credential.value.metadata) + // kilocode_change start - Kilo Gateway consumes the migrated OAuth account as its organization route + if (credential.value.type === "oauth" && credential.value.metadata?.accountID) { + draft.request.body.kilocodeOrganizationId = credential.value.metadata.accountID + delete draft.request.body.accountID + } + // kilocode_change end + }) + const key = apiKey(resolved, connection, credential) + if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") { return Effect.succeed( - withDefaults(model, OpenAIResponses.route) + withDefaults(resolved, OpenAIResponses.route) .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: model.api.id }), + .model({ id: resolved.api.id }), ) } - if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/anthropic") { + if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") { return Effect.succeed( - withDefaults(model, AnthropicMessages.route) + withDefaults(resolved, AnthropicMessages.route) .with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) }) - .model({ id: model.api.id }), + .model({ id: resolved.api.id }), ) } - if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai-compatible" && model.api.url) { + if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) { return Effect.succeed( - withDefaults(model, OpenAICompatibleChat.route) + withDefaults(resolved, OpenAICompatibleChat.route) .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: model.api.id }), + .model({ id: resolved.api.id }), ) } return Effect.fail( new UnsupportedApiError({ - providerID: model.providerID, - modelID: model.id, - api: apiName(model), + providerID: resolved.providerID, + modelID: resolved.id, + api: apiName(resolved), }), ) } -export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, provider?: ProviderV2.Info) => - fromCatalogModel(withVariant(model, session.model?.variant), provider) +export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) => + fromCatalogModel(withVariant(model, session.model?.variant)) export const supported = (model: ModelV2.Info) => model.api.type === "aisdk" && @@ -130,6 +148,8 @@ export const locationLayer = Layer.effect( Service, Effect.gen(function* () { const catalog = yield* Catalog.Service + const credentials = yield* Credential.Service + const integrations = yield* Integration.Service const boot = yield* PluginBoot.Service return Service.of({ resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { @@ -140,7 +160,12 @@ export const locationLayer = Layer.effect( : (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ?? (yield* catalog.model.available()).find(supported)) if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id }) - return yield* resolve(session, selected, yield* catalog.provider.get(selected.providerID)) + const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID)) + return yield* fromCatalogModel( + withVariant(selected, session.model?.variant), + connection, + connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined, + ) }), }) }), diff --git a/packages/opencode/src/shell/shell.ts b/packages/core/src/shell.ts similarity index 80% rename from packages/opencode/src/shell/shell.ts rename to packages/core/src/shell.ts index f7e83271633..f92906aebee 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/core/src/shell.ts @@ -1,11 +1,14 @@ -import { Flag } from "@opencode-ai/core/flag/flag" -import * as PowerShell from "@/kilocode/shell/shell" // kilocode_change - PowerShell args -import { lazy } from "@/util/lazy" -import { Filesystem } from "@/util/filesystem" -import { which } from "@opencode-ai/core/util/which" +export * as Shell from "./shell" + import path from "path" import { spawn, type ChildProcess } from "child_process" +import { readFile } from "fs/promises" +import { statSync } from "fs" import { setTimeout as sleep } from "node:timers/promises" +import { Flag } from "./flag/flag" +import { FSUtil } from "./fs-util" +import { which } from "./util/which" +import { PowerShell } from "./kilocode/powershell" // kilocode_change const SIGKILL_TIMEOUT_MS = 200 const META: Record = { @@ -48,7 +51,7 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole if (!opts?.exited?.()) { process.kill(-pid, "SIGKILL") } - } catch (_e) { + } catch { proc.kill("SIGTERM") await sleep(SIGKILL_TIMEOUT_MS) if (!opts?.exited?.()) { @@ -57,9 +60,13 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole } } +function stat(file: string) { + return statSync(file, { throwIfNoEntry: false }) ?? undefined +} + function full(file: string) { if (process.platform !== "win32") return file - const shell = Filesystem.windowsPath(file) + const shell = FSUtil.windowsPath(file) if (path.win32.dirname(shell) !== ".") { if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell return shell @@ -77,13 +84,13 @@ function ok(file: string) { } function rooted(file: string) { - return path.isAbsolute(Filesystem.windowsPath(file)) + return path.isAbsolute(FSUtil.windowsPath(file)) } function resolve(file: string) { const shell = full(file) if (rooted(shell)) { - if (Filesystem.stat(shell)?.isFile()) return shell + if (stat(shell)?.isFile()) return shell return } return which(shell) ?? undefined @@ -100,7 +107,7 @@ function win() { } async function unix() { - const text = await Filesystem.readText("/etc/shells").catch(() => "") + const text = await readFile("/etc/shells", "utf8").catch(() => "") if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#")))) return ["/bin/bash", "/bin/zsh", "/bin/sh"] } @@ -110,7 +117,7 @@ function select(file: string | undefined, opts?: { acceptable?: boolean }) { const shell = resolve(file) if (shell) return shell } - if (process.platform === "win32") return win()[0]! + if (process.platform === "win32") return win()[0] return fallback() } @@ -120,7 +127,7 @@ export function gitbash() { const git = which("git") if (!git) return const file = path.join(git, "..", "..", "bin", "bash.exe") - if (Filesystem.stat(file)?.size) return file + if (stat(file)?.size) return file } function fallback() { @@ -131,7 +138,7 @@ function fallback() { } export function name(file: string) { - if (process.platform === "win32") return path.win32.parse(Filesystem.windowsPath(file)).name.toLowerCase() + if (process.platform === "win32") return path.win32.parse(FSUtil.windowsPath(file)).name.toLowerCase() return path.basename(file).toLowerCase() } @@ -189,28 +196,32 @@ export function args(file: string, command: string, cwd: string) { ] } if (n === "cmd") return ["/c", command] - if (ps(file)) return PowerShell.args(command) // kilocode_change - PowerShell args + if (ps(file)) return PowerShell.args(command) // kilocode_change - preserve UTF-8 and script prologues return ["-c", command] } -const defaultPreferred = lazy(() => select(process.env.SHELL)) -const defaultAcceptable = lazy(() => select(process.env.SHELL, { acceptable: true })) +let defaultPreferred: string | undefined +let defaultAcceptable: string | undefined export function preferred(configShell?: string) { if (configShell) return select(configShell) - return defaultPreferred() + defaultPreferred ??= select(process.env.SHELL) + return defaultPreferred +} +preferred.reset = () => { + defaultPreferred = undefined } -preferred.reset = () => defaultPreferred.reset() export function acceptable(configShell?: string) { if (configShell) return select(configShell, { acceptable: true }) - return defaultAcceptable() + defaultAcceptable ??= select(process.env.SHELL, { acceptable: true }) + return defaultAcceptable +} +acceptable.reset = () => { + defaultAcceptable = undefined } -acceptable.reset = () => defaultAcceptable.reset() export async function list(): Promise { const shells = process.platform === "win32" ? win() : await unix() return shells.filter((s) => resolve(s)).map(info) } - -export * as Shell from "./shell" diff --git a/packages/core/sst-env.d.ts b/packages/core/sst-env.d.ts new file mode 100644 index 00000000000..64441936d7a --- /dev/null +++ b/packages/core/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 9b1cf89637d..77a18e79aef 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Layer, Option } from "effect" +import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" @@ -25,47 +25,29 @@ const it = testEffect( Layer.provideMerge( Layer.mock(Credential.Service)({ all: () => Effect.succeed([]), + list: () => Effect.succeed([]), }), ), ), ) describe("CatalogV2", () => { - it.effect("projects Kilo organization routing from OAuth credentials", () => { - const integrationID = Integration.ID.make("kilocode") - const credential = new Credential.Stored({ - id: Credential.ID.create(), - integrationID, - label: "Organization", - value: new Credential.OAuth({ - type: "oauth", - methodID: Integration.MethodID.make("oauth"), - access: "access", - refresh: "refresh", - expires: 1, - metadata: { accountID: "organization" }, - }), - }) - const layer = Catalog.locationLayer.pipe( - Layer.fresh, - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - Layer.provideMerge( - Layer.mock(Credential.Service)({ all: () => Effect.succeed([credential]) }), - ), - ) - - return Effect.gen(function* () { + it.effect("publishes an updated event after catalog changes", () => + Effect.gen(function* () { const catalog = yield* Catalog.Service - const transform = yield* catalog.transform() - yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("kilocode"), () => {})) - expect(yield* catalog.provider.get(ProviderV2.ID.make("kilocode"))).toMatchObject({ - request: { body: { apiKey: "access", kilocodeOrganizationId: "organization" } }, - }) - }).pipe(Effect.provide(layer)) - }) + const events = yield* EventV2.Service + const updated = yield* events + .subscribe(Catalog.Event.Updated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + yield* Effect.yieldNow - it.effect("projects active credentials without rebuilding catalog state", () => { + yield* (yield* catalog.transform())((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + + expect((yield* Fiber.join(updated)).length).toBe(1) + }), + ) + + it.effect("derives availability from active credentials without changing provider state", () => { const integrationID = Integration.ID.make("test") const first = { id: Credential.ID.create(), @@ -87,6 +69,7 @@ describe("CatalogV2", () => { Layer.provideMerge( Layer.mock(Credential.Service)({ all: () => Effect.sync(() => [active]), + list: () => Effect.sync(() => [active]), }), ), ) @@ -96,18 +79,44 @@ describe("CatalogV2", () => { const transform = yield* catalog.transform() yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) - expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({ - enabled: { via: "credential", credentialID: first.id }, - request: { body: { apiKey: "first", tenant: "one" } }, - }) + expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) + expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) active = second - expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({ - enabled: { via: "credential", credentialID: second.id }, - request: { body: { apiKey: "second", tenant: "two" } }, - }) + expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) + expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) }).pipe(Effect.provide(layer)) }) + it.effect("projects environment connections without a catalog plugin", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.CATALOG_TEST_API_KEY + process.env.CATALOG_TEST_API_KEY = "secret" + return previous + }), + () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const integrations = yield* Integration.Service + const providerID = ProviderV2.ID.make("test") + yield* integrations.update((editor) => + editor.method.update({ + integrationID: Integration.ID.make(providerID), + method: { type: "env", names: ["CATALOG_TEST_API_KEY"] }, + }), + ) + yield* (yield* catalog.transform())((editor) => editor.provider.update(providerID, () => {})) + + expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID) + }), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.CATALOG_TEST_API_KEY + else process.env.CATALOG_TEST_API_KEY = previous + }), + ), + ) + it.effect("normalizes provider baseURL into api url", () => Effect.gen(function* () { const catalog = yield* Catalog.Service @@ -326,9 +335,7 @@ describe("CatalogV2", () => { const transform = yield* catalog.transform() yield* transform((catalog) => { - catalog.provider.update(providerID, (provider) => { - provider.enabled = { via: "custom", data: {} } - }) + catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => { model.time.released = DateTime.makeUnsafe(1000) }) @@ -350,9 +357,7 @@ describe("CatalogV2", () => { const transform = yield* catalog.transform() const models = (catalog: Catalog.Editor) => { - catalog.provider.update(providerID, (provider) => { - provider.enabled = { via: "custom", data: {} } - }) + catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, old, (model) => { model.time.released = DateTime.makeUnsafe(1000) }) @@ -383,12 +388,10 @@ describe("CatalogV2", () => { yield* transform((catalog) => { catalog.provider.update(disabledProvider, (provider) => { - provider.enabled = false + provider.disabled = true }) catalog.model.update(disabledProvider, disabledModel, () => {}) - catalog.provider.update(enabledProvider, (provider) => { - provider.enabled = { via: "custom", data: {} } - }) + catalog.provider.update(enabledProvider, () => {}) catalog.model.update(enabledProvider, fallbackModel, () => {}) catalog.model.default.set(disabledProvider, disabledModel) }) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 6a2510bab8c..a2ecc9954b3 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -3,10 +3,11 @@ import { Effect, Option, Schema } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Config } from "@opencode-ai/core/config" import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" +import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it } from "../plugin/provider-helper" +import { it, withEnv } from "../plugin/provider-helper" function request(headers: Record, variant?: string) { return { @@ -21,6 +22,7 @@ describe("ConfigProviderPlugin.Plugin", () => { it.effect("partitions existing model variant bodies without changing config shape", () => Effect.gen(function* () { const catalog = yield* Catalog.Service + const integrations = yield* Integration.Service const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.opencode const modelID = ModelV2.ID.make("alpha-gpt-next") @@ -59,6 +61,7 @@ describe("ConfigProviderPlugin.Plugin", () => { effect: ConfigProviderPlugin.Plugin.effect.pipe( Effect.provideService(Config.Service, config), Effect.provideService(Catalog.Service, catalog), + Effect.provideService(Integration.Service, integrations), ), }) @@ -80,6 +83,7 @@ describe("ConfigProviderPlugin.Plugin", () => { it.effect("uses the effective provider package across layered config", () => Effect.gen(function* () { const catalog = yield* Catalog.Service + const integrations = yield* Integration.Service const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.opencode const modelID = ModelV2.ID.make("alpha-gpt-next") @@ -118,6 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => { effect: ConfigProviderPlugin.Plugin.effect.pipe( Effect.provideService(Config.Service, config), Effect.provideService(Catalog.Service, catalog), + Effect.provideService(Integration.Service, integrations), ), }) @@ -131,118 +136,126 @@ describe("ConfigProviderPlugin.Plugin", () => { ) it.effect("loads configured providers and applies later model overrides", () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const plugin = yield* PluginV2.Service - const providerID = ProviderV2.ID.make("custom") - const modelID = ModelV2.ID.make("chat") - const config = Config.Service.of({ - entries: () => - Effect.succeed([ - new Config.Document({ - type: "document", - info: decode({ - model: "custom/first", - providers: { - custom: { - name: "Configured", - env: ["CUSTOM_API_KEY"], - api: { type: "native", settings: {} }, - request: request({ first: "first", shared: "first" }), - models: { - chat: { - name: "First", - capabilities: { tools: true, input: ["text"], output: ["text"] }, - disabled: true, - limit: { context: 100, output: 50 }, - cost: { input: 1, output: 2 }, - request: request({ first: "first", shared: "first" }, "retained"), - variants: [ - { - id: "fast", - headers: { first: "first", shared: "first" }, - }, - ], + withEnv({ CUSTOM_API_KEY: "secret" }, () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const integrations = yield* Integration.Service + const plugin = yield* PluginV2.Service + const providerID = ProviderV2.ID.make("custom") + const modelID = ModelV2.ID.make("chat") + const config = Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ + model: "custom/first", + providers: { + custom: { + name: "Configured", + env: ["CUSTOM_API_KEY"], + api: { type: "native", settings: {} }, + request: request({ first: "first", shared: "first" }), + models: { + chat: { + name: "First", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + disabled: true, + limit: { context: 100, output: 50 }, + cost: { input: 1, output: 2 }, + request: request({ first: "first", shared: "first" }, "retained"), + variants: [ + { + id: "fast", + headers: { first: "first", shared: "first" }, + }, + ], + }, }, }, }, - }, + }), }), - }), - new Config.Document({ - type: "document", - info: decode({ - model: "custom/default", - providers: { - custom: { - api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" }, - request: request({ last: "last", shared: "last" }), - models: { - default: { - name: "Default", - }, - chat: { - api: { id: "api-chat" }, - name: "Last", - limit: { output: 75 }, - request: request({ last: "last", shared: "last" }), - variants: [ - { - id: "fast", - headers: { last: "last", shared: "last" }, - }, - { - id: "slow", - headers: { slow: "slow" }, - }, - ], + new Config.Document({ + type: "document", + info: decode({ + model: "custom/default", + providers: { + custom: { + api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" }, + request: request({ last: "last", shared: "last" }), + models: { + default: { + name: "Default", + }, + chat: { + api: { id: "api-chat" }, + name: "Last", + limit: { output: 75 }, + request: request({ last: "last", shared: "last" }), + variants: [ + { + id: "fast", + headers: { last: "last", shared: "last" }, + }, + { + id: "slow", + headers: { slow: "slow" }, + }, + ], + }, }, }, }, - }, + }), }), - }), - new Config.Document({ - type: "document", - info: decode({ - providers: { - custom: { name: "Renamed" }, - }, + new Config.Document({ + type: "document", + info: decode({ + providers: { + custom: { name: "Renamed" }, + }, + }), }), - }), - ]), - }) + ]), + }) - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect.pipe( - Effect.provideService(Config.Service, config), - Effect.provideService(Catalog.Service, catalog), - ), - }) + yield* plugin.add({ + ...ConfigProviderPlugin.Plugin, + effect: ConfigProviderPlugin.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(Integration.Service, integrations), + ), + }) - const provider = yield* catalog.provider.get(providerID) - const model = yield* catalog.model.get(providerID, modelID) - expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default")) - expect(provider.name).toBe("Renamed") - expect(provider.env).toEqual(["CUSTOM_API_KEY"]) - expect(provider.enabled).toEqual({ via: "custom", data: {} }) - expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" }) - expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" }) - expect(model.api.id).toBe(ModelV2.ID.make("api-chat")) - expect(model.name).toBe("Last") - expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) - expect(model.enabled).toBe(false) - expect(model.limit).toEqual({ context: 100, output: 75 }) - expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }]) - expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" }) - expect(model.request.variant).toBe("retained") - expect(model.variants.map((variant) => variant.id)).toEqual([ - ModelV2.VariantID.make("fast"), - ModelV2.VariantID.make("slow"), - ]) - expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" }) - expect(model.variants[1]?.headers).toEqual({ slow: "slow" }) - }), + const provider = yield* catalog.provider.get(providerID) + const model = yield* catalog.model.get(providerID, modelID) + expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default")) + expect(provider.name).toBe("Renamed") + expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({ + type: "env", + names: ["CUSTOM_API_KEY"], + }) + expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed") + expect(provider.disabled).toBeUndefined() + expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" }) + expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.api.id).toBe(ModelV2.ID.make("api-chat")) + expect(model.name).toBe("Last") + expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) + expect(model.enabled).toBe(false) + expect(model.limit).toEqual({ context: 100, output: 75 }) + expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }]) + expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.request.variant).toBe("retained") + expect(model.variants.map((variant) => variant.id)).toEqual([ + ModelV2.VariantID.make("fast"), + ModelV2.VariantID.make("slow"), + ]) + expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.variants[1]?.headers).toEqual({ slow: "slow" }) + }), + ), ) }) diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index fa05e23d95e..ca4362c6058 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -1,8 +1,7 @@ import { describe, expect } from "bun:test" -import { Duration, Effect, Exit, Layer, Scope } from "effect" +import { Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect" import * as TestClock from "effect/testing/TestClock" import { Integration } from "@opencode-ai/core/integration" -import { IntegrationConnection } from "@opencode-ai/core/integration/connection" import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" import { it } from "./lib/effect" @@ -25,7 +24,7 @@ function connectionLayer( }>, ) { return Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), + Layer.provideMerge(EventV2.defaultLayer), Layer.provide( Layer.mock(Credential.Service)({ create: (input) => @@ -103,7 +102,7 @@ describe("Integration", () => { .update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize, }), ) @@ -117,7 +116,7 @@ describe("Integration", () => { ]) editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT Override" }), + method: { id: methodID, type: "oauth", label: "ChatGPT Override" }, authorize, }) }) @@ -140,15 +139,20 @@ describe("Integration", () => { }> = [] return Effect.gen(function* () { const integrations = yield* Integration.Service + const events = yield* EventV2.Service const integrationID = Integration.ID.make("openai") yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.KeyMethod({ type: "key", label: "API key" }), + method: { type: "key", label: "API key" }, }), ) + const updated = yield* events + .subscribe(Integration.Event.Updated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + yield* Effect.yieldNow - yield* integrations.connect.key({ + yield* integrations.connection.key({ integrationID, key: "secret", label: "Work", @@ -161,6 +165,7 @@ describe("Integration", () => { value: new Credential.Key({ type: "key", key: "secret" }), }, ]) + expect((yield* Fiber.join(updated)).length).toBe(1) }).pipe(Effect.provide(connectionLayer(created))) }) @@ -177,7 +182,7 @@ describe("Integration", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize: () => Effect.succeed({ mode: "code" as const, @@ -198,7 +203,7 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connect.oauth({ + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {}, @@ -236,7 +241,7 @@ describe("Integration", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize: () => Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe( Effect.as({ @@ -249,7 +254,7 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf( Integration.CodeRequiredError, ) @@ -273,7 +278,7 @@ describe("Integration", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }), + method: { id: methodID, type: "oauth", label: "Browser" }, authorize: () => Effect.succeed({ mode: "auto" as const, @@ -286,7 +291,7 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) yield* Effect.yieldNow expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({ status: "complete", @@ -310,7 +315,7 @@ describe("Integration", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }), + method: { id: methodID, type: "oauth", label: "Browser" }, authorize: () => Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe( Effect.as({ @@ -323,7 +328,7 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10))) yield* TestClock.adjust(Duration.minutes(10)) yield* Effect.yieldNow @@ -373,23 +378,28 @@ describe("Integration", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.EnvMethod({ + method: { type: "env", names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"], - }), + }, }), ) // Stored credentials and detected env vars appear as connections. expect((yield* integrations.get(integrationID))?.connections).toEqual([ - new IntegrationConnection.CredentialInfo({ type: "credential", id: rows[0]!.id, label: "Work" }), - new IntegrationConnection.CredentialInfo({ + { type: "credential", id: rows[0]!.id, label: "Work" }, + { type: "credential", id: rows[1]!.id, label: "Personal", - }), - new IntegrationConnection.EnvInfo({ type: "env", name: "INTEGRATION_TEST_ACME_KEY" }), + }, + { type: "env", name: "INTEGRATION_TEST_ACME_KEY" }, ]) + expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({ + type: "credential", + id: rows[1]!.id, + label: "Personal", + }) }).pipe(Effect.provide(projectionLayer)), (previous) => Effect.sync(() => { diff --git a/packages/core/test/kilocode/integration-settlement.test.ts b/packages/core/test/kilocode/integration-settlement.test.ts index 0eae2b2aad9..9085d2526fb 100644 --- a/packages/core/test/kilocode/integration-settlement.test.ts +++ b/packages/core/test/kilocode/integration-settlement.test.ts @@ -65,7 +65,7 @@ describe("Integration settlement guards", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }), + method: { id: methodID, type: "oauth", label: "Browser" }, authorize: () => Effect.succeed({ mode: "auto" as const, @@ -78,7 +78,7 @@ describe("Integration settlement guards", () => { }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) yield* Effect.yieldNow expect(yield* integrations.attempt.status(attempt.attemptID)).toMatchObject({ status: "failed", @@ -104,7 +104,7 @@ describe("Integration settlement guards", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize: () => Effect.succeed({ mode: "code" as const, @@ -118,10 +118,10 @@ describe("Integration settlement guards", () => { }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) - const exit = yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" }).pipe( - Effect.exit, - ) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) + const exit = yield* integrations.attempt + .complete({ attemptID: attempt.attemptID, code: "1234" }) + .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("database unavailable") expect(yield* integrations.attempt.status(attempt.attemptID)).toMatchObject({ @@ -165,7 +165,7 @@ describe("Integration settlement guards", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize: () => Effect.succeed({ mode: "code" as const, @@ -179,7 +179,7 @@ describe("Integration settlement guards", () => { }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) const fiber = yield* integrations.attempt .complete({ attemptID: attempt.attemptID, code: "1234" }) .pipe(Effect.forkScoped) @@ -212,7 +212,7 @@ describe("Integration settlement guards", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize: () => Effect.succeed({ mode: "code" as const, @@ -229,7 +229,7 @@ describe("Integration settlement guards", () => { }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) const fiber = yield* integrations.attempt .complete({ attemptID: attempt.attemptID, code: "1234" }) .pipe(Effect.forkScoped) @@ -253,7 +253,7 @@ describe("Integration settlement guards", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize: () => Effect.addFinalizer(() => Effect.sync(() => (state.closed = true))).pipe( Effect.as({ @@ -266,7 +266,7 @@ describe("Integration settlement guards", () => { }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) const fiber = yield* integrations.attempt .complete({ attemptID: attempt.attemptID, code: "1234" }) .pipe(Effect.exit, Effect.forkScoped) @@ -301,7 +301,7 @@ describe("Integration settlement guards", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize: () => Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe( Effect.as({ @@ -310,14 +310,20 @@ describe("Integration settlement guards", () => { instructions: "Paste the code", callback: () => Effect.succeed( - new Credential.OAuth({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }), + new Credential.OAuth({ + type: "oauth", + methodID, + access: "access", + refresh: "refresh", + expires: 1, + }), ), }), ), }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) const fiber = yield* integrations.attempt .complete({ attemptID: attempt.attemptID, code: "1234" }) .pipe(Effect.exit, Effect.forkScoped) diff --git a/packages/core/test/kilocode/provider-isolation.test.ts b/packages/core/test/kilocode/provider-isolation.test.ts index 57d010755d7..c2d2d498bc3 100644 --- a/packages/core/test/kilocode/provider-isolation.test.ts +++ b/packages/core/test/kilocode/provider-isolation.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" @@ -16,7 +17,12 @@ describe("provider attribution isolation", () => { Effect.gen(function* () { const plugins = yield* PluginV2.Service const catalog = yield* Catalog.Service - for (const plugin of [LLMGatewayPlugin, NvidiaPlugin, OpenRouterPlugin, VercelPlugin, ZenmuxPlugin]) { + const integrations = yield* Integration.Service + yield* plugins.add({ + ...LLMGatewayPlugin, + effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), + }) + for (const plugin of [NvidiaPlugin, OpenRouterPlugin, VercelPlugin, ZenmuxPlugin]) { yield* plugins.add(plugin) } @@ -24,7 +30,6 @@ describe("provider attribution isolation", () => { yield* transform((catalog) => { const items = [ provider("custom-llmgateway", { - enabled: { via: "env", name: "CUSTOM_LLMGATEWAY_API_KEY" }, api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, }), provider("custom-nvidia", { @@ -47,7 +52,6 @@ describe("provider attribution isolation", () => { for (const item of items) { catalog.provider.update(item.id, (draft) => { - draft.enabled = item.enabled draft.api = item.api draft.request.headers.Existing = "value" }) diff --git a/packages/core/test/kilocode/session-runner-model.test.ts b/packages/core/test/kilocode/session-runner-model.test.ts new file mode 100644 index 00000000000..5f28d4609fa --- /dev/null +++ b/packages/core/test/kilocode/session-runner-model.test.ts @@ -0,0 +1,56 @@ +import { describe, expect } from "bun:test" +import { DateTime, Effect } from "effect" +import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { it } from "../lib/effect" + +describe("SessionRunnerModel Kilo credentials", () => { + it.effect("maps OAuth account IDs to Kilo organization routing", () => + Effect.gen(function* () { + const model = new ModelV2.Info({ + id: ModelV2.ID.make("test-model"), + providerID: ProviderV2.ID.make("kilo"), + name: "Test model", + api: { + id: ModelV2.ID.make("api-test-model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/openrouter", + }, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + request: { headers: {}, body: {}, generation: {}, options: {} }, + variants: [], + time: { released: DateTime.makeUnsafe(0) }, + cost: [], + status: "active", + enabled: true, + limit: { context: 100, output: 20 }, + }) + const credential = new Credential.Stored({ + id: Credential.ID.create(), + integrationID: Integration.ID.make("kilo"), + label: "Work", + value: new Credential.OAuth({ + type: "oauth", + methodID: Integration.MethodID.make("oauth"), + refresh: "refresh", + access: "access", + expires: 1, + metadata: { accountID: "org-enterprise" }, + }), + }) + + const resolved = yield* SessionRunnerModel.fromCatalogModel( + model, + { type: "credential", id: credential.id, label: credential.label }, + credential, + ) + + expect(resolved.route.defaults.http?.body).toMatchObject({ kilocodeOrganizationId: "org-enterprise" }) + expect(resolved.route.defaults.http?.body).not.toHaveProperty("accountID") + }), + ) +}) diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 8cdfc066cd2..f4a0e16bdd1 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -28,8 +28,10 @@ const connections = Credential.layer.pipe( Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)), Layer.provide(events), ) -const catalog = Catalog.layer.pipe(Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections))) const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections)) +const catalog = Catalog.layer.pipe( + Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)), +) const layer = Layer.mergeAll( catalog.pipe(Layer.provide(connections)), integrations, @@ -61,11 +63,11 @@ describe("ModelsDevPlugin", () => { id: Integration.ID.make("acme"), name: "Acme", methods: [ - new Integration.KeyMethod({ type: "key" }), - new Integration.EnvMethod({ + { type: "key" }, + { type: "env", names: ["ACME_API_KEY"], - }), + }, ], connections: [], }), diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index c4bdd806c9d..91ab854d5cb 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -1,35 +1,10 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { Credential } from "@opencode-ai/core/credential" -import { Integration } from "@opencode-ai/core/integration" -import { Database } from "@opencode-ai/core/database/database" +import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" -import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" - -const database = Database.layerFromPath(":memory:").pipe(Layer.fresh) -const preferences = Credential.layer.pipe(Layer.provide(database)) -const accounts = Layer.merge( - Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)), - preferences, -) -const itWithAccount = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(accounts), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), - ), - Layer.provideMerge(npmLayer), - ), -) +import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" describe("AzurePlugin", () => { it.effect("resolves resourceName from env", () => @@ -73,35 +48,7 @@ describe("AzurePlugin", () => { ), ) - itWithAccount.effect("prefers account resourceName over env", () => - withEnv( - { - AZURE_RESOURCE_NAME: "from-env", - }, - () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const catalog = yield* Catalog.Service - yield* credentials.create({ - integrationID: Integration.ID.make("azure"), - value: new Credential.Key({ - type: "key", - key: "key", - metadata: { resourceName: "from-account" }, - }), - }) - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - catalog.provider.update(ProviderV2.ID.azure, (item) => { - item.api = { type: "aisdk", package: "@ai-sdk/azure" } - }) - }) - expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-account") - }), - ), - ) + // kilocode_change - remove stale account projection coverage, matching the v1.17.10 upstream follow-up it.effect("falls back to env when configured resourceName is blank", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index 208ab8710d3..39323f77e1a 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -1,36 +1,11 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { Credential } from "@opencode-ai/core/credential" -import { Integration } from "@opencode-ai/core/integration" -import { Database } from "@opencode-ai/core/database/database" +import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { Location } from "@opencode-ai/core/location" -import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" -import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper" - -const database = Database.layerFromPath(":memory:").pipe(Layer.fresh) -const preferences = Credential.layer.pipe(Layer.provide(database)) -const accounts = Layer.merge( - Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)), - preferences, -) -const itWithAccount = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(accounts), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), - ), - Layer.provideMerge(npmLayer), - ), -) +import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper" function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") { return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel( @@ -126,41 +101,7 @@ describe("CloudflareWorkersAIPlugin", () => { ), ) - itWithAccount.effect("falls back to account metadata when account env is absent", () => - withEnv( - { - CLOUDFLARE_ACCOUNT_ID: undefined, - CLOUDFLARE_API_KEY: undefined, - }, - () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const catalog = yield* Catalog.Service - yield* credentials.create({ - integrationID: Integration.ID.make("cloudflare-workers-ai"), - value: new Credential.Key({ - type: "key", - key: "account-key", - metadata: { accountId: "account-acct" }, - }), - }) - yield* plugin.add(CloudflareWorkersAIPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => - catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { - provider.api = { type: "aisdk", package: "test-provider" } - }), - ) - expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).request.body).toMatchObject( - { - apiKey: "account-key", - accountId: "account-acct", - }, - ) - }), - ), - ) + // kilocode_change - remove stale account projection coverage, matching the v1.17.10 upstream follow-up it.effect("uses env account ID over configured account ID", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index dab52a1f7fa..3e036ad7125 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,26 +1,10 @@ import { describe, expect, mock } from "bun:test" -import { Effect, Layer } from "effect" -import { Credential } from "@opencode-ai/core/credential" -import { Integration } from "@opencode-ai/core/integration" -import { Database } from "@opencode-ai/core/database/database" -import { Catalog } from "@opencode-ai/core/catalog" -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" +import { Effect } from "effect" import { PluginV2 } from "@opencode-ai/core/plugin" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" -import { it, model, npmLayer, withEnv } from "./provider-helper" +import { it, model, withEnv } from "./provider-helper" const gitlabSDKOptions: Record[] = [] -const database = Database.layerFromPath(":memory:").pipe(Layer.fresh) -const preferences = Credential.layer.pipe(Layer.provide(database)) -const accounts = Layer.merge( - Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)), - preferences, -) void mock.module("gitlab-ai-provider", () => ({ VERSION: "test-version", @@ -35,17 +19,6 @@ void mock.module("gitlab-ai-provider", () => ({ isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact", })) -const itWithAccount = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(accounts), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/") }))), - ), - Layer.provideMerge(npmLayer), - ), -) - describe("GitLabPlugin", () => { it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () => withEnv( @@ -163,77 +136,7 @@ describe("GitLabPlugin", () => { }), ) - itWithAccount.effect("uses active account API token over GITLAB_TOKEN", () => - withEnv( - { - GITLAB_TOKEN: "env-token", - }, - () => - Effect.gen(function* () { - gitlabSDKOptions.length = 0 - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const catalog = yield* Catalog.Service - yield* credentials.create({ - integrationID: Integration.ID.make("gitlab"), - value: new Credential.Key({ type: "key", key: "account-token" }), - }) - yield* plugin.add(GitLabPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("gitlab", "claude"), - package: "gitlab-ai-provider", - options: provider.request.body, - }, - {}, - ) - expect(gitlabSDKOptions[0].apiKey).toBe("account-token") - }), - ), - ) - - itWithAccount.effect("uses active account OAuth access token when no API token exists", () => - withEnv( - { - GITLAB_TOKEN: undefined, - }, - () => - Effect.gen(function* () { - gitlabSDKOptions.length = 0 - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const catalog = yield* Catalog.Service - yield* credentials.create({ - integrationID: Integration.ID.make("gitlab"), - value: new Credential.OAuth({ - type: "oauth", - methodID: Integration.MethodID.make("oauth"), - refresh: "refresh-token", - access: "account-oauth-token", - expires: 9999999999999, - }), - }) - yield* plugin.add(GitLabPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("gitlab", "claude"), - package: "gitlab-ai-provider", - options: provider.request.body, - }, - {}, - ) - expect(gitlabSDKOptions[0].apiKey).toBe("account-oauth-token") - }), - ), - ) + // kilocode_change - remove stale account projection coverage, matching the v1.17.10 upstream follow-up it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () => Effect.gen(function* () { diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts index f9334480d1f..c15928435bc 100644 --- a/packages/core/test/plugin/provider-helper.ts +++ b/packages/core/test/plugin/provider-helper.ts @@ -53,6 +53,7 @@ const integrations = Integration.locationLayer.pipe( Layer.provide( Layer.mock(Credential.Service)({ create: () => Effect.die("unexpected credential creation"), + all: () => Effect.succeed([]), list: () => Effect.succeed([]), }), ), diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index a14c78821a5..4bc43c156f9 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -1,7 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { Credential } from "@opencode-ai/core/credential" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo" @@ -151,20 +150,18 @@ describe("KiloPlugin", () => { const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("kilo", { - enabled: { via: "credential", credentialID: Credential.ID.make("cred_kilo") }, request: { headers: {}, body: { apiKey: "authenticated-token", kilocodeOrganizationId: "authenticated-org" }, }, }) catalog.provider.update(item.id, (draft) => { - draft.enabled = item.enabled draft.request = item.request }) }) const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo")) - expect(result.enabled).toEqual({ via: "credential", credentialID: Credential.ID.make("cred_kilo") }) + expect(result.request.body.apiKey).toBe("authenticated-token") expect(result.request.body.kilocodeToken).toBe("authenticated-token") expect(result.request.body.kilocodeOrganizationId).toBe("environment-org") }), @@ -181,7 +178,10 @@ describe("KiloPlugin", () => { yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("kilo"), () => {})) const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo")) - expect(result.enabled).toEqual({ via: "custom", data: { anonymous: true } }) + expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain( + ProviderV2.ID.make("kilo"), + ) + expect(result.request.body.apiKey).toBe("anonymous") expect(result.request.body.kilocodeToken).toBe("anonymous") }), ), diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 5ffc6d78792..f92d5fc0bbb 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { Integration } from "@opencode-ai/core/integration" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" @@ -8,6 +9,14 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { expectPluginRegistered, it, provider } from "./provider-helper" describe("LLMGatewayPlugin", () => { + const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) { + const integrations = yield* Integration.Service + yield* plugin.add({ + ...LLMGatewayPlugin, + effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), + }) + }) + it.effect("is registered so legacy referer headers can be applied", () => Effect.sync(() => expectPluginRegistered( @@ -21,31 +30,29 @@ describe("LLMGatewayPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(LLMGatewayPlugin) + yield* add(plugin) + const integrations = yield* Integration.Service + yield* integrations.update((editor) => { + editor.update(Integration.ID.make("llmgateway"), () => {}) + editor.update(Integration.ID.make("openrouter"), () => {}) + }) const transform = yield* catalog.transform() yield* transform((catalog) => { const llmgateway = provider("llmgateway", { - enabled: { via: "env", name: "LLMGATEWAY_API_KEY" }, api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, request: { headers: { Existing: "value" }, body: {} }, }) catalog.provider.update(llmgateway.id, (draft) => { - draft.enabled = llmgateway.enabled draft.api = llmgateway.api draft.request = llmgateway.request }) - const openrouter = provider("openrouter", { - enabled: { via: "env", name: "OPENROUTER_API_KEY" }, - }) - catalog.provider.update(openrouter.id, (draft) => { - draft.enabled = openrouter.enabled - }) + catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://kilo.ai/", - "X-Title": "Kilo Code", - "X-Source": "kilo", + "X-Title": "Kilo Code", // kilocode_change + "X-Source": "kilo", // kilocode_change }) expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) }), @@ -55,7 +62,7 @@ describe("LLMGatewayPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(LLMGatewayPlugin) + yield* add(plugin) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("llmgateway", { @@ -66,7 +73,7 @@ describe("LLMGatewayPlugin", () => { }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).enabled).toBe(false) + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined() expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({}) }), ) diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index a317ba4bf7d..d30b585b961 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -21,16 +21,16 @@ describe("OpenAIPlugin", () => { const plugin = yield* PluginV2.Service yield* add(plugin, yield* Integration.Service) expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([ - new Integration.OAuthMethod({ + { id: Integration.MethodID.make("chatgpt-browser"), type: "oauth", label: "ChatGPT Pro/Plus (browser)", - }), - new Integration.OAuthMethod({ + }, + { id: Integration.MethodID.make("chatgpt-headless"), type: "oauth", label: "ChatGPT Pro/Plus (headless)", - }), + }, ]) }), ) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index d12f04c0c94..01cabf35811 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -3,6 +3,7 @@ import { DateTime, Effect, Layer, Option } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" +import { Integration } from "@opencode-ai/core/integration" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" @@ -18,13 +19,18 @@ const locationLayer = Layer.succeed( Location.Service.of(location({ directory: AbsolutePath.make("test") })), ) +const pluginWithIntegrations = (integrations: Integration.Interface) => ({ + ...OpencodePlugin, + effect: OpencodePlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), +}) + describe("OpencodePlugin", () => { it.effect("uses a public key and disables paid models without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("opencode") @@ -45,7 +51,7 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("opencode") @@ -66,7 +72,7 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("opencode") @@ -87,7 +93,7 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("opencode") @@ -108,13 +114,18 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + const integrations = yield* Integration.Service + yield* plugin.add(pluginWithIntegrations(integrations)) + yield* integrations.update((editor) => { + editor.method.update({ + integrationID: Integration.ID.make("opencode"), + method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] }, + }) + }) const transform = yield* catalog.transform() yield* transform((catalog) => { - const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }) - catalog.provider.update(item.id, (draft) => { - draft.env = [...item.env] - }) + const item = provider("opencode") + catalog.provider.update(item.id, () => {}) const paid = model("opencode", "paid", { cost: cost(1) }) catalog.model.update(item.id, paid.id, (draft) => { draft.cost = [...paid.cost] @@ -131,7 +142,7 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("opencode", { @@ -154,37 +165,12 @@ describe("OpencodePlugin", () => { ), ) - it.effect("uses auth-enabled providers as credentials", () => - withEnv({ OPENCODE_API_KEY: undefined }, () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("opencode", { - enabled: { via: "credential", credentialID: Credential.ID.make("credential") }, - }) - catalog.provider.update(item.id, (draft) => { - draft.enabled = item.enabled - }) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] - }) - }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) - }), - ), - ) - it.effect("ignores non-opencode providers and models", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("openai") diff --git a/packages/core/test/project-copy.test.ts b/packages/core/test/project-copy.test.ts index 823d8d842ec..47f2176c376 100644 --- a/packages/core/test/project-copy.test.ts +++ b/packages/core/test/project-copy.test.ts @@ -365,6 +365,18 @@ describe("ProjectCopy", () => { }), ) + it.live("refresh ignores existing directories that are no longer git checkouts", () => + Effect.gen(function* () { + const input = yield* setup() + yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true })) + const copy = yield* ProjectCopy.Service + + yield* copy.refresh({ projectID: input.projectID }) + + expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }]) + }), + ) + it.live("refresh with no roots is a no-op", () => Effect.gen(function* () { const copy = yield* ProjectCopy.Service diff --git a/packages/core/test/pty/info-schema.test.ts b/packages/core/test/pty/info-schema.test.ts index 9f58c45c886..6cdf263a5bb 100644 --- a/packages/core/test/pty/info-schema.test.ts +++ b/packages/core/test/pty/info-schema.test.ts @@ -24,4 +24,9 @@ describe("Pty.Info", () => { test("rejects a negative pid", () => { expect(() => Schema.decodeUnknownSync(Pty.Info)(sample(-1))).toThrow() }) + + test("accepts an exit code for retained exited sessions", () => { + const info = Schema.decodeUnknownSync(Pty.Info)({ ...sample(48012), status: "exited", exitCode: 4 }) + expect(info.exitCode).toBe(4) + }) }) diff --git a/packages/core/test/pty/input.test.ts b/packages/core/test/pty/input.test.ts deleted file mode 100644 index 2cfe9756b03..00000000000 --- a/packages/core/test/pty/input.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect } from "bun:test" -import { Effect } from "effect" -import { handlePtyInput } from "@opencode-ai/core/pty/input" -import { it } from "../lib/effect" - -describe("pty websocket input", () => { - it.effect("does not forward invalid binary frames to the PTY handler", () => - Effect.gen(function* () { - const messages: Array = [] - const handler = { onMessage: (message: string | ArrayBuffer) => messages.push(message) } - - yield* handlePtyInput(handler, "ready") - yield* handlePtyInput(handler, new Uint8Array([0xff, 0xfe, 0xfd])) - yield* handlePtyInput(handler, new TextEncoder().encode("hello")) - - expect(messages).toEqual(["ready", "hello"]) - }), - ) -}) diff --git a/packages/core/test/pty/protocol.test.ts b/packages/core/test/pty/protocol.test.ts new file mode 100644 index 00000000000..2bf9610595f --- /dev/null +++ b/packages/core/test/pty/protocol.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test" +import { PtyProtocol } from "@opencode-ai/core/pty/protocol" + +describe("pty protocol", () => { + test("drops invalid binary input frames and decodes valid ones", () => { + expect(PtyProtocol.decodeInput("ready")).toBe("ready") + expect(PtyProtocol.decodeInput(new Uint8Array([0xff, 0xfe, 0xfd]))).toBeUndefined() + expect(PtyProtocol.decodeInput(new TextEncoder().encode("hello"))).toBe("hello") + expect(PtyProtocol.decodeInput(new TextEncoder().encode("hello").buffer)).toBe("hello") + }) + + test("encodes the cursor as a 0x00-prefixed JSON control frame", () => { + const frame = PtyProtocol.metaFrame(42) + expect(frame[0]).toBe(0) + expect(JSON.parse(new TextDecoder().decode(frame.subarray(1)))).toEqual({ cursor: 42 }) + }) + + test("splits replay into bounded frames", () => { + expect(PtyProtocol.chunks("")).toEqual([]) + expect(PtyProtocol.chunks("abc")).toEqual(["abc"]) + const big = "x".repeat(PtyProtocol.REPLAY_CHUNK + 1) + const frames = PtyProtocol.chunks(big) + expect(frames.length).toBe(2) + expect(frames[0].length).toBe(PtyProtocol.REPLAY_CHUNK) + expect(frames.join("")).toBe(big) + }) +}) diff --git a/packages/core/test/pty/pty-output-isolation.test.ts b/packages/core/test/pty/pty-output-isolation.test.ts deleted file mode 100644 index 6e4d1f08d68..00000000000 --- a/packages/core/test/pty/pty-output-isolation.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect } from "bun:test" -import { Duration, Effect, Layer, Queue } from "effect" -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" -import { Pty } from "@opencode-ai/core/pty" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" - -type Socket = Parameters[1] - -const locationLayer = Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })), -) -const it = testEffect(Pty.layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer))) -const ptyTest = process.platform === "win32" ? it.live.skip : it.live - -const createPty = Effect.fn("PtyOutputIsolationTest.createPty")(function* (command: string) { - const pty = yield* Pty.Service - return yield* Effect.acquireRelease( - pty.create({ command, args: [], cwd: "/tmp", env: { TERM: "xterm-256color", KILO_TERMINAL: "1" } }), - (info) => pty.remove(info.id).pipe(Effect.ignore), - ) -}) - -const decodeOutput = (data: string | Uint8Array | ArrayBuffer) => - typeof data === "string" - ? data - : Buffer.from(data instanceof Uint8Array ? data : new Uint8Array(data)).toString("utf8") - -const makeSocket = Effect.fn("PtyOutputIsolationTest.makeSocket")(function* (data: unknown) { - const output = yield* Queue.unbounded() - const socket: Socket = { - readyState: 1, - data, - send: (data) => Queue.offerUnsafe(output, decodeOutput(data)), - close: () => {}, - } - return { socket, output } -}) - -const waitForOutput = (output: Queue.Queue, text: string, duration: Duration.Input = "5 seconds") => - Effect.gen(function* () { - let received = "" - while (!received.includes(text)) received += yield* Queue.take(output) - return received - }).pipe( - Effect.timeoutOrElse({ - duration, - orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)), - }), - ) - -describe("pty output isolation", () => { - ptyTest("does not leak output when websocket objects are reused", () => - Effect.gen(function* () { - const pty = yield* Pty.Service - const a = yield* createPty("cat") - const b = yield* createPty("cat") - const shared = yield* makeSocket({ events: { connection: "a" } }) - const outB = yield* Queue.unbounded() - - yield* pty.connect(a.id, shared.socket) - shared.socket.data = { events: { connection: "b" } } - shared.socket.send = (data) => Queue.offerUnsafe(outB, decodeOutput(data)) - yield* pty.connect(b.id, shared.socket) - yield* pty.write(a.id, "AAA\n") - - const verify = yield* makeSocket({ events: { connection: "verify-a" } }) - yield* pty.connect(a.id, verify.socket) - expect(yield* waitForOutput(verify.output, "AAA")).toContain("AAA") - expect(yield* waitForOutput(outB, "AAA", "100 millis").pipe(Effect.option)).toMatchObject({ _tag: "None" }) - }), - ) - - ptyTest("does not leak output when Bun recycles websocket objects before re-connect", () => - Effect.gen(function* () { - const pty = yield* Pty.Service - const info = yield* createPty("cat") - const first = yield* makeSocket({ events: { connection: "a" } }) - const recycled = yield* Queue.unbounded() - - yield* pty.connect(info.id, first.socket) - first.socket.data = { events: { connection: "b" } } - first.socket.send = (data) => Queue.offerUnsafe(recycled, decodeOutput(data)) - yield* pty.write(info.id, "AAA\n") - - const verify = yield* makeSocket({ events: { connection: "verify" } }) - yield* pty.connect(info.id, verify.socket) - expect(yield* waitForOutput(verify.output, "AAA")).toContain("AAA") - expect(yield* waitForOutput(recycled, "AAA", "100 millis").pipe(Effect.option)).toMatchObject({ _tag: "None" }) - }), - ) - - ptyTest("treats in-place socket data mutation as the same connection", () => - Effect.gen(function* () { - const pty = yield* Pty.Service - const info = yield* createPty("cat") - const data = { connId: 1 } - const socket = yield* makeSocket(data) - - yield* pty.connect(info.id, socket.socket) - data.connId = 2 - yield* pty.write(info.id, "AAA\n") - - expect(yield* waitForOutput(socket.output, "AAA")).toContain("AAA") - }), - ) -}) diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts index 3372442dbf0..6b78ec4d13c 100644 --- a/packages/core/test/pty/pty-session.test.ts +++ b/packages/core/test/pty/pty-session.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" -import { Cause, Effect, Exit, Layer, Queue } from "effect" +import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect" +import { Config } from "@opencode-ai/core/config" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { Pty } from "@opencode-ai/core/pty" @@ -14,7 +15,14 @@ const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })), ) -const it = testEffect(Pty.layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer))) +const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) +const it = testEffect( + Pty.layer.pipe( + Layer.provide(configLayer), + Layer.provideMerge(EventV2.defaultLayer), + Layer.provideMerge(locationLayer), + ), +) const ptyTest = process.platform === "win32" ? it.live.skip : it.live const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () { @@ -56,36 +64,176 @@ const waitForEvents = (events: Queue.Queue, id: PtyID, count: number) }), ) +const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function* (id: PtyID, cursor?: number) { + const pty = yield* Pty.Service + const output = yield* Queue.unbounded() + const ended = yield* Deferred.make<{ exitCode?: number }>() + const attachment = yield* pty.attach(id, { + cursor, + onData: (chunk) => Queue.offerUnsafe(output, chunk), + onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)), + }) + attachment.activate() + return { attachment, output, ended } +}) + +const waitForOutput = (output: Queue.Queue, text: string) => + Effect.gen(function* () { + let received = "" + while (!received.includes(text)) received += yield* Queue.take(output) + return received + }).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)), + }), + ) + describe("pty", () => { it.live("returns typed not found errors for missing sessions", () => Effect.gen(function* () { const pty = yield* Pty.Service const id = "pty_missing" as PtyID - let closed = false - const socket = { readyState: 1, send: () => {}, close: () => void (closed = true) } for (const result of [ yield* pty.get(id).pipe(Effect.asVoid, Effect.exit), yield* pty.update(id, { title: "missing" }).pipe(Effect.asVoid, Effect.exit), yield* pty.remove(id).pipe(Effect.exit), - yield* pty.resize(id, 80, 24).pipe(Effect.exit), yield* pty.write(id, "input").pipe(Effect.exit), - yield* pty.connect(id, socket).pipe(Effect.asVoid, Effect.exit), + yield* pty.attach(id, { onData: () => {}, onEnd: () => {} }).pipe(Effect.asVoid, Effect.exit), ]) { expect(Exit.isFailure(result)).toBe(true) if (Exit.isFailure(result)) expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id }) } - expect(closed).toBe(true) }), ) - ptyTest("publishes created, exited, deleted in order for a short-lived process", () => + ptyTest("retains exited sessions until removed", () => Effect.gen(function* () { + const pty = yield* Pty.Service const events = yield* subscribePtyEvents() - const info = yield* createPty("/usr/bin/env", ["sh", "-c", "sleep 0.1"]) + const info = yield* createPty("/usr/bin/env", ["sh", "-c", "exit 3"]) - expect(yield* waitForEvents(events, info.id, 3)).toEqual(["created", "exited", "deleted"]) + expect(yield* waitForEvents(events, info.id, 2)).toEqual(["created", "exited"]) + const exited = yield* pty.get(info.id) + expect(exited.status).toBe("exited") + expect(exited.exitCode).toBe(3) + + yield* pty.remove(info.id) + expect(yield* waitForEvents(events, info.id, 1)).toEqual(["deleted"]) + const missing = yield* pty.get(info.id).pipe(Effect.exit) + expect(Exit.isFailure(missing)).toBe(true) + }), + ) + + ptyTest("replays buffered output and streams live output to attachments", () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const info = yield* createPty("cat") + yield* pty.write(info.id, "AAA\n") + + const first = yield* attachCollecting(info.id) + expect(yield* waitForOutput(first.output, "AAA")).toContain("AAA") + + first.attachment.write("BBB\n") + yield* waitForOutput(first.output, "BBB") + + // A later attachment replays everything already buffered. + const replayed = yield* attachCollecting(info.id) + expect(replayed.attachment.replay).toContain("AAA") + expect(replayed.attachment.replay).toContain("BBB") + expect(replayed.attachment.cursor).toBeGreaterThan(0) + + // Tail attachments skip the buffer and only see subsequent output. + const tail = yield* attachCollecting(info.id, -1) + expect(tail.attachment.replay).toBe("") + expect(tail.attachment.cursor).toBe(replayed.attachment.cursor) + }), + ) + + ptyTest("stops delivering output after detach", () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const info = yield* createPty("cat") + const attached = yield* attachCollecting(info.id, -1) + + attached.attachment.detach() + yield* pty.write(info.id, "AAA\n") + + const verify = yield* attachCollecting(info.id) + yield* waitForOutput(verify.output, "AAA") + const leaked = yield* Queue.poll(attached.output) + expect(leaked._tag).toBe("None") + }), + ) + + ptyTest("isolates output between sessions", () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const a = yield* createPty("cat") + const b = yield* createPty("cat") + const attachedA = yield* attachCollecting(a.id) + const attachedB = yield* attachCollecting(b.id) + + yield* pty.write(a.id, "AAA\n") + yield* waitForOutput(attachedA.output, "AAA") + + const leaked = yield* Queue.poll(attachedB.output) + expect(leaked._tag).toBe("None") + }), + ) + + ptyTest("notifies attachments with the exit code and rejects attach after exit", () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const events = yield* subscribePtyEvents() + const info = yield* createPty("cat") + const attached = yield* attachCollecting(info.id) + + yield* pty.write(info.id, "\u0004") + expect(yield* Deferred.await(attached.ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 0 }) + yield* waitForEvents(events, info.id, 2) + + const result = yield* pty.attach(info.id, { onData: () => {}, onEnd: () => {} }).pipe(Effect.exit) + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) + expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.ExitedError", ptyID: info.id }) + }), + ) +}) + +const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash") +const configuredIt = testEffect( + Pty.layer.pipe( + Layer.provide( + Layer.mock(Config.Service)({ + entries: () => + Effect.succeed( + configuredShell + ? [new Config.Document({ type: "document", info: new Config.Info({ shell: configuredShell }) })] + : [], + ), + }), + ), + Layer.provideMerge(EventV2.defaultLayer), + Layer.provideMerge(locationLayer), + ), +) +const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live + +describe("pty create defaults", () => { + configuredTest("defaults command, login args, and cwd from config and location", () => + Effect.gen(function* () { + if (!configuredShell) return + const pty = yield* Pty.Service + const info = yield* Effect.acquireRelease(pty.create({ title: "configured" }), (created) => + pty.remove(created.id).pipe(Effect.ignore), + ) + expect(info.command).toBe(configuredShell) + expect(info.args).toEqual(["-l"]) + expect(info.cwd).toBe("/tmp") + expect(info.title).toBe("configured") }), ) }) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index c50e2f855af..e1f5e32b752 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -3,6 +3,8 @@ import { LLM } from "@opencode-ai/llm" import { LLMClient } from "@opencode-ai/llm/route" import { ConfigProvider, DateTime, Effect } from "effect" import { Headers } from "effect/unstable/http" +import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { ProjectV2 } from "@opencode-ai/core/project" @@ -45,8 +47,6 @@ const provider = (api: ProviderV2.Info["api"]) => new ProviderV2.Info({ id: ProviderV2.ID.make("test-provider"), name: "Test provider", - enabled: { via: "env", name: "TEST_PROVIDER_API_KEY" }, - env: ["TEST_PROVIDER_API_KEY"], api, request: { headers: {}, body: {} }, }) @@ -247,7 +247,7 @@ describe("SessionRunnerModel", () => { ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), request: { headers: {}, body: {}, generation: {}, options: {} }, }), - provider({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), + { type: "env", name: "TEST_PROVIDER_API_KEY" }, ) const request = LLM.request({ model: resolved, prompt: "Hello" }) const headers = yield* resolved.route.auth @@ -266,6 +266,35 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("prefers stored credentials over configured auth", () => + Effect.gen(function* () { + const credential = new Credential.Stored({ + id: Credential.ID.create(), + integrationID: Integration.ID.make("test-provider"), + label: "Work", + value: new Credential.Key({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }), + }) + const resolved = yield* SessionRunnerModel.fromCatalogModel( + new ModelV2.Info({ + ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), + request: { headers: {}, body: { apiKey: "configured-secret" }, generation: {}, options: {} }, + }), + { type: "credential", id: credential.id, label: credential.label }, + credential, + ) + const headers = yield* resolved.route.auth.apply({ + request: LLM.request({ model: resolved, prompt: "Hello" }), + method: "POST", + url: "https://openai.example/v1/responses", + body: "{}", + headers: Headers.empty, + }) + + expect(headers.authorization).toBe("Bearer stored-secret") + expect(resolved.route.defaults.http?.body).toEqual({ tenant: "work" }) + }), + ) + it.effect("rejects catalog APIs without a native route", () => Effect.gen(function* () { const failure = yield* SessionRunnerModel.fromCatalogModel( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index cfec0918611..39bf8433cd8 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -704,7 +704,7 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Moved, { sessionID, timestamp: DateTime.makeUnsafe(1), - location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), + location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), // kilocode_change }) expect( yield* db @@ -762,7 +762,7 @@ describe("SessionRunnerLLM", () => { .publish(SessionEvent.Moved, { sessionID, timestamp: DateTime.makeUnsafe(1), - location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), + location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), // kilocode_change }) .pipe(Effect.asVoid) }) @@ -1461,6 +1461,7 @@ describe("SessionRunnerLLM", () => { }) requests.length = 0 + executions.length = 0 responses = [ fragmentFixture("text", "text-summary-2", ["## Goal\n- Preserve the updated task"]).completeEvents, fragmentFixture("text", "text-final-2", ["Continued again"]).completeEvents, @@ -3177,7 +3178,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("fails after the bounded number of local tool continuation steps", () => + it.effect("continues past 25 local tool steps when the agent has no step limit", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -3188,62 +3189,10 @@ describe("SessionRunnerLLM", () => { executions.length = 0 streamGate = undefined streamStarted = undefined - responses = Array.from({ length: 25 }, (_, index) => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ]) - - const failure = yield* session.resume(sessionID).pipe(Effect.flip) - - expect(failure).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError", sessionID, limit: 25 }) - expect(requests).toHaveLength(25) - expect(executions).toHaveLength(25) - }), - ) - - it.effect("does not restart a capped tool loop for a coalesced stale wake", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const coordinator = yield* SessionRunCoordinator.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Loop forever" }), resume: false }) - - requests.length = 0 - responses = Array.from({ length: 25 }, (_, index) => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: `call-capped-${index}`, name: "echo", input: { text: `${index}` } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ]) - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() - - const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) - yield* coordinator.wake(sessionID) - yield* Deferred.succeed(streamGate, undefined) - expect(yield* Fiber.join(run).pipe(Effect.flip)).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError" }) - streamGate = undefined - streamStarted = undefined - yield* Effect.yieldNow - - expect(requests).toHaveLength(25) - }), - ) - - it.effect("accepts a terminal response on the final bounded provider turn", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false }) - - requests.length = 0 responses = [ - ...Array.from({ length: 24 }, (_, index) => [ + ...Array.from({ length: 25 }, (_, index) => [ LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: `call-terminal-${index}`, name: "echo", input: { text: `${index}` } }), + LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }), LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" }), ]), @@ -3256,7 +3205,56 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) - expect(requests).toHaveLength(25) + expect(requests).toHaveLength(26) + expect(executions).toHaveLength(25) + }), + ) + + it.effect("forces a text response on an agent's configured final step", () => + Effect.gen(function* () { + yield* setup + const agents = yield* AgentV2.Service + yield* agents.update((editor) => + editor.update(AgentV2.ID.make("build"), (agent) => { + agent.steps = 2 + }), + ) + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false }) + + requests.length = 0 + executions.length = 0 + responses = [ + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-terminal", name: "echo", input: { text: "done" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-forbidden", name: "echo", input: { text: "forbidden" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + ] + + yield* session.resume(sessionID) + + expect(requests).toHaveLength(2) + expect(requests[0]?.toolChoice).toBeUndefined() + expect(requests[1]?.toolChoice).toMatchObject({ type: "none" }) + expect(requests[1]?.tools).toEqual([]) + expect(requests[1]?.messages.at(-1)).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }], + }) + expect(executions).toEqual(["done"]) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user", text: "Finish at the limit" }, + { type: "assistant", content: [{ type: "tool", id: "call-terminal", state: { status: "completed" } }] }, + { type: "assistant", content: [{ type: "tool", id: "call-forbidden", state: { status: "error" } }] }, + ]) }), ) diff --git a/packages/opencode/test/shell/shell.test.ts b/packages/core/test/shell.test.ts similarity index 85% rename from packages/opencode/test/shell/shell.test.ts rename to packages/core/test/shell.test.ts index 1f76783ac1f..1cc47a79f6c 100644 --- a/packages/opencode/test/shell/shell.test.ts +++ b/packages/core/test/shell.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import path from "path" -import { Shell } from "../../src/shell/shell" -import { Filesystem } from "@/util/filesystem" +import { Shell } from "@opencode-ai/core/shell" +import { FSUtil } from "@opencode-ai/core/fs-util" import { which } from "@opencode-ai/core/util/which" const withShell = async (shell: string | undefined, fn: () => void | Promise) => { @@ -54,6 +54,15 @@ describe("shell", () => { expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu") }) + test("builds command args per shell family", () => { + expect(Shell.args("/bin/sh", "echo hi", "/tmp")).toEqual(["-c", "echo hi"]) + expect(Shell.args("/usr/bin/fish", "echo hi", "/tmp")).toEqual(["-c", "echo hi"]) + const zsh = Shell.args("/bin/zsh", "echo hi", "/tmp") + expect(zsh[0]).toBe("-l") + expect(zsh[1]).toBe("-c") + expect(zsh.at(-1)).toBe("/tmp") + }) + if (process.platform === "win32") { test("rejects blacklisted shells case-insensitively", async () => { await withShell("NU.EXE", async () => { @@ -64,7 +73,7 @@ describe("shell", () => { test("normalizes Git Bash shell paths from env", async () => { const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe" await withShell(shell, async () => { - expect(Shell.preferred()).toBe(Filesystem.windowsPath(shell)) + expect(Shell.preferred()).toBe(FSUtil.windowsPath(shell)) }) }) diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 021b5b4e906..920418a989f 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -7,8 +7,8 @@ "private": true, "scripts": { "test": "bun test --timeout 30000 --only-failures", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", - "typecheck": "tsgo --noEmit" + "typecheck": "tsgo --noEmit", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" }, "exports": { ".": "./src/index.ts", diff --git a/packages/effect-sqlite-node/sst-env.d.ts b/packages/effect-sqlite-node/sst-env.d.ts new file mode 100644 index 00000000000..64441936d7a --- /dev/null +++ b/packages/effect-sqlite-node/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 6eadd755345..fec3e374d06 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -28,10 +28,10 @@ }, "scripts": { "test": "bun test --timeout 30000 --only-failures", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "typecheck": "tsgo --noEmit", "build": "bun ./script/build.ts", - "verify:package": "bun ./script/verify-package.ts" + "verify:package": "bun ./script/verify-package.ts", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" }, "exports": { ".": "./src/index.ts", diff --git a/packages/http-recorder/sst-env.d.ts b/packages/http-recorder/sst-env.d.ts new file mode 100644 index 00000000000..64441936d7a --- /dev/null +++ b/packages/http-recorder/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/kilo-docs/lychee.toml b/packages/kilo-docs/lychee.toml index 73dc8a11fbf..bfff8a29206 100644 --- a/packages/kilo-docs/lychee.toml +++ b/packages/kilo-docs/lychee.toml @@ -60,6 +60,9 @@ exclude = [ # Consistently times out in CI '^https?://opncd\.ai', '^https?://zod\.dev/v4/changelog', + # Moonshot sites consistently time out from GitHub Actions runners. + '^https?://(www\.)?moonshot\.cn/?$', + '^https?://platform\.moonshot\.cn/?$', # Example punycode domain used in homograph attack documentation — does not exist '^https?://xn--pitest', # OpenAI docs return 404 to plain GET link checks but resolve in browsers diff --git a/packages/kilo-docs/pages/automate/code-reviews/overview.md b/packages/kilo-docs/pages/automate/code-reviews/overview.md index a78b4ae4a36..40e1109389c 100644 --- a/packages/kilo-docs/pages/automate/code-reviews/overview.md +++ b/packages/kilo-docs/pages/automate/code-reviews/overview.md @@ -159,6 +159,10 @@ When a pull request or merge request is opened or updated: Reviews are posted directly in your platform (GitHub or GitLab) as if coming from a team reviewer. +{% callout type="info" title="Bot-generated PRs are ignored by default" %} +Kilo does not automatically review pull or merge requests opened by bots, such as Dependabot, Renovate, or other automation accounts. This keeps review credits and notifications focused on human-authored changes. +{% /callout %} + ## Review Styles ### Strict @@ -236,3 +240,4 @@ The Review Agent is ideal for: - Some highly dynamic or domain-specific code may require additional context in `REVIEW.md`. - The agent will only run on **selected repositories**. - During beta, review capacity may be throttled for extremely large PRs. +- PRs/MRs opened by bots (e.g. Dependabot, Renovate) are ignored by default. diff --git a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md index d489dc63ebc..20ad7099085 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md @@ -387,7 +387,7 @@ You can also set options that apply to all models from a provider: |---|---|---| | `apiKey` | `string` | API key (supports `{env:VAR}` and `{file:...}` syntax in trusted config — see note below) | | `baseURL` | `string` | Override the provider's base API URL | -| `timeout` | `number \| false` | Request timeout in milliseconds. Defaults to `300000` (5 minutes); set to `false` to disable | +| `timeout` | `number \| false` | Request timeout in milliseconds, covering both the wait for response headers and the wait for the first byte of the response body. Defaults to `300000` (5 minutes); set to `false` to disable. Once data starts arriving the timeout no longer applies, so slow streaming responses are never cut short — use `chunkTimeout` for gaps inside a response | | `chunkTimeout` | `number` | Timeout in milliseconds between streamed response chunks. If no chunk arrives within this window, the request is aborted and retried. This catches silent provider dropouts where the TCP connection stays open but SSE streaming stops. Recommended: `15000`–`30000` (15–30 seconds) for providers with unreliable streaming. | {% callout type="warning" title="{env:} / {file:} only resolve in trusted config" %} 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 0438ce9395f..ac6b5490c01 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:e8318f427b6c0ff71d4cce5c8ac03ba4778f99fda5723d24d23c38471523b622 -size 52203 +oid sha256:a7c800d169ca92674fc9f7c7d83032ca17e7d7937bdcbea5c72cb921a7fdb89e +size 51975 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png index 37a0a3a3a86..85a8ddb7b65 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1bd31cd4e01c2e2a5ed579bce1b542368e7a11fc878d375a93fb238ba4f82469 -size 53288 +oid sha256:a7d7d047f14440d3dafc8a202131af6e755f80875012fd934a56580de21d36d5 +size 52887 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-collapsed-context-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-collapsed-context-chromium-linux.png index 17e37e9de1f..5568f063ac2 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-collapsed-context-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-collapsed-context-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47b7b7bfb4a6b5631d8fdb808c1b3a9eb631b6f96911ddf15880f0c2fee4f7e0 -size 38558 +oid sha256:29e79ac79e992bf7f19646aab8ba10464872f4d012abed458a2a432a223668da +size 38129 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/side-terminal-panel-empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/side-terminal-panel-empty-chromium-linux.png new file mode 100644 index 00000000000..7515cef4a3f --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/side-terminal-panel-empty-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31c246e1baf581b431033ee2b2ce1c5080cca02d034b42d4dbce20f9acb5f33f +size 9538 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png index f38949019a1..c2f3c6d8b7d 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e872bc8ad05ad2a7ad1dd6de8a2ff427957fd278cee46940db0b12d84fe42ecf -size 25534 +oid sha256:ee6ea794cf665570dcc208ad5975a14843d1680e59eb785fb5298b4919b7dd1d +size 25757 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png index 0eff7b16fa8..70ea4023789 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ff33e800f4d2379bcc997228a1e4dd83433521b931ae85edb7c8f0c4c93405e7 -size 20540 +oid sha256:ee9fb41fb522684de6aa7fd9acda8357f33f2ba90bcb3eb7679ddfbcdecc6cdb +size 20764 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/markdown-mermaid-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/markdown-mermaid-chromium-linux.png new file mode 100644 index 00000000000..17c6284a465 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/markdown-mermaid-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77964c111d0454d7985e3368b821e161e4d6d9fffee5438ee094ab34ff464d52 +size 18623 diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index e9b3199d5e8..a786a86d271 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -155,8 +155,6 @@ - -- - - - diff --git a/packages/kilo-i18n/src/ar.ts b/packages/kilo-i18n/src/ar.ts index 13170b89c6d..b538f126a61 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -1,12 +1,5 @@ export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "يمنحك Kilo Gateway الوصول إلى مجموعة مختارة من النماذج الموثوقة والمحسّنة لوكلاء البرمجة.", - "provider.connect.kiloGateway.line2": - "بمفتاح API واحد، ستحصل على إمكانية الوصول إلى نماذج مثل Claude وGPT وGemini وGLM والمزيد.", - "provider.connect.kiloGateway.visit.prefix": "قم بزيارة ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": " للحصول على مفتاح API الخاص بك.", "provider.connect.kiloGateway.byok.prefix": "للحصول على المزيد من إحصائيات الاستخدام، استخدم ", "provider.connect.kiloGateway.byok.link": "BYOK عبر Kilo's Gateway", "provider.connect.kiloGateway.byok.suffix": ".", @@ -24,14 +17,9 @@ export const dict = { "settings.providers.note.vercel": "وصول موحد إلى نماذج الذكاء الاصطناعي مع توجيه ذكي", // Reasoning block label - "ui.permission.run": "تشغيل", "ui.reasoning.label": "الاستدلال", // Marketplace - "marketplace.tab.skills": "المهارات", - "marketplace.tab.mcpServers": "خوادم MCP", - "marketplace.category.all": "الكل", - "marketplace.placeholder": "سيتم تنفيذه لاحقاً", "marketplace.card.installed": "مثبت", "marketplace.card.install": "تثبيت", "marketplace.card.remove": "إزالة", @@ -44,11 +32,9 @@ export const dict = { "marketplace.install.scope.global": "عالمي", "marketplace.install.scope.project.description": "هذا المشروع فقط. يمكن إضافة الملفات المثبتة إلى نظام التحكم في الإصدارات ومشاركتها مع فريقك.", - "marketplace.install.scope.global.description": - "جميع المشاريع على هذا الجهاز. تُحفظ في إعدادات المستخدم الخاصة بك.", + "marketplace.install.scope.global.description": "جميع المشاريع على هذا الجهاز. تُحفظ في إعدادات المستخدم الخاصة بك.", "marketplace.install.destination": "وجهة التثبيت", - "marketplace.install.about.mcp": - "يمنح خادم MCP منصة Kilo أدوات إضافية للعمل مع الخدمات الخارجية أو البرامج المحلية.", + "marketplace.install.about.mcp": "يمنح خادم MCP منصة Kilo أدوات إضافية للعمل مع الخدمات الخارجية أو البرامج المحلية.", "marketplace.install.about.agent": "يضيف الوكيل دورًا قابلاً لإعادة الاستخدام بتعليماته وأذوناته الخاصة.", "marketplace.install.about.skill": "تضيف المهارة تعليمات وموارد خاصة بمهام معينة يمكن لـ Kilo تحميلها عند الحاجة.", "marketplace.install.mcp.warning": @@ -72,8 +58,6 @@ export const dict = { "هل أنت متأكد أنك تريد إزالة هذا الـ {{type}}؟ سيؤدي هذا إلى إزالته من تكوين {{scope}} الخاص بك.", "marketplace.remove.cancel": "إلغاء", "marketplace.remove.confirm.button": "إزالة", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "وكلاء", "marketplace.search": "بحث...", "marketplace.filter.all": "جميع العناصر", "marketplace.filter.notInstalled": "غير مثبت", @@ -81,12 +65,10 @@ export const dict = { "marketplace.empty": "لم يتم العثور على عناصر", "marketplace.empty.relevant": "لم يتم العثور على عناصر سوق ذات صلة بمساحة العمل هذه.", "marketplace.badge.mcpServer": "خادم MCP", - "marketplace.badge.mode": "وضع", "marketplace.card.by": "بواسطة {{author}}", "marketplace.install.method": "طريقة التثبيت", "marketplace.install.parameters": "المعلمات", "marketplace.install.optional": "(اختياري)", - "marketplace.install.required": "{{name}} مطلوب", "marketplace.scope.project": "مشروع", "marketplace.scope.global": "عام", "marketplace.remove.type.mcp": "خادم MCP", @@ -128,8 +110,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "فتح في عارض الفروقات", - "ui.messagePart.shell.command": "الأمر", - "ui.messagePart.shell.output": "المخرجات", "ui.messagePart.openInEditor": "فتح في المحرر", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/br.ts b/packages/kilo-i18n/src/br.ts index 3e91b3d765e..3117506790f 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -1,12 +1,5 @@ export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "O Kilo Gateway oferece acesso a um conjunto selecionado de modelos confiáveis e otimizados para agentes de codificação.", - "provider.connect.kiloGateway.line2": - "Com uma única chave de API, você terá acesso a modelos como Claude, GPT, Gemini, GLM e mais.", - "provider.connect.kiloGateway.visit.prefix": "Visite ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": " para obter sua chave de API.", "provider.connect.kiloGateway.byok.prefix": "Para mais estatísticas de uso, utilize ", "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", "provider.connect.kiloGateway.byok.suffix": ".", @@ -24,14 +17,9 @@ export const dict = { "settings.providers.note.vercel": "Acesso unificado a modelos de IA com roteamento inteligente", // Reasoning block label - "ui.permission.run": "Executar", "ui.reasoning.label": "Raciocínio", // Marketplace - "marketplace.tab.skills": "Skills", - "marketplace.tab.mcpServers": "Servidores MCP", - "marketplace.category.all": "Todos", - "marketplace.placeholder": "A ser implementado", "marketplace.card.installed": "Instalado", "marketplace.card.install": "Instalar", "marketplace.card.remove": "Remover", @@ -59,7 +47,8 @@ export const dict = { "marketplace.install.learnMore": "Saiba como funcionam as instalações do Marketplace", "marketplace.install.learnMcp": "Saiba mais sobre MCP", "marketplace.install.installedAt": "Instalado em {{path}}", - "marketplace.intro": "Instale agentes, habilidades e ferramentas MCP reutilizáveis em um projeto ou em todos os projetos.", + "marketplace.intro": + "Instale agentes, habilidades e ferramentas MCP reutilizáveis em um projeto ou em todos os projetos.", "marketplace.intro.learnMore": "Sobre o Marketplace", "marketplace.install.prerequisites": "Pré-requisitos", "marketplace.install.installing": "Instalando...", @@ -73,8 +62,6 @@ export const dict = { "Tem certeza que deseja remover este {{type}}? Isso o removerá da sua configuração {{scope}}.", "marketplace.remove.cancel": "Cancelar", "marketplace.remove.confirm.button": "Remover", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "Agentes", "marketplace.search": "Pesquisar...", "marketplace.filter.all": "Todos os Itens", "marketplace.filter.notInstalled": "Não Instalado", @@ -82,12 +69,10 @@ export const dict = { "marketplace.empty": "Nenhum item encontrado", "marketplace.empty.relevant": "Nenhum item relevante do marketplace foi encontrado para este espaço de trabalho.", "marketplace.badge.mcpServer": "Servidor MCP", - "marketplace.badge.mode": "Modo", "marketplace.card.by": "por {{author}}", "marketplace.install.method": "Método de Instalação", "marketplace.install.parameters": "Parâmetros", "marketplace.install.optional": "(opcional)", - "marketplace.install.required": "{{name}} é obrigatório", "marketplace.scope.project": "projeto", "marketplace.scope.global": "global", "marketplace.remove.type.mcp": "servidor MCP", @@ -129,8 +114,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Abrir no Visualizador de Diferenças", - "ui.messagePart.shell.command": "Comando", - "ui.messagePart.shell.output": "Saída", "ui.messagePart.openInEditor": "Abrir no Editor", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/bs.ts b/packages/kilo-i18n/src/bs.ts index d1aece561ee..49cbbea5ef5 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -1,12 +1,5 @@ export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "Kilo Gateway omogućava pristup pažljivo odabranom skupu pouzdanih modela optimizovanih za rad sa agentima za kodiranje.", - "provider.connect.kiloGateway.line2": - "Sa jednim API ključem dobijate pristup modelima kao što su Claude, GPT, Gemini, GLM i drugi.", - "provider.connect.kiloGateway.visit.prefix": "Posjetite ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": " da preuzmete svoj API ključ.", "provider.connect.kiloGateway.byok.prefix": "Za više statistika korištenja, koristite ", "provider.connect.kiloGateway.byok.link": "BYOK putem Kilo's Gateway", "provider.connect.kiloGateway.byok.suffix": ".", @@ -29,14 +22,9 @@ export const dict = { "desktop.cli.installed.message": "CLI je instaliran u {{path}}\n\nRestartuj terminal da bi koristio komandu 'kilo'.", // Reasoning block label - "ui.permission.run": "Pokreni", "ui.reasoning.label": "Rezonovanje", // Marketplace - "marketplace.tab.skills": "Vještine", - "marketplace.tab.mcpServers": "MCP Serveri", - "marketplace.category.all": "Sve", - "marketplace.placeholder": "Biće implementirano", "marketplace.card.installed": "Instalirano", "marketplace.card.install": "Instaliraj", "marketplace.card.remove": "Ukloni", @@ -78,8 +66,6 @@ export const dict = { "Jeste li sigurni da želite ukloniti ovaj {{type}}? Ovo će ga ukloniti iz vaše {{scope}} konfiguracije.", "marketplace.remove.cancel": "Otkaži", "marketplace.remove.confirm.button": "Ukloni", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "Agenti", "marketplace.search": "Pretraži...", "marketplace.filter.all": "Sve stavke", "marketplace.filter.notInstalled": "Nije instalirano", @@ -87,12 +73,10 @@ export const dict = { "marketplace.empty": "Nema pronađenih stavki", "marketplace.empty.relevant": "Nisu pronađene relevantne stavke marketplacea za ovaj radni prostor.", "marketplace.badge.mcpServer": "MCP Server", - "marketplace.badge.mode": "Režim", "marketplace.card.by": "od {{author}}", "marketplace.install.method": "Metoda instalacije", "marketplace.install.parameters": "Parametri", "marketplace.install.optional": "(opciono)", - "marketplace.install.required": "{{name}} je obavezno", "marketplace.scope.project": "projekat", "marketplace.scope.global": "globalno", "marketplace.remove.type.mcp": "MCP server", @@ -134,8 +118,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Otvori u pregledniku razlika", - "ui.messagePart.shell.command": "Naredba", - "ui.messagePart.shell.output": "Izlaz", "ui.messagePart.openInEditor": "Otvori u editoru", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/da.ts b/packages/kilo-i18n/src/da.ts index 3147ec1eee1..960a5b4158f 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -1,12 +1,5 @@ export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "Kilo Gateway giver dig adgang til et udvalgt sæt pålidelige, optimerede modeller til kodningsagenter.", - "provider.connect.kiloGateway.line2": - "Med en enkelt API-nøgle får du adgang til modeller som Claude, GPT, Gemini, GLM og flere.", - "provider.connect.kiloGateway.visit.prefix": "Besøg ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": " for at hente din API-nøgle.", "provider.connect.kiloGateway.byok.prefix": "For flere brugsstatistikker, brug ", "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", "provider.connect.kiloGateway.byok.suffix": ".", @@ -24,14 +17,9 @@ export const dict = { "settings.providers.note.vercel": "Samlet adgang til AI-modeller med smart routing", // Reasoning block label - "ui.permission.run": "Kør", "ui.reasoning.label": "Ræsonnement", // Marketplace - "marketplace.tab.skills": "Skills", - "marketplace.tab.mcpServers": "MCP-servere", - "marketplace.category.all": "Alle", - "marketplace.placeholder": "Skal implementeres", "marketplace.card.installed": "Installeret", "marketplace.card.install": "Installer", "marketplace.card.remove": "Fjern", @@ -44,8 +32,7 @@ export const dict = { "marketplace.install.scope.global": "Global", "marketplace.install.scope.project.description": "Kun dette projekt. De installerede filer kan føjes til versionsstyring og deles med dit team.", - "marketplace.install.scope.global.description": - "Alle projekter på denne maskine. Gemmes i din brugerkonfiguration.", + "marketplace.install.scope.global.description": "Alle projekter på denne maskine. Gemmes i din brugerkonfiguration.", "marketplace.install.destination": "Installationsplacering", "marketplace.install.about.mcp": "En MCP-server giver Kilo yderligere værktøjer til at arbejde med eksterne tjenester eller lokale programmer.", @@ -73,8 +60,6 @@ export const dict = { "Er du sikker på, at du vil fjerne denne {{type}}? Dette vil fjerne den fra din {{scope}} konfiguration.", "marketplace.remove.cancel": "Annuller", "marketplace.remove.confirm.button": "Fjern", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "Agenter", "marketplace.search": "Søg...", "marketplace.filter.all": "Alle elementer", "marketplace.filter.notInstalled": "Ikke installeret", @@ -82,12 +67,10 @@ export const dict = { "marketplace.empty": "Ingen elementer fundet", "marketplace.empty.relevant": "Der blev ikke fundet relevante marketplace-elementer for dette arbejdsområde.", "marketplace.badge.mcpServer": "MCP-server", - "marketplace.badge.mode": "Tilstand", "marketplace.card.by": "af {{author}}", "marketplace.install.method": "Installationsmetode", "marketplace.install.parameters": "Parametre", "marketplace.install.optional": "(valgfrit)", - "marketplace.install.required": "{{name}} er påkrævet", "marketplace.scope.project": "projekt", "marketplace.scope.global": "global", "marketplace.remove.type.mcp": "MCP-server", @@ -129,8 +112,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Åbn i Diff-visning", - "ui.messagePart.shell.command": "Kommando", - "ui.messagePart.shell.output": "Output", "ui.messagePart.openInEditor": "Åbn i editor", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/de.ts b/packages/kilo-i18n/src/de.ts index 8e4fe468a5c..d939be47485 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -1,12 +1,5 @@ export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "Kilo Gateway bietet Ihnen Zugang zu einer kuratierten Auswahl zuverlässiger, optimierter Modelle für Coding-Agenten.", - "provider.connect.kiloGateway.line2": - "Mit einem einzigen API-Schlüssel erhalten Sie Zugang zu Modellen wie Claude, GPT, Gemini, GLM und mehr.", - "provider.connect.kiloGateway.visit.prefix": "Besuchen Sie ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": ", um Ihren API-Schlüssel zu erhalten.", "provider.connect.kiloGateway.byok.prefix": "Für weitere Nutzungsstatistiken ", "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", "provider.connect.kiloGateway.byok.suffix": " nutzen.", @@ -24,14 +17,9 @@ export const dict = { "settings.providers.note.vercel": "Einheitlicher Zugriff auf KI-Modelle mit intelligentem Routing", // Reasoning block label - "ui.permission.run": "Ausführen", "ui.reasoning.label": "Denken", // Marketplace - "marketplace.tab.skills": "Skills", - "marketplace.tab.mcpServers": "MCP-Server", - "marketplace.category.all": "Alle", - "marketplace.placeholder": "Noch nicht implementiert", "marketplace.card.installed": "Installiert", "marketplace.card.install": "Installieren", "marketplace.card.remove": "Entfernen", @@ -75,8 +63,6 @@ export const dict = { "Sind Sie sicher, dass Sie diese {{type}} entfernen möchten? Dadurch wird sie aus Ihrer {{scope}}-Konfiguration entfernt.", "marketplace.remove.cancel": "Abbrechen", "marketplace.remove.confirm.button": "Entfernen", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "Agenten", "marketplace.search": "Suchen...", "marketplace.filter.all": "Alle Elemente", "marketplace.filter.notInstalled": "Nicht installiert", @@ -84,12 +70,10 @@ export const dict = { "marketplace.empty": "Keine Elemente gefunden", "marketplace.empty.relevant": "Keine relevanten Marketplace-Elemente für diesen Arbeitsbereich gefunden.", "marketplace.badge.mcpServer": "MCP-Server", - "marketplace.badge.mode": "Modus", "marketplace.card.by": "von {{author}}", "marketplace.install.method": "Installationsmethode", "marketplace.install.parameters": "Parameter", "marketplace.install.optional": "(optional)", - "marketplace.install.required": "{{name}} ist erforderlich", "marketplace.scope.project": "Projekt", "marketplace.scope.global": "Global", "marketplace.remove.type.mcp": "MCP-Server", @@ -131,8 +115,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Im Diff-Viewer öffnen", - "ui.messagePart.shell.command": "Befehl", - "ui.messagePart.shell.output": "Ausgabe", "ui.messagePart.openInEditor": "Im Editor öffnen", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/en.ts b/packages/kilo-i18n/src/en.ts index 9eb679e3a03..6a36289765e 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -2,13 +2,6 @@ // Keys here will override any matching keys from upstream translations export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "Kilo Gateway gives you access to a curated set of reliable optimized models for coding agents.", - "provider.connect.kiloGateway.line2": - "With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more.", - "provider.connect.kiloGateway.visit.prefix": "Visit ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": " to collect your API key.", "provider.connect.kiloGateway.byok.prefix": "For more usage stats, ", "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", "provider.connect.kiloGateway.byok.suffix": ".", @@ -26,14 +19,9 @@ export const dict = { "settings.providers.note.vercel": "Unified access to AI models with smart routing", // Reasoning block label - "ui.permission.run": "Run", "ui.reasoning.label": "Reasoning", // Marketplace - "marketplace.tab.skills": "Skills", - "marketplace.tab.mcpServers": "MCP Servers", - "marketplace.category.all": "All", - "marketplace.placeholder": "To be implemented", "marketplace.card.installed": "Installed", "marketplace.card.install": "Install", "marketplace.card.remove": "Remove", @@ -51,7 +39,8 @@ export const dict = { "marketplace.install.about.mcp": "An MCP server gives Kilo additional tools for working with external services or local programs.", "marketplace.install.about.agent": "An agent adds a reusable role with its own instructions and permissions.", - "marketplace.install.about.skill": "A skill adds task-specific instructions and resources that Kilo can load when needed.", + "marketplace.install.about.skill": + "A skill adds task-specific instructions and resources that Kilo can load when needed.", "marketplace.install.mcp.warning": "MCP servers can run local commands or connect to external services. Kilo will ask for permission before using their tools unless your permissions allow them automatically.", "marketplace.install.project.warning": @@ -73,8 +62,6 @@ export const dict = { "Are you sure you want to remove this {{type}}? This will remove it from your {{scope}} configuration.", "marketplace.remove.cancel": "Cancel", "marketplace.remove.confirm.button": "Remove", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "Agents", "marketplace.search": "Search...", "marketplace.filter.all": "All Items", "marketplace.filter.notInstalled": "Not Installed", @@ -82,12 +69,10 @@ export const dict = { "marketplace.empty": "No items found", "marketplace.empty.relevant": "No relevant marketplace items found for this workspace.", "marketplace.badge.mcpServer": "MCP Server", - "marketplace.badge.mode": "Mode", "marketplace.card.by": "by {{author}}", "marketplace.install.method": "Installation Method", "marketplace.install.parameters": "Parameters", "marketplace.install.optional": "(optional)", - "marketplace.install.required": "{{name}} is required", "marketplace.scope.project": "project", "marketplace.scope.global": "global", "marketplace.remove.type.mcp": "MCP server", @@ -134,8 +119,6 @@ export const dict = { // Edit-tool header: hover-revealed action opening the diff in a full tab. "ui.messagePart.openInDiffViewer": "Open in Diff Viewer", // Shell-tool section labels and actions. - "ui.messagePart.shell.command": "Command", - "ui.messagePart.shell.output": "Output", "ui.messagePart.openInEditor": "Open in Editor", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/es.ts b/packages/kilo-i18n/src/es.ts index a72ba48094c..0e71b78c762 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -1,12 +1,5 @@ export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "Kilo Gateway te da acceso a un conjunto seleccionado de modelos optimizados y confiables para agentes de codificación.", - "provider.connect.kiloGateway.line2": - "Con una sola clave API tendrás acceso a modelos como Claude, GPT, Gemini, GLM y más.", - "provider.connect.kiloGateway.visit.prefix": "Visita ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": " para obtener tu clave API.", "provider.connect.kiloGateway.byok.prefix": "Para más estadísticas de uso, utiliza ", "provider.connect.kiloGateway.byok.link": "BYOK a través de Kilo's Gateway", "provider.connect.kiloGateway.byok.suffix": ".", @@ -24,14 +17,9 @@ export const dict = { "settings.providers.note.vercel": "Acceso unificado a modelos de IA con enrutamiento inteligente", // Reasoning block label - "ui.permission.run": "Ejecutar", "ui.reasoning.label": "Razonamiento", // Marketplace - "marketplace.tab.skills": "Skills", - "marketplace.tab.mcpServers": "Servidores MCP", - "marketplace.category.all": "Todos", - "marketplace.placeholder": "Por implementar", "marketplace.card.installed": "Instalado", "marketplace.card.install": "Instalar", "marketplace.card.remove": "Eliminar", @@ -49,8 +37,7 @@ export const dict = { "marketplace.install.destination": "Destino de la instalación", "marketplace.install.about.mcp": "Un servidor MCP proporciona a Kilo herramientas adicionales para trabajar con servicios externos o programas locales.", - "marketplace.install.about.agent": - "Un agente añade un rol reutilizable con sus propias instrucciones y permisos.", + "marketplace.install.about.agent": "Un agente añade un rol reutilizable con sus propias instrucciones y permisos.", "marketplace.install.about.skill": "Una habilidad añade instrucciones y recursos específicos para tareas que Kilo puede cargar cuando sea necesario.", "marketplace.install.mcp.warning": @@ -74,8 +61,6 @@ export const dict = { "¿Estás seguro de que deseas eliminar este {{type}}? Esto lo eliminará de tu configuración {{scope}}.", "marketplace.remove.cancel": "Cancelar", "marketplace.remove.confirm.button": "Eliminar", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "Agentes", "marketplace.search": "Buscar...", "marketplace.filter.all": "Todos los elementos", "marketplace.filter.notInstalled": "No instalado", @@ -83,12 +68,10 @@ export const dict = { "marketplace.empty": "No se encontraron elementos", "marketplace.empty.relevant": "No se encontraron elementos relevantes del marketplace para este espacio de trabajo.", "marketplace.badge.mcpServer": "Servidor MCP", - "marketplace.badge.mode": "Modo", "marketplace.card.by": "por {{author}}", "marketplace.install.method": "Método de instalación", "marketplace.install.parameters": "Parámetros", "marketplace.install.optional": "(opcional)", - "marketplace.install.required": "{{name}} es obligatorio", "marketplace.scope.project": "proyecto", "marketplace.scope.global": "global", "marketplace.remove.type.mcp": "servidor MCP", @@ -130,8 +113,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Abrir en el visor de diferencias", - "ui.messagePart.shell.command": "Comando", - "ui.messagePart.shell.output": "Salida", "ui.messagePart.openInEditor": "Abrir en el editor", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/fr.ts b/packages/kilo-i18n/src/fr.ts index 6f654770827..ae5635dfaa8 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -1,12 +1,5 @@ export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "Kilo Gateway vous donne accès à une sélection de modèles fiables et optimisés pour les agents de codage.", - "provider.connect.kiloGateway.line2": - "Avec une seule clé API, vous aurez accès à des modèles tels que Claude, GPT, Gemini, GLM et plus encore.", - "provider.connect.kiloGateway.visit.prefix": "Visitez ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": " pour obtenir votre clé API.", "provider.connect.kiloGateway.byok.prefix": "Pour plus de statistiques d'utilisation, utilisez ", "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", "provider.connect.kiloGateway.byok.suffix": ".", @@ -24,14 +17,9 @@ export const dict = { "settings.providers.note.vercel": "Accès unifié aux modèles IA avec routage intelligent", // Reasoning block label - "ui.permission.run": "Exécuter", "ui.reasoning.label": "Raisonnement", // Marketplace - "marketplace.tab.skills": "Skills", - "marketplace.tab.mcpServers": "Serveurs MCP", - "marketplace.category.all": "Tout", - "marketplace.placeholder": "À implémenter", "marketplace.card.installed": "Installé", "marketplace.card.install": "Installer", "marketplace.card.remove": "Supprimer", @@ -75,8 +63,6 @@ export const dict = { "Êtes-vous sûr de vouloir supprimer ce {{type}} ? Cela le supprimera de votre configuration {{scope}}.", "marketplace.remove.cancel": "Annuler", "marketplace.remove.confirm.button": "Supprimer", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "Agents", "marketplace.search": "Rechercher...", "marketplace.filter.all": "Tous les éléments", "marketplace.filter.notInstalled": "Non installé", @@ -84,12 +70,10 @@ export const dict = { "marketplace.empty": "Aucun élément trouvé", "marketplace.empty.relevant": "Aucun élément pertinent du marketplace trouvé pour cet espace de travail.", "marketplace.badge.mcpServer": "Serveur MCP", - "marketplace.badge.mode": "Mode", "marketplace.card.by": "par {{author}}", "marketplace.install.method": "Méthode d'installation", "marketplace.install.parameters": "Paramètres", "marketplace.install.optional": "(facultatif)", - "marketplace.install.required": "{{name}} est requis", "marketplace.scope.project": "projet", "marketplace.scope.global": "global", "marketplace.remove.type.mcp": "serveur MCP", @@ -131,8 +115,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Ouvrir dans le visualiseur de différences", - "ui.messagePart.shell.command": "Commande", - "ui.messagePart.shell.output": "Sortie", "ui.messagePart.openInEditor": "Ouvrir dans l'éditeur", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/it.ts b/packages/kilo-i18n/src/it.ts index 229be83c14a..d1445f057f7 100644 --- a/packages/kilo-i18n/src/it.ts +++ b/packages/kilo-i18n/src/it.ts @@ -2,13 +2,6 @@ // Keys here will override any matching keys from upstream translations export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "Kilo Gateway ti offre una selezione curata di modelli affidabili e ottimizzati per agenti di coding.", - "provider.connect.kiloGateway.line2": - "Con una sola API key puoi accedere a modelli come Claude, GPT, Gemini, GLM e altri.", - "provider.connect.kiloGateway.visit.prefix": "Visita ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": " per ottenere la tua API key.", "provider.connect.kiloGateway.byok.prefix": "Per ulteriori statistiche sull'utilizzo, utilizza ", "provider.connect.kiloGateway.byok.link": "BYOK tramite Kilo's Gateway", "provider.connect.kiloGateway.byok.suffix": ".", @@ -26,15 +19,9 @@ export const dict = { "settings.providers.note.vercel": "Accesso unificato ai modelli IA con routing intelligente", // Reasoning block label - "ui.permission.run": "Esegui", "ui.reasoning.label": "Ragionamento", // Marketplace - "marketplace.tab.skills": "Skill", - "marketplace.tab.mcpServers": "Server MCP", - "marketplace.tab.agents": "Agenti", - "marketplace.category.all": "Tutti", - "marketplace.placeholder": "Da implementare", "marketplace.card.installed": "Installato", "marketplace.card.install": "Installa", "marketplace.card.remove": "Rimuovi", @@ -76,7 +63,6 @@ export const dict = { "marketplace.remove.confirm": "Vuoi davvero rimuovere questo {{type}}? Verrà rimosso dalla configurazione {{scope}}.", "marketplace.remove.cancel": "Annulla", "marketplace.remove.confirm.button": "Rimuovi", - "marketplace.tab.mcp": "MCP", "marketplace.search": "Cerca...", "marketplace.filter.all": "Tutti gli elementi", "marketplace.filter.notInstalled": "Non installati", @@ -84,12 +70,10 @@ export const dict = { "marketplace.empty": "Nessun elemento trovato", "marketplace.empty.relevant": "Nessun elemento rilevante del marketplace trovato per questo spazio di lavoro.", "marketplace.badge.mcpServer": "Server MCP", - "marketplace.badge.mode": "Modalità", "marketplace.card.by": "di {{author}}", "marketplace.install.method": "Metodo di installazione", "marketplace.install.parameters": "Parametri", "marketplace.install.optional": "(opzionale)", - "marketplace.install.required": "{{name}} è obbligatorio", "marketplace.scope.project": "progetto", "marketplace.scope.global": "globale", "marketplace.remove.type.mcp": "server MCP", @@ -129,8 +113,6 @@ export const dict = { "Disattiva gli snapshot di Kilo per questo progetto. Perderai annulla/ripeti sulle modifiche ai file fatte da Kilo, ma git continuerà a tracciare tutto.", "ui.messagePart.openInDiffViewer": "Apri nel visualizzatore diff", - "ui.messagePart.shell.command": "Comando", - "ui.messagePart.shell.output": "Output", "ui.messagePart.openInEditor": "Apri nell'editor", "ui.message.feedback.helpful": "È stato utile", diff --git a/packages/kilo-i18n/src/ja.ts b/packages/kilo-i18n/src/ja.ts index c1699834b6d..ed30565559f 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -1,11 +1,5 @@ export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "Kilo Gatewayは、コーディングエージェント向けに厳選された信頼性の高い最適化モデルへのアクセスを提供します。", - "provider.connect.kiloGateway.line2": "1つのAPIキーで、Claude、GPT、Gemini、GLMなどのモデルにアクセスできます。", - "provider.connect.kiloGateway.visit.prefix": "", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": " にアクセスしてAPIキーを取得してください。", "provider.connect.kiloGateway.byok.prefix": "詳細な使用統計については、", "provider.connect.kiloGateway.byok.link": "Kilo's Gateway経由でBYOK", "provider.connect.kiloGateway.byok.suffix": "をご利用ください。", @@ -23,14 +17,9 @@ export const dict = { "settings.providers.note.vercel": "スマートルーティングによるAIモデルへの統合アクセス", // Reasoning block label - "ui.permission.run": "実行", "ui.reasoning.label": "推論", // Marketplace - "marketplace.tab.skills": "スキル", - "marketplace.tab.mcpServers": "MCPサーバー", - "marketplace.category.all": "すべて", - "marketplace.placeholder": "未実装", "marketplace.card.installed": "インストール済み", "marketplace.card.install": "インストール", "marketplace.card.remove": "削除", @@ -43,14 +32,12 @@ export const dict = { "marketplace.install.scope.global": "グローバル", "marketplace.install.scope.project.description": "このプロジェクトのみ。インストールしたファイルはバージョン管理に追加し、チームと共有できます。", - "marketplace.install.scope.global.description": - "このマシン上のすべてのプロジェクト。ユーザー設定に保存されます。", + "marketplace.install.scope.global.description": "このマシン上のすべてのプロジェクト。ユーザー設定に保存されます。", "marketplace.install.destination": "インストール先", "marketplace.install.about.mcp": "MCPサーバーは、外部サービスやローカルプログラムを操作するための追加ツールをKiloに提供します。", "marketplace.install.about.agent": "エージェントは、独自の指示と権限を持つ再利用可能な役割を追加します。", - "marketplace.install.about.skill": - "スキルは、必要に応じてKiloが読み込めるタスク固有の指示とリソースを追加します。", + "marketplace.install.about.skill": "スキルは、必要に応じてKiloが読み込めるタスク固有の指示とリソースを追加します。", "marketplace.install.mcp.warning": "MCPサーバーはローカルコマンドを実行したり、外部サービスに接続したりできます。権限で自動的に許可されていない限り、Kiloはツールを使用する前に許可を求めます。", "marketplace.install.project.warning": @@ -72,8 +59,6 @@ export const dict = { "marketplace.remove.confirm": "この{{type}}を削除してもよろしいですか? これにより、{{scope}}設定から削除されます。", "marketplace.remove.cancel": "キャンセル", "marketplace.remove.confirm.button": "削除", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "エージェント", "marketplace.search": "検索...", "marketplace.filter.all": "すべてのアイテム", "marketplace.filter.notInstalled": "未インストール", @@ -81,12 +66,10 @@ export const dict = { "marketplace.empty": "アイテムが見つかりません", "marketplace.empty.relevant": "このワークスペースに関連するマーケットプレイスのアイテムが見つかりませんでした。", "marketplace.badge.mcpServer": "MCPサーバー", - "marketplace.badge.mode": "モード", "marketplace.card.by": "作成者: {{author}}", "marketplace.install.method": "インストール方法", "marketplace.install.parameters": "パラメーター", "marketplace.install.optional": "(任意)", - "marketplace.install.required": "{{name}} は必須です", "marketplace.scope.project": "プロジェクト", "marketplace.scope.global": "グローバル", "marketplace.remove.type.mcp": "MCPサーバー", @@ -128,8 +111,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "差分ビューアーで開く", - "ui.messagePart.shell.command": "コマンド", - "ui.messagePart.shell.output": "出力", "ui.messagePart.openInEditor": "エディタで開く", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/ko.ts b/packages/kilo-i18n/src/ko.ts index a0e760dd541..cc7fa9c8f8c 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -1,11 +1,5 @@ export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "Kilo Gateway는 코딩 에이전트를 위한 신뢰할 수 있는 최적화된 모델 세트를 제공합니다.", - "provider.connect.kiloGateway.line2": "하나의 API 키로 Claude, GPT, Gemini, GLM 등의 모델을 사용할 수 있습니다.", - "provider.connect.kiloGateway.visit.prefix": "", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": "를 방문하여 API 키를 받으세요.", "provider.connect.kiloGateway.byok.prefix": "더 많은 사용 통계를 보려면 ", "provider.connect.kiloGateway.byok.link": "Kilo's Gateway를 통해 BYOK", "provider.connect.kiloGateway.byok.suffix": "를 사용하세요.", @@ -23,14 +17,9 @@ export const dict = { "settings.providers.note.vercel": "스마트 라우팅으로 AI 모델에 통합 액세스", // Reasoning block label - "ui.permission.run": "실행", "ui.reasoning.label": "추론", // Marketplace - "marketplace.tab.skills": "스킬", - "marketplace.tab.mcpServers": "MCP 서버", - "marketplace.category.all": "전체", - "marketplace.placeholder": "구현 예정", "marketplace.card.installed": "설치됨", "marketplace.card.install": "설치", "marketplace.card.remove": "제거", @@ -43,14 +32,12 @@ export const dict = { "marketplace.install.scope.global": "글로벌", "marketplace.install.scope.project.description": "이 프로젝트에만 적용됩니다. 설치된 파일을 버전 관리에 추가하고 팀과 공유할 수 있습니다.", - "marketplace.install.scope.global.description": - "이 컴퓨터의 모든 프로젝트에 적용됩니다. 사용자 구성에 저장됩니다.", + "marketplace.install.scope.global.description": "이 컴퓨터의 모든 프로젝트에 적용됩니다. 사용자 구성에 저장됩니다.", "marketplace.install.destination": "설치 위치", "marketplace.install.about.mcp": "MCP 서버는 외부 서비스나 로컬 프로그램과 작업할 수 있는 추가 도구를 Kilo에 제공합니다.", "marketplace.install.about.agent": "에이전트는 자체 지침과 권한을 가진 재사용 가능한 역할을 추가합니다.", - "marketplace.install.about.skill": - "스킬은 필요할 때 Kilo가 불러올 수 있는 작업별 지침과 리소스를 추가합니다.", + "marketplace.install.about.skill": "스킬은 필요할 때 Kilo가 불러올 수 있는 작업별 지침과 리소스를 추가합니다.", "marketplace.install.mcp.warning": "MCP 서버는 로컬 명령을 실행하거나 외부 서비스에 연결할 수 있습니다. 권한 설정에서 자동으로 허용하지 않는 한 Kilo는 도구를 사용하기 전에 권한을 요청합니다.", "marketplace.install.project.warning": @@ -58,8 +45,7 @@ export const dict = { "marketplace.install.learnMore": "Marketplace 설치 방식 알아보기", "marketplace.install.learnMcp": "MCP 자세히 알아보기", "marketplace.install.installedAt": "{{path}}에 설치됨", - "marketplace.intro": - "재사용 가능한 에이전트, 스킬 및 MCP 도구를 하나의 프로젝트 또는 모든 프로젝트에 설치하세요.", + "marketplace.intro": "재사용 가능한 에이전트, 스킬 및 MCP 도구를 하나의 프로젝트 또는 모든 프로젝트에 설치하세요.", "marketplace.intro.learnMore": "Marketplace 정보", "marketplace.install.prerequisites": "사전 요구 사항", "marketplace.install.installing": "설치 중...", @@ -72,8 +58,6 @@ export const dict = { "marketplace.remove.confirm": "이 {{type}}을(를) 제거하시겠습니까? {{scope}} 구성에서 제거됩니다.", "marketplace.remove.cancel": "취소", "marketplace.remove.confirm.button": "제거", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "에이전트", "marketplace.search": "검색...", "marketplace.filter.all": "모든 항목", "marketplace.filter.notInstalled": "설치되지 않음", @@ -81,12 +65,10 @@ export const dict = { "marketplace.empty": "항목을 찾을 수 없음", "marketplace.empty.relevant": "이 작업 영역과 관련된 마켓플레이스 항목을 찾을 수 없습니다.", "marketplace.badge.mcpServer": "MCP 서버", - "marketplace.badge.mode": "모드", "marketplace.card.by": "제작: {{author}}", "marketplace.install.method": "설치 방법", "marketplace.install.parameters": "매개변수", "marketplace.install.optional": "(선택 사항)", - "marketplace.install.required": "{{name}}이(가) 필요합니다", "marketplace.scope.project": "프로젝트", "marketplace.scope.global": "글로벌", "marketplace.remove.type.mcp": "MCP 서버", @@ -128,8 +110,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "차이점 뷰어에서 열기", - "ui.messagePart.shell.command": "명령어", - "ui.messagePart.shell.output": "출력", "ui.messagePart.openInEditor": "편집기에서 열기", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/nl.ts b/packages/kilo-i18n/src/nl.ts index bf4f116fd47..948254ee771 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -2,13 +2,6 @@ // Keys here will override any matching keys from upstream translations export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "Kilo Gateway geeft je toegang tot een gecureerde set van betrouwbare, geoptimaliseerde modellen voor coding agents.", - "provider.connect.kiloGateway.line2": - "Met één enkele API key krijg je toegang tot modellen zoals Claude, GPT, Gemini, GLM en meer.", - "provider.connect.kiloGateway.visit.prefix": "Bezoek ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": " om je API key op te halen.", "provider.connect.kiloGateway.byok.prefix": "Voor meer gebruiksstatistieken, gebruik ", "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", "provider.connect.kiloGateway.byok.suffix": ".", @@ -26,14 +19,9 @@ export const dict = { "settings.providers.note.vercel": "Geïntegreerde toegang tot AI-modellen met slimme routering", // Reasoning block label - "ui.permission.run": "Uitvoeren", "ui.reasoning.label": "Redenering", // Marketplace - "marketplace.tab.skills": "Skills", - "marketplace.tab.mcpServers": "MCP Servers", - "marketplace.category.all": "Alle", - "marketplace.placeholder": "Nog te implementeren", "marketplace.card.installed": "Geïnstalleerd", "marketplace.card.install": "Installeren", "marketplace.card.remove": "Verwijderen", @@ -76,8 +64,6 @@ export const dict = { "Weet je zeker dat je deze {{type}} wilt verwijderen? Dit verwijdert het uit je {{scope}} configuratie.", "marketplace.remove.cancel": "Annuleren", "marketplace.remove.confirm.button": "Verwijderen", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "Agenten", "marketplace.search": "Zoeken...", "marketplace.filter.all": "Alle items", "marketplace.filter.notInstalled": "Niet geïnstalleerd", @@ -85,12 +71,10 @@ export const dict = { "marketplace.empty": "Geen items gevonden", "marketplace.empty.relevant": "Geen relevante marketplace-items gevonden voor deze werkruimte.", "marketplace.badge.mcpServer": "MCP Server", - "marketplace.badge.mode": "Modus", "marketplace.card.by": "door {{author}}", "marketplace.install.method": "Installatiemethode", "marketplace.install.parameters": "Parameters", "marketplace.install.optional": "(optioneel)", - "marketplace.install.required": "{{name}} is vereist", "marketplace.scope.project": "project", "marketplace.scope.global": "globaal", "marketplace.remove.type.mcp": "MCP server", @@ -132,8 +116,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Openen in Diff-weergave", - "ui.messagePart.shell.command": "Opdracht", - "ui.messagePart.shell.output": "Uitvoer", "ui.messagePart.openInEditor": "Openen in editor", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/no.ts b/packages/kilo-i18n/src/no.ts index c28bfb16cda..cdee5f5a106 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -1,12 +1,5 @@ export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "Kilo Gateway gir deg tilgang til et utvalgt sett med pålitelige, optimaliserte modeller for kodingsagenter.", - "provider.connect.kiloGateway.line2": - "Med én enkelt API-nøkkel får du tilgang til modeller som Claude, GPT, Gemini, GLM og flere.", - "provider.connect.kiloGateway.visit.prefix": "Besøk ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": " for å hente API-nøkkelen din.", "provider.connect.kiloGateway.byok.prefix": "For mer bruksstatistikk, bruk ", "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", "provider.connect.kiloGateway.byok.suffix": ".", @@ -24,14 +17,9 @@ export const dict = { "settings.providers.note.vercel": "Samlet tilgang til AI-modeller med smart ruting", // Reasoning block label - "ui.permission.run": "Kjør", "ui.reasoning.label": "Resonnement", // Marketplace - "marketplace.tab.skills": "Skills", - "marketplace.tab.mcpServers": "MCP-servere", - "marketplace.category.all": "Alle", - "marketplace.placeholder": "Skal implementeres", "marketplace.card.installed": "Installert", "marketplace.card.install": "Installer", "marketplace.card.remove": "Fjern", @@ -73,8 +61,6 @@ export const dict = { "Er du sikker på at du vil fjerne denne {{type}}? Dette vil fjerne den fra din {{scope}}-konfigurasjon.", "marketplace.remove.cancel": "Avbryt", "marketplace.remove.confirm.button": "Fjern", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "Agenter", "marketplace.search": "Søk...", "marketplace.filter.all": "Alle elementer", "marketplace.filter.notInstalled": "Ikke installert", @@ -82,12 +68,10 @@ export const dict = { "marketplace.empty": "Ingen elementer funnet", "marketplace.empty.relevant": "Ingen relevante marketplace-elementer funnet for dette arbeidsområdet.", "marketplace.badge.mcpServer": "MCP-server", - "marketplace.badge.mode": "Modus", "marketplace.card.by": "av {{author}}", "marketplace.install.method": "Installasjonsmetode", "marketplace.install.parameters": "Parametere", "marketplace.install.optional": "(valgfritt)", - "marketplace.install.required": "{{name}} er påkrevd", "marketplace.scope.project": "prosjekt", "marketplace.scope.global": "global", "marketplace.remove.type.mcp": "MCP-server", @@ -129,8 +113,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Åpne i diff-visning", - "ui.messagePart.shell.command": "Kommando", - "ui.messagePart.shell.output": "Utdata", "ui.messagePart.openInEditor": "Åpne i editor", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/pl.ts b/packages/kilo-i18n/src/pl.ts index 6764a61f265..ba4679a5443 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -1,12 +1,5 @@ export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "Kilo Gateway zapewnia dostęp do wyselekcjonowanego zestawu niezawodnych, zoptymalizowanych modeli dla agentów kodowania.", - "provider.connect.kiloGateway.line2": - "Za pomocą jednego klucza API uzyskasz dostęp do modeli takich jak Claude, GPT, Gemini, GLM i innych.", - "provider.connect.kiloGateway.visit.prefix": "Odwiedź ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": ", aby otrzymać swój klucz API.", "provider.connect.kiloGateway.byok.prefix": "Aby uzyskać więcej statystyk użycia, użyj ", "provider.connect.kiloGateway.byok.link": "BYOK przez Kilo's Gateway", "provider.connect.kiloGateway.byok.suffix": ".", @@ -24,14 +17,9 @@ export const dict = { "settings.providers.note.vercel": "Ujednolicony dostęp do modeli AI z inteligentnym routingiem", // Reasoning block label - "ui.permission.run": "Uruchom", "ui.reasoning.label": "Rozumowanie", // Marketplace - "marketplace.tab.skills": "Skills", - "marketplace.tab.mcpServers": "Serwery MCP", - "marketplace.category.all": "Wszystkie", - "marketplace.placeholder": "Do zaimplementowania", "marketplace.card.installed": "Zainstalowano", "marketplace.card.install": "Zainstaluj", "marketplace.card.remove": "Usuń", @@ -74,8 +62,6 @@ export const dict = { "Czy na pewno chcesz usunąć ten {{type}}? Spowoduje to usunięcie z konfiguracji: {{scope}}.", "marketplace.remove.cancel": "Anuluj", "marketplace.remove.confirm.button": "Usuń", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "Agenci", "marketplace.search": "Szukaj...", "marketplace.filter.all": "Wszystkie elementy", "marketplace.filter.notInstalled": "Nie zainstalowano", @@ -83,12 +69,10 @@ export const dict = { "marketplace.empty": "Nie znaleziono elementów", "marketplace.empty.relevant": "Nie znaleziono elementów marketplace istotnych dla tego obszaru roboczego.", "marketplace.badge.mcpServer": "Serwer MCP", - "marketplace.badge.mode": "Tryb", "marketplace.card.by": "autor: {{author}}", "marketplace.install.method": "Metoda instalacji", "marketplace.install.parameters": "Parametry", "marketplace.install.optional": "(opcjonalne)", - "marketplace.install.required": "{{name}} jest wymagane", "marketplace.scope.project": "projekt", "marketplace.scope.global": "globalny", "marketplace.remove.type.mcp": "serwer MCP", @@ -130,8 +114,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Otwórz w podglądzie różnic", - "ui.messagePart.shell.command": "Polecenie", - "ui.messagePart.shell.output": "Wyjście", "ui.messagePart.openInEditor": "Otwórz w edytorze", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/ru.ts b/packages/kilo-i18n/src/ru.ts index d426d1c0f1f..4875ff7100f 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -1,12 +1,5 @@ export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "Kilo Gateway предоставляет доступ к подборке надёжных оптимизированных моделей для агентов кодирования.", - "provider.connect.kiloGateway.line2": - "С одним API-ключом вы получите доступ к моделям Claude, GPT, Gemini, GLM и другим.", - "provider.connect.kiloGateway.visit.prefix": "Посетите ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": ", чтобы получить ваш API-ключ.", "provider.connect.kiloGateway.byok.prefix": "Для получения дополнительной статистики использования используйте ", "provider.connect.kiloGateway.byok.link": "BYOK через Kilo's Gateway", "provider.connect.kiloGateway.byok.suffix": ".", @@ -24,14 +17,9 @@ export const dict = { "settings.providers.note.vercel": "Единый доступ к AI-моделям с умной маршрутизацией", // Reasoning block label - "ui.permission.run": "Запустить", "ui.reasoning.label": "Рассуждение", // Marketplace - "marketplace.tab.skills": "Навыки", - "marketplace.tab.mcpServers": "MCP-серверы", - "marketplace.category.all": "Все", - "marketplace.placeholder": "Будет реализовано", "marketplace.card.installed": "Установлено", "marketplace.card.install": "Установить", "marketplace.card.remove": "Удалить", @@ -75,8 +63,6 @@ export const dict = { "Вы уверены, что хотите удалить этот {{type}}? Это удалит его из вашей конфигурации ({{scope}}).", "marketplace.remove.cancel": "Отмена", "marketplace.remove.confirm.button": "Удалить", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "Агенты", "marketplace.search": "Поиск...", "marketplace.filter.all": "Все элементы", "marketplace.filter.notInstalled": "Не установлено", @@ -84,12 +70,10 @@ export const dict = { "marketplace.empty": "Элементы не найдены", "marketplace.empty.relevant": "Для этого рабочего пространства не найдено подходящих элементов маркетплейса.", "marketplace.badge.mcpServer": "MCP-сервер", - "marketplace.badge.mode": "Режим", "marketplace.card.by": "от {{author}}", "marketplace.install.method": "Способ установки", "marketplace.install.parameters": "Параметры", "marketplace.install.optional": "(необязательно)", - "marketplace.install.required": "Требуется {{name}}", "marketplace.scope.project": "проект", "marketplace.scope.global": "глобально", "marketplace.remove.type.mcp": "MCP-сервер", @@ -131,8 +115,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Открыть в просмотре различий", - "ui.messagePart.shell.command": "Команда", - "ui.messagePart.shell.output": "Вывод", "ui.messagePart.openInEditor": "Открыть в редакторе", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/th.ts b/packages/kilo-i18n/src/th.ts index 11a2f317c9a..12455abf348 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -1,12 +1,5 @@ export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "Kilo Gateway ให้คุณเข้าถึงชุดโมเดลที่คัดสรรมาอย่างดีและเชื่อถือได้สำหรับตัวแทนเขียนโค้ด", - "provider.connect.kiloGateway.line2": - "ด้วย API key เดียว คุณจะสามารถเข้าถึงโมเดลต่างๆ เช่น Claude, GPT, Gemini, GLM และอื่นๆ", - "provider.connect.kiloGateway.visit.prefix": "เยี่ยมชม ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": " เพื่อรับ API key ของคุณ", "provider.connect.kiloGateway.byok.prefix": "สำหรับสถิติการใช้งานเพิ่มเติม โปรดใช้ ", "provider.connect.kiloGateway.byok.link": "BYOK ผ่าน Kilo's Gateway", "provider.connect.kiloGateway.byok.suffix": "", @@ -24,14 +17,9 @@ export const dict = { "settings.providers.note.vercel": "เข้าถึงโมเดล AI แบบรวมศูนย์พร้อมการกำหนดเส้นทางอัจฉริยะ", // Reasoning block label - "ui.permission.run": "เรียกใช้", "ui.reasoning.label": "การให้เหตุผล", // Marketplace - "marketplace.tab.skills": "ทักษะ", - "marketplace.tab.mcpServers": "เซิร์ฟเวอร์ MCP", - "marketplace.category.all": "ทั้งหมด", - "marketplace.placeholder": "ยังไม่ได้ดำเนินการ", "marketplace.card.installed": "ติดตั้งแล้ว", "marketplace.card.install": "ติดตั้ง", "marketplace.card.remove": "ลบ", @@ -44,14 +32,12 @@ export const dict = { "marketplace.install.scope.global": "โกลบอล", "marketplace.install.scope.project.description": "เฉพาะโปรเจกต์นี้ ไฟล์ที่ติดตั้งสามารถเพิ่มลงในระบบควบคุมเวอร์ชันและแชร์กับทีมของคุณได้", - "marketplace.install.scope.global.description": - "ทุกโปรเจกต์ในเครื่องนี้ จัดเก็บไว้ในการกำหนดค่าผู้ใช้ของคุณ", + "marketplace.install.scope.global.description": "ทุกโปรเจกต์ในเครื่องนี้ จัดเก็บไว้ในการกำหนดค่าผู้ใช้ของคุณ", "marketplace.install.destination": "ปลายทางการติดตั้ง", "marketplace.install.about.mcp": "เซิร์ฟเวอร์ MCP เพิ่มเครื่องมือให้ Kilo สำหรับทำงานกับบริการภายนอกหรือโปรแกรมในเครื่อง", "marketplace.install.about.agent": "เอเจนต์เพิ่มบทบาทที่นำกลับมาใช้ใหม่ได้พร้อมคำสั่งและสิทธิ์ของตนเอง", - "marketplace.install.about.skill": - "ทักษะเพิ่มคำสั่งและทรัพยากรเฉพาะงานที่ Kilo สามารถโหลดได้เมื่อจำเป็น", + "marketplace.install.about.skill": "ทักษะเพิ่มคำสั่งและทรัพยากรเฉพาะงานที่ Kilo สามารถโหลดได้เมื่อจำเป็น", "marketplace.install.mcp.warning": "เซิร์ฟเวอร์ MCP สามารถเรียกใช้คำสั่งในเครื่องหรือเชื่อมต่อกับบริการภายนอกได้ Kilo จะขออนุญาตก่อนใช้เครื่องมือ เว้นแต่สิทธิ์ของคุณจะอนุญาตโดยอัตโนมัติ", "marketplace.install.project.warning": @@ -73,8 +59,6 @@ export const dict = { "คุณแน่ใจหรือไม่ว่าต้องการลบ {{type}} นี้? สิ่งนี้จะลบออกจากการกำหนดค่า {{scope}} ของคุณ", "marketplace.remove.cancel": "ยกเลิก", "marketplace.remove.confirm.button": "ลบ", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "เอเจนต์", "marketplace.search": "ค้นหา...", "marketplace.filter.all": "รายการทั้งหมด", "marketplace.filter.notInstalled": "ยังไม่ได้ติดตั้ง", @@ -82,12 +66,10 @@ export const dict = { "marketplace.empty": "ไม่พบรายการ", "marketplace.empty.relevant": "ไม่พบรายการในมาร์เก็ตเพลสที่เกี่ยวข้องกับพื้นที่ทำงานนี้", "marketplace.badge.mcpServer": "เซิร์ฟเวอร์ MCP", - "marketplace.badge.mode": "โหมด", "marketplace.card.by": "โดย {{author}}", "marketplace.install.method": "วิธีการติดตั้ง", "marketplace.install.parameters": "พารามิเตอร์", "marketplace.install.optional": "(ไม่บังคับ)", - "marketplace.install.required": "จำเป็นต้องใช้ {{name}}", "marketplace.scope.project": "โปรเจ็กต์", "marketplace.scope.global": "โกลบอล", "marketplace.remove.type.mcp": "เซิร์ฟเวอร์ MCP", @@ -129,8 +111,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "เปิดในตัวดูความแตกต่าง", - "ui.messagePart.shell.command": "คำสั่ง", - "ui.messagePart.shell.output": "ผลลัพธ์", "ui.messagePart.openInEditor": "เปิดในตัวแก้ไข", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/tr.ts b/packages/kilo-i18n/src/tr.ts index a787e69f62c..bf162698d88 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -1,12 +1,5 @@ export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "Kilo Gateway, kodlama ajanları için optimize edilmiş güvenilir modellere erişim sağlar.", - "provider.connect.kiloGateway.line2": - "Tek bir API anahtarıyla Claude, GPT, Gemini, GLM ve daha fazlası gibi modellere erişebilirsiniz.", - "provider.connect.kiloGateway.visit.prefix": "API anahtarınızı almak için ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": " adresini ziyaret edin.", "provider.connect.kiloGateway.byok.prefix": "Daha fazla kullanım istatistiği için ", "provider.connect.kiloGateway.byok.link": "Kilo's Gateway üzerinden BYOK", "provider.connect.kiloGateway.byok.suffix": " kullanın.", @@ -24,14 +17,9 @@ export const dict = { "settings.providers.note.vercel": "Akıllı yönlendirme ile AI modellerine birleşik erişim", // Reasoning block label - "ui.permission.run": "Çalıştır", "ui.reasoning.label": "Akıl Yürütme", // Marketplace - "marketplace.tab.skills": "Yetenekler", - "marketplace.tab.mcpServers": "MCP Sunucuları", - "marketplace.category.all": "Tümü", - "marketplace.placeholder": "Uygulanacak", "marketplace.card.installed": "Yüklendi", "marketplace.card.install": "Yükle", "marketplace.card.remove": "Kaldır", @@ -44,12 +32,12 @@ export const dict = { "marketplace.install.scope.global": "Genel", "marketplace.install.scope.project.description": "Yalnızca bu proje. Yüklenen dosyalar sürüm kontrolüne eklenebilir ve ekibinizle paylaşılabilir.", - "marketplace.install.scope.global.description": - "Bu makinedeki tüm projeler. Kullanıcı yapılandırmanızda saklanır.", + "marketplace.install.scope.global.description": "Bu makinedeki tüm projeler. Kullanıcı yapılandırmanızda saklanır.", "marketplace.install.destination": "Yükleme hedefi", "marketplace.install.about.mcp": "Bir MCP sunucusu, harici hizmetler veya yerel programlarla çalışmak için Kilo'ya ek araçlar sağlar.", - "marketplace.install.about.agent": "Bir ajan, kendi talimatları ve izinleri olan yeniden kullanılabilir bir rol ekler.", + "marketplace.install.about.agent": + "Bir ajan, kendi talimatları ve izinleri olan yeniden kullanılabilir bir rol ekler.", "marketplace.install.about.skill": "Bir yetenek, Kilo'nun gerektiğinde yükleyebileceği göreve özel talimatlar ve kaynaklar ekler.", "marketplace.install.mcp.warning": @@ -74,8 +62,6 @@ export const dict = { "Bu {{type}} öğesini kaldırmak istediğinizden emin misiniz? Bu, {{scope}} yapılandırmanızdan kaldırılacaktır.", "marketplace.remove.cancel": "İptal", "marketplace.remove.confirm.button": "Kaldır", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "Ajanlar", "marketplace.search": "Ara...", "marketplace.filter.all": "Tüm Öğeler", "marketplace.filter.notInstalled": "Yüklü Değil", @@ -83,12 +69,10 @@ export const dict = { "marketplace.empty": "Öğe bulunamadı", "marketplace.empty.relevant": "Bu çalışma alanıyla ilgili marketplace öğesi bulunamadı.", "marketplace.badge.mcpServer": "MCP Sunucusu", - "marketplace.badge.mode": "Mod", "marketplace.card.by": "{{author}} tarafından", "marketplace.install.method": "Yükleme Yöntemi", "marketplace.install.parameters": "Parametreler", "marketplace.install.optional": "(isteğe bağlı)", - "marketplace.install.required": "{{name}} gereklidir", "marketplace.scope.project": "proje", "marketplace.scope.global": "genel", "marketplace.remove.type.mcp": "MCP sunucusu", @@ -130,8 +114,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Fark Görüntüleyicide Aç", - "ui.messagePart.shell.command": "Komut", - "ui.messagePart.shell.output": "Çıktı", "ui.messagePart.openInEditor": "Düzenleyicide Aç", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/uk.ts b/packages/kilo-i18n/src/uk.ts index b95a8240da7..9c7dc984a5c 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -1,12 +1,5 @@ export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": - "Kilo Gateway надає доступ до добірки надійних оптимізованих моделей для агентів кодування.", - "provider.connect.kiloGateway.line2": - "За допомогою одного API-ключа ви отримаєте доступ до таких моделей, як Claude, GPT, Gemini, GLM та інших.", - "provider.connect.kiloGateway.visit.prefix": "Відвідайте ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": " щоб отримати свій API-ключ.", "provider.connect.kiloGateway.byok.prefix": "Для отримання додаткової статистики використання використовуйте ", "provider.connect.kiloGateway.byok.link": "BYOK через Kilo's Gateway", "provider.connect.kiloGateway.byok.suffix": ".", @@ -24,14 +17,9 @@ export const dict = { "settings.providers.note.vercel": "Єдиний доступ до моделей ШІ з розумною маршрутизацією", // Reasoning block label - "ui.permission.run": "Виконати", "ui.reasoning.label": "Міркування", // Marketplace - "marketplace.tab.skills": "Навички", - "marketplace.tab.mcpServers": "MCP-сервери", - "marketplace.category.all": "Усі", - "marketplace.placeholder": "Буде реалізовано", "marketplace.card.installed": "Встановлено", "marketplace.card.install": "Встановити", "marketplace.card.remove": "Видалити", @@ -49,8 +37,7 @@ export const dict = { "marketplace.install.destination": "Місце встановлення", "marketplace.install.about.mcp": "MCP-сервер надає Kilo додаткові інструменти для роботи із зовнішніми сервісами або локальними програмами.", - "marketplace.install.about.agent": - "Агент додає багаторазову роль із власними інструкціями та дозволами.", + "marketplace.install.about.agent": "Агент додає багаторазову роль із власними інструкціями та дозволами.", "marketplace.install.about.skill": "Навичка додає інструкції та ресурси для певних завдань, які Kilo може завантажити за потреби.", "marketplace.install.mcp.warning": @@ -60,8 +47,7 @@ export const dict = { "marketplace.install.learnMore": "Дізнайтеся, як працює встановлення з Marketplace", "marketplace.install.learnMcp": "Докладніше про MCP", "marketplace.install.installedAt": "Встановлено в {{path}}", - "marketplace.intro": - "Встановлюйте багаторазових агентів, навички та інструменти MCP для одного або всіх проєктів.", + "marketplace.intro": "Встановлюйте багаторазових агентів, навички та інструменти MCP для одного або всіх проєктів.", "marketplace.intro.learnMore": "Про Marketplace", "marketplace.install.prerequisites": "Передумови", "marketplace.install.installing": "Встановлення...", @@ -75,8 +61,6 @@ export const dict = { "Ви впевнені, що хочете видалити цей {{type}}? Це видалить його з вашої конфігурації {{scope}}.", "marketplace.remove.cancel": "Скасувати", "marketplace.remove.confirm.button": "Видалити", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "Агенти", "marketplace.search": "Пошук...", "marketplace.filter.all": "Усі елементи", "marketplace.filter.notInstalled": "Не встановлено", @@ -84,12 +68,10 @@ export const dict = { "marketplace.empty": "Елементів не знайдено", "marketplace.empty.relevant": "Для цього робочого простору не знайдено відповідних елементів маркетплейсу.", "marketplace.badge.mcpServer": "MCP-сервер", - "marketplace.badge.mode": "Режим", "marketplace.card.by": "від {{author}}", "marketplace.install.method": "Метод встановлення", "marketplace.install.parameters": "Параметри", "marketplace.install.optional": "(необов'язково)", - "marketplace.install.required": "{{name}} є обов'язковим", "marketplace.scope.project": "проєкт", "marketplace.scope.global": "глобально", "marketplace.remove.type.mcp": "MCP-сервер", @@ -131,8 +113,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Відкрити в переглядачі відмінностей", - "ui.messagePart.shell.command": "Команда", - "ui.messagePart.shell.output": "Вивід", "ui.messagePart.openInEditor": "Відкрити в редакторі", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/zh.ts b/packages/kilo-i18n/src/zh.ts index 8baddc98f38..ffc77d1ec03 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -1,10 +1,5 @@ export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": "Kilo Gateway 为您提供一组精选的可靠优化模型,专为编码代理设计。", - "provider.connect.kiloGateway.line2": "只需一个 API 密钥,您就可以使用 Claude、GPT、Gemini、GLM 等模型。", - "provider.connect.kiloGateway.visit.prefix": "访问 ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": " 获取您的 API 密钥。", "provider.connect.kiloGateway.byok.prefix": "如需更多使用统计信息,请", "provider.connect.kiloGateway.byok.link": "通过 Kilo's Gateway 进行 BYOK", "provider.connect.kiloGateway.byok.suffix": "。", @@ -22,14 +17,9 @@ export const dict = { "settings.providers.note.vercel": "通过智能路由统一访问 AI 模型", // Reasoning block label - "ui.permission.run": "运行", "ui.reasoning.label": "推理", // Marketplace - "marketplace.tab.skills": "技能", - "marketplace.tab.mcpServers": "MCP 服务器", - "marketplace.category.all": "全部", - "marketplace.placeholder": "待实现", "marketplace.card.installed": "已安装", "marketplace.card.install": "安装", "marketplace.card.remove": "移除", @@ -40,12 +30,10 @@ export const dict = { "marketplace.install.scope": "作用域", "marketplace.install.scope.project": "项目", "marketplace.install.scope.global": "全局", - "marketplace.install.scope.project.description": - "仅限此项目。安装的文件可以提交到版本控制并与你的团队共享。", + "marketplace.install.scope.project.description": "仅限此项目。安装的文件可以提交到版本控制并与你的团队共享。", "marketplace.install.scope.global.description": "此计算机上的所有项目。存储在你的用户配置中。", "marketplace.install.destination": "安装位置", - "marketplace.install.about.mcp": - "MCP 服务器为 Kilo 提供用于处理外部服务或本地程序的额外工具。", + "marketplace.install.about.mcp": "MCP 服务器为 Kilo 提供用于处理外部服务或本地程序的额外工具。", "marketplace.install.about.agent": "智能体会添加一个具有专属指令和权限的可复用角色。", "marketplace.install.about.skill": "技能会添加特定任务的指令和资源,Kilo 可在需要时加载它们。", "marketplace.install.mcp.warning": @@ -68,8 +56,6 @@ export const dict = { "marketplace.remove.confirm": "确定要移除此 {{type}} 吗?这会将其从你的 {{scope}} 配置中移除。", "marketplace.remove.cancel": "取消", "marketplace.remove.confirm.button": "移除", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "智能体", "marketplace.search": "搜索...", "marketplace.filter.all": "所有项目", "marketplace.filter.notInstalled": "未安装", @@ -77,12 +63,10 @@ export const dict = { "marketplace.empty": "未找到项目", "marketplace.empty.relevant": "未找到与此工作区相关的市场项目。", "marketplace.badge.mcpServer": "MCP 服务器", - "marketplace.badge.mode": "模式", "marketplace.card.by": "作者:{{author}}", "marketplace.install.method": "安装方法", "marketplace.install.parameters": "参数", "marketplace.install.optional": "(可选)", - "marketplace.install.required": "{{name}} 是必填项", "marketplace.scope.project": "项目", "marketplace.scope.global": "全局", "marketplace.remove.type.mcp": "MCP 服务器", @@ -122,8 +106,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "在差异查看器中打开", - "ui.messagePart.shell.command": "命令", - "ui.messagePart.shell.output": "输出", "ui.messagePart.openInEditor": "在编辑器中打开", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-i18n/src/zht.ts b/packages/kilo-i18n/src/zht.ts index fdb58ffcaf7..3774a9a495e 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -1,10 +1,5 @@ export const dict = { // Kilo Gateway provider translations - "provider.connect.kiloGateway.line1": "Kilo Gateway 為您提供一組精選的可靠優化模型,專為編碼代理設計。", - "provider.connect.kiloGateway.line2": "只需一個 API 金鑰,您就可以使用 Claude、GPT、Gemini、GLM 等模型。", - "provider.connect.kiloGateway.visit.prefix": "訪問 ", - "provider.connect.kiloGateway.visit.link": "kilo.ai", - "provider.connect.kiloGateway.visit.suffix": " 獲取您的 API 金鑰。", "provider.connect.kiloGateway.byok.prefix": "如需更多使用統計資訊,請", "provider.connect.kiloGateway.byok.link": "透過 Kilo's Gateway 進行 BYOK", "provider.connect.kiloGateway.byok.suffix": "。", @@ -22,14 +17,9 @@ export const dict = { "settings.providers.note.vercel": "透過智慧路由統一存取 AI 模型", // Reasoning block label - "ui.permission.run": "執行", "ui.reasoning.label": "推理", // Marketplace - "marketplace.tab.skills": "技能", - "marketplace.tab.mcpServers": "MCP 伺服器", - "marketplace.category.all": "全部", - "marketplace.placeholder": "待實作", "marketplace.card.installed": "已安裝", "marketplace.card.install": "安裝", "marketplace.card.remove": "移除", @@ -40,18 +30,15 @@ export const dict = { "marketplace.install.scope": "作用域", "marketplace.install.scope.project": "專案", "marketplace.install.scope.global": "全域", - "marketplace.install.scope.project.description": - "僅限此專案。安裝的檔案可以提交至版本控制並與你的團隊分享。", + "marketplace.install.scope.project.description": "僅限此專案。安裝的檔案可以提交至版本控制並與你的團隊分享。", "marketplace.install.scope.global.description": "此電腦上的所有專案。儲存在你的使用者設定中。", "marketplace.install.destination": "安裝位置", - "marketplace.install.about.mcp": - "MCP 伺服器為 Kilo 提供用於處理外部服務或本機程式的額外工具。", + "marketplace.install.about.mcp": "MCP 伺服器為 Kilo 提供用於處理外部服務或本機程式的額外工具。", "marketplace.install.about.agent": "智能體會新增一個具有專屬指示和權限的可重複使用角色。", "marketplace.install.about.skill": "技能會新增特定任務的指示和資源,Kilo 可在需要時載入它們。", "marketplace.install.mcp.warning": "MCP 伺服器可以執行本機命令或連線至外部服務。除非你的權限允許自動使用,否則 Kilo 會在使用其工具前請求許可。", - "marketplace.install.project.warning": - "專案檔案可能會提交至版本控制。除非設定引用了環境變數,否則請勿在此儲存密鑰。", + "marketplace.install.project.warning": "專案檔案可能會提交至版本控制。除非設定引用了環境變數,否則請勿在此儲存密鑰。", "marketplace.install.learnMore": "瞭解 Marketplace 安裝的運作方式", "marketplace.install.learnMcp": "深入瞭解 MCP", "marketplace.install.installedAt": "已安裝至 {{path}}", @@ -68,8 +55,6 @@ export const dict = { "marketplace.remove.confirm": "確定要移除此 {{type}} 嗎?這會將其從你的 {{scope}} 設定中移除。", "marketplace.remove.cancel": "取消", "marketplace.remove.confirm.button": "移除", - "marketplace.tab.mcp": "MCP", - "marketplace.tab.agents": "智能體", "marketplace.search": "搜尋...", "marketplace.filter.all": "所有項目", "marketplace.filter.notInstalled": "未安裝", @@ -77,12 +62,10 @@ export const dict = { "marketplace.empty": "找不到項目", "marketplace.empty.relevant": "找不到與此工作區相關的市集項目。", "marketplace.badge.mcpServer": "MCP 伺服器", - "marketplace.badge.mode": "模式", "marketplace.card.by": "作者:{{author}}", "marketplace.install.method": "安裝方法", "marketplace.install.parameters": "參數", "marketplace.install.optional": "(選填)", - "marketplace.install.required": "{{name}} 是必填項", "marketplace.scope.project": "專案", "marketplace.scope.global": "全域", "marketplace.remove.type.mcp": "MCP 伺服器", @@ -122,8 +105,6 @@ export const dict = { // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "在差異檢視器中開啟", - "ui.messagePart.shell.command": "命令", - "ui.messagePart.shell.output": "輸出", "ui.messagePart.openInEditor": "在編輯器中開啟", // Message feedback (thumbs up/down per assistant response) diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 714f92feb08..05a80bf50cb 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -157,6 +157,7 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi - CLI process spawning, download, extraction, and lifecycle belong in `backend`. - By default, the plugin does not bundle CLI binaries. At connect time the backend downloads the GitHub Release asset for the version pinned in `packages/kilo-jetbrains/package.json`; `backend` resources include `kilo.properties` with `cli.version` and `cli.pinned` for split-mode RPC and runtime use. +- Bundled release builds pass `-Pkilo.cli.bundled=true` while keeping `kilo.cli.pinned=true`. This build-only flag stages all pinned CLI release assets into `kilo-cli.zip`; runtime detects that resource and extracts only the current platform instead of downloading. Do not add a `cli.bundled` key to `kilo.properties` or repurpose `kilo.cli.pinned=false` for public bundled releases. - For release questions, use the `release-jetbrains` skill and reference `.kilo/skills/release-jetbrains/SKILL.md`; it verifies the CLI pin before creating immutable `jetbrains/v*` tags. - For OS and environment checks, prefer IntelliJ Platform classes over raw JVM APIs such as `System.getProperty(...)` or `System.getenv(...)`. - Detect architecture with `com.intellij.util.system.CpuArch.CURRENT`, not `System.getProperty("os.arch")`. diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index d8619ecfc1b..7c48f6085e1 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -102,6 +102,39 @@ ## [Unreleased] +## [7.0.12-rc.2] - 2026-07-27 + +### Added + +### Fixed + +- Fix the GitHub-hosted bundled JetBrains plugin build so signing uses certificate and private-key files during verification. + +### Changed + +## [7.0.12-rc.1] - 2026-07-27 + +### Added + +- Support sending another JetBrains prompt while a session is still running. Queued prompts now appear in the conversation and can be removed before Kilo starts processing them. + +## [7.0.11] - 2026-07-27 + +### Added + +- Add a signed GitHub-hosted bundled JetBrains plugin build that includes the Kilo CLI for offline or restricted-network installs. + +### Fixed + +- Load global skills reliably from JetBrains projects that are not inside a Git repository. +- Support adaptive thinking for Claude Opus and Sonnet 5+ model identifiers across Anthropic, AI Gateway, and Bedrock providers. +- Flush pending cloud session updates when the Kilo Core runtime shuts down, reducing cases where the final assistant message is missing when a session is reopened elsewhere. +- Prune stale bundled CLI versions after upgrading bundled JetBrains installs. + +### Changed + +- Update the JetBrains CLI pin from Kilo Core 7.4.15 to 7.4.16. +## [7.0.10] - 2026-07-24 ## [7.0.10] - 2026-07-24 ### Added diff --git a/packages/kilo-jetbrains/README.md b/packages/kilo-jetbrains/README.md index e5452e7ddd0..754202c3c09 100644 --- a/packages/kilo-jetbrains/README.md +++ b/packages/kilo-jetbrains/README.md @@ -78,7 +78,7 @@ The built plugin archive is at `build/distributions/kilo.jetbrains-.zip ## Releasing -See [RELEASING.md](RELEASING.md) for the full release process, including how to tag and push an RC, where to watch workflow progress, and how to install RC builds via the custom plugin repository. +See [RELEASING.md](RELEASING.md) for the full release process, including how to tag and push an RC, where to watch workflow progress, how to install RC builds, and how the signed bundled CLI build is published to the GitHub-hosted stable plugin repository. --- diff --git a/packages/kilo-jetbrains/RELEASE_TODO.md b/packages/kilo-jetbrains/RELEASE_TODO.md index b74103894b7..1500e75128b 100644 --- a/packages/kilo-jetbrains/RELEASE_TODO.md +++ b/packages/kilo-jetbrains/RELEASE_TODO.md @@ -14,6 +14,8 @@ - Create a JetBrains Marketplace permanent token from Marketplace `My Tokens`. - Add `JETBRAINS_MARKETPLACE_TOKEN` to GitHub Actions secrets or the protected environment. - Confirm `GITHUB_TOKEN` has `contents: write` permission for creating and updating GitHub Releases for `jetbrains/v*` tags. +- Confirm `GITHUB_TOKEN` has `actions: write`, `pages: write`, and `id-token: write` permission for dispatching bundled releases and publishing the stable GitHub Pages plugin repository. +- Configure GitHub Pages for this repository with source set to GitHub Actions. - Confirm `KILO_MAINTAINER_APP_ID` and `KILO_MAINTAINER_APP_SECRET` are available to create release PRs and immediate release tags. - Optionally create a protected `jetbrains-marketplace` GitHub Environment with required reviewers. - If using an environment, move the Marketplace and signing secrets there and set the workflow job environment. @@ -35,6 +37,7 @@ - Review and edit `packages/kilo-jetbrains/CHANGELOG.md` in the generated release PR. - Merge the release PR to trigger publish from `jetbrains/vx.y.z-rc.n`, for example `jetbrains/v7.0.1-rc.1`. - Watch the `publish-jetbrains` workflow. +- Confirm the follow-up `publish-jetbrains-bundled` workflow completes and attaches `kilo-code-x.y.z-rc.n-bundled.zip` to the prerelease. - Download and retain the workflow artifact if needed. - Confirm the update appears on the JetBrains Marketplace `eap` channel. - Confirm the GitHub Release for the `jetbrains/vx.y.z-rc.n` tag exists and contains the JetBrains plugin ZIP asset. @@ -48,5 +51,6 @@ - Review and edit `packages/kilo-jetbrains/CHANGELOG.md` in the generated release PR. - Merge the release PR to trigger publish from `jetbrains/vx.y.z`. - Watch the `publish-jetbrains` workflow. +- Confirm the follow-up `publish-jetbrains-bundled` workflow completes, attaches `kilo-code-x.y.z-bundled.zip`, and updates `https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml`. - Confirm the update appears on the default JetBrains Marketplace channel. - Confirm the GitHub Release for the `jetbrains/vx.y.z` tag exists and contains the JetBrains plugin ZIP asset. diff --git a/packages/kilo-jetbrains/RELEASING.md b/packages/kilo-jetbrains/RELEASING.md index 606065a27d4..8f763037019 100644 --- a/packages/kilo-jetbrains/RELEASING.md +++ b/packages/kilo-jetbrains/RELEASING.md @@ -132,6 +132,16 @@ Publishing behavior: The workflow checks out `jetbrains/v` for verification, signing, and Marketplace publishing. It overlays the reviewed `packages/kilo-jetbrains/gradle.properties` and `packages/kilo-jetbrains/CHANGELOG.md` from the merged PR before rendering release notes and before `publishPlugin`, so the Marketplace plugin version, Marketplace notes, and GitHub Release use the reviewed metadata. +After Marketplace publishing succeeds, `publish-jetbrains` dispatches `publish-jetbrains-bundled`. The bundled workflow rebuilds the same `jetbrains/v` tag with `-Pkilo.cli.bundled=true`, signs and verifies the all-platform plugin ZIP, then uploads `kilo-code--bundled.zip` to the same GitHub Release. Bundled builds keep `kilo.cli.pinned=true`; the build flag only embeds the pinned CLI release assets so runtime extracts the bundled current-platform CLI instead of downloading it. + +Stable bundled releases also publish the GitHub Pages custom plugin repository XML: + +```text +https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml +``` + +RC bundled ZIPs are attached to prereleases for install-from-disk testing, but they do not update the stable custom repository XML. + ## Installing RC Builds RC builds are published to the `eap` channel. To get them in IntelliJ IDEA: diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index 374aa08a85b..77d014030d1 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -22,6 +22,7 @@ val generatedProps = layout.buildDirectory.dir("generated/kilo-props") val generatedCli = layout.buildDirectory.dir("generated/kilo-cli-res") val pinned = providers.gradleProperty("kilo.cli.pinned").map { it.trim().toBoolean() }.orElse(true) val repoCli = pinned.map { !it } +val bundled = providers.gradleProperty("kilo.cli.bundled").map { it.trim().toBoolean() }.orElse(false) val repoRootDir = rootProject.layout.projectDirectory.dir("../opencode") val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text -> @@ -32,11 +33,15 @@ val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirector sourceSets { main { resources.srcDir(generatedProps) - if (repoCli.get()) resources.srcDir(generatedCli) + if (repoCli.get() || bundled.get()) resources.srcDir(generatedCli) kotlin.srcDir(generatedApi) } } +if (repoCli.get() && bundled.get()) { + error("kilo.cli.bundled=true requires kilo.cli.pinned=true; do not combine release CLI bundling with local repo CLI mode.") +} + val writeKiloProperties by tasks.registering(WriteProperties::class) { description = "Write pinned Kilo CLI properties" val out = generatedProps.map { it.file("kilo.properties") } @@ -88,6 +93,17 @@ val stageRepoCli by tasks.registering(StageRepoCliTask::class) { outputs.upToDateWhen { false } } +val stageBundledCli by tasks.registering(StageBundledCliTask::class) { + description = "Stage all pinned Kilo CLI release assets into backend resources" + cliVersion.set(pinnedCliVersion) + token.set( + providers.environmentVariable("GH_TOKEN") + .orElse(providers.environmentVariable("GITHUB_TOKEN")) + ) + cacheDir.set(layout.buildDirectory.dir("cli-cache")) + archive.set(generatedCli.map { it.file("kilo-cli.zip") }) +} + val normalizeOpenApiSpec by tasks.registering(NormalizeOpenApiSpecTask::class) { description = "Normalize upstream CLI OpenAPI metadata before Kotlin client generation" dependsOn(generateOpenApiSpec) @@ -143,12 +159,14 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) { tasks.named("compileKotlin") { dependsOn(fixGeneratedApi, writeKiloProperties) if (repoCli.get()) dependsOn(stageRepoCli) + if (bundled.get()) dependsOn(stageBundledCli) inputs.dir(generatedApi) } tasks.named("processResources") { dependsOn(writeKiloProperties) if (repoCli.get()) dependsOn(stageRepoCli) + if (bundled.get()) dependsOn(stageBundledCli) } tasks.named("compileTestKotlin") { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt index d7c2be2cdf8..4ebd49744e2 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt @@ -65,6 +65,7 @@ class KiloBackendChatManager( "session.status", "session.updated", "session.idle", + "session.queue.changed", "session.compacted", "session.diff", "permission.asked", @@ -90,14 +91,15 @@ class KiloBackendChatManager( if (watcher?.isActive == true) return watcher = cs.launch { sse.collect { event -> - if (event.type in CHAT_EVENTS) { + val type = if (event.type in CHAT_EVENTS) event.type else KiloCliDataParser.extractEventType(event.data) + if (type in CHAT_EVENTS) { val events = try { - normalizer.parse(event.type, event.data) + normalizer.parse(type, event.data) } catch (e: CancellationException) { throw e } catch (e: Exception) { log.warn( - "route=chat-events parse=false type=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}", + "route=chat-events parse=false type=$type raw=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}", e, ) return@collect @@ -120,7 +122,7 @@ class KiloBackendChatManager( _events.emit(parsed) } } else { - log.warn("route=chat-events parse=null type=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}") + log.warn("route=chat-events parse=null type=$type raw=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}") } } } @@ -264,6 +266,27 @@ class KiloBackendChatManager( postCancellable("/session/$id/revert?directory=${encode(dir)}", body, "revert", "${ChatLogSummary.sid(id)} kind=revert") } + suspend fun deleteMessage(id: String, dir: String, message: String): Boolean { + log.info("${ChatLogSummary.sid(id)} kind=deleteMessage ${ChatLogSummary.dir(dir)} message=$message") + val http = requireClient() + val url = requireBase() + val request = Request.Builder() + .url("$url/session/$id/message/$message?directory=${encode(dir)}") + .delete() + .build() + val call = http.newCall(request) + call.timeout().timeout(REVERT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + return call.await().use { response -> + val raw = response.body?.string().orEmpty().trim() + if (!response.isSuccessful) { + log.warn("deleteMessage failed: HTTP ${response.code}") + raw.takeIf { it.isNotBlank() }?.let { log.debug { "${ChatLogSummary.sid(id)} kind=deleteMessage error=${ChatLogSummary.body(it)}" } } + return@use false + } + raw != "false" + } + } + suspend fun unrevert(id: String, dir: String) { log.info("${ChatLogSummary.sid(id)} kind=unrevert ${ChatLogSummary.dir(dir)}") postCancellable("/session/$id/unrevert?directory=${encode(dir)}", "{}", "unrevert", "${ChatLogSummary.sid(id)} kind=unrevert") diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt index ce7b9fd256b..9425f0779f7 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt @@ -126,8 +126,8 @@ class KiloBackendCliManager( private suspend fun resolveCli(onProgress: (CliDownload) -> Unit): File { val force = forceExtract forceExtract = false - if (!KiloProps.pinned()) { - if (force) log.info("Force re-extracting local repo CLI ${KiloProps.cliVersion()}") + if (KiloRepoCli.available()) { + if (force) log.info("Force re-extracting bundled CLI ${KiloProps.cliVersion()}") val cli = KiloRepoCli.extract(force) onProgress(CliDownload(100, KiloProps.cliVersion(), KiloCliPlatform.current())) return cli diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index a87fd32c6aa..1d4fa2ee4b7 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -248,6 +248,12 @@ object KiloCliDataParser { ChatEventDto.SessionIdle(sid) } + "session.queue.changed" -> { + val sid = props.str("sessionID") ?: return null + val queued = props["queued"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + ChatEventDto.SessionQueueChanged(sid, queued) + } + "session.compacted" -> { val sid = props.str("sessionID") ?: return null ChatEventDto.SessionCompacted(sid) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt index a46ea79506f..8138ae4daed 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt @@ -1,5 +1,6 @@ package ai.kilocode.backend.cli +import ai.kilocode.log.KiloLog import com.intellij.openapi.application.PathManager import com.intellij.openapi.util.SystemInfo import kotlinx.coroutines.Dispatchers @@ -10,20 +11,33 @@ import java.io.OutputStream import java.util.zip.ZipInputStream object KiloRepoCli { + private const val ARCHIVE = "kilo-cli.zip" + private val log = KiloLog.create(KiloRepoCli::class.java) + + fun available(): Boolean = KiloRepoCli::class.java.classLoader.getResource(ARCHIVE) != null + suspend fun extract(force: Boolean): File = extract( force = force, - root = File(PathManager.getSystemPath(), "kilo/repo-cli"), + root = File(PathManager.getSystemPath(), "kilo/repo-cli/${KiloProps.cliVersion()}"), + cleanup = true, source = { - KiloRepoCli::class.java.classLoader.getResourceAsStream("kilo-cli.zip") - ?: throw IllegalStateException("kilo-cli.zip resource not found; rebuild with kilo.cli.pinned=false") + KiloRepoCli::class.java.classLoader.getResourceAsStream(ARCHIVE) + ?: throw IllegalStateException("kilo-cli.zip resource not found; rebuild with bundled CLI resources") }, ) - internal suspend fun extract(force: Boolean, root: File, source: () -> InputStream): File = withContext(Dispatchers.IO) { - val exe = File(root, "bin/${KiloCliPlatform.exe()}") + internal suspend fun extract( + force: Boolean, + root: File, + cleanup: Boolean = false, + source: () -> InputStream, + ): File = withContext(Dispatchers.IO) { + val platform = KiloCliPlatform.current() + val exe = File(root, "$platform/bin/${KiloCliPlatform.exe()}") val done = File(root, ".complete") if (!force && done.isFile && exe.isFile) { if (!SystemInfo.isWindows) exe.setExecutable(true) + if (cleanup) prune(root) return@withContext exe } @@ -38,21 +52,55 @@ object KiloRepoCli { ZipInputStream(input.buffered()).use { zip -> while (true) { val entry = zip.nextEntry ?: break - write(root, entry.name, entry.isDirectory) { out -> zip.copyTo(out) } + val path = select(root, entry.name, platform) + if (path != null) write(root, path, entry.isDirectory) { out -> zip.copyTo(out) } zip.closeEntry() } } } - if (!exe.isFile) throw IllegalStateException("Local repo CLI archive did not contain bin/${KiloCliPlatform.exe()}") + if (!exe.isFile) throw IllegalStateException("Bundled CLI archive did not contain $platform/bin/${KiloCliPlatform.exe()}") if (!SystemInfo.isWindows) exe.setExecutable(true) done.writeText("ok\n") + if (cleanup) prune(root) return@withContext exe } + private fun prune(root: File) { + val parent = root.parentFile ?: return + val entries = parent.listFiles() ?: return + for (entry in entries) { + if (!entry.isDirectory || entry.name == root.name || entry.name.startsWith(".")) continue + log.info("Removing stale bundled Kilo CLI version ${entry.absolutePath}") + if (!entry.deleteRecursively()) { + log.warn("Failed to remove stale bundled Kilo CLI version ${entry.absolutePath}") + } + } + } + + private fun select(dir: File, name: String, platform: String): String? { + check(dir, name) + val path = name.replace('\\', '/') + val prefix = "$platform/" + if (path.startsWith(prefix)) return path + if (path.startsWith("bin/")) return "$platform/$path" + return null + } + + private fun check(dir: File, name: String) { + val raw = name.replace('\\', '/') + if (raw.startsWith("/")) throw IllegalStateException("Archive entry escapes target directory: $name") + val parts = raw.split('/').filter { it.isNotEmpty() } + if (parts.any { it == ".." }) throw IllegalStateException("Archive entry escapes target directory: $name") + val target = File(dir, name).canonicalFile + val base = dir.canonicalFile + if (target != base && !target.path.startsWith(base.path + File.separator)) { + throw IllegalStateException("Archive entry escapes target directory: $name") + } + } + private fun write(dir: File, name: String, directory: Boolean, copy: (OutputStream) -> Unit) { - val path = if (name.startsWith("bin/")) name else "bin/$name" - val target = File(dir, path).canonicalFile + val target = File(dir, name).canonicalFile val base = dir.canonicalFile if (target != base && !target.path.startsWith(base.path + File.separator)) { throw IllegalStateException("Archive entry escapes target directory: $name") diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 0501d04d2a5..9e6843bc82f 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -135,6 +135,9 @@ class KiloSessionRpcApiImpl internal constructor( override suspend fun revert(id: String, directory: String, messageID: String, partID: String?) = ready { chat.revert(id, sessions.getDirectory(id, directory), messageID, partID) } + override suspend fun deleteMessage(id: String, directory: String, messageID: String): Boolean = + ready { chat.deleteMessage(id, sessions.getDirectory(id, directory), messageID) } + override suspend fun unrevert(id: String, directory: String) = ready { chat.unrevert(id, sessions.getDirectory(id, directory)) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt index 0d62f668dc4..3225fef1a03 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt @@ -90,6 +90,32 @@ class KiloBackendChatManagerTest { assertEquals("{}", mock.lastUnrevertBody) } + @Test + fun `delete message sends queued message delete request`() = runBlocking { + val port = mock.start() + val chat = KiloBackendChatManager(scope, TestLog()) + chat.start(OkHttpClient(), port, MutableSharedFlow()) + + val result = chat.deleteMessage("ses_abc", "/test/project", "msg1") + + assertTrue(result) + assertEquals(1, mock.requestCount("/session/ses_abc/message/msg1")) + assertTrue(mock.lastMessageDeletePath!!.startsWith("/session/ses_abc/message/msg1?directory=")) + } + + @Test + fun `delete message returns false for queued drop miss`() = runBlocking { + val port = mock.start() + val chat = KiloBackendChatManager(scope, TestLog()) + chat.start(OkHttpClient(), port, MutableSharedFlow()) + mock.messageDeleteResponse = "false" + + val result = chat.deleteMessage("ses_abc", "/test/project", "msg1") + + assertEquals(false, result) + assertEquals(1, mock.requestCount("/session/ses_abc/message/msg1")) + } + @Test fun `revert failure throws on non successful response`() = runBlocking { val port = mock.start() @@ -202,4 +228,21 @@ class KiloBackendChatManagerTest { assertEquals("ses_abc", event.sessionID) assertTrue(log.messages.any { it.contains("route=chat-events parse=false type=session.error") }, log.messages.joinToString("\n")) } + + @Test + fun `global message event type is extracted from payload`() = runBlocking { + val port = mock.start() + val sse = MutableSharedFlow(replay = 8) + val chat = KiloBackendChatManager(scope, TestLog()) + chat.start(OkHttpClient(), port, sse) + + val received = async(start = CoroutineStart.UNDISPATCHED) { withTimeout(5_000) { chat.events.first() } } + withTimeout(5_000) { sse.subscriptionCount.first { it > 0 } } + sse.emit(SseEvent("message", """{"payload":{"type":"session.queue.changed","properties":{"sessionID":"ses_abc","queued":["msg2"]}}}""")) + + val event = received.await() + assertTrue(event is ChatEventDto.SessionQueueChanged) + assertEquals("ses_abc", event.sessionID) + assertEquals(listOf("msg2"), event.queued) + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 8e71d6ce373..2a32a5060bd 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -651,6 +651,19 @@ class KiloCliDataParserTest { assertTrue(result is ChatEventDto.SessionCompacted) } + @Test + fun `parseChatEvent - session queue changed`() { + val data = globalEvent(""" + "type": "session.queue.changed", + "properties": { "sessionID": "ses_1", "queued": ["msg2", "msg3"] } + """) + val result = KiloCliDataParser.parseChatEvent("session.queue.changed", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionQueueChanged) + assertEquals("ses_1", result.sessionID) + assertEquals(listOf("msg2", "msg3"), result.queued) + } + @Test fun `parseChatEvent - session updated`() { val data = globalEvent(""" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloRepoCliTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloRepoCliTest.kt index 4c2f1400532..b5a2adf93f8 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloRepoCliTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloRepoCliTest.kt @@ -12,6 +12,7 @@ import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue class KiloRepoCliTest { @@ -38,6 +39,38 @@ class KiloRepoCliTest { assertEquals("#!/bin/new\n", forced.readText()) } + @Test + fun `extracts only current platform from multi platform archive`() = runBlocking { + val platform = KiloCliPlatform.current() + val other = if (platform == "windows-x64") "darwin-arm64" else "windows-x64" + val cli = KiloRepoCli.extract(false, dir) { + ByteArrayInputStream(multi(platform, other)) + } + + assertTrue(cli.isFile) + assertEquals("current", cli.readText()) + assertFalse(File(dir, "$other/bin/kilo.exe").exists()) + assertFalse(File(dir, "$other/bin/kilo").exists()) + } + + @Test + fun `prunes stale bundled cli versions after resolve`() = runBlocking { + val root = File(dir, "7.4.11") + val stale = File(dir, "7.4.10") + File(stale, "old").apply { + parentFile.mkdirs() + writeText("old") + } + + val cli = KiloRepoCli.extract(false, root, cleanup = true) { + ByteArrayInputStream(archive("current")) + } + + assertTrue(cli.isFile) + assertFalse(stale.exists()) + assertTrue(root.isDirectory) + } + @Test fun `rejects archive entries that escape root`() = runBlocking { val ex = assertFailsWith { @@ -66,4 +99,17 @@ class KiloRepoCliTest { } return out.toByteArray() } + + private fun multi(platform: String, other: String): ByteArray { + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zip -> + zip.putNextEntry(ZipEntry("$platform/bin/${KiloCliPlatform.exe()}")) + zip.write("current".toByteArray()) + zip.closeEntry() + zip.putNextEntry(ZipEntry("$other/bin/kilo.exe")) + zip.write("other".toByteArray()) + zip.closeEntry() + } + return out.toByteArray() + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 5e05d255733..d016709a685 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -117,11 +117,14 @@ class MockCliServer : AutoCloseable { @Volatile var lastCloudSessionImportBody: String? = null @Volatile var summarizeStatus = 200 @Volatile var revertStatus = 200 + @Volatile var messageDeleteStatus = 200 + @Volatile var messageDeleteResponse = "true" @Volatile var unrevertStatus = 200 @Volatile var lastSummarizePath: String? = null @Volatile var lastSummarizeBody: String? = null @Volatile var lastRevertPath: String? = null @Volatile var lastRevertBody: String? = null + @Volatile var lastMessageDeletePath: String? = null @Volatile var lastUnrevertPath: String? = null @Volatile var lastUnrevertBody: String? = null @Volatile var promptStatus = 200 @@ -438,6 +441,10 @@ class MockCliServer : AutoCloseable { lastRevertBody = body respond(output, revertStatus, sessionCreate) } + bare.matches(Regex("/session/ses_[^/]+/message/[^/]+")) && method == "DELETE" -> { + lastMessageDeletePath = path + respond(output, messageDeleteStatus, messageDeleteResponse) + } bare.matches(Regex("/session/ses_[^/]+/unrevert")) && method == "POST" -> { lastUnrevertPath = path lastUnrevertBody = body diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt new file mode 100644 index 00000000000..62ae78a2317 --- /dev/null +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt @@ -0,0 +1,225 @@ +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.net.HttpURLConnection +import java.net.URI +import java.security.MessageDigest +import java.time.Instant +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +abstract class StageBundledCliTask : DefaultTask() { + companion object { + private val DIGEST = Regex("^sha256:[a-f0-9]{64}$") + private val JSON = Json { ignoreUnknownKeys = true } + private const val API = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags" + private val PLATFORMS = listOf( + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "windows-arm64", + "windows-x64", + ) + } + + @get:Input + abstract val cliVersion: Property + + @get:Internal + abstract val token: Property + + @get:Internal + abstract val cacheDir: DirectoryProperty + + @get:OutputFile + abstract val archive: RegularFileProperty + + @TaskAction + fun run() { + val ver = cliVersion.get() + val assets = assets(ver) + val files = PLATFORMS.associateWith { platform -> + val ext = ext(platform) + val name = "kilo-$platform.$ext" + val digest = assets[name] ?: throw GradleException("Kilo CLI release $ver did not include $name") + val file = cacheDir.dir(ver).map { it.dir(platform).file(name) }.get().asFile + fetch(ver, platform, name, digest, file) + file + } + + val out = archive.get().asFile + out.parentFile.mkdirs() + ZipOutputStream(out.outputStream().buffered()).use { zip -> + for ((platform, file) in files) { + if (file.name.endsWith(".zip")) { + zip(platform, file, zip) + continue + } + tar(platform, file, zip) + } + } + } + + private fun assets(ver: String): Map { + val url = "$API/v$ver" + logger.lifecycle("Fetching pinned Kilo CLI release metadata from $url") + val conn = connect(url) + try { + val code = conn.responseCode + if (code !in 200..299) fail(conn, code, "Failed to fetch pinned Kilo CLI release metadata") + val body = conn.inputStream.bufferedReader().use { it.readText() } + return JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray + ?.associate { item -> + val obj = item.jsonObject + val name = obj["name"]?.jsonPrimitive?.contentOrNull + val digest = obj["digest"]?.jsonPrimitive?.contentOrNull + if (name.isNullOrBlank() || digest.isNullOrBlank()) return@associate "" to "" + name to digest + } + ?.filter { it.key.isNotEmpty() } + ?.mapValues { item -> + val digest = item.value + if (!digest.matches(DIGEST)) { + throw GradleException("Pinned Kilo CLI release $ver asset ${item.key} has invalid digest") + } + digest + } + ?: emptyMap() + } finally { + conn.disconnect() + } + } + + private fun fetch(ver: String, platform: String, name: String, digest: String, file: File) { + if (file.isFile && sum(file) == digest) return + file.parentFile.mkdirs() + val url = "https://github.com/Kilo-Org/kilocode/releases/download/v$ver/$name" + logger.lifecycle("Downloading pinned Kilo CLI $platform from $url") + val conn = connect(url) + try { + val code = conn.responseCode + if (code !in 200..299) fail(conn, code, "Failed to download pinned Kilo CLI $platform") + conn.inputStream.use { input -> + file.outputStream().use { output -> input.copyTo(output) } + } + } finally { + conn.disconnect() + } + verify(file, digest) + } + + private fun zip(platform: String, file: File, out: ZipOutputStream) { + ZipInputStream(file.inputStream().buffered()).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + if (!entry.isDirectory) write(out, platform, entry.name) { zip.copyTo(out) } + zip.closeEntry() + } + } + } + + private fun tar(platform: String, file: File, out: ZipOutputStream) { + TarArchiveInputStream(GzipCompressorInputStream(file.inputStream().buffered())).use { tar -> + while (true) { + val entry = tar.nextEntry ?: break + if (entry.isDirectory) continue + if (entry.isSymbolicLink || !entry.isFile) { + throw GradleException("Unsupported CLI tar entry type in ${file.name}: ${entry.name}") + } + write(out, platform, entry.name) { tar.copyTo(out) } + } + } + } + + private fun write(out: ZipOutputStream, platform: String, name: String, copy: () -> Unit) { + out.putNextEntry(ZipEntry(path(platform, name))) + copy() + out.closeEntry() + } + + private fun path(platform: String, name: String): String { + val raw = name.replace('\\', '/') + if (raw.startsWith("/")) throw GradleException("Archive entry escapes target directory: $name") + val parts = raw.split('/').filter { it.isNotEmpty() && it != "." } + if (parts.isEmpty()) throw GradleException("Archive entry is empty: $name") + if (parts.any { it == ".." }) throw GradleException("Archive entry escapes target directory: $name") + val path = if (parts.first() == "bin") parts else listOf("bin") + parts + return "$platform/${path.joinToString("/")}" + } + + private fun verify(file: File, digest: String) { + val actual = sum(file) + if (actual == digest) return + if (file.exists() && !file.delete()) logger.warn("Failed to delete invalid pinned Kilo CLI archive ${file.absolutePath}") + throw GradleException("Pinned Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual") + } + + private fun sum(file: File) = "sha256:${sha256(file)}" + + private fun sha256(file: File): String { + val md = MessageDigest.getInstance("SHA-256") + file.inputStream().buffered().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val n = input.read(buffer) + if (n < 0) break + md.update(buffer, 0, n) + } + } + return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) } + } + + private fun connect(url: String): HttpURLConnection { + val conn = URI(url).toURL().openConnection() as HttpURLConnection + conn.connectTimeout = 30_000 + conn.readTimeout = 120_000 + conn.instanceFollowRedirects = true + conn.setRequestProperty("Accept", "application/vnd.github+json") + token.getOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { conn.setRequestProperty("Authorization", "Bearer $it") } + return conn + } + + private fun fail(conn: HttpURLConnection, code: Int, msg: String): Nothing { + val info = rate(conn) + val body = runCatching { conn.errorStream?.bufferedReader()?.use { it.readText() } } + .getOrNull() + ?.take(500) + val detail = if (body.isNullOrBlank()) "" else ": $body" + if (limited(conn, code)) { + throw GradleException("GitHub API rate limit exceeded while staging bundled Kilo CLI ($info)$detail") + } + throw GradleException("$msg: HTTP $code from ${conn.url} ($info)$detail") + } + + private fun rate(conn: HttpURLConnection): String { + val reset = conn.getHeaderField("X-RateLimit-Reset") + ?.toLongOrNull() + ?.let { Instant.ofEpochSecond(it).toString() } + return "limit=${conn.getHeaderField("X-RateLimit-Limit")} remaining=${conn.getHeaderField("X-RateLimit-Remaining")} " + + "used=${conn.getHeaderField("X-RateLimit-Used")} reset=$reset retryAfter=${conn.getHeaderField("Retry-After")}" + } + + private fun limited(conn: HttpURLConnection, code: Int) = + code == 429 || (code == 403 && conn.getHeaderField("X-RateLimit-Remaining") == "0") + + private fun ext(platform: String) = if (platform.startsWith("linux-")) "tar.gz" else "zip" +} diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt index f5a77cd5321..1f58091ee03 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt @@ -28,12 +28,13 @@ abstract class StageRepoCliTask : DefaultTask() { } val out = archive.get().asFile + val platform = platform() out.parentFile.mkdirs() ZipOutputStream(out.outputStream().buffered()).use { zip -> dir.walkTopDown() .filter { it.isFile } .forEach { file -> - val name = "bin/${file.relativeTo(dir).invariantSeparatorsPath}" + val name = "$platform/bin/${file.relativeTo(dir).invariantSeparatorsPath}" zip.putNextEntry(ZipEntry(name)) file.inputStream().use { it.copyTo(zip) } zip.closeEntry() @@ -42,4 +43,20 @@ abstract class StageRepoCliTask : DefaultTask() { } private fun exe() = if (System.getProperty("os.name").lowercase().contains("windows")) "kilo.exe" else "kilo" + + private fun platform(): String { + val os = System.getProperty("os.name").lowercase() + val name = when { + os.contains("mac") || os.contains("darwin") -> "darwin" + os.contains("linux") -> "linux" + os.contains("windows") -> "windows" + else -> throw GradleException("Unsupported OS: ${System.getProperty("os.name")}") + } + val arch = when (System.getProperty("os.arch").lowercase()) { + "aarch64", "arm64" -> "arm64" + "x86_64", "amd64" -> "x64" + else -> throw GradleException("Unsupported architecture: ${System.getProperty("os.arch")}") + } + return "$name-$arch" + } } diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index c13a70c2b64..0ff583165aa 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -4,6 +4,7 @@ import org.jetbrains.intellij.platform.gradle.TestFrameworkType import org.jetbrains.intellij.platform.gradle.tasks.InstrumentCodeTask import org.jetbrains.intellij.platform.gradle.tasks.RunIdeTask import org.jetbrains.intellij.platform.gradle.tasks.aware.SplitModeAware.PluginInstallationTarget +import java.io.File import java.time.LocalDate group = "ai.kilocode.jetbrains" @@ -207,8 +208,12 @@ intellijPlatform { } signing { + // CI passes raw secret content so signing can run without writing secrets to disk. + // Local release builds can still point these properties at pre-existing secret files. certificateChain = providers.environmentVariable("JETBRAINS_CERTIFICATE_CHAIN") privateKey = providers.environmentVariable("JETBRAINS_PRIVATE_KEY") + certificateChainFile.fileProvider(providers.environmentVariable("JETBRAINS_CERTIFICATE_CHAIN_FILE").map { File(it) }) + privateKeyFile.fileProvider(providers.environmentVariable("JETBRAINS_PRIVATE_KEY_FILE").map { File(it) }) password = providers.environmentVariable("JETBRAINS_PRIVATE_KEY_PASSWORD") } @@ -220,6 +225,10 @@ intellijPlatform { } tasks { + named("verifyPluginSignature") { + dependsOn("signPlugin") + } + withType { enabled = false } diff --git a/packages/kilo-jetbrains/docs/bundled-release-plan.md b/packages/kilo-jetbrains/docs/bundled-release-plan.md new file mode 100644 index 00000000000..7a45f698e2e --- /dev/null +++ b/packages/kilo-jetbrains/docs/bundled-release-plan.md @@ -0,0 +1,64 @@ +# JetBrains Bundled-CLI Release Plan + +Ship a signed, all-platform, CLI-bundled build of the Kilo JetBrains plugin to a GitHub-hosted custom plugin repository, as an alternative to the JetBrains Marketplace, which caps plugin ZIPs at 400 MB. The Marketplace build stays lean and downloads the CLI at runtime; the bundled build embeds every platform's CLI so it works offline or on restricted networks. + +## Decisions + +1. Host `updatePlugins.xml` via GitHub Pages deployed by Actions. +2. Maintain a single stable custom repo: one `updatePlugins.xml`, updated on stable releases only. +3. Auto-trigger the bundled workflow after `publish-jetbrains` succeeds. +4. Decide runtime delivery by presence of the bundled `kilo-cli.zip` resource. Do not add a `kilo.properties` flag, and do not edit committed files for a bundled build. + +## Core Principle + +- A bundled build uses the same `jetbrains/v` tag, the same source, and `kilo.cli.pinned=true`. +- The only build difference is the override `-Pkilo.cli.bundled=true`. +- `kilo.properties` stays byte-identical between Marketplace and bundled builds. The only build-output difference is whether `kilo-cli.zip` is embedded in the backend jar. +- `kilo.cli.pinned` keeps its existing meaning: which CLI version / OpenAPI source / release guard. It does not control runtime delivery. + +## Phase 1: Backend Delivery + +- Add `KiloRepoCli.available()` to detect `kilo-cli.zip` on the classpath. +- Change `KiloBackendCliManager.resolveCli()` to extract when `KiloRepoCli.available()` is true; otherwise download the pinned release asset. +- Store bundled archives as `/bin/kilo[.exe]` for all six platforms: `darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`, `windows-arm64`, `windows-x64`. +- Extract only the current platform's subtree to disk so users do not store all six binaries locally. +- Keep path traversal checks for every archive entry. +- Update repo CLI dev staging to use the same layout. + +## Phase 2: Gradle Bundling + +- Add a build-only property `kilo.cli.bundled`, defaulting to false. +- Keep `kilo.cli.pinned=true` for bundled production builds. +- Add a task that downloads all six pinned CLI release assets from GitHub, verifies their `sha256` digests from release metadata, and assembles `kilo-cli.zip` as a backend resource. +- Wire that generated resource only when `-Pkilo.cli.bundled=true` or local repo CLI mode is active. +- Leave the production guard against `kilo.cli.pinned=false` intact. + +Bundled build command: + +```bash +./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin \ + -Pproduction=true -Pkilo.version= -Pkilo.channel=default \ + -Pkilo.cli.bundled=true +``` + +## Phase 3: Bundle Workflow + +- Add `.github/workflows/publish-jetbrains-bundled.yml`. +- Add a final success step to `publish-jetbrains.yml` that dispatches the bundle workflow with the merged release PR and merge commit. +- The bundle workflow checks out the merged release PR for validation, then checks out the immutable `jetbrains/v` tag, restores reviewed release metadata, builds the bundled variant, signs it, verifies it, and uploads `kilo-code--bundled.zip` to the same GitHub Release. +- Bundle ZIPs are produced for RC and stable releases. Only stable releases update the custom plugin repository XML. + +## Phase 4: GitHub Pages Repository + +- Generate `jetbrains/updatePlugins.xml` from the signed bundled ZIP metadata on stable releases. +- Point the plugin URL at the uploaded GitHub Release asset. +- Deploy the XML with GitHub Pages Actions to `https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml`. +- Users add that URL in JetBrains IDEs under Settings -> Plugins -> Manage Plugin Repositories. + +## Acceptance Criteria + +- Marketplace builds remain unchanged and download the CLI at runtime. +- Bundled builds use the same tag and source, keep `kilo.cli.pinned=true`, and differ only by `-Pkilo.cli.bundled=true`. +- Bundled ZIPs are signed and attached to the `jetbrains/v` release. +- Runtime extracts the bundled current-platform CLI and never downloads when `kilo-cli.zip` is present. +- Stable releases update the GitHub Pages `updatePlugins.xml` with the latest bundled signed ZIP URL. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 8acd2bc1ae8..0ab8e0432af 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -204,6 +204,9 @@ class KiloSessionService internal constructor( log.info("${ChatLogSummary.sid(id)} kind=revert ok=true") } + suspend fun deleteMessage(id: String, dir: String, message: String): Boolean = + call { deleteMessage(id, dir, message) } + suspend fun unrevert(id: String, dir: String) { call { unrevert(id, dir) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 86333825399..c59014686dc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -367,6 +367,7 @@ class SessionUi( resize = { anchor, fn -> scroll.preserve(anchor, fn) }, revert = ::revert, cancelRevert = ::cancelRevert, + deleteQueued = { id -> controller.deleteQueuedMessage(id) }, banner = RevertBanner(controller.model, ::redo, controller::redoAll, ::cancelRevert, focus), ).also { it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) } @@ -543,6 +544,8 @@ class SessionUi( is SessionModelEvent.RevertChanged -> onRevertChanged(event.revert) + is SessionModelEvent.QueueChanged -> Unit + is SessionModelEvent.TurnAdded, is SessionModelEvent.TurnUpdated, is SessionModelEvent.ContentAdded, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 0570d4bd366..9b1e517ac6d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -464,6 +464,27 @@ class SessionController( } } + fun deleteQueuedMessage(message: String) { + assertEdt() + val id = sid ?: return + cs.launch { + try { + val ok = sessions.deleteMessage(id, directory, message) + if (!ok) { + capture("Session Error", sessionProps(id) + mapOf("context" to "delete-message", "errorClass" to "DeleteMiss")) + LOG.warn("${ChatLogSummary.sid(id)} kind=deleteMessage missed message=$message") + return@launch + } + capture("Conversation Queued Message Removed", sessionProps(id)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + capture("Session Error", sessionProps(id) + mapOf("context" to "delete-message", "errorClass" to e::class.java.name)) + LOG.warn("${ChatLogSummary.sid(id)} kind=deleteMessage failed message=${e.message}", e) + } + } + } + fun unrevert() { assertEdt() val id = sid ?: return @@ -1368,6 +1389,8 @@ class SessionController( idle() } + is ChatEventDto.SessionQueueChanged -> updateModel { model.setQueued(event.queued.toSet()) } + is ChatEventDto.SessionCompacted -> { capture("Context Condensed", sessionProps(event.sessionID)) model.markCompacted() @@ -1406,7 +1429,8 @@ class SessionController( is ChatEventDto.QuestionRejected, is ChatEventDto.SessionStatusChanged, is ChatEventDto.SessionUpdated, - is ChatEventDto.SessionIdle -> { + is ChatEventDto.SessionIdle, + is ChatEventDto.SessionQueueChanged -> { edt { if (disposed) return@edt updateModel { handleMetadata(event) } @@ -1428,6 +1452,7 @@ class SessionController( is ChatEventDto.SessionStatusChanged -> status(event.status) is ChatEventDto.SessionUpdated -> model.setSession(event.session) is ChatEventDto.SessionIdle -> idle() + is ChatEventDto.SessionQueueChanged -> model.setQueued(event.queued.toSet()) else -> Unit } } @@ -2312,6 +2337,7 @@ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (eve is ChatEventDto.SessionStatusChanged -> event.sessionID == id is ChatEventDto.SessionUpdated -> event.sessionID == id is ChatEventDto.SessionIdle -> event.sessionID == id + is ChatEventDto.SessionQueueChanged -> event.sessionID == id is ChatEventDto.SessionCompacted -> event.sessionID == id is ChatEventDto.SessionDiffChanged -> event.sessionID == id is ChatEventDto.TodoUpdated -> event.sessionID == id diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index fa9412ff3e9..14f7b8c0271 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -74,6 +74,9 @@ class SessionModel { private var revert: SessionRevertDto? = null + var queued: Set = emptySet() + private set + var header: SessionHeaderSnapshot = emptyHeader() private set @@ -125,6 +128,9 @@ class SessionModel { return idx >= 0 && pos >= idx } + @RequiresEdt + fun isQueued(id: String): Boolean = id in queued + @RequiresEdt fun turn(id: String): Turn? = turnEntries[id] @@ -295,6 +301,13 @@ class SessionModel { fire(SessionModelEvent.RevertChanged(revert)) } + @RequiresEdt + fun setQueued(ids: Set) { + if (queued == ids) return + queued = ids + fire(SessionModelEvent.QueueChanged(ids)) + } + @RequiresEdt fun setDiff(diff: List) { this.diff = diff @@ -329,6 +342,7 @@ class SessionModel { hiddenText.clear() session = null revert = null + queued = emptySet() state = SessionState.Idle diff = emptyList() todos = emptyList() @@ -363,6 +377,7 @@ class SessionModel { hiddenText.clear() session = null revert = null + queued = emptySet() state = SessionState.Idle diff = emptyList() todos = emptyList() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt index bc553cfe58c..d3af00f6388 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt @@ -60,6 +60,9 @@ sealed class SessionModelEvent { data class RevertChanged(val revert: SessionRevertDto?) : SessionModelEvent() { override fun toString() = "RevertChanged ${revert?.messageID ?: "none"}" } + data class QueueChanged(val queued: Set) : SessionModelEvent() { + override fun toString() = "QueueChanged [${queued.sorted().joinToString(", ")}]" + } data class HeaderUpdated(val header: SessionHeaderSnapshot) : SessionModelEvent() { override fun toString() = "HeaderUpdated visible=${header.visible}" } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index a0dc4d7dfdc..3396a1c1cec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -60,6 +60,7 @@ class SessionMessageListPanel( private val resize: ((JComponent, () -> Unit) -> Unit)? = null, private val revert: ((String) -> Unit)? = null, private val cancelRevert: (() -> Unit)? = null, + private val deleteQueued: ((String) -> Unit)? = null, private val banner: RevertBanner? = null, ) : SessionLayoutPanel( SessionUiStyle.SessionLayout.GAP, @@ -148,6 +149,12 @@ class SessionMessageListPanel( refresh() } + is SessionModelEvent.QueueChanged -> { + syncQueued() + syncSettled() + refresh() + } + // Message events: structural changes are handled via turn events above. is SessionModelEvent.MessageAdded, is SessionModelEvent.MessageUpdated, @@ -216,7 +223,7 @@ class SessionMessageListPanel( // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -224,6 +231,7 @@ class SessionMessageListPanel( register(msgId, tv, mv) } tv.syncCopyToolbars() + syncQueued(tv) syncReverted() add(tv) syncSettled() @@ -251,6 +259,7 @@ class SessionMessageListPanel( register(id, tv, mv) } tv.syncCopyToolbars() + syncQueued(tv) syncReverted() syncSettled() @@ -279,7 +288,7 @@ class SessionMessageListPanel( removeAll() for (turn in model.turns()) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -287,11 +296,13 @@ class SessionMessageListPanel( register(msgId, tv, mv) } tv.syncCopyToolbars() + syncQueued(tv) add(tv) } syncActive(model.state) syncSettled(model.state) + syncQueued() syncReverted() syncReverting(model.state) banner?.update() @@ -321,6 +332,7 @@ class SessionMessageListPanel( removeAll() syncActive(model.state) syncSettled(model.state) + syncQueued() syncReverting(model.state) banner?.update() anchorFooter() @@ -384,10 +396,18 @@ class SessionMessageListPanel( } private fun syncSettled(state: SessionState = model.state) { - val active = if (state.isBusy()) turnViews.values.lastOrNull() else null + val active = if (state.isBusy()) turnViews.values.lastOrNull { !model.isQueued(it.id) } else null for (view in turnViews.values) view.setSettled(view !== active) } + private fun syncQueued() { + for (view in turnViews.values) syncQueued(view) + } + + private fun syncQueued(view: TurnView) { + view.setQueued(model.isQueued(view.id)) { id -> deleteQueued?.invoke(id) } + } + /** * Re-insert [question], [permission], [login], and [progress] as the last children * so active views always render after all turn views, and progress is last. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index d3a47c3260d..96d2a8e7682 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -207,6 +207,7 @@ class SessionHeaderPanel( is SessionModelEvent.TodosUpdated, is SessionModelEvent.SessionUpdated, is SessionModelEvent.RevertChanged, + is SessionModelEvent.QueueChanged, is SessionModelEvent.Compacted, is SessionModelEvent.HistoryLoaded, is SessionModelEvent.Cleared, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index ec5f7e7c1ae..9a4fd04d14c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -212,7 +212,7 @@ class PromptPanel( isFocusPainted = false addActionListener { syncTooltip() - val id = if (busy) StopSessionAction.ID else SendPromptAction.ID + val id = if (busy && !hasDraft()) StopSessionAction.ID else SendPromptAction.ID val action = ActionManager.getInstance().getAction(id) ?: return@addActionListener val ctx = DataManager.getInstance().getDataContext(button) @@ -258,7 +258,7 @@ class PromptPanel( private var request = 0L override val isSendEnabled: Boolean - get() = ready && !busy && !submitting && (text().isNotEmpty() || attachments.isNotEmpty()) + get() = ready && !submitting && (text().isNotEmpty() || attachments.isNotEmpty()) override val isStopEnabled: Boolean get() = busy @@ -273,6 +273,7 @@ class PromptPanel( syncEditorHeight() triggerCompletion(e) syncHighlights() + syncButton() onChange() } }) @@ -418,7 +419,7 @@ class PromptPanel( fun setBusy(value: Boolean) { busy = value if (value) invalidateEnhancement() else syncEnhance() - button.icon = if (value) STOP_ICON else SEND_ICON + syncButton() syncTooltip() } @@ -628,6 +629,11 @@ class PromptPanel( } } + @RequiresEdt + private fun syncButton() { + button.icon = if (busy && !hasDraft()) STOP_ICON else SEND_ICON + } + @RequiresEdt private fun submit(src: String) { if (!isSendEnabled) return @@ -885,19 +891,20 @@ class PromptPanel( } private fun tooltip(): String { - val id = if (busy) StopSessionAction.ID else SendPromptAction.ID - val text = if (busy) { + val stop = busy && !hasDraft() + val id = if (stop) StopSessionAction.ID else SendPromptAction.ID + val text = if (stop) { KiloBundle.message("prompt.button.stop") } else { KiloBundle.message("prompt.button.send") } val tip = KeymapUtil.createTooltipText(text, id) - if (busy) return tip - val stop = KeymapUtil.getFirstKeyboardShortcutText(StopSessionAction.ID) - if (stop.isEmpty()) return tip + if (stop) return tip + val shortcut = KeymapUtil.getFirstKeyboardShortcutText(StopSessionAction.ID) + if (shortcut.isEmpty()) return tip return XmlStringUtil.wrapInHtml( XmlStringUtil.escapeString(tip) + "
" + - XmlStringUtil.escapeString(KiloBundle.message("prompt.button.send.tooltip.stop", stop)) + XmlStringUtil.escapeString(KiloBundle.message("prompt.button.send.tooltip.stop", shortcut)) ) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index aed735293b8..5ccc77850f3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -19,13 +19,21 @@ import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.ToolbarButtonAction import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align +import ai.kilocode.client.ui.toolbarButton +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer +import com.intellij.ui.components.JBLabel import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import java.awt.Point import java.awt.Graphics @@ -397,6 +405,12 @@ class MessageView( wrap?.setReverting(active, text, onCancel) } + @RequiresEdt + fun setQueued(active: Boolean, onDelete: () -> Unit) { + if (role != SessionUiStyle.View.Message.USER_ROLE) return + wrap?.setQueued(active, onDelete) + } + private val promptToolbar: MessageToolbar? get() = wrap?.bar @@ -494,21 +508,26 @@ class MessageView( private inner class PromptWrap( private val box: JPanel, ) : JPanel(BorderLayout()), SessionCopyTarget { + private val footer = JPanel(BorderLayout()).also { it.isOpaque = false } val bar = MessageToolbar( { prompt?.copyMarkdown(trim = false) }, revert?.let { fn -> { fn(msg.info.id) } }, ) private val placeholder = bar.placeholder() - private var progress: RevertProgress? = null private var reverting = false + private var progress: RevertProgress? = null + private var queuedRow: JPanel? = null + private var queued = false override val copyAnchor: JComponent get() = placeholder - override val copyToolbar: JComponent? get() = if (reverting) null else bar + override val copyToolbar: JComponent? get() = if (reverting || queued) null else bar init { isOpaque = false add(box, BorderLayout.CENTER) - add(placeholder.align(HAlign.RIGHT, VAlign.TOP), BorderLayout.SOUTH) + footer.border = JBUI.Borders.emptyTop(UiStyle.Gap.xs()) + footer.add(placeholder.align(HAlign.RIGHT, VAlign.TOP), BorderLayout.CENTER) + add(footer, BorderLayout.SOUTH) } override fun copyText(): String? = prompt?.copyMarkdown(trim = false) @@ -523,19 +542,54 @@ class MessageView( node.setText(text) if (reverting) return reverting = true - remove((layout as BorderLayout).getLayoutComponent(BorderLayout.SOUTH)) - add(node.align(HAlign.LEFT, VAlign.TOP), BorderLayout.SOUTH) + swapFooter(node.align(HAlign.LEFT, VAlign.TOP)) revalidate() repaint() return } if (!reverting) return reverting = false - remove((layout as BorderLayout).getLayoutComponent(BorderLayout.SOUTH)) - add(placeholder.align(HAlign.RIGHT, VAlign.TOP), BorderLayout.SOUTH) + swapFooter(placeholder.align(HAlign.RIGHT, VAlign.TOP)) revalidate() repaint() } + + @RequiresEdt + fun setQueued(active: Boolean, onDelete: () -> Unit) { + if (active) { + val node = queuedRow ?: queue(onDelete).also { queuedRow = it } + if (queued) return + queued = true + swapFooter(node.align(HAlign.RIGHT, VAlign.TOP)) + revalidate() + repaint() + return + } + if (!queued) return + queued = false + swapFooter(placeholder.align(HAlign.RIGHT, VAlign.TOP)) + revalidate() + repaint() + } + + private fun swapFooter(node: JComponent) { + footer.removeAll() + footer.add(node, BorderLayout.CENTER) + } + + private fun queue(onDelete: () -> Unit) = Stack.horizontal(UiStyle.Gap.sm()).also { row -> + row.isOpaque = false + row.next(JBLabel(KiloBundle.message("session.queued")).apply { + foreground = UIUtil.getContextHelpForeground() + }) + row.next(toolbarButton( + ToolbarButtonAction( + AllIcons.Actions.Close, + KiloBundle.message("session.queued.remove"), + onDelete, + ), + )) + } } private fun assistantBorder() = JBUI.Borders.empty() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index b4d8684004f..8723fc4ce5d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -36,6 +36,7 @@ class TurnView( private val repo: String? = null, private val hover: ((PartView, Boolean) -> Unit)? = null, private val revert: ((String) -> Unit)? = null, + private val deleteQueued: ((String) -> Unit)? = null, ) : SessionLayoutPanel(SessionUiStyle.SessionLayout.GAP), Disposable, SessionEditorStyleTarget, SessionView { private val messages = LinkedHashMap() @@ -71,6 +72,12 @@ class TurnView( return view } + @RequiresEdt + fun setQueued(active: Boolean, onDelete: (String) -> Unit) { + val anchor = messages.values.firstOrNull { it.role == SessionUiStyle.View.Message.USER_ROLE } ?: return + anchor.setQueued(active) { onDelete(id) } + } + /** Remove the [MessageView] for [msgId] if present. */ fun removeMessage(msgId: String) { removeMessageChanged(msgId) diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index a8f596cb076..a8fa9d983d7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -31,6 +31,8 @@ session.copy.hover=Copy session.copy.prompt=Copy prompt session.copy.response=Copy response session.copy.copied=Copied +session.queued=Queued +session.queued.remove=Remove queued message session.drop.files.title=Drop files here session.drop.files.subtitle=to add them to the prompt session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index f439e041589..999dcb7c540 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -23,6 +23,8 @@ session.copy.hover=نسخ session.copy.prompt=نسخ الموجه session.copy.response=نسخ الرد session.copy.copied=تم النسخ +session.queued=في قائمة الانتظار +session.queued.remove=إزالة الرسالة من قائمة الانتظار session.tab.new=جلسة جديدة session.tab.untitled=جلسة بدون عنوان diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index 3c3fe1f71a9..fb61208a23c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopiraj session.copy.prompt=Kopiraj prompt session.copy.response=Kopiraj odgovor session.copy.copied=Kopirano +session.queued=U redu čekanja +session.queued.remove=Ukloni poruku iz reda čekanja session.tab.new=Nova sesija session.tab.untitled=Sesija bez naslova diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 8d0d26a7223..a785788ba07 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopiér session.copy.prompt=Kopiér prompt session.copy.response=Kopiér svar session.copy.copied=Kopieret +session.queued=I kø +session.queued.remove=Fjern besked fra køen session.tab.new=Ny session session.tab.untitled=Unavngivet session diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 181ac9f1654..837e4d8dd70 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopieren session.copy.prompt=Prompt kopieren session.copy.response=Antwort kopieren session.copy.copied=Kopiert +session.queued=In Warteschlange +session.queued.remove=Nachricht aus Warteschlange entfernen session.tab.new=Neue Sitzung session.tab.untitled=Unbenannte Sitzung diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index fb67a0de551..25279f34921 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -23,6 +23,8 @@ session.copy.hover=Copiar session.copy.prompt=Copiar prompt session.copy.response=Copiar respuesta session.copy.copied=Copiado +session.queued=En cola +session.queued.remove=Eliminar mensaje en cola session.tab.new=Nueva sesión session.tab.untitled=Sesión sin título diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index 3848ae7ee98..3c279709d25 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -23,6 +23,8 @@ session.copy.hover=Copier session.copy.prompt=Copier le prompt session.copy.response=Copier la réponse session.copy.copied=Copié +session.queued=En attente +session.queued.remove=Supprimer le message en attente session.tab.new=Nouvelle session session.tab.untitled=Session sans titre diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index 8c9e3d12c06..9d853d2a796 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -23,6 +23,8 @@ session.copy.hover=コピー session.copy.prompt=プロンプトをコピー session.copy.response=応答をコピー session.copy.copied=コピーしました +session.queued=キュー済み +session.queued.remove=キュー済みメッセージを削除 session.tab.new=新しいセッション session.tab.untitled=名前なしのセッション diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index b463a9b1951..9b1797052c7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -23,6 +23,8 @@ session.copy.hover=복사 session.copy.prompt=프롬프트 복사 session.copy.response=응답 복사 session.copy.copied=복사됨 +session.queued=대기 중 +session.queued.remove=대기 중인 메시지 제거 session.tab.new=새 세션 session.tab.untitled=제목 없는 세션 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index b29e1b9dc21..85fc3f6b31c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopiëren session.copy.prompt=Prompt kopiëren session.copy.response=Antwoord kopiëren session.copy.copied=Gekopieerd +session.queued=In wachtrij +session.queued.remove=Bericht uit wachtrij verwijderen session.tab.new=Nieuwe sessie session.tab.untitled=Naamloze sessie diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index c577c420520..c160fdf4000 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopier session.copy.prompt=Kopier prompt session.copy.response=Kopier svar session.copy.copied=Kopiert +session.queued=I kø +session.queued.remove=Fjern melding fra køen session.tab.new=Ny økt session.tab.untitled=Uten tittel diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index d57e58ac718..db9776e10b5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopiuj session.copy.prompt=Kopiuj prompt session.copy.response=Kopiuj odpowiedź session.copy.copied=Skopiowano +session.queued=W kolejce +session.queued.remove=Usuń wiadomość z kolejki session.tab.new=Nowa sesja session.tab.untitled=Sesja bez tytułu diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 6655ce84b0f..35dda74bfed 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -23,6 +23,8 @@ session.copy.hover=Copiar session.copy.prompt=Copiar prompt session.copy.response=Copiar resposta session.copy.copied=Copiado +session.queued=Na fila +session.queued.remove=Remover mensagem da fila session.tab.new=Nova sessão session.tab.untitled=Sessão sem título diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index c1eaee006ad..3d53c6262d6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -23,6 +23,8 @@ session.copy.hover=Копировать session.copy.prompt=Скопировать промпт session.copy.response=Скопировать ответ session.copy.copied=Скопировано +session.queued=В очереди +session.queued.remove=Удалить сообщение из очереди session.tab.new=Новая сессия session.tab.untitled=Незаголовок сессия diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 89e6d9a8df9..3b400b23d01 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -23,6 +23,8 @@ session.copy.hover=คัดลอก session.copy.prompt=คัดลอกพรอมต์ session.copy.response=คัดลอกคำตอบ session.copy.copied=คัดลอกแล้ว +session.queued=อยู่ในคิว +session.queued.remove=ลบข้อความในคิว session.tab.new=เซสชันใหม่ session.tab.untitled=เซสชันไม่มีชื่อ diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index d6ee1e84d97..8b7a2de3cda 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopyala session.copy.prompt=Promptu kopyala session.copy.response=Yanıtı kopyala session.copy.copied=Kopyalandı +session.queued=Kuyrukta +session.queued.remove=Kuyruktaki mesajı kaldır session.tab.new=Yeni oturum session.tab.untitled=Başlıksız oturum diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index f385d236f4e..896ee37e7ad 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -23,6 +23,8 @@ session.copy.hover=Копіювати session.copy.prompt=Скопіювати промпт session.copy.response=Скопіювати відповідь session.copy.copied=Скопійовано +session.queued=У черзі +session.queued.remove=Видалити повідомлення з черги session.tab.new=Нова сесія session.tab.untitled=Сесія без назви diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index 3f4b3fbc3db..864bce7a28c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -23,6 +23,8 @@ session.copy.hover=复制 session.copy.prompt=复制提示词 session.copy.response=复制回复 session.copy.copied=已复制 +session.queued=已排队 +session.queued.remove=移除排队消息 session.tab.new=新建会话 session.tab.untitled=无标题会话 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index dd2898d09a8..48c5418ae78 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -23,6 +23,8 @@ session.copy.hover=複製 session.copy.prompt=複製提示詞 session.copy.response=複製回覆 session.copy.copied=已複製 +session.queued=已排入佇列 +session.queued.remove=移除佇列中的訊息 session.tab.new=新建工作階段 session.tab.untitled=未命名的工作階段 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index b84cdb5da6f..9d5e4225e15 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -102,6 +102,46 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertFalse(message.properties.containsValue("git-changes")) } + fun `test session queue changed updates queued set`() { + val (c, _, modelEvents) = prompted() + + emit(ChatEventDto.SessionQueueChanged("ses_test", listOf("u2"))) + + assertEquals(setOf("u2"), c.model.queued) + assertModelEvents( + """ + QueueChanged [u2] + """, + modelEvents, + ) + + emit(ChatEventDto.SessionQueueChanged("ses_test", emptyList())) + + assertEquals(emptySet(), c.model.queued) + } + + fun `test delete queued message delegates to RPC`() { + val (c, _, _) = prompted() + + edt { c.deleteQueuedMessage("u2") } + flush() + + assertEquals(listOf(ai.kilocode.client.testing.FakeSessionRpcApi.MessageDeleteCall("ses_test", "/test", "u2")), rpc.messageDeletes) + assertTrue(appRpc.telemetry.any { it.event == "Conversation Queued Message Removed" }) + } + + fun `test delete queued message miss captures error`() { + val (c, _, _) = prompted() + rpc.messageDeleteResult = false + + edt { c.deleteQueuedMessage("u2") } + flush() + + assertEquals(listOf(ai.kilocode.client.testing.FakeSessionRpcApi.MessageDeleteCall("ses_test", "/test", "u2")), rpc.messageDeletes) + assertFalse(appRpc.telemetry.any { it.event == "Conversation Queued Message Removed" }) + assertTrue(appRpc.telemetry.any { it.event == "Session Error" && it.properties["context"] == "delete-message" }) + } + fun `test PermissionAsked moves state to AwaitingPermission`() { val (m, _, _) = prompted() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 70a6dc52bba..745062e15f5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -1056,7 +1056,7 @@ class PromptPanelTest : BasePlatformTestCase() { assertTrue(resource("/icons/send_dark.svg").contains("fill=\"#0A7BD8\"")) } - fun `test busy disables send button`() { + fun `test busy allows sending draft`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) panel.setReady(true) ApplicationManager.getApplication().invokeAndWait { panel.setText("hello") } @@ -1065,8 +1065,9 @@ class PromptPanelTest : BasePlatformTestCase() { panel.setBusy(true) - assertFalse(panel.isSendEnabled) + assertTrue(panel.isSendEnabled) assertTrue(panel.isStopEnabled) + assertNotSame(AllIcons.Actions.Suspend, panel.buttonForTest().icon) } fun `test auto approve button toggles and updates tooltip`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index d5e72cfc081..b27219697a3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -28,6 +28,7 @@ import ai.kilocode.client.session.views.tool.TaskToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.views.todo.TodoWriteView import ai.kilocode.client.ui.DiffStatBadge +import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.layout.Stack import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto @@ -129,6 +130,32 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { ) } + fun `test queued turn shows badge and remove action`() { + var deleted: String? = null + Disposer.dispose(parent) + parent = Disposer.newDisposable("test-queued") + model = SessionModel() + panel = SessionMessageListPanel(model, parent, openFile = openFile, deleteQueued = { deleted = it }) + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", text = "first")) + model.upsertMessage(msg("u2", "user")) + model.updateContent("u2", part("p2", "u2", "text", text = "second")) + + model.setQueued(setOf("u2")) + + val u1 = panel.findMessage("u1")!! + val u2 = panel.findMessage("u2")!! + assertFalse(components(u1).filterIsInstance().any { it.text == KiloBundle.message("session.queued") }) + assertTrue(components(u2).filterIsInstance().any { it.text == KiloBundle.message("session.queued") }) + + val remove = components(u2).filterIsInstance().single() + assertEquals(KiloBundle.message("session.queued.remove"), remove.toolTipText) + assertEquals(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR), remove.cursor) + remove.doClick() + + assertEquals("u2", deleted) + } + // ------ TurnAdded ------ fun `test user message creates turn and is findable by message id`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index 30361406101..adb4afd0aae 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -96,6 +96,8 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val aborts = mutableListOf>() val compacts = mutableListOf>() val reverts = mutableListOf() + val messageDeletes = mutableListOf() + var messageDeleteResult = true val unreverts = mutableListOf>() val configs = mutableListOf>() val permissionReplies = mutableListOf>() @@ -118,6 +120,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { data class AttachmentCall(val id: String, val directory: String, val messageId: String, val partId: String, val attachmentKey: String?) data class CommandCall(val id: String, val directory: String, val command: String, val arguments: String, val prompt: PromptDto) data class RevertCall(val id: String, val directory: String, val message: String, val part: String?) + data class MessageDeleteCall(val id: String, val directory: String, val message: String) // --- Implementation --- @@ -231,6 +234,12 @@ class FakeSessionRpcApi : KiloSessionRpcApi { reverts.add(RevertCall(id, directory, messageID, partID)) } + override suspend fun deleteMessage(id: String, directory: String, messageID: String): Boolean { + assertNotEdt("deleteMessage") + messageDeletes.add(MessageDeleteCall(id, directory, messageID)) + return messageDeleteResult + } + override suspend fun unrevert(id: String, directory: String) { assertNotEdt("unrevert") unrevertGate?.await() diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index 6782f168445..9e9e7cd62da 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.0.10 +kilo.jetbrains.version=7.0.12-rc.2 # When true (default) the JetBrains plugin uses the pinned CLI release from package.json. # Set to false ONLY for local dev: generate the client from local source + bundle the local binary. # false is NOT releasable -- production builds fail unless this is true. diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt index 4f8e06f1693..588c0c9d0e6 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt @@ -33,6 +33,7 @@ object ChatLogSummary { is ChatEventDto.SessionStatusChanged -> event.sessionID is ChatEventDto.SessionUpdated -> event.sessionID is ChatEventDto.SessionIdle -> event.sessionID + is ChatEventDto.SessionQueueChanged -> event.sessionID is ChatEventDto.SessionCompacted -> event.sessionID is ChatEventDto.SessionDiffChanged -> event.sessionID is ChatEventDto.TodoUpdated -> event.sessionID @@ -211,6 +212,12 @@ object ChatLogSummary { "evt=session.idle", ) + is ChatEventDto.SessionQueueChanged -> join( + sid(event.sessionID), + "evt=session.queue.changed", + "queued=${event.queued.size}", + ) + is ChatEventDto.SessionCompacted -> join( sid(event.sessionID), "evt=session.compacted", diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index 6b6cfa4f97f..0fab2e8696e 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -90,6 +90,9 @@ interface KiloSessionRpcApi : RemoteApi { /** Revert a session to a prior user message or part. */ suspend fun revert(id: String, directory: String, messageID: String, partID: String?) + /** Delete a single message (used to remove a queued prompt). */ + suspend fun deleteMessage(id: String, directory: String, messageID: String): Boolean + /** Redo all reverted changes for a session. */ suspend fun unrevert(id: String, directory: String) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index dbb053f8fd5..edbbed7b02b 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -254,6 +254,13 @@ sealed class ChatEventDto { val sessionID: String, ) : ChatEventDto() + @Serializable + @SerialName("session.queue.changed") + data class SessionQueueChanged( + val sessionID: String, + val queued: List = emptyList(), + ) : ChatEventDto() + @Serializable @SerialName("session.compacted") data class SessionCompacted( diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 450133e0929..3043aedbab9 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -66,6 +66,7 @@ "./text-strikethrough": "./src/components/text-strikethrough.tsx", "./context": "./src/context/index.ts", "./context/code": "./src/context/code.tsx", + "./context/clipboard": "./src/context/clipboard.tsx", "./context/data": "./src/context/data.tsx", "./context/dialog": "./src/context/dialog.tsx", "./context/diff": "./src/context/diff.tsx", diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css index c4495f862ff..3c67c36e2ad 100644 --- a/packages/kilo-ui/src/components/basic-tool.css +++ b/packages/kilo-ui/src/components/basic-tool.css @@ -516,3 +516,10 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty font-family: var(--font-family-mono); } } + +/* Subagent card: the line renders inside the trigger's info row (an align-baseline flex), + so force it onto its own full-width line directly under the title/description. */ +[data-slot="basic-tool-tool-info-main"] > [data-slot="tool-approval-line"] { + flex-basis: 100%; + padding: 2px 0 0; +} diff --git a/packages/kilo-ui/src/components/basic-tool.test.ts b/packages/kilo-ui/src/components/basic-tool.test.ts new file mode 100644 index 00000000000..c4009ba55f7 --- /dev/null +++ b/packages/kilo-ui/src/components/basic-tool.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { shouldRenderApprovalInBody } from "./basic-tool" + +describe("shouldRenderApprovalInBody", () => { + test("renders in the body by default when an approval exists", () => { + expect(shouldRenderApprovalInBody(undefined, true)).toBe(true) + expect(shouldRenderApprovalInBody("body", true)).toBe(true) + }) + + test("does not render when there is no approval", () => { + expect(shouldRenderApprovalInBody("body", false)).toBe(false) + expect(shouldRenderApprovalInBody(undefined, false)).toBe(false) + }) + + test("never renders in the body for hidden placement, even with an approval", () => { + expect(shouldRenderApprovalInBody("hidden", true)).toBe(false) + expect(shouldRenderApprovalInBody("hidden", false)).toBe(false) + }) +}) diff --git a/packages/kilo-ui/src/components/basic-tool.tsx b/packages/kilo-ui/src/components/basic-tool.tsx index ce545b832ee..7dbaeee9395 100644 --- a/packages/kilo-ui/src/components/basic-tool.tsx +++ b/packages/kilo-ui/src/components/basic-tool.tsx @@ -11,6 +11,7 @@ export interface BasicToolProps extends BaseProps { tool?: string callID?: string partID?: string + approvalPlacement?: "body" | "hidden" } type OpenProps = Pick @@ -19,26 +20,41 @@ export function initialOpen(props: OpenProps) { return props.forceOpen ? true : readToolOpen(toolOpenKey(props), props.defaultOpen) } +export function useToolApprovalLine() { + const approval = useToolApproval() + return () => { + const value = approval() + return value ? : null + } +} + +/** + * Whether BasicTool should inject the approval line into its body. + */ +export function shouldRenderApprovalInBody(placement: BasicToolProps["approvalPlacement"], hasApproval: boolean) { + return placement !== "hidden" && hasApproval +} + export function BasicTool(props: BasicToolProps) { const key = () => toolOpenKey(props) const initial = () => initialOpen(props) const approval = useToolApproval() + const inBody = () => shouldRenderApprovalInBody(props.approvalPlacement, approval() !== undefined) const change = (open: boolean) => { writeToolOpen(key(), open) props.onOpenChange?.(open) } - // The "why was this allowed" line lives in the expanded body, above any tool-specific details. const details = () => (
- {(value) => } + {(value) => } {props.children}
) - if (!("children" in props) && !approval()) { + if (!("children" in props) && !inBody()) { return } return ( - + {details()} ) diff --git a/packages/kilo-ui/src/components/code.tsx b/packages/kilo-ui/src/components/code.tsx index c5ff061f33f..cbf22e2d4c5 100644 --- a/packages/kilo-ui/src/components/code.tsx +++ b/packages/kilo-ui/src/components/code.tsx @@ -19,7 +19,7 @@ const VIRTUALIZE_BYTES = 500_000 const codeMetrics = { ...DEFAULT_VIRTUAL_FILE_METRICS, lineHeight: 24, - fileGap: 0, + spacing: 0, } satisfies Partial const codeStyle = { diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 73e917d3c55..cbb8982989d 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -30,8 +30,9 @@ import { import { useData } from "../context" import { useFileComponent } from "../context/file" import { useDialog } from "../context/dialog" +import { useClipboard } from "../context/clipboard" import { type UiI18n, useI18n } from "../context/i18n" -import { GenericTool, BasicTool } from "./basic-tool" +import { BasicTool, useToolApprovalLine } from "./basic-tool" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Card } from "./card" @@ -753,6 +754,7 @@ export function UserMessageDisplay(props: { const data = useData() const dialog = useDialog() const i18n = useI18n() + const clipboard = useClipboard() const [copied, setCopied] = createSignal(false) const textPart = createMemo( @@ -815,7 +817,7 @@ export function UserMessageDisplay(props: { const handleCopy = async () => { const content = props.copyText ?? text() if (!content) return - await navigator.clipboard.writeText(content) + await clipboard.write(content) setCopied(true) setTimeout(() => setCopied(false), 2000) } @@ -1311,7 +1313,14 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { }} - resolveToolApproval(meta(), i18n.t as (k: string, p?: Record) => string)}> + + resolveToolApproval( + meta(), + i18n.t as (k: string, p?: Record) => string, + ) + } + > props.part as TextPart const displayText = () => (part().text ?? "").trim() @@ -1396,7 +1406,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const handleCopy = async () => { const content = displayText() if (!content) return - await navigator.clipboard.writeText(content) + await clipboard.write(content) setCopied(true) setTimeout(() => setCopied(false), 2000) } @@ -1497,9 +1507,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { /> - - {(el) => {el()}} - + {(el) => {el()}} @@ -2172,6 +2180,8 @@ ToolRegistry.register({ }, 50) } + const approvalLine = useToolApprovalLine() + const trigger = () => (
@@ -2190,11 +2200,22 @@ ToolRegistry.register({ + {/* Keep the auto-approve line attached to the subagent card instead of forcing a collapsible body. */} + {approvalLine()}
) - return + return ( + + ) }, }) @@ -2932,6 +2953,7 @@ ToolRegistry.register({ (current: T | undefined, next: T | undefined, key: (item: T) => string) { + if (current === undefined || next === undefined) return current !== next + return key(current) !== key(next) +} diff --git a/packages/kilo-ui/src/components/select.test.ts b/packages/kilo-ui/src/components/select.test.ts new file mode 100644 index 00000000000..cc81ce944a4 --- /dev/null +++ b/packages/kilo-ui/src/components/select.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test" +import { changed } from "./select-change" + +describe("changed", () => { + const key = (item: { value: string }) => item.value + + test("ignores recreated options with the current key", () => { + expect(changed({ value: "ollama" }, { value: "ollama" }, key)).toBe(false) + }) + + test("reports selected and cleared values", () => { + expect(changed({ value: "ollama" }, { value: "kilo" }, key)).toBe(true) + expect(changed({ value: "ollama" }, undefined, key)).toBe(true) + expect(changed(undefined, { value: "ollama" }, key)).toBe(true) + expect(changed(undefined, undefined, key)).toBe(false) + }) +}) diff --git a/packages/kilo-ui/src/components/select.tsx b/packages/kilo-ui/src/components/select.tsx index d4a6c72f2cb..d6a72b1a8dd 100644 --- a/packages/kilo-ui/src/components/select.tsx +++ b/packages/kilo-ui/src/components/select.tsx @@ -1 +1,19 @@ +import { Select as Base, type SelectProps } from "@opencode-ai/ui/select" +import type { ButtonProps } from "@opencode-ai/ui/button" +import { changed } from "./select-change" + export * from "@opencode-ai/ui/select" + +export function Select(props: SelectProps & Omit) { + const key = (item: T) => (props.value ? props.value(item) : (item as string)) + + return ( + { + if (!changed(props.current, next, key)) return + props.onSelect?.(next) + }} + /> + ) +} diff --git a/packages/kilo-ui/src/components/tool-approval.test.ts b/packages/kilo-ui/src/components/tool-approval.test.ts new file mode 100644 index 00000000000..746e1f58d48 --- /dev/null +++ b/packages/kilo-ui/src/components/tool-approval.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { resolveToolApproval } from "./tool-approval" + +// Echo the key + params so assertions can see which string was chosen without a real dictionary. +const t = (key: string, params?: Record) => + params + ? `${key}(${Object.entries(params) + .map(([k, v]) => `${k}=${v}`) + .join(",")})` + : key + +describe("resolveToolApproval", () => { + test("returns undefined when there is no approval on the metadata", () => { + expect(resolveToolApproval(undefined, t)).toBeUndefined() + expect(resolveToolApproval({ other: 1 }, t)).toBeUndefined() + }) + + test("manual approvals show only the decision, no source or rule", () => { + const out = resolveToolApproval({ approval: { source: "manual" } }, t) + expect(out).toEqual({ + approval: { source: "manual" }, + decision: "ui.approval.manual", + source: undefined, + rule: undefined, + }) + }) + + test("a specific rule is shown with permission + pattern", () => { + const approval = { source: "project" as const, rule: { permission: "bash", pattern: "git *", action: "allow" } } + const out = resolveToolApproval({ approval }, t) + expect(out?.decision).toBe("ui.approval.auto") + expect(out?.source).toBe("ui.approval.source.project") + expect(out?.rule).toBe("ui.approval.rule(permission=bash,pattern=git *)") + }) + + test("a per-tool rule with a wildcard pattern still shows the tool name", () => { + const approval = { + source: "agent" as const, + agent: "explore", + rule: { permission: "task", pattern: "*", action: "allow" }, + } + const out = resolveToolApproval({ approval }, t) + expect(out?.rule).toBe("ui.approval.rule(permission=task,pattern=*)") + }) + + test("the catch-all */* rule is dropped so the line is not noisy for blanket agent defaults", () => { + // e.g. the code agent auto-approving `task`/`todowrite` via its "*": "allow" default. + const approval = { + source: "agent" as const, + agent: "code", + rule: { permission: "*", pattern: "*", action: "allow" }, + } + const out = resolveToolApproval({ approval }, t) + expect(out?.source).toBe("ui.approval.source.agent(agent=code)") + expect(out?.rule).toBeUndefined() + }) +}) diff --git a/packages/kilo-ui/src/components/tool-approval.tsx b/packages/kilo-ui/src/components/tool-approval.tsx index af07af86ddf..d555e105df3 100644 --- a/packages/kilo-ui/src/components/tool-approval.tsx +++ b/packages/kilo-ui/src/components/tool-approval.tsx @@ -59,13 +59,18 @@ export function resolveToolApproval( if (approval.source === "manual") return undefined return t(`ui.approval.source.${approval.source}`) } + const rule = approval.rule + // The catch-all "*"/"*" rule carries no useful detail (it's the blanket allow-everything default), + // so drop the "matched `*` rule `*`" fragment and let the source alone explain the approval. + const ruleText = + rule && !(rule.permission === "*" && rule.pattern === "*") + ? t("ui.approval.rule", { permission: rule.permission, pattern: rule.pattern }) + : undefined return { approval, decision: approval.source === "manual" ? t("ui.approval.manual") : t("ui.approval.auto"), source: sourceText(), - rule: approval.rule - ? t("ui.approval.rule", { permission: approval.rule.permission, pattern: approval.rule.pattern }) - : undefined, + rule: ruleText, } } @@ -76,9 +81,7 @@ export function ToolApprovalLine(props: { display: ToolApprovalDisplay }) {
{props.display.decision} - - {(text) => {text()}} - + {(text) => {text()}} {(text) => {text()}}
diff --git a/packages/kilo-ui/src/context/clipboard.tsx b/packages/kilo-ui/src/context/clipboard.tsx new file mode 100644 index 00000000000..3cc19dfb861 --- /dev/null +++ b/packages/kilo-ui/src/context/clipboard.tsx @@ -0,0 +1,17 @@ +import { createContext, type ParentComponent, useContext } from "solid-js" + +type ClipboardContextValue = { + write: (text: string) => void | Promise +} + +const ClipboardContext = createContext({ + write: (text) => navigator.clipboard.writeText(text), +}) + +export const ClipboardProvider: ParentComponent = (props) => ( + {props.children} +) + +export function useClipboard() { + return useContext(ClipboardContext) +} diff --git a/packages/kilo-vscode/bunfig.toml b/packages/kilo-vscode/bunfig.toml index 2c6410f31e1..0ae571971a2 100644 --- a/packages/kilo-vscode/bunfig.toml +++ b/packages/kilo-vscode/bunfig.toml @@ -1,2 +1,2 @@ [test] -preload = ["./tests/setup/vscode-mock.ts"] +preload = ["./tests/setup/vscode-mock.ts", "./tests/setup/worker-url.ts"] diff --git a/packages/kilo-vscode/esbuild.js b/packages/kilo-vscode/esbuild.js index db068238ede..eed8e2475b2 100644 --- a/packages/kilo-vscode/esbuild.js +++ b/packages/kilo-vscode/esbuild.js @@ -77,6 +77,26 @@ const pierreWorkerAliasPlugin = { }, } +/** + * Replace Markdown's Vite-only worker URL import with the URI injected by the + * extension host. The worker itself is emitted as a separate dist asset below. + * + * @type {import('esbuild').Plugin} + */ +const markdownWorkerUrlPlugin = { + name: "markdown-worker-url", + setup(build) { + build.onResolve({ filter: /markdown-shiki\.worker\.ts\?worker&url$/ }, () => ({ + path: "markdown-shiki-worker-url", + namespace: "kilo-worker-url", + })) + build.onLoad({ filter: /.*/, namespace: "kilo-worker-url" }, () => ({ + contents: "export default window.KILO_MARKDOWN_SHIKI_WORKER_URI", + loader: "js", + })) + }, +} + /** * Resolve the synthetic `kilo-shiki-worker` entry point to Pierre's Shiki worker * so esbuild can bundle it (and its inlined oniguruma WebAssembly) into a single @@ -159,6 +179,7 @@ function createBrowserWebviewContext(entryPoint, outfile) { plugins: [ solidDedupePlugin, pierreWorkerAliasPlugin, + markdownWorkerUrlPlugin, svgSpritePlugin, cssPackageResolvePlugin, solidPlugin(), @@ -184,6 +205,21 @@ function createShikiWorkerContext() { }) } +function createMarkdownShikiWorkerContext() { + return esbuild.context({ + entryPoints: [path.join(__dirname, "..", "ui", "src", "components", "markdown-shiki.worker.ts")], + bundle: true, + format: "esm", + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: "browser", + outfile: "dist/markdown-shiki-worker.js", + logLevel: "silent", + plugins: [esbuildProblemMatcherPlugin], + }) +} + async function main() { // Build extension const extensionCtx = await esbuild.context({ @@ -229,6 +265,7 @@ async function main() { // Build the shared Shiki highlighting worker asset const shikiWorkerCtx = await createShikiWorkerContext() + const markdownShikiWorkerCtx = await createMarkdownShikiWorkerContext() if (watch) { await Promise.all([ @@ -240,6 +277,7 @@ async function main() { kiloClawCtx.watch(), marketplaceCtx.watch(), shikiWorkerCtx.watch(), + markdownShikiWorkerCtx.watch(), ]) } else { await Promise.all([ @@ -251,6 +289,7 @@ async function main() { diffViewerCtx.rebuild(), diffVirtualCtx.rebuild(), shikiWorkerCtx.rebuild(), + markdownShikiWorkerCtx.rebuild(), ]) await Promise.all([ extensionCtx.dispose(), @@ -261,6 +300,7 @@ async function main() { kiloClawCtx.dispose(), marketplaceCtx.dispose(), shikiWorkerCtx.dispose(), + markdownShikiWorkerCtx.dispose(), ]) } } diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index e67360acecc..45d6fbeb525 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -804,13 +804,13 @@ "command": "kilo-code.new.cycleAgentMode", "key": "ctrl+.", "mac": "cmd+.", - "when": "sideBarFocus && kilo-code.new.sidebarVisible || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'" + "when": "kilo-code.new.sidebarFocused || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'" }, { "command": "kilo-code.new.cyclePreviousAgentMode", "key": "ctrl+shift+.", "mac": "cmd+shift+.", - "when": "sideBarFocus && kilo-code.new.sidebarVisible || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'" + "when": "kilo-code.new.sidebarFocused || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'" }, { "command": "kilo-code.new.autocomplete.cancelSuggestions", @@ -1016,6 +1016,20 @@ "scope": "application", "description": "Prefix for automatically named Agent Manager branches, for example 'marius/' or 'feature/'. Explicit branch names are unchanged." }, + "kilo-code.new.agentManager.terminalButtonDestination": { + "type": "string", + "scope": "application", + "default": "vscode", + "enum": [ + "vscode", + "agentManager" + ], + "enumDescriptions": [ + "Open or focus the VS Code integrated terminal.", + "Open or focus an embedded terminal in the Agent Manager side panel." + ], + "description": "Choose where the Agent Manager terminal button and Focus Terminal keyboard shortcut open a terminal." + }, "kilo-code.new.indexing.showButtonWhenDisabled": { "type": "boolean", "default": true, diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 72f6f4378f3..48817cf9018 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -741,6 +741,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private setSidebarVisible(visible: boolean): void { this.setStreamVisibility(visible) vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", visible) + if (!visible && this.opts.focusContext) { + void vscode.commands.executeCommand("setContext", this.opts.focusContext, false) + } } /** Resolve a WebviewPanel for displaying Kilo in an editor tab. */ @@ -949,6 +952,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper dir: this.getWorkspaceDirectory(this.currentSession?.id), post: (msg) => this.postMessage(msg), exportTranscript: (sessionID) => this.handleExportSessionTranscript(sessionID), + copy: (text) => vscode.env.clipboard.writeText(text), openSessions: (ids) => this.trackOpenSessions(ids), }) ) { @@ -981,6 +985,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } if (await this.handleModelSelectorExpandedMessage(message)) return + this.handleWebviewFocusMessage(message) this.visibleTaskStreams.handle(message) if (await this.handleMemoryMessage(message)) return if (this.handleLegacyMigrationMessage(message)) return @@ -1466,6 +1471,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.webviewMessageDisposable = watchWorkStyleConfig((msg) => this.postMessage(msg), this.webviewMessageDisposable) } + private handleWebviewFocusMessage(message: TypedWebviewMessage & { focused?: unknown }): void { + if (message.type !== "webviewFocusChanged") return + if (this.opts.focusContext) { + void vscode.commands.executeCommand("setContext", this.opts.focusContext, message.focused === true) + } + } + private handleEditorOpenMessage(message: Parameters[0]): boolean { return handleEditorAction(message, { dir: () => this.getWorkspaceDirectory(this.currentSession?.id), @@ -4559,6 +4571,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * Does NOT kill the server — that's the connection service's job. */ dispose(): void { + if (this.opts.focusContext) { + void vscode.commands.executeCommand("setContext", this.opts.focusContext, false) + } this.unsubscribeRemote?.() this.streams.focus(undefined) this.connectionService.unregisterVisible(this.instanceId) diff --git a/packages/kilo-vscode/src/SettingsEditorProvider.ts b/packages/kilo-vscode/src/SettingsEditorProvider.ts index 94d2536dfd2..84dc741d6d2 100644 --- a/packages/kilo-vscode/src/SettingsEditorProvider.ts +++ b/packages/kilo-vscode/src/SettingsEditorProvider.ts @@ -64,7 +64,7 @@ export class SettingsEditorProvider implements vscode.Disposable { const provider = this.providers.get(view) provider?.postMessage({ type: "navigate", view, tab }) } - existing.reveal(vscode.ViewColumn.One) + existing.reveal(vscode.ViewColumn.Active) this.providers.get(view)?.postMessage({ type: "navigate", view, ...(tab ? { tab } : {}) }) return } @@ -72,7 +72,7 @@ export class SettingsEditorProvider implements vscode.Disposable { const panel = vscode.window.createWebviewPanel( `kilo-code.new.${view}Panel`, PANEL_TITLES[view], - vscode.ViewColumn.One, + vscode.ViewColumn.Active, { enableScripts: true, retainContextWhenHidden: true, diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 6e480cc212a..bf72af74ab8 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -46,6 +46,7 @@ import { pruneSubagents } from "./prune-subagents" import { startSession } from "./mcp-warmup" import { readTerminalFont, watchTerminalFont } from "./terminal-font" +import { readTerminalDestination, watchTerminalDestination } from "./terminal-destination" import { buildKeybindingMap } from "./format-keybinding" import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version" import { ensureSandbox } from "./sandbox-bootstrap" @@ -78,6 +79,7 @@ export class AgentManagerProvider implements Disposable { private unsubTool: (() => void) | undefined private unsubStatus: (() => void) | undefined private unsubFont: (() => void) | undefined + private unsubDestination: (() => void) | undefined private closing: Promise | undefined private onVisibilityChange: ((visible: boolean) => void) | undefined // Tracks sessions owned by this panel until they are explicitly closed. @@ -111,6 +113,9 @@ export class AgentManagerProvider implements Disposable { this.unsubFont = watchTerminalFont((font) => { this.postToWebview({ type: "agentManager.terminal.fontChanged", font }) }) + this.unsubDestination = watchTerminalDestination((destination) => { + this.postToWebview({ type: "agentManager.terminal.destinationChanged", destination }) + }) this.run = new RunController({ root: () => this.getRoot(), state: () => this.getStateManager(), @@ -304,6 +309,7 @@ export class AgentManagerProvider implements Disposable { this.activeSessionId = undefined this.visiblePresence.clear() this.panel = undefined + void this.terminalRouter.dispose() this.onVisibilityChange?.(false) } ctx.sessions.dispose() @@ -661,10 +667,6 @@ export class AgentManagerProvider implements Disposable { void this.importer.branches() return null } - if (m.type === "agentManager.requestExternalWorktrees") { - void this.importer.external() - return null - } if (m.type === "agentManager.importFromBranch") { void this.importer.branch(m.branch) return null @@ -673,14 +675,6 @@ export class AgentManagerProvider implements Disposable { void this.importer.pr(m.url) return null } - if (m.type === "agentManager.importExternalWorktree") { - void this.importer.path(m.path, m.branch) - return null - } - if (m.type === "agentManager.importAllExternalWorktrees") { - void this.importer.all() - return null - } } private onDiffMessage(m: AgentManagerInMessage): Record | null | undefined { @@ -868,7 +862,7 @@ export class AgentManagerProvider implements Disposable { } } - /** Send worktreeSetup.ready + sessionMeta + pushState after worktree creation. */ + /** Send worktreeSetup.ready + pushState after worktree creation. */ private notifyWorktreeReady(sessionId: string, result: CreateWorktreeResult, worktreeId?: string): void { this.pushState() this.postToWebview({ @@ -879,14 +873,6 @@ export class AgentManagerProvider implements Disposable { branch: result.branch, worktreeId, }) - this.postToWebview({ - type: "agentManager.sessionMeta", - sessionId, - mode: "worktree", - branch: result.branch, - path: result.path, - parentBranch: result.parentBranch, - }) } private async waitForStateReady(context: string): Promise { @@ -907,8 +893,6 @@ export class AgentManagerProvider implements Disposable { case "agentManager.requestBranches": case "agentManager.importFromBranch": case "agentManager.importFromPR": - case "agentManager.importExternalWorktree": - case "agentManager.importAllExternalWorktrees": case "agentManager.setTabOrder": case "agentManager.setWorktreeOrder": case "agentManager.setSessionsCollapsed": @@ -922,6 +906,7 @@ export class AgentManagerProvider implements Disposable { case "agentManager.toggleSectionCollapsed": case "agentManager.moveToSection": case "agentManager.moveSection": + case "agentManager.terminal.create": return true default: return false @@ -1339,6 +1324,7 @@ export class AgentManagerProvider implements Disposable { this.registerWorktreeSession(session.id, wt.result.path) this.notifyWorktreeReady(session.id, wt.result, wt.worktree.id) + this.panel?.sessions.registerSession(session) // Set the per-version model immediately so the UI selector reflects // the correct model as soon as the worktree appears, before Phase 2. @@ -1623,6 +1609,7 @@ export class AgentManagerProvider implements Disposable { sidebarCollapsed: state.getSidebarCollapsed(), reviewDiffStyle: state.getReviewDiffStyle(), reviewMarkdownRender: getDiffMarkdownRender(), + terminalDestination: readTerminalDestination(), isGitRepo: true, defaultBaseBranch: state.getDefaultBaseBranch(), ...run, @@ -1645,6 +1632,7 @@ export class AgentManagerProvider implements Disposable { staleWorktreeIds: [], reviewDiffStyle: "unified", reviewMarkdownRender: getDiffMarkdownRender(), + terminalDestination: readTerminalDestination(), isGitRepo: false, runStatuses: [], runScriptConfigured: false, @@ -1922,6 +1910,7 @@ export class AgentManagerProvider implements Disposable { this.unsubTool?.() this.unsubStatus?.() this.unsubFont?.() + this.unsubDestination?.() this.orchestration.dispose() this.visiblePresence.clear() this.diffs.stop() diff --git a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts index efc8b54f429..6120ba6a1cb 100644 --- a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts +++ b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts @@ -174,18 +174,6 @@ export class SessionTerminalManager { return true } - /** - * Check if a session has an active terminal. - */ - hasTerminal(sessionId: string): boolean { - const entry = this.terminals.get(sessionId) - return entry !== undefined && entry.terminal.exitStatus === undefined - } - - hasActiveTerminal(): boolean { - return this.host.activeTerminal() !== undefined - } - activeSession(): string | undefined { const active = this.host.activeTerminal() if (!active) return undefined diff --git a/packages/kilo-vscode/src/agent-manager/SetupScriptService.ts b/packages/kilo-vscode/src/agent-manager/SetupScriptService.ts index f579d0779c0..16fb48d93f6 100644 --- a/packages/kilo-vscode/src/agent-manager/SetupScriptService.ts +++ b/packages/kilo-vscode/src/agent-manager/SetupScriptService.ts @@ -66,18 +66,6 @@ export class SetupScriptService { return this.resolveScript(platform) !== undefined } - /** Read the setup script content. Returns null if not found or read fails. */ - async getScript(platform: NodeJS.Platform = process.platform): Promise { - const script = this.resolveScript(platform) - if (!script) return null - try { - return await fs.promises.readFile(script.path, "utf-8") - } catch (error) { - this.log(`Failed to read setup script: ${error}`) - return null - } - } - /** Create a default setup script with helpful comments for the current platform. */ async createDefaultScript(platform: NodeJS.Platform = process.platform): Promise { if (!fs.existsSync(this.dir)) { diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index 96b3bcceb3e..32f2fb1b9f9 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -67,11 +67,6 @@ export interface CreateWorktreeResult { startPointWarning?: string } -export interface ExternalWorktreeItem { - path: string - branch: string -} - /** * Backward compat: split a possibly-prefixed branch like "origin/main" into * `{ branch: "main", remote: "origin" }`. If no slash is found, returns bare branch. @@ -815,7 +810,7 @@ export class WorktreeManager { // 4. Derived fallback if (allowFallback) { - const fallbacks = await this.derivedFallbackBranches(branch) + const fallbacks = await this.derivedFallbackBranches() for (const fallback of fallbacks) { if (fallback === branch) continue // already tried try { @@ -863,7 +858,7 @@ export class WorktreeManager { } } - async derivedFallbackBranches(requested: string): Promise { + async derivedFallbackBranches(): Promise { const defaults = [] try { defaults.push(await this.defaultBranch()) @@ -1015,22 +1010,6 @@ export class WorktreeManager { } } - async listExternalWorktrees(managedPaths: Set): Promise { - try { - const raw = await this.git.raw(["worktree", "list", "--porcelain"]) - const normalizedRoot = normalizePath(this.root) - const normalizedManaged = new Set([...managedPaths].map(normalizePath)) - return parseWorktreeList(raw) - .filter( - (e) => !e.bare && normalizePath(e.path) !== normalizedRoot && !normalizedManaged.has(normalizePath(e.path)), - ) - .map((e) => ({ path: e.path, branch: e.branch })) - } catch (error) { - this.log(`Failed to list external worktrees: ${error}`) - return [] - } - } - async createFromPR(url: string): Promise { return this.withGitLock(() => this.createFromPRImpl(url)) } diff --git a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts index 05352e380a7..045a108fafc 100644 --- a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts +++ b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts @@ -77,7 +77,6 @@ function createMockHost(): Host { openFolder: vi.fn(), createOutput: () => ({ appendLine: vi.fn(), dispose: vi.fn() }) as OutputHandle, extensionKeybindings: () => [], - serverPort: () => undefined, capture: vi.fn(), dispose: vi.fn(), } diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index 193d77cfcbd..ed8b1f2f633 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -125,9 +125,6 @@ export interface Host { /** Read extension keybinding metadata. */ extensionKeybindings(): Array<{ command: string; key?: string; mac?: string }> - /** Get the CLI server port (for webview CSP). */ - serverPort(): number | undefined - /** Copy text to the system clipboard. */ copyToClipboard(text: string): void diff --git a/packages/kilo-vscode/src/agent-manager/terminal-destination.ts b/packages/kilo-vscode/src/agent-manager/terminal-destination.ts new file mode 100644 index 00000000000..f6fbceb5911 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/terminal-destination.ts @@ -0,0 +1,37 @@ +/** + * Read and watch the user's Agent Manager terminal destination setting. + * + * The terminal button and `Cmd/Ctrl+/` either open the VS Code integrated + * terminal (default, backwards compatible) or an embedded xterm in the + * Agent Manager side panel. Kept next to `terminal-font.ts`; a separate + * module so the font helpers stay untouched. + */ + +import * as vscode from "vscode" + +export type TerminalDestination = "vscode" | "agentManager" + +const KEY = "kilo-code.new.agentManager.terminalButtonDestination" + +/** Unknown values fall back to the VS Code terminal so a stale or + * hand-edited setting never strands the user without a terminal. */ +export function resolveTerminalDestination(value: unknown): TerminalDestination { + return value === "agentManager" ? value : "vscode" +} + +export function readTerminalDestination(): TerminalDestination { + const config = vscode.workspace.getConfiguration("kilo-code.new.agentManager") + return resolveTerminalDestination(config.get("terminalButtonDestination")) +} + +export function affectsTerminalDestination(e: vscode.ConfigurationChangeEvent): boolean { + return e.affectsConfiguration(KEY) +} + +/** Subscribe to destination changes. Returns a cleanup function. */ +export function watchTerminalDestination(callback: (destination: TerminalDestination) => void): () => void { + const sub = vscode.workspace.onDidChangeConfiguration((e) => { + if (affectsTerminalDestination(e)) callback(readTerminalDestination()) + }) + return () => sub.dispose() +} diff --git a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts index 7c8c7e46cc2..566952f9b21 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts @@ -7,7 +7,7 @@ * Owns: * - the `TerminalManager` lifecycle (create / close / resize / dispose) * - the per-context "Terminal N" ordinal counter - * - cwd resolution (worktree path → workspace root fallback) + * - cwd resolution (selected worktree or workspace root) * - WebSocket URL construction with loopback `auth_token` auth * * Vscode-free: all VS Code access is funnelled through the `deps` @@ -15,7 +15,7 @@ */ import type { KiloClient } from "@kilocode/sdk/v2/client" -import type { AgentManagerInMessage, AgentManagerOutMessage, TerminalFont } from "./types" +import type { AgentManagerInMessage, AgentManagerOutMessage, TerminalFont, TerminalPlacement } from "./types" import { TerminalManager } from "./terminal-manager" interface ServerConfig { @@ -52,14 +52,19 @@ function isTerminalMessage( } export class TerminalRouter { - private readonly manager: TerminalManager + private manager: TerminalManager private readonly ordinals = new Map() + private generation = 0 constructor(private readonly deps: TerminalRoutingDeps) { - this.manager = new TerminalManager({ - getClient: () => deps.getClient(), + this.manager = this.createManager() + } + + private createManager(): TerminalManager { + return new TerminalManager({ + getClient: () => this.deps.getClient(), buildWsUrl: (ptyID, cwd) => this.buildWsUrl(ptyID, cwd), - log: deps.log, + log: this.deps.log, }) } @@ -71,7 +76,7 @@ export class TerminalRouter { handle(m: AgentManagerInMessage): boolean { if (!isTerminalMessage(m)) return false if (m.type === "agentManager.terminal.create") { - void this.handleCreate(m.worktreeId) + void this.handleCreate(m.createId, m.placement, m.worktreeId) return true } if (m.type === "agentManager.terminal.close") { @@ -85,25 +90,44 @@ export class TerminalRouter { return true } - /** Tear down every live PTY. Forwards to `TerminalManager.dispose`. */ + /** + * Tear down every live PTY and invalidate in-flight create requests. + * The router stays usable afterwards: a create landing from before the + * disposal is closed immediately instead of leaking a PTY the webview + * no longer tracks. + */ dispose(): Promise { - return this.manager.dispose() + this.generation++ + const manager = this.manager + this.manager = this.createManager() + return manager.dispose() } - private async handleCreate(worktreeId: string | null): Promise { + private async handleCreate(createId: string, placement: TerminalPlacement, worktreeId: string | null): Promise { + const generation = this.generation + const manager = this.manager const cwd = this.resolveCwd(worktreeId) if (!cwd) { this.deps.post({ type: "agentManager.terminal.error", - message: "Open a folder before creating a terminal", + createId, + message: worktreeId + ? "The selected worktree is no longer available" + : "Open a folder before creating a terminal", }) return } const title = `Terminal ${this.nextOrdinal(worktreeId)}` try { - const created = await this.manager.create({ worktreeId, cwd, title }) + const created = await manager.create({ worktreeId, cwd, title }) + if (generation !== this.generation) { + await manager.close(created.terminalId) + return + } this.deps.post({ type: "agentManager.terminal.created", + createId, + placement, worktreeId: created.worktreeId, terminalId: created.terminalId, title: created.title, @@ -111,22 +135,25 @@ export class TerminalRouter { font: this.deps.getTerminalFont(), }) } catch (err) { + if (generation !== this.generation) return const message = err instanceof Error ? err.message : String(err) this.deps.log(`Terminal create failed: ${message}`) - this.deps.post({ type: "agentManager.terminal.error", message }) + this.deps.post({ type: "agentManager.terminal.error", createId, message }) } } /** * Resolve the cwd for a terminal in the given context. * - * LOCAL (null) falls back to the workspace root; a worktree id - * resolves to its on-disk path. Returns undefined when no folder is - * open — the caller surfaces this as a user-facing error. + * LOCAL (null) uses the workspace root; a worktree id resolves strictly + * to its on-disk path — silently falling back to the workspace root + * would run the shell in the wrong directory. Returns undefined when + * no folder is open or the worktree is gone; the caller surfaces this + * as a user-facing error. */ private resolveCwd(worktreeId: string | null): string | undefined { if (worktreeId === null) return this.deps.getRoot() - return this.deps.getWorktreePath(worktreeId) ?? this.deps.getRoot() + return this.deps.getWorktreePath(worktreeId) } /** Per-context counter so default titles are "Terminal 1", "Terminal 2"… diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 63ade03fc94..8950c68d22f 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -13,18 +13,19 @@ import type { Worktree, ManagedSession, Section } from "./WorktreeStateManager" import type { WorktreeStats, LocalStats } from "./GitStatsPoller" import type { ApplyConflict } from "./GitOps" import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import" -import type { ExternalWorktreeItem } from "./WorktreeManager" import type { RunStatus } from "./run/manager" import type { TerminalFont } from "./terminal-font" +import type { TerminalDestination } from "./terminal-destination" export type { TerminalFont } +/** Where a terminal lives: main tab strip or right-side inspector panel. */ +export type TerminalPlacement = "tab" | "side" + // --------------------------------------------------------------------------- // Shared payload types // --------------------------------------------------------------------------- -type SessionMode = "worktree" | "local" - export type ApplyDiffStatus = "checking" | "applying" | "success" | "conflict" | "error" export type WorktreeDiffEntry = SnapshotFileDiff & { @@ -114,15 +115,6 @@ interface WorktreeSetupMessage { errorCode?: WorktreeSetupErrorCode } -interface SessionMetaMessage { - type: "agentManager.sessionMeta" - sessionId: string - mode: SessionMode - branch?: string - path?: string - parentBranch?: string -} - interface StateMessage { type: "agentManager.state" worktrees: Worktree[] @@ -140,6 +132,7 @@ interface StateMessage { runStatuses?: RunStatus[] runScriptConfigured?: boolean runScriptPath?: string + terminalDestination?: TerminalDestination } // --------------------------------------------------------------------------- @@ -148,6 +141,11 @@ interface StateMessage { interface TerminalCreatedMessage { type: "agentManager.terminal.created" + /** Correlates with the create request; lets the webview spot stale + * creates. Deliberately not named `requestId`: that field name is the + * generic webview request/response correlation channel. */ + createId: string + placement: TerminalPlacement /** null for LOCAL, worktree id otherwise */ worktreeId: string | null terminalId: string @@ -164,9 +162,16 @@ interface TerminalClosedMessage { interface TerminalErrorMessage { type: "agentManager.terminal.error" terminalId?: string + /** Set when the error answers a specific create request. */ + createId?: string message: string } +interface TerminalDestinationChangedMessage { + type: "agentManager.terminal.destinationChanged" + destination: TerminalDestination +} + interface TerminalFontChangedMessage { type: "agentManager.terminal.fontChanged" font: TerminalFont @@ -228,11 +233,6 @@ interface BranchesMessage { defaultBranch: string } -interface ExternalWorktreesMessage { - type: "agentManager.externalWorktrees" - worktrees: ExternalWorktreeItem[] -} - interface ImportResultMessage { type: "agentManager.importResult" success: boolean @@ -307,7 +307,6 @@ export type AgentManagerOutMessage = | WorktreeStatsMessage | LocalStatsMessage | WorktreeSetupMessage - | SessionMetaMessage | StateMessage | ErrorOutMessage | SessionAddedMessage @@ -317,7 +316,6 @@ export type AgentManagerOutMessage = | SetSessionModelMessage | SendInitialMessage | BranchesMessage - | ExternalWorktreesMessage | ImportResultMessage | KeybindingsMessage | RepoInfoMessage @@ -332,6 +330,7 @@ export type AgentManagerOutMessage = | TerminalCreatedMessage | TerminalClosedMessage | TerminalErrorMessage + | TerminalDestinationChangedMessage | TerminalFontChangedMessage // --------------------------------------------------------------------------- @@ -500,10 +499,6 @@ interface SetDefaultBaseBranchIn { branch?: string } -interface RequestExternalWorktreesIn { - type: "agentManager.requestExternalWorktrees" -} - interface ImportFromBranchIn { type: "agentManager.importFromBranch" branch: string @@ -514,16 +509,6 @@ interface ImportFromPRIn { url: string } -interface ImportExternalWorktreeIn { - type: "agentManager.importExternalWorktree" - path: string - branch: string -} - -interface ImportAllExternalWorktreesIn { - type: "agentManager.importAllExternalWorktrees" -} - interface RequestWorktreeDiffIn { type: "agentManager.requestWorktreeDiff" sessionId: string @@ -758,6 +743,9 @@ interface MoveSectionIn { interface TerminalCreateIn { type: "agentManager.terminal.create" + /** Webview-generated correlation id, echoed back in created/error. */ + createId: string + placement: TerminalPlacement /** null for LOCAL, worktree id otherwise */ worktreeId: string | null } @@ -807,11 +795,8 @@ export type AgentManagerInMessage = | SetReviewDiffStyleIn | SetReviewMarkdownRenderIn | SetDefaultBaseBranchIn - | RequestExternalWorktreesIn | ImportFromBranchIn | ImportFromPRIn - | ImportExternalWorktreeIn - | ImportAllExternalWorktreesIn | RequestWorktreeDiffIn | RequestWorktreeDiffFileIn | ApplyWorktreeDiffIn diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index 69dd31a858e..fc5dfd01078 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -6,7 +6,7 @@ */ import * as vscode from "vscode" -import type { Host, PanelContext, OutputHandle, SessionProvider, Disposable } from "./host" +import type { Host, PanelContext, OutputHandle, SessionProvider } from "./host" import type { KiloConnectionService } from "../services/cli-backend" import { KiloProvider } from "../KiloProvider" import { PLATFORM, SNAPSHOT_INITIALIZATION } from "./constants" @@ -216,10 +216,6 @@ export class VscodeHost implements Host { return ext?.packageJSON?.contributes?.keybindings ?? [] } - serverPort(): number | undefined { - return this.connectionService.getServerInfo()?.port - } - copyToClipboard(text: string): void { void vscode.env.clipboard.writeText(text) } diff --git a/packages/kilo-vscode/src/agent-manager/worktree-importer.ts b/packages/kilo-vscode/src/agent-manager/worktree-importer.ts index cfa3d1ab96c..d840f67eb55 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-importer.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-importer.ts @@ -2,9 +2,7 @@ import type { Session } from "@kilocode/sdk/v2/client" import type { AgentManagerOutMessage } from "./types" import type { WorktreeManager, CreateWorktreeResult } from "./WorktreeManager" import type { WorktreeStateManager } from "./WorktreeStateManager" -import { classifyWorktreeError, normalizePath } from "./git-import" - -type Worktree = ReturnType +import { classifyWorktreeError } from "./git-import" export interface WorktreeImporterHost { manager(): WorktreeManager | undefined @@ -57,24 +55,6 @@ export class WorktreeImporter { } } - async external(): Promise { - const manager = this.host.manager() - const state = this.host.state() - if (!manager || !state) { - this.host.post({ type: "agentManager.externalWorktrees", worktrees: [] }) - return - } - - try { - const paths = new Set(state.getWorktrees().map((worktree) => worktree.path)) - const worktrees = await manager.listExternalWorktrees(paths) - this.host.post({ type: "agentManager.externalWorktrees", worktrees }) - } catch (error) { - this.host.log(`Failed to list external worktrees: ${error}`) - this.host.post({ type: "agentManager.externalWorktrees", worktrees: [] }) - } - } - async branch(branch: string): Promise { const manager = this.host.manager() const state = this.host.state() @@ -189,136 +169,6 @@ export class WorktreeImporter { } } - async path(path: string, branch: string): Promise { - const state = this.host.state() - const manager = this.host.manager() - if (!state || !manager) { - this.host.post({ type: "agentManager.importResult", success: false, message: "State not initialized" }) - return - } - if (this.busy()) return - - this.importing = true - let worktree: Worktree | undefined - try { - const paths = new Set(state.getWorktrees().map((worktree) => worktree.path)) - const externals = await manager.listExternalWorktrees(paths) - if (!externals.some((worktree) => normalizePath(worktree.path) === normalizePath(path))) { - this.host.post({ - type: "agentManager.importResult", - success: false, - message: "Path is not a valid worktree for this repository", - }) - return - } - - const base = await manager.resolveBaseBranch() - worktree = state.addWorktree({ branch, path, parentBranch: base.branch, remote: base.remote, branchOwned: false }) - this.host.push() - - const session = await this.host.session(path, branch, worktree.id) - if (!session) { - state.removeWorktree(worktree.id) - this.host.push() - this.host.post({ type: "agentManager.importResult", success: false, message: "Failed to create session" }) - return - } - - state.addSession(session.id, worktree.id) - this.host.register(session.id, path) - this.host.push() - this.host.post({ - type: "agentManager.worktreeSetup", - status: "ready", - message: "Worktree imported", - sessionId: session.id, - branch, - worktreeId: worktree.id, - }) - this.host.post({ - type: "agentManager.sessionMeta", - sessionId: session.id, - mode: "worktree", - branch, - path, - parentBranch: base.branch, - }) - this.host.post({ type: "agentManager.importResult", success: true, message: `Imported ${branch}` }) - this.host.log(`Imported external worktree ${path} (${branch})`) - } catch (error) { - if (worktree) { - state.removeWorktree(worktree.id) - this.host.push() - } - const message = error instanceof Error ? error.message : String(error) - this.host.post({ type: "agentManager.importResult", success: false, message }) - } finally { - this.importing = false - } - } - - async all(): Promise { - if (this.busy()) return - - const manager = this.host.manager() - const state = this.host.state() - if (!manager || !state) { - this.host.post({ type: "agentManager.importResult", success: false, message: "Not a git repository" }) - return - } - this.importing = true - - try { - const paths = new Set(state.getWorktrees().map((worktree) => worktree.path)) - const externals = await manager.listExternalWorktrees(paths) - if (externals.length === 0) { - this.host.post({ - type: "agentManager.importResult", - success: true, - message: "No external worktrees to import", - }) - return - } - - const imported: string[] = [] - const base = await manager.resolveBaseBranch() - for (const external of externals) { - try { - const worktree = state.addWorktree({ - branch: external.branch, - path: external.path, - parentBranch: base.branch, - remote: base.remote, - branchOwned: false, - }) - const session = await this.host.session(external.path, external.branch, worktree.id) - if (session) { - state.addSession(session.id, worktree.id) - this.host.register(session.id, external.path) - imported.push(worktree.id) - continue - } - state.removeWorktree(worktree.id) - } catch (error) { - this.host.log(`Failed to import external worktree ${external.path}: ${error}`) - } - } - - this.host.push() - this.host.post({ - type: "agentManager.importResult", - success: true, - message: `Imported ${imported.length} worktree${imported.length !== 1 ? "s" : ""}`, - }) - this.host.log(`Imported ${imported.length}/${externals.length} external worktrees`) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - this.host.post({ type: "agentManager.importResult", success: false, message }) - } finally { - this.importing = false - } - } - private busy(): boolean { if (!this.importing) return false this.host.post({ diff --git a/packages/kilo-vscode/src/diff/sources/session.ts b/packages/kilo-vscode/src/diff/sources/session.ts index 58fc6d8bdd4..924c232659d 100644 --- a/packages/kilo-vscode/src/diff/sources/session.ts +++ b/packages/kilo-vscode/src/diff/sources/session.ts @@ -83,7 +83,15 @@ export function toSessionDiffFile(raw: SnapshotFileDiff): DiffFile { // Empty patch means binary or summarized (>256 KB) — normalize() can't // parse it, so short-circuit to empty strings. Binary snapshot images do // not retain their sides, while text-backed SVG patches can be rebuilt. - const view = raw.patch === "" || (mime && mime !== "image/svg+xml") ? null : normalize(raw) + const view = (() => { + if (raw.patch === "" || (mime && mime !== "image/svg+xml")) return null + try { + return normalize(raw) + } catch (err) { + console.warn("[Kilo New] Failed to parse session diff", { file, err }) + return null + } + })() const before = view ? text(view, "deletions") : "" const after = view ? text(view, "additions") : "" const image = (() => { @@ -108,7 +116,8 @@ export function toSessionDiffFile(raw: SnapshotFileDiff): DiffFile { generatedLike: false, // A zero-stat empty patch has no text body to fetch; nonzero stats // indicate a deferred large-file summary. - summarized: !mime && raw.patch === "" && (raw.additions !== 0 || raw.deletions !== 0), + summarized: + !mime && ((!view && raw.patch !== "") || (raw.patch === "" && (raw.additions !== 0 || raw.deletions !== 0))), kind: mime ? "image" : undefined, image, stamp: mime ? fingerprint(raw) : undefined, diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index b4467fb347b..c750637d18b 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -122,7 +122,9 @@ export function activate(context: vscode.ExtensionContext) { } // Create the provider with shared service - const provider = new KiloProvider(context.extensionUri, connectionService, context) + const provider = new KiloProvider(context.extensionUri, connectionService, context, { + focusContext: "kilo-code.new.sidebarFocused", + }) provider.setRemoteService(remoteService) // Register the webview view provider for the sidebar. @@ -592,7 +594,7 @@ export async function deactivate() { TelemetryProxy.getInstance().shutdown() } -async function openKiloInNewTab( +function openKiloInNewTab( context: vscode.ExtensionContext, connectionService: KiloConnectionService, agentManagerProvider: AgentManagerProvider, @@ -601,20 +603,16 @@ async function openKiloInNewTab( remoteService: RemoteStatusService, autoApprove: ReturnType, ) { - const lastCol = Math.max(...vscode.window.visibleTextEditors.map((e) => e.viewColumn || 0), 0) - const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0 - - if (!hasVisibleEditors) { - await vscode.commands.executeCommand("workbench.action.newGroupRight") - } - - const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two - - const panel = vscode.window.createWebviewPanel("kilo-code.new.TabPanel", EXTENSION_DISPLAY_NAME, targetCol, { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [context.extensionUri], - }) + const panel = vscode.window.createWebviewPanel( + "kilo-code.new.TabPanel", + EXTENSION_DISPLAY_NAME, + vscode.ViewColumn.Active, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [context.extensionUri], + }, + ) panel.iconPath = { light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "kilo-light.svg"), @@ -636,11 +634,6 @@ async function openKiloInNewTab( tabProvider.resolveWebviewPanel(panel) tabPanels.set(panel, tabProvider) - // Wait for the new panel to become active before locking the editor group. - // This avoids the race where VS Code hasn't switched focus yet. - await waitForWebviewPanelToBeActive(panel) - await vscode.commands.executeCommand("workbench.action.lockEditorGroup") - panel.onDidDispose( () => { console.log("[Kilo New] Tab panel disposed") @@ -671,19 +664,3 @@ function ensureCommandsSkipShell(commands: string[]): void { if (missing.length === 0) return config.update("commandsToSkipShell", [...existing, ...missing], target) } - -function waitForWebviewPanelToBeActive(panel: vscode.WebviewPanel): Promise { - if (panel.active) { - return Promise.resolve() - } - - return new Promise((resolve) => { - const disposable = panel.onDidChangeViewState((event) => { - if (!event.webviewPanel.active) { - return - } - disposable.dispose() - resolve() - }) - }) -} diff --git a/packages/kilo-vscode/src/kilo-provider/early-message.ts b/packages/kilo-vscode/src/kilo-provider/early-message.ts index 67cb6634b2f..1273d65584e 100644 --- a/packages/kilo-vscode/src/kilo-provider/early-message.ts +++ b/packages/kilo-vscode/src/kilo-provider/early-message.ts @@ -12,10 +12,32 @@ type Ctx = { dir: string post: (msg: unknown) => void exportTranscript: (sessionID: string) => Promise + copy: (text: string) => PromiseLike openSessions: (ids: string[]) => void } -export async function routeEarlyMessage(message: { type: string }, ctx: Ctx): Promise { +export async function routeEarlyMessage( + message: { type: string; id?: unknown; text?: unknown }, + ctx: Ctx, +): Promise { + if (message.type === "copyToClipboard") { + if (typeof message.id !== "string") return true + if (typeof message.text !== "string") { + ctx.post({ type: "clipboardWriteResult", id: message.id, ok: false, error: "Invalid clipboard text" }) + return true + } + await ctx.copy(message.text).then( + () => ctx.post({ type: "clipboardWriteResult", id: message.id, ok: true }), + (err) => + ctx.post({ + type: "clipboardWriteResult", + id: message.id, + ok: false, + error: err instanceof Error ? err.message : String(err), + }), + ) + return true + } await routeSuggestionWebviewMessage(ctx.question, message) if (await ModelState.handleMessage(message.type, message, ctx.client, ctx.post)) return true if (message.type === "exportSessionTranscript") { diff --git a/packages/kilo-vscode/src/kilo-provider/options.ts b/packages/kilo-vscode/src/kilo-provider/options.ts index f32caa4736f..63b76a27664 100644 --- a/packages/kilo-vscode/src/kilo-provider/options.ts +++ b/packages/kilo-vscode/src/kilo-provider/options.ts @@ -1,4 +1,6 @@ export type KiloProviderOptions = { + /** Context key updated from focus events reported by this provider's webview. */ + focusContext?: string projectDirectory?: string | null platform?: string snapshotInitialization?: "wait" diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/ar.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/ar.ts index 8b31c9e4dc9..f6e3eeb1ef2 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/ar.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/ar.ts @@ -3,38 +3,15 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "مؤجل", "kilocode:autocomplete.statusBar.warning": "$(warning) الإكمال التلقائي", "kilocode:autocomplete.statusBar.tooltip.basic": "الإكمال التلقائي في Kilo Code", - "kilocode:autocomplete.statusBar.tooltip.disabled": "الإكمال التلقائي في Kilo Code (معطل)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**لم يتم تكوين نموذج للإكمال التلقائي**\n\nلتفعيل الإكمال التلقائي، أضف ملفا شخصيا يحتوي على أحد هؤلاء المزودين المدعومين: {{providers}}.\n\n[فتح الإعدادات]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "إجمالي تكلفة الجلسة:", - "kilocode:autocomplete.statusBar.tooltip.provider": "المزود:", - "kilocode:autocomplete.statusBar.tooltip.model": "النموذج:", - "kilocode:autocomplete.statusBar.tooltip.profile": "الملف الشخصي: ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "افتراضي", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "تم تنفيذ {{count}} عمليات إكمال بين {{startTime}} و {{endTime}}، بتكلفة إجمالية قدرها {{cost}}.", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "يتم توفير الإكمال التلقائي بواسطة {{model}} عبر {{provider}}.", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "الإكمال التلقائي في Kilo Code {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "جار تحليل الكود...", - "kilocode:autocomplete.progress.generating": "جار إنشاء التعديلات المقترحة...", - "kilocode:autocomplete.progress.processing": "جار معالجة التعديلات المقترحة...", - "kilocode:autocomplete.progress.showing": "جار عرض التعديلات المقترحة...", - "kilocode:autocomplete.input.title": "Kilo Code: مهمة سريعة", - "kilocode:autocomplete.input.placeholder": "مثال: 'أعد هيكلة هذه الدالة لتكون أكثر كفاءة'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code: إنشاء تعديلات مقترحة", - "kilocode:autocomplete.commands.displaySuggestions": "عرض التعديلات المقترحة", - "kilocode:autocomplete.commands.cancelSuggestions": "إلغاء التعديلات المقترحة", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "تطبيق التعديل المقترح الحالي", - "kilocode:autocomplete.commands.applyAllSuggestions": "تطبيق كل التعديلات المقترحة", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code: تعديلات مقترحة", - "kilocode:autocomplete.chatParticipant.fullName": "وكيل Kilo Code", - "kilocode:autocomplete.chatParticipant.name": "الوكيل", - "kilocode:autocomplete.chatParticipant.description": "يمكنني مساعدتك في المهام السريعة والتعديلات المقترحة.", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "يتم حظر الإكمال التلقائي في Kilo Code بسبب تعارض مع GitHub Copilot. لإصلاح ذلك، يجب تعطيل اقتراحات Copilot المضمنة.", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "تعطيل Copilot", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/br.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/br.ts index c21cfc57f6c..50db537de0a 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/br.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/br.ts @@ -3,37 +3,14 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "adiado", "kilocode:autocomplete.statusBar.warning": "$(warning) Autocompletar", "kilocode:autocomplete.statusBar.tooltip.basic": "Autocompletar do Kilo Code", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Autocompletar do Kilo Code (desativado)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**Nenhum modelo de autocompletar configurado**\n\nPara ativar o autocompletar, adicione um perfil com um destes provedores compatíveis: {{providers}}.\n\n[Abrir Configurações]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "Custo total da sessão:", - "kilocode:autocomplete.statusBar.tooltip.provider": "Provedor:", - "kilocode:autocomplete.statusBar.tooltip.model": "Modelo:", - "kilocode:autocomplete.statusBar.tooltip.profile": "Perfil: ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "Padrão", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "Foram realizadas {{count}} conclusões entre {{startTime}} e {{endTime}}, com custo total de {{cost}}.", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "Autocompletar fornecido por {{model}} via {{provider}}.", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Autocompletar do Kilo Code {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "Analisando seu código...", - "kilocode:autocomplete.progress.generating": "Gerando edições sugeridas...", - "kilocode:autocomplete.progress.processing": "Processando edições sugeridas...", - "kilocode:autocomplete.progress.showing": "Exibindo edições sugeridas...", - "kilocode:autocomplete.input.title": "Kilo Code: Tarefa rápida", - "kilocode:autocomplete.input.placeholder": "ex.: 'refatore esta função para ser mais eficiente'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code: Gerar edições sugeridas", - "kilocode:autocomplete.commands.displaySuggestions": "Exibir edições sugeridas", - "kilocode:autocomplete.commands.cancelSuggestions": "Cancelar edições sugeridas", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "Aplicar edição sugerida atual", - "kilocode:autocomplete.commands.applyAllSuggestions": "Aplicar todas as edições sugeridas", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code: Edições sugeridas", - "kilocode:autocomplete.chatParticipant.fullName": "Agente do Kilo Code", - "kilocode:autocomplete.chatParticipant.name": "Agente", - "kilocode:autocomplete.chatParticipant.description": "Posso ajudar com tarefas rápidas e edições sugeridas.", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "O Autocompletar do Kilo Code está sendo bloqueado por um conflito com o GitHub Copilot. Para corrigir isso, você deve desativar as sugestões inline do Copilot.", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "Desativar Copilot", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/bs.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/bs.ts index 648a46c92e3..0d80d18dac8 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/bs.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/bs.ts @@ -3,37 +3,14 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "odgođeno", "kilocode:autocomplete.statusBar.warning": "$(warning) Automatsko dovršavanje", "kilocode:autocomplete.statusBar.tooltip.basic": "Kilo Code automatsko dovršavanje", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Kilo Code automatsko dovršavanje (onemogućeno)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**Nije konfigurisan model za automatsko dovršavanje**\n\nDa omogućite automatsko dovršavanje, dodajte profil s jednim od ovih podržanih pružalaca: {{providers}}.\n\n[Otvori postavke]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "Ukupni trošak sesije:", - "kilocode:autocomplete.statusBar.tooltip.provider": "Pružalac:", - "kilocode:autocomplete.statusBar.tooltip.model": "Model:", - "kilocode:autocomplete.statusBar.tooltip.profile": "Profil: ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "Zadano", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "Izvršeno je {{count}} dovršavanja između {{startTime}} i {{endTime}}, uz ukupni trošak od {{cost}}.", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "Automatska dovršavanja pruža {{model}} putem {{provider}}.", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Kilo Code automatsko dovršavanje {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "Analiziram vaš kod...", - "kilocode:autocomplete.progress.generating": "Generišem predložene izmjene...", - "kilocode:autocomplete.progress.processing": "Obrađujem predložene izmjene...", - "kilocode:autocomplete.progress.showing": "Prikazujem predložene izmjene...", - "kilocode:autocomplete.input.title": "Kilo Code: Brzi zadatak", - "kilocode:autocomplete.input.placeholder": "npr. 'refaktoriši ovu funkciju da bude efikasnija'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code: Generiši predložene izmjene", - "kilocode:autocomplete.commands.displaySuggestions": "Prikaži predložene izmjene", - "kilocode:autocomplete.commands.cancelSuggestions": "Otkaži predložene izmjene", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "Primijeni trenutnu predloženu izmjenu", - "kilocode:autocomplete.commands.applyAllSuggestions": "Primijeni sve predložene izmjene", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code: Predložene izmjene", - "kilocode:autocomplete.chatParticipant.fullName": "Kilo Code agent", - "kilocode:autocomplete.chatParticipant.name": "Agent", - "kilocode:autocomplete.chatParticipant.description": "Mogu pomoći s brzim zadacima i predloženim izmjenama.", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "Kilo Code automatsko dovršavanje blokira sukob s GitHub Copilotom. Da to popravite, morate onemogućiti Copilotove inline prijedloge.", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "Onemogući Copilot", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/da.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/da.ts index 9b613b6c1e4..5aabf0c58e8 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/da.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/da.ts @@ -3,38 +3,14 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "udsat", "kilocode:autocomplete.statusBar.warning": "$(warning) Autofuldførelse", "kilocode:autocomplete.statusBar.tooltip.basic": "Kilo Code autofuldførelse", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Kilo Code autofuldførelse (deaktiveret)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**Ingen autocomplete-model konfigureret**\n\nFor at aktivere autocomplete skal du tilføje en profil med en af disse understøttede udbydere: {{providers}}.\n\n[Åbn indstillinger]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "Samlet sessionsomkostning:", - "kilocode:autocomplete.statusBar.tooltip.provider": "Udbyder:", - "kilocode:autocomplete.statusBar.tooltip.model": "Model:", - "kilocode:autocomplete.statusBar.tooltip.profile": "Profil: ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "Standard", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "Udførte {{count}} fuldførelser mellem {{startTime}} og {{endTime}}, til en samlet pris på {{cost}}.", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "Autocomplete leveres af {{model}} via {{provider}}.", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Kilo Code autofuldførelse {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "Analyserer din kode...", - "kilocode:autocomplete.progress.generating": "Genererer foreslåede redigeringer...", - "kilocode:autocomplete.progress.processing": "Behandler foreslåede redigeringer...", - "kilocode:autocomplete.progress.showing": "Viser foreslåede redigeringer...", - "kilocode:autocomplete.input.title": "Kilo Code: Hurtig opgave", - "kilocode:autocomplete.input.placeholder": "f.eks. 'refaktorer denne funktion, så den bliver mere effektiv'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code: Generer foreslåede redigeringer", - "kilocode:autocomplete.commands.displaySuggestions": "Vis foreslåede redigeringer", - "kilocode:autocomplete.commands.cancelSuggestions": "Annuller foreslåede redigeringer", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "Anvend aktuel foreslået redigering", - "kilocode:autocomplete.commands.applyAllSuggestions": "Anvend alle foreslåede redigeringer", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code: Foreslåede redigeringer", - "kilocode:autocomplete.chatParticipant.fullName": "Kilo Code-agent", - "kilocode:autocomplete.chatParticipant.name": "Agent", - "kilocode:autocomplete.chatParticipant.description": - "Jeg kan hjælpe dig med hurtige opgaver og foreslåede redigeringer.", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "Kilo Code autofuldførelse blokeres af en konflikt med GitHub Copilot. For at løse dette skal du deaktivere Copilots inline-forslag.", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "Deaktiver Copilot", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/de.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/de.ts index 678170ae517..edd031fa2f8 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/de.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/de.ts @@ -3,39 +3,15 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "pausiert", "kilocode:autocomplete.statusBar.warning": "$(warning) Autovervollständigung", "kilocode:autocomplete.statusBar.tooltip.basic": "Kilo Code Autovervollständigung", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Kilo Code Autovervollständigung (deaktiviert)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**Kein Autovervollständigungsmodell konfiguriert**\n\nUm die Autovervollständigung zu aktivieren, füge ein Profil mit einem dieser unterstützten Anbieter hinzu: {{providers}}.\n\n[Einstellungen öffnen]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "Sitzungskosten gesamt:", - "kilocode:autocomplete.statusBar.tooltip.provider": "Anbieter:", - "kilocode:autocomplete.statusBar.tooltip.model": "Modell:", - "kilocode:autocomplete.statusBar.tooltip.profile": "Profil: ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "Standard", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "{{count}} Vervollständigungen zwischen {{startTime}} und {{endTime}} ausgeführt, für Gesamtkosten von {{cost}}.", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "Autovervollständigungen bereitgestellt von {{model}} über {{provider}}.", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Kilo Code Autovervollständigung {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "Code wird analysiert...", - "kilocode:autocomplete.progress.generating": "Vorgeschlagene Änderungen werden generiert...", - "kilocode:autocomplete.progress.processing": "Vorgeschlagene Änderungen werden verarbeitet...", - "kilocode:autocomplete.progress.showing": "Vorgeschlagene Änderungen werden angezeigt...", - "kilocode:autocomplete.input.title": "Kilo Code: Schnellaufgabe", - "kilocode:autocomplete.input.placeholder": "z. B. 'diese Funktion effizienter refaktorisieren'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code: Vorgeschlagene Änderungen generieren", - "kilocode:autocomplete.commands.displaySuggestions": "Vorgeschlagene Änderungen anzeigen", - "kilocode:autocomplete.commands.cancelSuggestions": "Vorgeschlagene Änderungen abbrechen", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "Aktuelle vorgeschlagene Änderung anwenden", - "kilocode:autocomplete.commands.applyAllSuggestions": "Alle vorgeschlagenen Änderungen anwenden", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code: Vorgeschlagene Änderungen", - "kilocode:autocomplete.chatParticipant.fullName": "Kilo Code-Agent", - "kilocode:autocomplete.chatParticipant.name": "Assistent", - "kilocode:autocomplete.chatParticipant.description": - "Ich kann dir bei Schnellaufgaben und vorgeschlagenen Änderungen helfen.", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "Kilo Code Autovervollständigung wird durch einen Konflikt mit GitHub Copilot blockiert. Um dies zu beheben, musst du die Inline-Vorschläge von Copilot deaktivieren.", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "Copilot deaktivieren", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/en.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/en.ts index e2e2f423dd4..f0e2641adcd 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/en.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/en.ts @@ -6,37 +6,14 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "snoozed", "kilocode:autocomplete.statusBar.warning": "$(warning) Autocomplete", "kilocode:autocomplete.statusBar.tooltip.basic": "Kilo Code Autocomplete", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Kilo Code Autocomplete (disabled)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**No autocomplete model configured**\n\nTo enable autocomplete, add a profile with one of these supported providers: {{providers}}.\n\n[Open Settings]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "Session total cost:", - "kilocode:autocomplete.statusBar.tooltip.provider": "Provider:", - "kilocode:autocomplete.statusBar.tooltip.model": "Model:", - "kilocode:autocomplete.statusBar.tooltip.profile": "Profile: ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "Default", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "Performed {{count}} completions between {{startTime}} and {{endTime}}, for a total cost of {{cost}}.", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "Autocompletions provided by {{model}} via {{provider}}.", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Kilo Code Autocomplete {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "Analyzing your code...", - "kilocode:autocomplete.progress.generating": "Generating suggested edits...", - "kilocode:autocomplete.progress.processing": "Processing suggested edits...", - "kilocode:autocomplete.progress.showing": "Displaying suggested edits...", - "kilocode:autocomplete.input.title": "Kilo Code: Quick Task", - "kilocode:autocomplete.input.placeholder": "e.g., 'refactor this function to be more efficient'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code: Generate Suggested Edits", - "kilocode:autocomplete.commands.displaySuggestions": "Display Suggested Edits", - "kilocode:autocomplete.commands.cancelSuggestions": "Cancel Suggested Edits", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "Apply Current Suggested Edit", - "kilocode:autocomplete.commands.applyAllSuggestions": "Apply All Suggested Edits", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code: Suggested Edits", - "kilocode:autocomplete.chatParticipant.fullName": "Kilo Code Agent", - "kilocode:autocomplete.chatParticipant.name": "Agent", - "kilocode:autocomplete.chatParticipant.description": "I can help you with quick tasks and suggested edits.", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "The Kilo Code Autocomplete is being blocked by a conflict with GitHub Copilot. To fix this, you must disable Copilot's inline suggestions.", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "Disable Copilot", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/es.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/es.ts index 80dcab6a1d8..f0a9cd2eda4 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/es.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/es.ts @@ -3,38 +3,15 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "pospuesto", "kilocode:autocomplete.statusBar.warning": "$(warning) Autocompletado", "kilocode:autocomplete.statusBar.tooltip.basic": "Autocompletado de Kilo Code", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Autocompletado de Kilo Code (deshabilitado)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**No hay ningún modelo de autocompletado configurado**\n\nPara habilitar el autocompletado, añade un perfil con uno de estos proveedores compatibles: {{providers}}.\n\n[Abrir configuración]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "Coste total de la sesión:", - "kilocode:autocomplete.statusBar.tooltip.provider": "Proveedor:", - "kilocode:autocomplete.statusBar.tooltip.model": "Modelo:", - "kilocode:autocomplete.statusBar.tooltip.profile": "Perfil: ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "Predeterminado", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "Se realizaron {{count}} completados entre {{startTime}} y {{endTime}}, con un coste total de {{cost}}.", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "Autocompletados proporcionados por {{model}} mediante {{provider}}.", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Autocompletado de Kilo Code {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "Analizando tu código...", - "kilocode:autocomplete.progress.generating": "Generando ediciones sugeridas...", - "kilocode:autocomplete.progress.processing": "Procesando ediciones sugeridas...", - "kilocode:autocomplete.progress.showing": "Mostrando ediciones sugeridas...", - "kilocode:autocomplete.input.title": "Kilo Code: Tarea rápida", - "kilocode:autocomplete.input.placeholder": "p. ej., 'refactoriza esta función para que sea más eficiente'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code: Generar ediciones sugeridas", - "kilocode:autocomplete.commands.displaySuggestions": "Mostrar ediciones sugeridas", - "kilocode:autocomplete.commands.cancelSuggestions": "Cancelar ediciones sugeridas", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "Aplicar edición sugerida actual", - "kilocode:autocomplete.commands.applyAllSuggestions": "Aplicar todas las ediciones sugeridas", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code: Ediciones sugeridas", - "kilocode:autocomplete.chatParticipant.fullName": "Agente de Kilo Code", - "kilocode:autocomplete.chatParticipant.name": "Agente", - "kilocode:autocomplete.chatParticipant.description": "Puedo ayudarte con tareas rápidas y ediciones sugeridas.", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "El autocompletado de Kilo Code está bloqueado por un conflicto con GitHub Copilot. Para solucionarlo, debes deshabilitar las sugerencias en línea de Copilot.", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "Deshabilitar Copilot", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/fr.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/fr.ts index 946a7a1c335..e465d9a97bc 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/fr.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/fr.ts @@ -3,39 +3,15 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "mis en pause", "kilocode:autocomplete.statusBar.warning": "$(warning) Saisie automatique", "kilocode:autocomplete.statusBar.tooltip.basic": "Saisie automatique Kilo Code", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Saisie automatique Kilo Code (désactivée)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**Aucun modèle de saisie automatique configuré**\n\nPour activer la saisie automatique, ajoutez un profil avec l'un de ces fournisseurs pris en charge : {{providers}}.\n\n[Ouvrir les paramètres]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "Coût total de la session :", - "kilocode:autocomplete.statusBar.tooltip.provider": "Fournisseur :", - "kilocode:autocomplete.statusBar.tooltip.model": "Modèle :", - "kilocode:autocomplete.statusBar.tooltip.profile": "Profil : ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "Par défaut", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "{{count}} complétions effectuées entre {{startTime}} et {{endTime}}, pour un coût total de {{cost}}.", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "Saisies automatiques fournies par {{model}} via {{provider}}.", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Saisie automatique Kilo Code {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "Analyse de votre code...", - "kilocode:autocomplete.progress.generating": "Génération des modifications suggérées...", - "kilocode:autocomplete.progress.processing": "Traitement des modifications suggérées...", - "kilocode:autocomplete.progress.showing": "Affichage des modifications suggérées...", - "kilocode:autocomplete.input.title": "Kilo Code : tâche rapide", - "kilocode:autocomplete.input.placeholder": "p. ex., 'refactoriser cette fonction pour la rendre plus efficace'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code : générer des modifications suggérées", - "kilocode:autocomplete.commands.displaySuggestions": "Afficher les modifications suggérées", - "kilocode:autocomplete.commands.cancelSuggestions": "Annuler les modifications suggérées", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "Appliquer la modification suggérée actuelle", - "kilocode:autocomplete.commands.applyAllSuggestions": "Appliquer toutes les modifications suggérées", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code : modifications suggérées", - "kilocode:autocomplete.chatParticipant.fullName": "Agent Kilo Code", - "kilocode:autocomplete.chatParticipant.name": "Assistant", - "kilocode:autocomplete.chatParticipant.description": - "Je peux vous aider avec des tâches rapides et des modifications suggérées.", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "La saisie automatique Kilo Code est bloquée par un conflit avec GitHub Copilot. Pour résoudre ce problème, vous devez désactiver les suggestions en ligne de Copilot.", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "Désactiver Copilot", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/it.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/it.ts index 56a2609079b..2919061b8d7 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/it.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/it.ts @@ -3,38 +3,15 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "posticipato", "kilocode:autocomplete.statusBar.warning": "$(warning) Completamento automatico", "kilocode:autocomplete.statusBar.tooltip.basic": "Completamento automatico Kilo Code", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Completamento automatico Kilo Code (disabilitato)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**Nessun modello di completamento automatico configurato**\n\nPer abilitare il completamento automatico, aggiungi un profilo con uno di questi provider supportati: {{providers}}.\n\n[Apri impostazioni]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "Costo totale della sessione:", - "kilocode:autocomplete.statusBar.tooltip.provider": "Fornitore:", - "kilocode:autocomplete.statusBar.tooltip.model": "Modello:", - "kilocode:autocomplete.statusBar.tooltip.profile": "Profilo: ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "Predefinito", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "Eseguiti {{count}} completamenti tra {{startTime}} e {{endTime}}, per un costo totale di {{cost}}.", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "Completamenti automatici forniti da {{model}} tramite {{provider}}.", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Completamento automatico Kilo Code {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "Analisi del codice in corso...", - "kilocode:autocomplete.progress.generating": "Generazione delle modifiche suggerite...", - "kilocode:autocomplete.progress.processing": "Elaborazione delle modifiche suggerite...", - "kilocode:autocomplete.progress.showing": "Visualizzazione delle modifiche suggerite...", - "kilocode:autocomplete.input.title": "Kilo Code: attività rapida", - "kilocode:autocomplete.input.placeholder": "es. 'rifattorizza questa funzione per renderla più efficiente'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code: genera modifiche suggerite", - "kilocode:autocomplete.commands.displaySuggestions": "Mostra modifiche suggerite", - "kilocode:autocomplete.commands.cancelSuggestions": "Annulla modifiche suggerite", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "Applica la modifica suggerita corrente", - "kilocode:autocomplete.commands.applyAllSuggestions": "Applica tutte le modifiche suggerite", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code: modifiche suggerite", - "kilocode:autocomplete.chatParticipant.fullName": "Agente Kilo Code", - "kilocode:autocomplete.chatParticipant.name": "Agente", - "kilocode:autocomplete.chatParticipant.description": "Posso aiutarti con attività rapide e modifiche suggerite.", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "Il completamento automatico di Kilo Code è bloccato da un conflitto con GitHub Copilot. Per risolvere il problema, devi disabilitare i suggerimenti inline di Copilot.", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "Disabilita Copilot", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/ja.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/ja.ts index b0913d99235..e1a44858d89 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/ja.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/ja.ts @@ -3,38 +3,15 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "一時停止中", "kilocode:autocomplete.statusBar.warning": "$(warning) オートコンプリート", "kilocode:autocomplete.statusBar.tooltip.basic": "Kilo Code オートコンプリート", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Kilo Code オートコンプリート (無効)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**オートコンプリートモデルが設定されていません**\n\nオートコンプリートを有効にするには、次の対応プロバイダーのいずれかを含むプロファイルを追加してください: {{providers}}。\n\n[設定を開く]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "セッション合計コスト:", - "kilocode:autocomplete.statusBar.tooltip.provider": "プロバイダー:", - "kilocode:autocomplete.statusBar.tooltip.model": "モデル:", - "kilocode:autocomplete.statusBar.tooltip.profile": "プロファイル: ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "デフォルト", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "{{startTime}} から {{endTime}} までに {{count}} 件の補完を実行し、合計コストは {{cost}} でした。", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "オートコンプリートは {{provider}} 経由の {{model}} によって提供されています。", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Kilo Code オートコンプリート {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "コードを解析しています...", - "kilocode:autocomplete.progress.generating": "提案された編集を生成しています...", - "kilocode:autocomplete.progress.processing": "提案された編集を処理しています...", - "kilocode:autocomplete.progress.showing": "提案された編集を表示しています...", - "kilocode:autocomplete.input.title": "Kilo Code: クイックタスク", - "kilocode:autocomplete.input.placeholder": "例: 'この関数をより効率的にリファクタリングする'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code: 提案された編集を生成", - "kilocode:autocomplete.commands.displaySuggestions": "提案された編集を表示", - "kilocode:autocomplete.commands.cancelSuggestions": "提案された編集をキャンセル", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "現在の提案された編集を適用", - "kilocode:autocomplete.commands.applyAllSuggestions": "すべての提案された編集を適用", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code: 提案された編集", - "kilocode:autocomplete.chatParticipant.fullName": "Kilo Code エージェント", - "kilocode:autocomplete.chatParticipant.name": "エージェント", - "kilocode:autocomplete.chatParticipant.description": "クイックタスクや提案された編集をお手伝いできます。", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "Kilo Code オートコンプリートは GitHub Copilot との競合によりブロックされています。修正するには、Copilot のインライン提案を無効にする必要があります。", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "Copilot を無効化", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/ko.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/ko.ts index ed3b47c426a..a0a6c8391bc 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/ko.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/ko.ts @@ -3,37 +3,14 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "일시 중지됨", "kilocode:autocomplete.statusBar.warning": "$(warning) 자동 완성", "kilocode:autocomplete.statusBar.tooltip.basic": "Kilo Code 자동 완성", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Kilo Code 자동 완성(비활성화됨)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**자동 완성 모델이 구성되지 않았습니다**\n\n자동 완성을 활성화하려면 다음 지원 제공자 중 하나가 포함된 프로필을 추가하세요: {{providers}}.\n\n[설정 열기]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "세션 총 비용:", - "kilocode:autocomplete.statusBar.tooltip.provider": "제공자:", - "kilocode:autocomplete.statusBar.tooltip.model": "모델:", - "kilocode:autocomplete.statusBar.tooltip.profile": "프로필: ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "기본값", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "{{startTime}}부터 {{endTime}}까지 {{count}}개의 완성을 수행했으며 총 비용은 {{cost}}입니다.", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "자동 완성은 {{provider}}를 통해 {{model}}에서 제공됩니다.", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Kilo Code 자동 완성 {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "코드를 분석하는 중...", - "kilocode:autocomplete.progress.generating": "제안된 편집을 생성하는 중...", - "kilocode:autocomplete.progress.processing": "제안된 편집을 처리하는 중...", - "kilocode:autocomplete.progress.showing": "제안된 편집을 표시하는 중...", - "kilocode:autocomplete.input.title": "Kilo Code: 빠른 작업", - "kilocode:autocomplete.input.placeholder": "예: '이 함수를 더 효율적으로 리팩터링'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code: 제안된 편집 생성", - "kilocode:autocomplete.commands.displaySuggestions": "제안된 편집 표시", - "kilocode:autocomplete.commands.cancelSuggestions": "제안된 편집 취소", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "현재 제안된 편집 적용", - "kilocode:autocomplete.commands.applyAllSuggestions": "모든 제안된 편집 적용", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code: 제안된 편집", - "kilocode:autocomplete.chatParticipant.fullName": "Kilo Code 에이전트", - "kilocode:autocomplete.chatParticipant.name": "에이전트", - "kilocode:autocomplete.chatParticipant.description": "빠른 작업과 제안된 편집을 도와드릴 수 있습니다.", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "Kilo Code 자동 완성이 GitHub Copilot과의 충돌로 차단되고 있습니다. 이 문제를 해결하려면 Copilot의 인라인 제안을 비활성화해야 합니다.", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "Copilot 비활성화", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/nl.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/nl.ts index f4feddfe8a6..181a0935dd4 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/nl.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/nl.ts @@ -3,38 +3,15 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "gesluimerd", "kilocode:autocomplete.statusBar.warning": "$(warning) Automatisch aanvullen", "kilocode:autocomplete.statusBar.tooltip.basic": "Kilo Code automatisch aanvullen", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Kilo Code automatisch aanvullen (uitgeschakeld)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**Geen model voor automatisch aanvullen geconfigureerd**\n\nVoeg een profiel toe met een van deze ondersteunde providers om automatisch aanvullen in te schakelen: {{providers}}.\n\n[Instellingen openen]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "Totale sessiekosten:", - "kilocode:autocomplete.statusBar.tooltip.provider": "Aanbieder:", - "kilocode:autocomplete.statusBar.tooltip.model": "Model:", - "kilocode:autocomplete.statusBar.tooltip.profile": "Profiel: ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "Standaard", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "{{count}} aanvullingen uitgevoerd tussen {{startTime}} en {{endTime}}, voor totale kosten van {{cost}}.", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "Automatische aanvullingen geleverd door {{model}} via {{provider}}.", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Kilo Code automatisch aanvullen {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "Je code wordt geanalyseerd...", - "kilocode:autocomplete.progress.generating": "Voorgestelde bewerkingen worden gegenereerd...", - "kilocode:autocomplete.progress.processing": "Voorgestelde bewerkingen worden verwerkt...", - "kilocode:autocomplete.progress.showing": "Voorgestelde bewerkingen worden weergegeven...", - "kilocode:autocomplete.input.title": "Kilo Code: Snelle taak", - "kilocode:autocomplete.input.placeholder": "bijv. 'refactor deze functie zodat deze efficiënter is'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code: Voorgestelde bewerkingen genereren", - "kilocode:autocomplete.commands.displaySuggestions": "Voorgestelde bewerkingen weergeven", - "kilocode:autocomplete.commands.cancelSuggestions": "Voorgestelde bewerkingen annuleren", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "Huidige voorgestelde bewerking toepassen", - "kilocode:autocomplete.commands.applyAllSuggestions": "Alle voorgestelde bewerkingen toepassen", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code: Voorgestelde bewerkingen", - "kilocode:autocomplete.chatParticipant.fullName": "Kilo Code-agent", - "kilocode:autocomplete.chatParticipant.name": "Assistent", - "kilocode:autocomplete.chatParticipant.description": "Ik kan helpen met snelle taken en voorgestelde bewerkingen.", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "Kilo Code automatisch aanvullen wordt geblokkeerd door een conflict met GitHub Copilot. Schakel de inline suggesties van Copilot uit om dit op te lossen.", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "Copilot uitschakelen", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/no.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/no.ts index aa50c748f8d..dcd89df7e0c 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/no.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/no.ts @@ -3,38 +3,14 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "utsatt", "kilocode:autocomplete.statusBar.warning": "$(warning) Autofullføring", "kilocode:autocomplete.statusBar.tooltip.basic": "Kilo Code autofullføring", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Kilo Code autofullføring (deaktivert)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**Ingen autofullføringsmodell konfigurert**\n\nFor å aktivere autofullføring, legg til en profil med en av disse støttede leverandørene: {{providers}}.\n\n[Åpne innstillinger]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "Total kostnad for økten:", - "kilocode:autocomplete.statusBar.tooltip.provider": "Leverandør:", - "kilocode:autocomplete.statusBar.tooltip.model": "Modell:", - "kilocode:autocomplete.statusBar.tooltip.profile": "Profil: ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "Standard", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "Utførte {{count}} fullføringer mellom {{startTime}} og {{endTime}}, til en total kostnad på {{cost}}.", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "Autofullføringer leveres av {{model}} via {{provider}}.", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Kilo Code autofullføring {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "Analyserer koden din...", - "kilocode:autocomplete.progress.generating": "Genererer foreslåtte redigeringer...", - "kilocode:autocomplete.progress.processing": "Behandler foreslåtte redigeringer...", - "kilocode:autocomplete.progress.showing": "Viser foreslåtte redigeringer...", - "kilocode:autocomplete.input.title": "Kilo Code: Hurtig oppgave", - "kilocode:autocomplete.input.placeholder": "f.eks. 'refaktorer denne funksjonen så den blir mer effektiv'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code: Generer foreslåtte redigeringer", - "kilocode:autocomplete.commands.displaySuggestions": "Vis foreslåtte redigeringer", - "kilocode:autocomplete.commands.cancelSuggestions": "Avbryt foreslåtte redigeringer", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "Bruk gjeldende foreslåtte redigering", - "kilocode:autocomplete.commands.applyAllSuggestions": "Bruk alle foreslåtte redigeringer", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code: Foreslåtte redigeringer", - "kilocode:autocomplete.chatParticipant.fullName": "Kilo Code-agent", - "kilocode:autocomplete.chatParticipant.name": "Agent", - "kilocode:autocomplete.chatParticipant.description": - "Jeg kan hjelpe deg med hurtige oppgaver og foreslåtte redigeringer.", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "Kilo Code autofullføring blokkeres av en konflikt med GitHub Copilot. For å fikse dette må du deaktivere Copilots inline-forslag.", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "Deaktiver Copilot", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/pl.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/pl.ts index 1ade66e91d0..24168a6a36e 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/pl.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/pl.ts @@ -3,37 +3,14 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "wstrzymane", "kilocode:autocomplete.statusBar.warning": "$(warning) Autouzupełnianie", "kilocode:autocomplete.statusBar.tooltip.basic": "Autouzupełnianie Kilo Code", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Autouzupełnianie Kilo Code (wyłączone)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**Nie skonfigurowano modelu autouzupełniania**\n\nAby włączyć autouzupełnianie, dodaj profil z jednym z tych obsługiwanych dostawców: {{providers}}.\n\n[Otwórz ustawienia]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "Łączny koszt sesji:", - "kilocode:autocomplete.statusBar.tooltip.provider": "Dostawca:", - "kilocode:autocomplete.statusBar.tooltip.model": "Model:", - "kilocode:autocomplete.statusBar.tooltip.profile": "Profil: ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "Domyślny", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "Wykonano {{count}} uzupełnień między {{startTime}} a {{endTime}}, za łączny koszt {{cost}}.", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "Autouzupełnianie zapewnia {{model}} przez {{provider}}.", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Autouzupełnianie Kilo Code {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "Analizowanie kodu...", - "kilocode:autocomplete.progress.generating": "Generowanie sugerowanych edycji...", - "kilocode:autocomplete.progress.processing": "Przetwarzanie sugerowanych edycji...", - "kilocode:autocomplete.progress.showing": "Wyświetlanie sugerowanych edycji...", - "kilocode:autocomplete.input.title": "Kilo Code: Szybkie zadanie", - "kilocode:autocomplete.input.placeholder": "np. 'zrefaktoryzuj tę funkcję, aby była bardziej wydajna'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code: Generuj sugerowane edycje", - "kilocode:autocomplete.commands.displaySuggestions": "Wyświetl sugerowane edycje", - "kilocode:autocomplete.commands.cancelSuggestions": "Anuluj sugerowane edycje", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "Zastosuj bieżącą sugerowaną edycję", - "kilocode:autocomplete.commands.applyAllSuggestions": "Zastosuj wszystkie sugerowane edycje", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code: Sugerowane edycje", - "kilocode:autocomplete.chatParticipant.fullName": "Agent Kilo Code", - "kilocode:autocomplete.chatParticipant.name": "Agent", - "kilocode:autocomplete.chatParticipant.description": "Mogę pomóc w szybkich zadaniach i sugerowanych edycjach.", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "Autouzupełnianie Kilo Code jest blokowane przez konflikt z GitHub Copilot. Aby to naprawić, musisz wyłączyć sugestie inline Copilota.", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "Wyłącz Copilota", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/ru.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/ru.ts index 9b3915f4ae7..18bd1aa41ba 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/ru.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/ru.ts @@ -3,38 +3,15 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "отложено", "kilocode:autocomplete.statusBar.warning": "$(warning) Автодополнение", "kilocode:autocomplete.statusBar.tooltip.basic": "Автодополнение Kilo Code", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Автодополнение Kilo Code (отключено)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**Модель автодополнения не настроена**\n\nЧтобы включить автодополнение, добавьте профиль с одним из этих поддерживаемых провайдеров: {{providers}}.\n\n[Открыть настройки]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "Общая стоимость сеанса:", - "kilocode:autocomplete.statusBar.tooltip.provider": "Провайдер:", - "kilocode:autocomplete.statusBar.tooltip.model": "Модель:", - "kilocode:autocomplete.statusBar.tooltip.profile": "Профиль: ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "По умолчанию", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "Выполнено {{count}} дополнений с {{startTime}} до {{endTime}}, общая стоимость {{cost}}.", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "Автодополнения предоставляются {{model}} через {{provider}}.", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Автодополнение Kilo Code {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "Анализ кода...", - "kilocode:autocomplete.progress.generating": "Создание предлагаемых правок...", - "kilocode:autocomplete.progress.processing": "Обработка предлагаемых правок...", - "kilocode:autocomplete.progress.showing": "Отображение предлагаемых правок...", - "kilocode:autocomplete.input.title": "Kilo Code: Быстрая задача", - "kilocode:autocomplete.input.placeholder": "например: 'отрефакторить эту функцию, чтобы она была эффективнее'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code: Создать предлагаемые правки", - "kilocode:autocomplete.commands.displaySuggestions": "Показать предлагаемые правки", - "kilocode:autocomplete.commands.cancelSuggestions": "Отменить предлагаемые правки", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "Применить текущую предлагаемую правку", - "kilocode:autocomplete.commands.applyAllSuggestions": "Применить все предлагаемые правки", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code: Предлагаемые правки", - "kilocode:autocomplete.chatParticipant.fullName": "Агент Kilo Code", - "kilocode:autocomplete.chatParticipant.name": "Агент", - "kilocode:autocomplete.chatParticipant.description": "Я могу помочь с быстрыми задачами и предлагаемыми правками.", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "Автодополнение Kilo Code блокируется из-за конфликта с GitHub Copilot. Чтобы исправить это, необходимо отключить встроенные предложения Copilot.", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "Отключить Copilot", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/th.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/th.ts index 45f999ce768..77f17f0d04d 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/th.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/th.ts @@ -3,38 +3,15 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "พักไว้", "kilocode:autocomplete.statusBar.warning": "$(warning) เติมข้อความอัตโนมัติ", "kilocode:autocomplete.statusBar.tooltip.basic": "การเติมข้อความอัตโนมัติของ Kilo Code", - "kilocode:autocomplete.statusBar.tooltip.disabled": "การเติมข้อความอัตโนมัติของ Kilo Code (ปิดใช้งาน)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**ยังไม่ได้กำหนดค่าโมเดลเติมข้อความอัตโนมัติ**\n\nหากต้องการเปิดใช้การเติมข้อความอัตโนมัติ ให้เพิ่มโปรไฟล์ที่มีผู้ให้บริการที่รองรับต่อไปนี้: {{providers}}\n\n[เปิดการตั้งค่า]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "ค่าใช้จ่ายรวมของเซสชัน:", - "kilocode:autocomplete.statusBar.tooltip.provider": "ผู้ให้บริการ:", - "kilocode:autocomplete.statusBar.tooltip.model": "โมเดล:", - "kilocode:autocomplete.statusBar.tooltip.profile": "โปรไฟล์: ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "ค่าเริ่มต้น", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "ดำเนินการเติมข้อความ {{count}} ครั้งระหว่าง {{startTime}} ถึง {{endTime}} โดยมีค่าใช้จ่ายรวม {{cost}}", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "การเติมข้อความอัตโนมัติให้บริการโดย {{model}} ผ่าน {{provider}}", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "การเติมข้อความอัตโนมัติของ Kilo Code {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "กำลังวิเคราะห์โค้ดของคุณ...", - "kilocode:autocomplete.progress.generating": "กำลังสร้างการแก้ไขที่แนะนำ...", - "kilocode:autocomplete.progress.processing": "กำลังประมวลผลการแก้ไขที่แนะนำ...", - "kilocode:autocomplete.progress.showing": "กำลังแสดงการแก้ไขที่แนะนำ...", - "kilocode:autocomplete.input.title": "Kilo Code: งานด่วน", - "kilocode:autocomplete.input.placeholder": "เช่น 'รีแฟกเตอร์ฟังก์ชันนี้ให้มีประสิทธิภาพมากขึ้น'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code: สร้างการแก้ไขที่แนะนำ", - "kilocode:autocomplete.commands.displaySuggestions": "แสดงการแก้ไขที่แนะนำ", - "kilocode:autocomplete.commands.cancelSuggestions": "ยกเลิกการแก้ไขที่แนะนำ", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "ใช้การแก้ไขที่แนะนำปัจจุบัน", - "kilocode:autocomplete.commands.applyAllSuggestions": "ใช้การแก้ไขที่แนะนำทั้งหมด", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code: การแก้ไขที่แนะนำ", - "kilocode:autocomplete.chatParticipant.fullName": "ตัวแทน Kilo Code", - "kilocode:autocomplete.chatParticipant.name": "ตัวแทน", - "kilocode:autocomplete.chatParticipant.description": "ฉันช่วยคุณทำงานด่วนและการแก้ไขที่แนะนำได้", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "การเติมข้อความอัตโนมัติของ Kilo Code ถูกบล็อกเนื่องจากขัดแย้งกับ GitHub Copilot หากต้องการแก้ไข คุณต้องปิดข้อเสนอแนะแบบอินไลน์ของ Copilot", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "ปิดใช้งาน Copilot", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/tr.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/tr.ts index 30908d72573..43132160074 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/tr.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/tr.ts @@ -3,39 +3,15 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "ertelendi", "kilocode:autocomplete.statusBar.warning": "$(warning) Otomatik tamamlama", "kilocode:autocomplete.statusBar.tooltip.basic": "Kilo Code otomatik tamamlama", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Kilo Code otomatik tamamlama (devre dışı)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**Otomatik tamamlama modeli yapılandırılmadı**\n\nOtomatik tamamlamayı etkinleştirmek için desteklenen şu sağlayıcılardan birini içeren bir profil ekleyin: {{providers}}.\n\n[Ayarları Aç]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "Oturum toplam maliyeti:", - "kilocode:autocomplete.statusBar.tooltip.provider": "Sağlayıcı:", - "kilocode:autocomplete.statusBar.tooltip.model": "Model:", - "kilocode:autocomplete.statusBar.tooltip.profile": "Profil: ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "Varsayılan", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "{{startTime}} ile {{endTime}} arasında {{count}} tamamlama yapıldı, toplam maliyet {{cost}}.", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "Otomatik tamamlamalar {{provider}} üzerinden {{model}} tarafından sağlanır.", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Kilo Code otomatik tamamlama {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "Kodunuz analiz ediliyor...", - "kilocode:autocomplete.progress.generating": "Önerilen düzenlemeler oluşturuluyor...", - "kilocode:autocomplete.progress.processing": "Önerilen düzenlemeler işleniyor...", - "kilocode:autocomplete.progress.showing": "Önerilen düzenlemeler gösteriliyor...", - "kilocode:autocomplete.input.title": "Kilo Code: Hızlı görev", - "kilocode:autocomplete.input.placeholder": "örn. 'bu fonksiyonu daha verimli olacak şekilde yeniden düzenle'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code: Önerilen düzenlemeler oluştur", - "kilocode:autocomplete.commands.displaySuggestions": "Önerilen düzenlemeleri göster", - "kilocode:autocomplete.commands.cancelSuggestions": "Önerilen düzenlemeleri iptal et", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "Geçerli önerilen düzenlemeyi uygula", - "kilocode:autocomplete.commands.applyAllSuggestions": "Tüm önerilen düzenlemeleri uygula", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code: Önerilen düzenlemeler", - "kilocode:autocomplete.chatParticipant.fullName": "Kilo Code aracı", - "kilocode:autocomplete.chatParticipant.name": "Aracı", - "kilocode:autocomplete.chatParticipant.description": - "Hızlı görevler ve önerilen düzenlemeler konusunda yardımcı olabilirim.", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "Kilo Code otomatik tamamlama, GitHub Copilot ile çakışma nedeniyle engelleniyor. Bunu düzeltmek için Copilot'un satır içi önerilerini devre dışı bırakmalısınız.", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "Copilot'u devre dışı bırak", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/uk.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/uk.ts index fe70edbd80b..fb70d04b77b 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/uk.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/uk.ts @@ -3,38 +3,14 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "відкладено", "kilocode:autocomplete.statusBar.warning": "$(warning) Автодоповнення", "kilocode:autocomplete.statusBar.tooltip.basic": "Автодоповнення Kilo Code", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Автодоповнення Kilo Code (вимкнено)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**Модель автодоповнення не налаштована**\n\nЩоб увімкнути автодоповнення, додайте профіль з одним із цих підтримуваних постачальників: {{providers}}.\n\n[Відкрити налаштування]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "Загальна вартість сеансу:", - "kilocode:autocomplete.statusBar.tooltip.provider": "Постачальник:", - "kilocode:autocomplete.statusBar.tooltip.model": "Модель:", - "kilocode:autocomplete.statusBar.tooltip.profile": "Профіль: ", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "За замовчуванням", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "Виконано {{count}} доповнень між {{startTime}} і {{endTime}}, із загальною вартістю {{cost}}.", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "Автодоповнення надає {{model}} через {{provider}}.", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Автодоповнення Kilo Code {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "Аналіз вашого коду...", - "kilocode:autocomplete.progress.generating": "Створення запропонованих змін...", - "kilocode:autocomplete.progress.processing": "Обробка запропонованих змін...", - "kilocode:autocomplete.progress.showing": "Показ запропонованих змін...", - "kilocode:autocomplete.input.title": "Kilo Code: Швидке завдання", - "kilocode:autocomplete.input.placeholder": "наприклад, 'рефакторинг цієї функції, щоб вона була ефективнішою'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code: Створити запропоновані зміни", - "kilocode:autocomplete.commands.displaySuggestions": "Показати запропоновані зміни", - "kilocode:autocomplete.commands.cancelSuggestions": "Скасувати запропоновані зміни", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "Застосувати поточну запропоновану зміну", - "kilocode:autocomplete.commands.applyAllSuggestions": "Застосувати всі запропоновані зміни", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code: Запропоновані зміни", - "kilocode:autocomplete.chatParticipant.fullName": "Агент Kilo Code", - "kilocode:autocomplete.chatParticipant.name": "Агент", - "kilocode:autocomplete.chatParticipant.description": - "Я можу допомогти зі швидкими завданнями та запропонованими змінами.", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "Автодоповнення Kilo Code блокується через конфлікт із GitHub Copilot. Щоб виправити це, потрібно вимкнути вбудовані пропозиції Copilot.", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "Вимкнути Copilot", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/zh.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/zh.ts index 1ca452b68ea..3a87451e4d2 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/zh.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/zh.ts @@ -3,37 +3,14 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "已暂停", "kilocode:autocomplete.statusBar.warning": "$(warning) 自动补全", "kilocode:autocomplete.statusBar.tooltip.basic": "Kilo Code 自动补全", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Kilo Code 自动补全(已禁用)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**未配置自动补全模型**\n\n要启用自动补全,请添加一个包含以下受支持提供商之一的配置文件:{{providers}}。\n\n[打开设置]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "会话总成本:", - "kilocode:autocomplete.statusBar.tooltip.provider": "提供商:", - "kilocode:autocomplete.statusBar.tooltip.model": "模型:", - "kilocode:autocomplete.statusBar.tooltip.profile": "配置文件:", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "默认", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "在 {{startTime}} 到 {{endTime}} 之间执行了 {{count}} 次补全,总成本为 {{cost}}。", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "自动补全由 {{provider}} 通过 {{model}} 提供。", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Kilo Code 自动补全 {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "正在分析你的代码...", - "kilocode:autocomplete.progress.generating": "正在生成建议的编辑...", - "kilocode:autocomplete.progress.processing": "正在处理建议的编辑...", - "kilocode:autocomplete.progress.showing": "正在显示建议的编辑...", - "kilocode:autocomplete.input.title": "Kilo Code:快速任务", - "kilocode:autocomplete.input.placeholder": "例如:'重构此函数以提高效率'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code:生成建议的编辑", - "kilocode:autocomplete.commands.displaySuggestions": "显示建议的编辑", - "kilocode:autocomplete.commands.cancelSuggestions": "取消建议的编辑", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "应用当前建议的编辑", - "kilocode:autocomplete.commands.applyAllSuggestions": "应用所有建议的编辑", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code:建议的编辑", - "kilocode:autocomplete.chatParticipant.fullName": "Kilo Code 代理", - "kilocode:autocomplete.chatParticipant.name": "代理", - "kilocode:autocomplete.chatParticipant.description": "我可以帮助你处理快速任务和建议的编辑。", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "Kilo Code 自动补全因与 GitHub Copilot 冲突而被阻止。要修复此问题,必须禁用 Copilot 的内联建议。", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "禁用 Copilot", diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/zht.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/zht.ts index ab8f3177c65..38900fcf58a 100644 --- a/packages/kilo-vscode/src/services/i18n/autocomplete/zht.ts +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/zht.ts @@ -3,37 +3,14 @@ export const dict = { "kilocode:autocomplete.statusBar.snoozed": "已暫停", "kilocode:autocomplete.statusBar.warning": "$(warning) 自動完成", "kilocode:autocomplete.statusBar.tooltip.basic": "Kilo Code 自動完成", - "kilocode:autocomplete.statusBar.tooltip.disabled": "Kilo Code 自動完成(已停用)", "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": "**尚未設定自動完成模型**\n\n若要啟用自動完成,請新增包含下列其中一個支援提供者的設定檔:{{providers}}。\n\n[開啟設定]({{command}})", - "kilocode:autocomplete.statusBar.tooltip.sessionTotal": "工作階段總成本:", - "kilocode:autocomplete.statusBar.tooltip.provider": "提供者:", - "kilocode:autocomplete.statusBar.tooltip.model": "模型:", - "kilocode:autocomplete.statusBar.tooltip.profile": "設定檔:", - "kilocode:autocomplete.statusBar.tooltip.defaultProfile": "預設", "kilocode:autocomplete.statusBar.tooltip.completionSummary": "在 {{startTime}} 到 {{endTime}} 之間執行了 {{count}} 次完成,總成本為 {{cost}}。", "kilocode:autocomplete.statusBar.tooltip.providerInfo": "自動完成由 {{provider}} 透過 {{model}} 提供。", "kilocode:autocomplete.statusBar.cost.zero": "$0.00", "kilocode:autocomplete.statusBar.cost.lessThanCent": "<$0.01", - "kilocode:autocomplete.toggleMessage": "Kilo Code 自動完成 {{status}}", - "kilocode:autocomplete.progress.title": "Kilo Code", - "kilocode:autocomplete.progress.analyzing": "正在分析你的程式碼...", - "kilocode:autocomplete.progress.generating": "正在產生建議的編輯...", - "kilocode:autocomplete.progress.processing": "正在處理建議的編輯...", - "kilocode:autocomplete.progress.showing": "正在顯示建議的編輯...", - "kilocode:autocomplete.input.title": "Kilo Code:快速工作", - "kilocode:autocomplete.input.placeholder": "例如:'重構此函式以提高效率'", - "kilocode:autocomplete.commands.generateSuggestions": "Kilo Code:產生建議的編輯", - "kilocode:autocomplete.commands.displaySuggestions": "顯示建議的編輯", - "kilocode:autocomplete.commands.cancelSuggestions": "取消建議的編輯", - "kilocode:autocomplete.commands.applyCurrentSuggestion": "套用目前建議的編輯", - "kilocode:autocomplete.commands.applyAllSuggestions": "套用所有建議的編輯", - "kilocode:autocomplete.commands.category": "Kilo Code", "kilocode:autocomplete.codeAction.title": "Kilo Code:建議的編輯", - "kilocode:autocomplete.chatParticipant.fullName": "Kilo Code 代理程式", - "kilocode:autocomplete.chatParticipant.name": "代理程式", - "kilocode:autocomplete.chatParticipant.description": "我可以協助你處理快速工作和建議的編輯。", "kilocode:autocomplete.incompatibilityExtensionPopup.message": "Kilo Code 自動完成因與 GitHub Copilot 衝突而被封鎖。若要修正此問題,必須停用 Copilot 的內嵌建議。", "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "停用 Copilot", diff --git a/packages/kilo-vscode/src/utils.ts b/packages/kilo-vscode/src/utils.ts index 87ff464e251..fba6bb74b70 100644 --- a/packages/kilo-vscode/src/utils.ts +++ b/packages/kilo-vscode/src/utils.ts @@ -45,6 +45,7 @@ export function buildWebviewHtml( ): string { const nonce = getNonce() const csp = buildCspString(webview.cspSource, nonce, opts.port) + const markdownWorkerUri = opts.workerUri.toString().replace(/shiki-worker\.js$/, "markdown-shiki-worker.js") return ` @@ -82,7 +83,7 @@ export function buildWebviewHtml(
- + ` diff --git a/packages/kilo-vscode/tests/accessibility.spec.ts b/packages/kilo-vscode/tests/accessibility.spec.ts index 151c71efb06..4df3806db55 100644 --- a/packages/kilo-vscode/tests/accessibility.spec.ts +++ b/packages/kilo-vscode/tests/accessibility.spec.ts @@ -13,6 +13,7 @@ const STORIES = [ { id: "settings--providers-configure", name: "Settings / providers empty state" }, { id: "marketplace--empty-list", name: "Marketplace / empty state" }, { id: "agentmanager--sidebar-search-open", name: "Agent Manager / sidebar search" }, + { id: "agentmanager--side-terminal-panel-empty", name: "Agent Manager / side terminal" }, { id: "session-tabs--switcher-open", name: "Session tabs / switcher" }, ] diff --git a/packages/kilo-vscode/tests/markdown-mermaid.spec.ts b/packages/kilo-vscode/tests/markdown-mermaid.spec.ts new file mode 100644 index 00000000000..e18acc7e33f --- /dev/null +++ b/packages/kilo-vscode/tests/markdown-mermaid.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@playwright/test" + +test("renders Mermaid from delimiter-free Markdown source", async ({ page }) => { + await page.goto("/iframe.html?id=shared--markdown-mermaid&viewMode=story") + + const markdown = page.locator('[data-component="markdown"]') + await expect(markdown.getByRole("heading", { name: "Flow" })).toBeVisible() + const diagram = markdown.locator('[data-mermaid-state="rendered"]') + await expect(diagram.locator('svg[aria-roledescription="flowchart-v2"]')).toBeVisible() + + const source = diagram.locator('code[data-lang="mermaid"]') + await expect(source).toContainText("flowchart TD") + await expect(source).not.toContainText("```mermaid") + await expect(markdown.getByText("Rendered after the diagram.")).toBeVisible() +}) diff --git a/packages/kilo-vscode/tests/setup/worker-url.ts b/packages/kilo-vscode/tests/setup/worker-url.ts new file mode 100644 index 00000000000..85b0b76adfb --- /dev/null +++ b/packages/kilo-vscode/tests/setup/worker-url.ts @@ -0,0 +1,11 @@ +import { plugin } from "bun" + +plugin({ + name: "worker-url", + setup(build) { + build.onLoad({ filter: /\?worker&url$/ }, () => ({ + contents: "export default 'test-worker.js'", + loader: "js", + })) + }, +}) 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 ff6a272555d..804471f9bb0 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -42,6 +42,8 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/WorktreeSectionActions.tsx"), path.join(ROOT, "webview-ui/agent-manager/tab-rendering.tsx"), path.join(ROOT, "webview-ui/agent-manager/terminal/TerminalTab.tsx"), + path.join(ROOT, "webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"), + path.join(ROOT, "webview-ui/agent-manager/terminal/TerminalDestinationButton.tsx"), path.join(ROOT, "webview-ui/agent-manager/terminal/SortableTerminalTab.tsx"), path.join(ROOT, "webview-ui/agent-manager/terminal/render.tsx"), path.join(ROOT, "webview-ui/diff-virtual/DiffVirtualApp.tsx"), @@ -186,8 +188,6 @@ describe("Agent Manager Provider Messages", () => { "agentManager.forgetSession", "agentManager.importFromBranch", "agentManager.importFromPR", - "agentManager.importExternalWorktree", - "agentManager.importAllExternalWorktrees", "agentManager.createSection", "agentManager.moveToSection", ] @@ -435,7 +435,6 @@ describe("Agent Manager Provider — onMessage routing", () => { const text = body("onSessionMessage") const show = text.indexOf("this.terminalManager.prepareContext(m.sessionID)") expect(show).toBeGreaterThan(-1) - expect(text).not.toContain("!this.terminalManager.hasActiveTerminal()") expect(text).toContain('type: "terminalContextError"') }) @@ -493,6 +492,19 @@ describe("Agent Manager Provider — onMessage routing", () => { expect(text).toContain("removeWorktree") }) + it("multi-version creation registers each session after publishing its worktree mapping", () => { + const text = body("onCreateMultiVersion") + const ready = text.indexOf("this.notifyWorktreeReady(session.id, wt.result, wt.worktree.id)") + const register = text.indexOf("this.panel?.sessions.registerSession(session)") + const initial = text.indexOf("agentManager.sendInitialMessage") + + expect(ready, "multi-version path must publish ready state").toBeGreaterThan(-1) + expect(register, "multi-version path must register the created session").toBeGreaterThan(-1) + expect(register, "sessionCreated must follow the worktree mapping").toBeGreaterThan(ready) + expect(initial, "initial prompt phase must exist").toBeGreaterThan(-1) + expect(register, "session must be registered before the initial prompt").toBeLessThan(initial) + }) + // -- onPromoteSession invariants ------------------------------------------- /** @@ -531,8 +543,6 @@ describe("Agent Manager Provider — onMessage routing", () => { const pushIdx = text.indexOf("this.pushState()") const readyIdx = text.indexOf("agentManager.worktreeSetup") expect(pushIdx, "pushState must come before worktreeSetup").toBeLessThan(readyIdx) - // Must also send sessionMeta so the webview knows the branch/path - expect(text).toContain("agentManager.sessionMeta") }) // -- agentManager.requestState in non-git workspace ------------------------- @@ -570,7 +580,6 @@ describe("Agent Manager Provider — onMessage routing", () => { const providerText = body("onImportMessage") expect(text).toContain("class WorktreeImporter") expect(text).toContain("createFromPR") - expect(text).toContain("listExternalWorktrees") expect(text).toContain("createWorktree") expect(providerText).toContain("this.importer") }) @@ -788,6 +797,10 @@ const VSCODE_ALLOWED: Record = { "terminal-font.ts": { note: "vscode config reader for integrated terminal font settings", }, + // Reads + watches the terminal button destination setting + "terminal-destination.ts": { + note: "vscode config reader for the terminal destination setting", + }, } /** diff --git a/packages/kilo-vscode/tests/unit/agent-manager-i18n.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-i18n.test.ts index 691b8a7ab67..8142d2c20c0 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-i18n.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-i18n.test.ts @@ -26,6 +26,8 @@ const ROOT = path.resolve(import.meta.dir, "../..") const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"), path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"), + path.join(ROOT, "webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"), + path.join(ROOT, "webview-ui/agent-manager/terminal/TerminalDestinationButton.tsx"), ] /** diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-destination.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-destination.test.ts new file mode 100644 index 00000000000..40330aa29c4 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-destination.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test" +import { affectsTerminalDestination, resolveTerminalDestination } from "../../src/agent-manager/terminal-destination" + +function event(key: string) { + return { + affectsConfiguration: (target: string) => target === key, + } as Parameters[0] +} + +describe("Agent Manager terminal destination", () => { + it("defaults unknown settings to the VS Code terminal", () => { + expect(resolveTerminalDestination(undefined)).toBe("vscode") + expect(resolveTerminalDestination("invalid")).toBe("vscode") + expect(resolveTerminalDestination("vscode")).toBe("vscode") + expect(resolveTerminalDestination("agentManager")).toBe("agentManager") + }) + + it("watches only the terminal button destination setting", () => { + expect(affectsTerminalDestination(event("kilo-code.new.agentManager.terminalButtonDestination"))).toBe(true) + expect(affectsTerminalDestination(event("terminal.integrated.fontFamily"))).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts index 9bf257a8c3b..64504d2600b 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts @@ -61,7 +61,14 @@ describe("Agent Manager terminal font", () => { getTerminalFont: () => font, }) - expect(router.handle({ type: "agentManager.terminal.create", worktreeId: null })).toBe(true) + expect( + router.handle({ + type: "agentManager.terminal.create", + createId: "font-1", + placement: "tab", + worktreeId: null, + }), + ).toBe(true) }) const created = await message @@ -82,9 +89,12 @@ describe("Agent Manager terminal font", () => { saveTabMemory: () => undefined, setSelection: () => undefined, showError: () => undefined, + postMessage: () => undefined, }) const message = { type: "agentManager.terminal.created", + createId: "font-2", + placement: "tab", worktreeId: null, terminalId: "terminal-1", title: "Terminal 1", diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts new file mode 100644 index 00000000000..bfa243d1fc8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "bun:test" +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { TerminalRouter } from "../../src/agent-manager/terminal-routing" +import type { AgentManagerOutMessage } from "../../src/agent-manager/types" + +const font = { fontFamily: "Menlo", fontSize: 12 } + +function wait() { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe("Agent Manager terminal routing", () => { + it("round-trips side placement and rejects missing worktrees", async () => { + const messages: AgentManagerOutMessage[] = [] + const client = { + pty: { + create: async () => ({ data: { id: "pty-1", title: "Terminal 1" } }), + remove: async () => ({ data: true }), + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: (id) => (id === "wt-1" ? "/workspace/wt-1" : undefined), + log: () => undefined, + post: (message) => messages.push(message), + getTerminalFont: () => font, + }) + + router.handle({ + type: "agentManager.terminal.create", + createId: "side-1", + placement: "side", + worktreeId: "wt-1", + }) + await wait() + expect(messages[0]).toMatchObject({ + type: "agentManager.terminal.created", + createId: "side-1", + placement: "side", + worktreeId: "wt-1", + }) + + router.handle({ + type: "agentManager.terminal.create", + createId: "side-missing", + placement: "side", + worktreeId: "missing", + }) + expect(messages[1]).toMatchObject({ + type: "agentManager.terminal.error", + createId: "side-missing", + }) + await router.dispose() + }) + + it("isolates a reopened panel from an in-flight disposal", async () => { + const messages: AgentManagerOutMessage[] = [] + const removed: string[] = [] + const resolvers: Array<(value: { data: { id: string; title: string } }) => void> = [] + let creates = 0 + const client = { + pty: { + create: () => + new Promise<{ data: { id: string; title: string } }>((resolve) => { + creates++ + if (creates === 1) resolvers.push(resolve) + else resolve({ data: { id: "pty-new", title: "Terminal 1" } }) + }), + remove: async ({ ptyID }: { ptyID: string }) => { + removed.push(ptyID) + return { data: true } + }, + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + log: () => undefined, + post: (message) => messages.push(message), + getTerminalFont: () => font, + }) + + router.handle({ + type: "agentManager.terminal.create", + createId: "old", + placement: "side", + worktreeId: null, + }) + await router.dispose() + router.handle({ + type: "agentManager.terminal.create", + createId: "new", + placement: "side", + worktreeId: null, + }) + resolvers[0]?.({ data: { id: "pty-old", title: "Terminal 1" } }) + await wait() + + expect(messages).toHaveLength(1) + expect(messages[0]).toMatchObject({ type: "agentManager.terminal.created", createId: "new" }) + expect(removed).toContain("pty-old") + await router.dispose() + expect(removed).toContain("pty-new") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts new file mode 100644 index 00000000000..199d3adca2b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "bun:test" +import { createSideTerminal } from "../../webview-ui/agent-manager/terminal/side" + +function scene(opts: { destination?: "vscode" | "agentManager"; visible?: boolean; focused?: boolean } = {}) { + const calls = { + requestSide: 0, + closeSide: 0, + hide: 0, + refocus: 0, + openVscode: 0, + posted: [] as Array>, + tracked: [] as string[], + } + let visible = opts.visible ?? false + let focused = opts.focused ?? false + const ctl = createSideTerminal({ + handlers: { + requestSide: () => { + calls.requestSide++ + visible = true + }, + closeSide: () => { + calls.closeSide++ + visible = false + return true + }, + }, + visible: () => visible, + focused: () => focused, + hide: () => { + calls.hide++ + visible = false + }, + refocus: () => calls.refocus++, + postMessage: (msg) => calls.posted.push(msg as Record), + track: (button) => calls.tracked.push(button), + openVscode: () => calls.openVscode++, + }) + if (opts.destination) ctl.setDestination(opts.destination) + return { ctl, calls } +} + +describe("Agent Manager side terminal controller", () => { + it("toggles the panel and hands focus to the chat only when the terminal had it", () => { + const focused = scene({ destination: "agentManager", visible: true, focused: true }) + focused.ctl.toggle() + expect(focused.calls.hide).toBe(1) + expect(focused.calls.refocus).toBe(1) + + const elsewhere = scene({ destination: "agentManager", visible: true, focused: false }) + elsewhere.ctl.toggle() + expect(elsewhere.calls.hide).toBe(1) + expect(elsewhere.calls.refocus).toBe(0) + + const hidden = scene({ destination: "agentManager", visible: false }) + hidden.ctl.toggle() + expect(hidden.calls.requestSide).toBe(1) + expect(hidden.calls.hide).toBe(0) + }) + + it("refocuses the chat after killing a focused terminal, not otherwise", () => { + const focused = scene({ focused: true }) + expect(focused.ctl.close()).toBe(true) + expect(focused.calls.closeSide).toBe(1) + expect(focused.calls.refocus).toBe(1) + + const elsewhere = scene({ focused: false }) + expect(elsewhere.ctl.close()).toBe(true) + expect(elsewhere.calls.refocus).toBe(0) + }) + + it("routes the primary action by destination", () => { + const vscodeFirst = scene({ destination: "vscode" }) + vscodeFirst.ctl.openPreferred("tab_toolbar") + expect(vscodeFirst.calls.openVscode).toBe(1) + expect(vscodeFirst.calls.requestSide).toBe(0) + + const panelFirst = scene({ destination: "agentManager" }) + panelFirst.ctl.openPreferred("keyboard_shortcut") + expect(panelFirst.calls.requestSide).toBe(1) + expect(panelFirst.calls.openVscode).toBe(0) + }) + + it("persists the picked destination with a section-relative settings key", () => { + const item = scene() + item.ctl.choose("agentManager") + expect(item.ctl.destination()).toBe("agentManager") + expect(item.calls.posted).toEqual([ + { type: "updateSetting", key: "agentManager.terminalButtonDestination", value: "agentManager" }, + ]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts new file mode 100644 index 00000000000..432eed1fb6b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { LOCAL } from "../../webview-ui/agent-manager/navigate" +import { + createTerminalHandlers, + createTerminalMessageHandler, + createTerminalState, +} from "../../webview-ui/agent-manager/terminal/state" +import type { ExtensionMessage } from "../../webview-ui/src/types/messages/extension-messages" + +const font = { fontFamily: "Menlo", fontSize: 12 } + +function scene(initial: string | null = LOCAL) { + const [selection, setSelection] = createSignal(initial) + const state = createTerminalState(selection) + const posted: Array> = [] + const events = { activated: [] as string[], selected: [] as string[], saved: 0, shown: [] as string[], hidden: 0 } + const tabs = () => state.current().map((term) => term.id) + const handlers = createTerminalHandlers({ + state, + tabIds: tabs, + selectReview: () => undefined, + selectSessionTab: () => undefined, + clearSession: () => undefined, + resetOthers: () => undefined, + isPendingId: () => false, + findTab: () => undefined, + postMessage: (message) => posted.push(message as Record), + onShowSide: (key) => events.shown.push(key), + onHideSide: () => events.hidden++, + getSelection: selection, + LOCAL, + REVIEW_TAB_ID: "review", + }) + const dispatch = createTerminalMessageHandler({ + state, + activate: (id) => events.activated.push(id), + saveTabMemory: () => events.saved++, + setSelection: (value) => { + events.selected.push(value) + setSelection(value) + }, + showError: () => undefined, + postMessage: (message) => posted.push(message as Record), + }) + return { state, selection, setSelection, posted, events, handlers, dispatch } +} + +describe("Agent Manager terminal state", () => { + it("keeps side terminals out of the tab state and shares root context with unassigned sessions", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { + id: "terminal:tab", + title: "Terminal 1", + wsUrl: "ws://tab", + font, + placement: "tab", + }) + item.state.add(null, { + id: "terminal:side", + title: "Terminal 2", + wsUrl: "ws://side", + font, + placement: "side", + }) + + expect(item.state.current().map((term) => term.id)).toEqual(["terminal:tab"]) + expect(item.state.all().map((term) => term.id)).toEqual(["terminal:tab"]) + expect(item.state.sides().map((term) => term.id)).toEqual(["terminal:side"]) + expect(item.state.side()?.id).toBe("terminal:side") + + item.setSelection(null) + expect(item.state.current()).toEqual([]) + expect(item.state.sideKey()).toBe(LOCAL) + expect(item.state.side()?.id).toBe("terminal:side") + dispose() + }) + }) + + it("deduplicates side creation and reuses the terminal without tab side effects", () => { + createRoot((dispose) => { + const item = scene() + item.handlers.requestSide() + item.handlers.requestSide() + + expect(item.posted).toHaveLength(1) + const request = item.posted[0]! + expect(request).toMatchObject({ type: "agentManager.terminal.create", placement: "side", worktreeId: null }) + const createId = String(request.createId) + const created = { + type: "agentManager.terminal.created", + createId, + placement: "side", + worktreeId: null, + terminalId: "terminal:side", + title: "Terminal 1", + wsUrl: "ws://side", + font, + } satisfies ExtensionMessage + expect(item.dispatch(created)).toBe(true) + expect(item.state.side()?.id).toBe("terminal:side") + expect(item.events.activated).toEqual([]) + expect(item.events.selected).toEqual([]) + expect(item.events.saved).toBe(0) + + item.handlers.requestSide() + expect(item.posted).toHaveLength(1) + expect(item.state.focusRequest()?.id).toBe("terminal:side") + dispose() + }) + }) + + it("creates explicit terminal tabs independently of the side destination", () => { + createRoot((dispose) => { + const item = scene("wt-1") + item.handlers.requestNew() + expect(item.posted[0]).toMatchObject({ + type: "agentManager.terminal.create", + placement: "tab", + worktreeId: "wt-1", + }) + dispose() + }) + }) + + it("cancels a side terminal that is still starting", () => { + createRoot((dispose) => { + const item = scene() + item.handlers.requestSide() + const request = item.posted[0]! + expect(item.handlers.closeSide()).toBe(true) + expect(item.state.pendingSide(LOCAL)).toBeUndefined() + + item.dispatch({ + type: "agentManager.terminal.created", + createId: String(request.createId), + placement: "side", + worktreeId: null, + terminalId: "terminal:late", + title: "Terminal 1", + wsUrl: "ws://late", + font, + }) + expect(item.state.side()).toBeUndefined() + expect(item.posted.at(-1)).toEqual({ type: "agentManager.terminal.close", terminalId: "terminal:late" }) + dispose() + }) + }) + + it("closes a side terminal without changing the active chat tab", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { + id: "terminal:side", + title: "Terminal 1", + wsUrl: "ws://side", + font, + placement: "side", + }) + expect(item.handlers.closeSide()).toBe(true) + expect(item.state.side()).toBeUndefined() + expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:side" }]) + expect(item.events.hidden).toBe(1) + dispose() + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/diff-session-source.test.ts b/packages/kilo-vscode/tests/unit/diff-session-source.test.ts index ce870f0abbe..081fec45caf 100644 --- a/packages/kilo-vscode/tests/unit/diff-session-source.test.ts +++ b/packages/kilo-vscode/tests/unit/diff-session-source.test.ts @@ -91,6 +91,39 @@ describe("createSessionDiffSource.fetch", () => { expect(result.diffs[2]?.summarized).toBe(true) }) + it("keeps valid files visible when one persisted patch is malformed", async () => { + const malformed = [ + "diff --git a/broken.ts b/broken.ts", + "--- a/broken.ts", + "+++ b/broken.ts", + "@@ -1,2 +1,2 @@", + "-old", + "+new", + "", + ].join("\n") + const { fetch } = recording([ + { file: "broken.ts", patch: malformed, additions: 1, deletions: 1, status: "modified" }, + { file: "foo.ts", patch: modifiedPatch, additions: 1, deletions: 1, status: "modified" }, + ]) + + const result = await createSessionDiffSource("s-malformed", fetch, "/repo").fetch() + + expect(result.diffs).toHaveLength(2) + expect(result.diffs[0]).toMatchObject({ + file: "broken.ts", + before: "", + after: "", + patch: malformed, + summarized: true, + }) + expect(result.diffs[1]).toMatchObject({ + file: "foo.ts", + before: "keep\nold\n", + after: "keep\nnew\n", + summarized: false, + }) + }) + it("rebuilds text-backed SVG snapshot sides without sending them to Pierre", async () => { const before = '' const after = '' diff --git a/packages/kilo-vscode/tests/unit/early-message.test.ts b/packages/kilo-vscode/tests/unit/early-message.test.ts new file mode 100644 index 00000000000..6075faff48a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/early-message.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "bun:test" +import { routeEarlyMessage } from "../../src/kilo-provider/early-message" + +type Ctx = Parameters[1] + +function context(copied: string[], posted: unknown[], fail = false) { + return { + copy: async (text: string) => { + if (fail) throw new Error("clipboard unavailable") + copied.push(text) + }, + post: (message: unknown) => posted.push(message), + } as Ctx +} + +describe("routeEarlyMessage clipboard handling", () => { + it("routes clipboard text to the host", async () => { + const copied: string[] = [] + const posted: unknown[] = [] + + const handled = await routeEarlyMessage( + { type: "copyToClipboard", id: "copy-1", text: "message text" }, + context(copied, posted), + ) + + expect(handled).toBe(true) + expect(copied).toEqual(["message text"]) + expect(posted).toEqual([{ type: "clipboardWriteResult", id: "copy-1", ok: true }]) + }) + + it("reports host clipboard failures", async () => { + const copied: string[] = [] + const posted: unknown[] = [] + + const handled = await routeEarlyMessage( + { type: "copyToClipboard", id: "copy-2", text: "message text" }, + context(copied, posted, true), + ) + + expect(handled).toBe(true) + expect(copied).toEqual([]) + expect(posted).toEqual([{ type: "clipboardWriteResult", id: "copy-2", ok: false, error: "clipboard unavailable" }]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/extension-arch.test.ts b/packages/kilo-vscode/tests/unit/extension-arch.test.ts index 4ff0d758ec8..3e8e4d30677 100644 --- a/packages/kilo-vscode/tests/unit/extension-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/extension-arch.test.ts @@ -16,6 +16,7 @@ const PKG_JSON_FILE = path.join(ROOT, "package.json") const SRC_DIR = path.join(ROOT, "src") const EXTENSION_FILE = path.join(ROOT, "src/extension.ts") const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") +const SETTINGS_PROVIDER_FILE = path.join(ROOT, "src/SettingsEditorProvider.ts") const VSCODE_HOST_FILE = path.join(ROOT, "src/agent-manager/vscode-host.ts") function sliceBlock(source: string, start: number): string { @@ -116,6 +117,36 @@ describe("Extension — package.json command sync", () => { }) }) + it("keeps Agent Manager terminal shortcuts distinct", () => { + const terminal = pkg.contributes?.keybindings?.find( + (item: { command: string }) => item.command === "kilo-code.new.agentManager.showTerminal", + ) + const create = pkg.contributes?.keybindings?.find( + (item: { command: string }) => item.command === "kilo-code.new.agentManager.newTerminal", + ) + expect(terminal).toMatchObject({ + key: "ctrl+/", + mac: "cmd+/", + when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'", + }) + expect(create).toMatchObject({ + key: "ctrl+shift+t", + mac: "cmd+shift+t", + when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'", + }) + }) + + it("declares the Agent Manager terminal destination setting", () => { + const setting = pkg.contributes?.configuration?.properties?.["kilo-code.new.agentManager.terminalButtonDestination"] + expect(setting).toMatchObject({ + type: "string", + scope: "application", + default: "vscode", + enum: ["vscode", "agentManager"], + }) + expect(setting.enumDescriptions).toHaveLength(setting.enum.length) + }) + it("scopes the open PR shortcut to Agent Manager", () => { const binding = pkg.contributes?.keybindings?.find( (item: { command: string }) => item.command === "kilo-code.new.agentManager.openPR", @@ -126,6 +157,23 @@ describe("Extension — package.json command sync", () => { when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'", }) }) + + it("scopes agent mode shortcuts to focused Kilo webviews", () => { + const bindings = pkg.contributes?.keybindings?.filter( + (item: { command: string }) => + item.command === "kilo-code.new.cycleAgentMode" || item.command === "kilo-code.new.cyclePreviousAgentMode", + ) + const when = + "kilo-code.new.sidebarFocused || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'" + + expect(bindings).toHaveLength(2) + expect(bindings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ command: "kilo-code.new.cycleAgentMode", when }), + expect.objectContaining({ command: "kilo-code.new.cyclePreviousAgentMode", when }), + ]), + ) + }) }) // --------------------------------------------------------------------------- @@ -202,6 +250,32 @@ describe("Extension — KiloProvider handler wiring", () => { }) }) +describe("Extension — editor panel placement", () => { + const ext = fs.readFileSync(EXTENSION_FILE, "utf-8") + const settings = fs.readFileSync(SETTINGS_PROVIDER_FILE, "utf-8") + + it("opens Kilo as a tab in the active editor group", () => { + const fn = ext.indexOf("function openKiloInNewTab") + expect(fn, "openKiloInNewTab must exist").toBeGreaterThan(-1) + const body = sliceBlock(ext, fn) + + expect(body).toContain("vscode.ViewColumn.Active") + expect(body).not.toContain("visibleTextEditors") + expect(body).not.toContain("workbench.action.newGroupRight") + expect(body).not.toContain("workbench.action.lockEditorGroup") + }) + + it("opens and reveals Settings in the active editor group", () => { + const fn = settings.indexOf("openPanel(view") + expect(fn, "SettingsEditorProvider.openPanel must exist").toBeGreaterThan(-1) + const body = sliceBlock(settings, fn) + + expect(body).toContain("existing.reveal(vscode.ViewColumn.Active)") + expect(body.match(/vscode\.ViewColumn\.Active/g)).toHaveLength(2) + expect(body).not.toContain("vscode.ViewColumn.One") + }) +}) + // --------------------------------------------------------------------------- // KiloProvider — continueInWorktree error fallback // diff --git a/packages/kilo-vscode/tests/unit/i18n-unused-keys.test.ts b/packages/kilo-vscode/tests/unit/i18n-unused-keys.test.ts new file mode 100644 index 00000000000..ec10e384eb3 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/i18n-unused-keys.test.ts @@ -0,0 +1,153 @@ +/** + * Localization lint: unused translation keys (all Kilo-owned pools) + * + * Ensures every key in every Kilo-owned english dictionary is either + * referenced from repository source or explicitly protected as a runtime + * protocol key. Keys that lose their last usage during refactors tend to + * linger in all 20 locale files forever; this test fails for review instead. + * + * Complements i18n-keys.test.ts, which checks the opposite direction + * (t() calls referencing keys missing from the dictionaries). + * + * Covered pools: + * app webview sidebar dict (webview-ui/src/i18n) + * kilo-i18n shared webview overrides (packages/kilo-i18n) + * agent-manager agent manager webview dict + * cli-backend extension server/remote dict + * host extension host dict (autocomplete) + * + * The upstream @opencode-ai/ui dict (packages/ui/src/i18n) is intentionally + * NOT covered: it is upstream-owned code where removals create merge + * conflicts, and its keys may be consumed by upstream surfaces outside + * this repo. + * + * This is intentionally conservative: false "used" results are acceptable, + * but a live key must never be classified as unused. A key counts as used + * when either: + * - a quoted string literal equal to the key appears in scanned code + * (covers t("key") calls, ternaries producing key strings, key strings + * embedded in the CLI backend that travel to the webview as data, e.g. + * provider metadata noteKeys in opencode/src/kilocode/provider), or + * - a template literal with a static dotted head ending right before ${} + * covers the key by prefix (e.g. t(`agentManager.setup.error.${code}`), + * t(`workStyle.choice.${choice}.title`), const k = `a.b.${x}`). + * - a quoted dotted prefix appears in production source (conservatively + * covering concatenation and indirect prefix reuse), or + * - the pool explicitly protects a runtime namespace used by backend + * payloads. Add indirect/external keys there rather than deleting them. + */ + +import { describe, it, expect } from "bun:test" +import { Glob } from "bun" +import path from "node:path" + +import { dict as appEn } from "../../webview-ui/src/i18n/en" +import { dict as kiloEn } from "../../../kilo-i18n/src/en" +import { dict as amEn } from "../../webview-ui/agent-manager/i18n/en" +import { dict as cliEn } from "../../src/services/cli-backend/i18n/en" +import { dict as hostEn } from "../../src/services/i18n/en" + +const REPO = path.resolve(import.meta.dir, "../../../..") +const VSCODE = path.join(REPO, "packages/kilo-vscode") + +// Webview key strings are consumed not only in the webview itself but also +// embedded as data in the CLI backend / extension host (provider metadata, +// question flows), so those sources are scanned for webview pools too. +const WEBVIEW_ROOTS = [ + VSCODE, + path.join(REPO, "packages/kilo-ui"), + path.join(REPO, "packages/opencode"), + path.join(REPO, "packages/kilo-gateway"), +] + +const pools = [ + { name: "app", dict: appEn, roots: WEBVIEW_ROOTS, runtime: ["settings.providers.note."] }, + // kilo-i18n also overrides upstream ui.* keys consumed by components in + // packages/ui/src (and the TUI), so those count as usage too. + { + name: "kilo-i18n", + dict: kiloEn, + roots: [...WEBVIEW_ROOTS, path.join(REPO, "packages/ui"), path.join(REPO, "packages/tui")], + runtime: ["plan.followup.", "snapshot.slowRepo.", "settings.providers.note."], + }, + { name: "agent-manager", dict: amEn, roots: WEBVIEW_ROOTS, runtime: ["agentManager.setup.error."] }, + { name: "cli-backend", dict: cliEn, roots: [VSCODE], runtime: [] }, + { name: "host", dict: hostEn, roots: [VSCODE], runtime: [] }, +] as const + +const EXACT_RE = /["'`]([a-zA-Z][a-zA-Z0-9_]*(?:[.:][a-zA-Z0-9_]+)+)["'`]/g +const DYNAMIC_RE = /`([a-zA-Z][a-zA-Z0-9_]*(?:[.:][a-zA-Z0-9_]+)*[.:])\$\{/g +const PREFIX_RE = /["'`]([a-zA-Z][a-zA-Z0-9_]*(?:[.:][a-zA-Z0-9_]+)*[.:])["'`]/g +const SOURCE_GLOB = "**/*.{ts,tsx,js,jsx,mjs,cjs,json,jsonc,json5,yaml,yml,toml,md,mdx,html,xml,properties,kt,kts,java}" +const SKIP = ["/i18n/", "/tests/", "/test/", "/node_modules/", "/dist/", "/out/", "/build/", "/coverage/", "/.turbo/"] + +interface Usage { + exact: Set + prefixes: Set +} + +const cache = new Map>() + +function scan(root: string): Promise { + const hit = cache.get(root) + if (hit) return hit + const job = (async (): Promise => { + const glob = new Glob(SOURCE_GLOB) + const exact = new Set() + const prefixes = new Set() + for await (const file of glob.scan({ cwd: root, absolute: true })) { + // skip the locale dictionaries themselves — they contain every key + if (SKIP.some((part) => file.includes(part)) || file.includes(".test.") || file.includes(".spec.")) continue + const text = await Bun.file(file).text() + for (const match of text.matchAll(EXACT_RE)) { + const key = match[1] + if (!key) continue + exact.add(key) + } + for (const match of text.matchAll(DYNAMIC_RE)) { + if (match[1]) prefixes.add(match[1]) + } + for (const match of text.matchAll(PREFIX_RE)) { + if (match[1]) prefixes.add(match[1]) + } + } + return { exact, prefixes } + })() + cache.set(root, job) + return job +} + +async function unused( + dict: Record, + roots: readonly string[], + runtime: readonly string[], +): Promise { + const exact = new Set() + const prefixes = new Set() + for (const root of roots) { + const usage = await scan(root) + for (const key of usage.exact) exact.add(key) + for (const key of usage.prefixes) prefixes.add(key) + } + return Object.keys(dict).filter((key) => { + if (exact.has(key)) return false + if (runtime.some((prefix) => key.startsWith(prefix))) return false + for (const prefix of prefixes) { + if (key.startsWith(prefix)) return false + } + return true + }) +} + +describe("i18n — no unused keys", () => { + for (const pool of pools) { + it(`references every ${pool.name} dictionary key from code`, async () => { + const dead = await unused(pool.dict, pool.roots, pool.runtime) + const report = dead.map((key) => ` "${key}": "${pool.dict[key as keyof typeof pool.dict]}"`).join("\n") + expect( + dead, + `Found ${dead.length} apparently unused ${pool.name} i18n key(s). Do not delete automatically: verify runtime/external consumers, then either add a source reference, protect the runtime namespace in this test, or remove the key from every locale:\n${report}`, + ).toEqual([]) + }) + } +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts index 7c226ecaba0..3cef7f06794 100644 --- a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts @@ -19,6 +19,7 @@ import path from "node:path" const MONOREPO_ROOT = path.resolve(import.meta.dir, "../../../..") const KILO_UI_DIR = path.join(MONOREPO_ROOT, "packages/kilo-ui") +const WORKER_URL = path.join(MONOREPO_ROOT, "packages/kilo-vscode/tests/setup/worker-url.ts") const BASIC_TOOL_FILE = path.join(MONOREPO_ROOT, "packages/ui/src/components/basic-tool.tsx") const DATA_CONTEXT_FILE = path.join(MONOREPO_ROOT, "packages/ui/src/context/data.tsx") const MESSAGE_PART_FILE = path.join(MONOREPO_ROOT, "packages/ui/src/components/message-part.tsx") @@ -44,7 +45,7 @@ const TRANSCRIPT_PARTS_FILE = path.join(MONOREPO_ROOT, "packages/kilo-vscode/web const CHAT_LAYOUT_FILE = path.join(MONOREPO_ROOT, "packages/kilo-vscode/webview-ui/src/styles/chat-layout.css") function check(code: string): { ok: boolean; output: string } { - const result = Bun.spawnSync(["bun", "--conditions=browser", "-e", code], { + const result = Bun.spawnSync(["bun", "--preload", WORKER_URL, "--conditions=browser", "-e", code], { cwd: KILO_UI_DIR, stdout: "pipe", stderr: "pipe", diff --git a/packages/kilo-vscode/tests/unit/markdown-raf-coalesce.test.ts b/packages/kilo-vscode/tests/unit/markdown-raf-coalesce.test.ts deleted file mode 100644 index 8a582935abc..00000000000 --- a/packages/kilo-vscode/tests/unit/markdown-raf-coalesce.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect } from "bun:test" -import { readFileSync } from "node:fs" -import { join } from "node:path" - -/** - * Regression guard for the markdown rAF-coalesced parse fix. - * - * PROBLEM: - * The `Markdown` component's render effect did `temp.innerHTML = content` - * + `morphdom(...)` on every update. During LLM token streaming, this - * fired 60–200 times per second, reparsing the entire accumulated HTML - * every time. CPU profile of a 7s streaming window showed 2,940 ParseHTML - * events (~619ms, ~46% of blocked main-thread time). - * - * FIX: - * Queue the latest content in a component-scoped variable and run the - * morphdom pass inside a requestAnimationFrame callback. Further updates - * before the frame fires simply overwrite the pending content — K rapid - * token updates collapse to 1 parse. The onCleanup handler cancels any - * queued frame so it doesn't touch the unmounted DOM. - * - * For the matching runtime assertion, see - * `tests/webview-reactivity/markdown-parse-rate.test.ts`. - */ -describe("Markdown rAF-coalesced parse — regression guard", () => { - const path = join(__dirname, "..", "..", "..", "ui", "src", "components", "markdown.tsx") - const helper = join(__dirname, "..", "..", "..", "ui", "src", "kilocode", "markdown-stream-highlight.ts") - - const stripComments = (src: string): string => - src - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/\{\/\*[\s\S]*?\*\/\}/g, "") - .replace(/^\s*\/\/.*$/gm, "") - - const src = stripComments(readFileSync(path, "utf8")) - const body = stripComments(readFileSync(helper, "utf8")) - - it("render effect uses requestAnimationFrame to coalesce parses", () => { - // Locate the createEffect that owns the morphdom call. - const match = src.match(/createEffect\s*\(\s*\(\s*\)\s*=>\s*\{[\s\S]*?morphdom\s*\([\s\S]*?^\s*\}\s*\)/m) - expect(match, "render createEffect must contain a morphdom call").toBeTruthy() - const body = match![0] - expect(body).toMatch(/requestAnimationFrame/) - }) - - it("cleans up the queued frame on dispose", () => { - expect(src).toMatch(/cancelAnimationFrame/) - }) - - it("exposes a pending frame/content state scoped to the component", () => { - // Any of these forms count. We just need the state to exist so that - // rapid updates can collapse into it. - expect(src).toMatch(/\b(pendingFrame|pendingContent)\b/) - }) - - it("delegates streamed Shiki refreshes to the Kilo-owned helper", () => { - expect(src).toContain("preserveStreamingHighlight(fromEl, toEl, local.streaming ?? false)") - expect(body).toContain("export function preserveStreamingHighlight") - expect(body).toMatch(/continues\(before, after\)[\s\S]*queue\(from, after, lang\)/) - expect(body).toMatch(/const done = \(\) => \{\s*job\.busy = false\s*if \(!pre\.isConnected\) return/) - }) -}) diff --git a/packages/kilo-vscode/tests/unit/session-agent.test.ts b/packages/kilo-vscode/tests/unit/session-agent.test.ts index d72839a6f80..a6da4e4e177 100644 --- a/packages/kilo-vscode/tests/unit/session-agent.test.ts +++ b/packages/kilo-vscode/tests/unit/session-agent.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "bun:test" import { + cycleAgent, createDraftAgentSeed, draftAgentSelection, resolveSessionAgent, @@ -81,6 +82,54 @@ describe("resolveSessionAgent", () => { }) }) +describe("cycleAgent", () => { + const agents = [ + { name: "ask", mode: "primary" }, + { name: "plan", mode: "primary" }, + { name: "task", mode: "subagent" }, + { name: "hidden", mode: "primary", hidden: true }, + { name: "code", mode: "primary" }, + ] + + function cycle(current: string, direction: 1 | -1, scope = "pending-1") { + const calls: Array<[string, string | undefined]> = [] + const name = cycleAgent({ + agents, + scope, + direction, + selected: (id) => { + expect(id).toBe(scope) + return current + }, + select: (agent, id) => calls.push([agent, id]), + }) + return { name, calls } + } + + it("cycles the same pending scope read by the visible selector", () => { + expect(cycle("ask", 1)).toEqual({ name: "plan", calls: [["plan", "pending-1"]] }) + expect(cycle("ask", -1)).toEqual({ name: "code", calls: [["code", "pending-1"]] }) + }) + + it("wraps and starts from the first agent when the selection is unknown", () => { + expect(cycle("code", 1).name).toBe("ask") + expect(cycle("missing", 1).name).toBe("ask") + }) + + it("does nothing when there is no alternative", () => { + const selected: string[] = [] + expect( + cycleAgent({ + agents: [{ name: "code" }], + direction: 1, + selected: () => "code", + select: (name) => selected.push(name), + }), + ).toBeUndefined() + expect(selected).toEqual([]) + }) +}) + describe("draftAgentSelection", () => { it("carries a pending agent into a new draft scope", () => { const result = draftAgentSelection({}, "draft-1", "plan") diff --git a/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts b/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts index da340afc0b9..4e763196bfa 100644 --- a/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts @@ -130,11 +130,6 @@ describe("SessionTerminalManager structure", () => { expect(text).toContain("panel command registration skipped") }) - it("exposes active terminal state for terminal context routing", () => { - const text = body("hasActiveTerminal") - expect(text).toContain("this.host.activeTerminal()") - }) - it("resolves the session that owns the active managed terminal", () => { const text = body("activeSession") expect(text).toContain("this.host.activeTerminal()") diff --git a/packages/kilo-vscode/tests/unit/transcript-parts.test.ts b/packages/kilo-vscode/tests/unit/transcript-parts.test.ts index 8d0b9abccc3..c94054a5d5a 100644 --- a/packages/kilo-vscode/tests/unit/transcript-parts.test.ts +++ b/packages/kilo-vscode/tests/unit/transcript-parts.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test" import path from "node:path" const WEBVIEW = path.resolve(import.meta.dir, "../../webview-ui") +const WORKER_URL = path.resolve(import.meta.dir, "../setup/worker-url.ts") const PASS = "TRANSCRIPT_PARTS_PASS" const FAIL = "TRANSCRIPT_PARTS_FAIL:" @@ -61,7 +62,7 @@ const SCRIPT = ` describe("transcript parts", () => { it("keeps timeline candidates aligned with visible transcript parts", () => { - const result = Bun.spawnSync(["bun", "--conditions=browser", "-e", SCRIPT], { + const result = Bun.spawnSync(["bun", "--preload", WORKER_URL, "--conditions=browser", "-e", SCRIPT], { cwd: WEBVIEW, stdout: "pipe", stderr: "pipe", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 60252b3977b..ce6f795941b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -13,7 +13,6 @@ import { type JSX, } from "solid-js" import type { - ExtensionMessage, AgentManagerRepoInfoMessage, AgentManagerWorktreeSetupMessage, AgentManagerStateMessage, @@ -70,7 +69,6 @@ import { Button } from "@kilocode/kilo-ui/button" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Spinner } from "@kilocode/kilo-ui/spinner" import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" -import { Popover } from "@kilocode/kilo-ui/popover" import { VSCodeProvider, useVSCode } from "../src/context/vscode" import { ServerProvider } from "../src/context/server" import { ProviderProvider } from "../src/context/provider" @@ -120,7 +118,15 @@ import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order" import { createTabOrderSync } from "./tab-order-sync" import { reportRemoteSessions, reportVisibleSession, visible } from "./remote-sessions" import { ConstrainDragYAxis } from "../src/components/chat/TabDnd" -import { isTerminalTabId, createTerminalState, createTerminalHandlers, createTerminalMessageHandler } from "./terminal" +import { + SideTerminalPanel, + TerminalDestinationButton, + isTerminalTabId, + createTerminalState, + createTerminalHandlers, + createTerminalMessageHandler, + createSideTerminal, +} from "./terminal" import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering" import { useTabScroll } from "./tab-scroll" import { DiffPanel } from "./DiffPanel" @@ -160,6 +166,7 @@ import { buildShortcutCategories } from "./shortcuts" import { tracker } from "./telemetry" import "./agent-manager.css" import "./agent-manager-review.css" +import { cycleAgent as cycle } from "../src/context/session-agent" const REVIEW_TAB_ID = "review" interface SetupState { @@ -184,7 +191,7 @@ interface ApplyState { } /** Sidebar selection: LOCAL for local repo, worktree ID for a worktree, or null for an unassigned session. */ type SidebarSelection = typeof LOCAL | string | null -type SidePanel = "diff" | "pr" | null +type SidePanel = "diff" | "pr" | "terminal" | null const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) // Fallback keybindings before extension sends resolved ones const MAX_JUMP_INDEX = 9 @@ -269,8 +276,8 @@ const AgentManagerContent: Component = () => { // rAF coalescing for resize handlers — at most one signal write per frame let sidebarRaf: number | undefined let pendingSidebarWidth: number | undefined - let diffRaf: number | undefined - let pendingDiffWidth: number | undefined + let sideRaf: number | undefined + let pendingSideWidth: number | undefined const [history, setHistory] = createSignal(false) const [sidePanel, setSidePanel] = createSignal(null) @@ -278,7 +285,33 @@ const AgentManagerContent: Component = () => { const [diffDatas, setDiffDatas] = createSignal>({}) const [diffLoading, setDiffLoading] = createSignal(false) const [diffFileLoading, setDiffFileLoading] = createSignal>>({}) + // The diff and terminal panels each remember their own width: a diff + // benefits from half the window, a terminal only needs about a third. + const TERMINAL_MIN_WIDTH = 360 + const TERMINAL_MAX_WIDTH = 640 const [diffWidth, setDiffWidth] = createSignal(Math.round(window.innerWidth * 0.5)) + const [terminalWidth, setTerminalWidth] = createSignal( + Math.min(TERMINAL_MAX_WIDTH, Math.max(TERMINAL_MIN_WIDTH, Math.round(window.innerWidth / 3))), + ) + // The hidden-but-mounted host still fits the terminal, so pick the + // terminal's width whenever one is alive and no other mode is showing. + const widthMode = () => sidePanel() ?? (terms.sides().length > 0 ? "terminal" : null) + const hostWidth = () => (widthMode() === "terminal" ? terminalWidth() : diffWidth()) + const sideMin = () => (widthMode() === "terminal" ? TERMINAL_MIN_WIDTH : 200) + const resizeSide = (width: number) => { + pendingSideWidth = Math.max(sideMin(), Math.min(width, window.innerWidth * 0.8)) + if (sideRaf !== undefined) return + sideRaf = requestAnimationFrame(() => { + sideRaf = undefined + if (widthMode() === "terminal") setTerminalWidth(pendingSideWidth!) + else setDiffWidth(pendingSideWidth!) + }) + } + const showSideTerminal = () => { + setHistory(false) + setReviewActive(false) + setSidePanel("terminal") + } const [reviewOpenByContext, setReviewOpenByContext] = createSignal>({}) const [reviewCommentsByContext, setReviewCommentsByContext] = createSignal>({}) @@ -1071,14 +1104,14 @@ const AgentManagerContent: Component = () => { } const cycleAgent = (direction: 1 | -1) => { - const available = session.agents().filter((a) => a.mode !== "subagent" && !a.hidden) - if (available.length <= 1) return - const current = session.selectedAgent() - const idx = available.findIndex((a) => a.name === current) - const raw = idx + direction - const next = raw < 0 ? available.length - 1 : raw >= available.length ? 0 : raw - const agent = available[next] - if (agent) session.selectAgent(agent.name) + const id = session.currentSessionID() ?? activePendingId() + cycle({ + agents: session.agents(), + scope: id, + direction, + selected: session.selectedAgent, + select: session.selectAgent, + }) } const syncRunStatuses = (items: RunStatus[] = []) => { @@ -1103,12 +1136,7 @@ const AgentManagerContent: Component = () => { requestAnimationFrame(() => sidebarSearchMenu?.open()) } } else if (msg.action === "showTerminal") { - // Cmd+/ opens the legacy VS Code integrated terminal for the - // active session (or local). The new xterm tab affordance has - // its own keybind (Cmd+Shift+T) so both coexist. - const id = session.currentSessionID() - if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id }) - else if (selection() === LOCAL) vscode.postMessage({ type: "agentManager.showLocalTerminal" }) + sideCtl.openPreferred("keyboard_shortcut") } else if (msg.action === "toggleDiff") { if (reviewActive()) { closeReviewTab() @@ -1248,7 +1276,14 @@ const AgentManagerContent: Component = () => { setSelection, showError: (message) => showToast({ variant: "error", title: t("agentManager.terminal.errorTitle"), description: message }), + postMessage: (message) => vscode.postMessage(message as never), onCreated: (contextKey, terminalId) => appendToTabOrder(contextKey, terminalId), + onSideCreated: (contextKey, terminalId) => { + // Focus only when the user is still looking at this panel — + // a slow create landing after a mode switch must not steal it. + if (sidePanel() === "terminal" && terms.sideKey() === contextKey) terms.requestFocus(terminalId) + }, + onDestinationChanged: (destination) => sideCtl.setDestination(destination), }) const unsubTerminals = vscode.onMessage((msg) => { terminalDispatch(msg) @@ -2056,11 +2091,30 @@ const AgentManagerContent: Component = () => { findTab: (id) => tabLookup().get(id), postMessage: (msg) => vscode.postMessage(msg as never), onRemove: freezeTabs, + onShowSide: showSideTerminal, + onHideSide: () => { + if (sidePanel() === "terminal") setSidePanel(null) + }, getSelection: selection, LOCAL, REVIEW_TAB_ID, }) + const sideCtl = createSideTerminal({ + handlers: termHandlers, + visible: () => sidePanel() === "terminal", + focused: () => terms.focusedId() !== undefined && terms.focusedId() === terms.side()?.id, + hide: () => setSidePanel(null), + refocus: () => window.dispatchEvent(new Event("focusPrompt")), + postMessage: (msg) => vscode.postMessage(msg as never), + track: (button, surface, properties) => metrics.track(button, surface, properties), + openVscode: () => { + const id = session.currentSessionID() + if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id }) + else if (selection() === LOCAL) vscode.postMessage({ type: "agentManager.showLocalTerminal" }) + }, + }) + const handleReviewTabMouseDown = (e: MouseEvent) => { if (e.button !== 1) return e.preventDefault() @@ -2159,6 +2213,12 @@ const AgentManagerContent: Component = () => { // Close the currently active tab via keyboard shortcut. // If no tabs remain, fall through to close the selected worktree. const closeActiveTab = () => { + // A focused side terminal owns Cmd+W while its panel is visible — + // closing a chat tab out from under the user's cursor would be + // surprising. + if (sidePanel() === "terminal" && terms.focusedId() && terms.focusedId() === terms.side()?.id) { + if (sideCtl.close()) return + } if (termHandlers.closeActive()) { tabFocus.restore() return @@ -2814,28 +2874,17 @@ const AgentManagerContent: Component = () => { />
- {/* Legacy VS Code integrated terminal shortcut. Coexists - with the xterm terminal tabs (accessed via the `+` - split-button or Cmd+Shift+T): Cmd+/ still opens the - integrated terminal for the active session. */} - - { - metrics.track("vscode_terminal", "tab_toolbar") - const id = session.currentSessionID() - if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id }) - else if (selection() === LOCAL) vscode.postMessage({ type: "agentManager.showLocalTerminal" }) - }} - /> - + {/* Terminal destination split button: the primary action + follows the user's setting (VS Code integrated terminal + or the embedded side panel), the dropdown picks which. + Cmd+Shift+T still creates an xterm tab via the `+` menu. */} + sidePanel() === "terminal"} + keybind={() => kb().showTerminal ?? ""} + onOpen={() => sideCtl.openPreferred("tab_toolbar")} + onChoose={sideCtl.choose} + /> @@ -2985,24 +3034,26 @@ const AgentManagerContent: Component = () => { - -
- { - pendingDiffWidth = Math.max(200, Math.min(w, window.innerWidth * 0.8)) - if (diffRaf === undefined) { - diffRaf = requestAnimationFrame(() => { - diffRaf = undefined - setDiffWidth(pendingDiffWidth!) - }) - } - }} - /> + {/* One inspector host for all right-side modes. It stays + mounted while a side terminal is alive — hidden via + .am-side-host-hidden (absolute + opacity), never + unmounted, so xterm render loops keep streaming. */} + 0}> +
+ + +
{ activeTerminalId={terms.activeId()} /> + sidePanel() === "terminal"} + onClose={() => sideCtl.close()} + onStart={() => termHandlers.requestSide()} + />
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 97113821690..6cfde9cf3bb 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -2515,6 +2515,13 @@ body.am-wt-dragging-active * { min-width: 250px; } +/* Fixed-width slot so menu items with and without a check mark align. */ +.am-menu-check { + display: flex; + flex-shrink: 0; + width: 16px; +} + .am-split-menu [data-slot="dropdown-menu-item"] { padding: 6px 10px; } @@ -4535,6 +4542,75 @@ body.vscode-high-contrast-light { z-index: 1; } +/* Side terminal panel — lives inside .am-diff-panel-wrapper next to the + diff and PR panels. Header reuses .am-diff-header metrics so the + chrome does not shift when switching inspector modes. Visibility is + opacity-only: the xterm render loop dies if the subtree leaves the + paint tree. */ +.am-side-terminal { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + opacity: 0; + pointer-events: none; + z-index: 1; + background: var(--vscode-terminal-background, #1e1e1e); + /* Force a dedicated compositor layer so opacity flips do not + re-lay-out the xterm canvases underneath. */ + will-change: opacity; +} + +.am-side-terminal-visible { + opacity: 1; + pointer-events: auto; +} + +.am-side-terminal-layer { + position: relative; + flex: 1; + min-width: 0; + min-height: 0; + opacity: 0; + pointer-events: none; +} + +.am-side-terminal-layer-active { + opacity: 1; + pointer-events: auto; +} + +.am-side-terminal-state { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-weak); + font-size: var(--font-size-small); + background: var(--vscode-terminal-background, #1e1e1e); +} + +.am-side-terminal-empty { + color: var(--text-weak); +} + +/* Hidden-but-alive side panel host: taken out of the flow so the chat + reclaims the width, but kept painted so hidden side terminals keep + streaming. Anchored to .am-detail-stack (position: relative). */ +.am-side-host-hidden { + position: absolute; + top: 0; + right: 0; + bottom: 0; + opacity: 0; + pointer-events: none; +} + .am-terminal-host { flex: 1; min-height: 0; diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index c930131f420..79d4b74c51e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -21,17 +21,14 @@ export const dict = { "agentManager.worktree.copyPath": "نسخ المسار", "agentManager.worktree.openInVscode": "فتح في VS Code", "agentManager.worktree.rename": "إعادة تسمية", - "agentManager.worktree.moveToSection": "نقل إلى القسم", "agentManager.worktree.newSection": "قسم جديد", "agentManager.worktree.ungrouped": "غير مجمع", "agentManager.section.rename": "إعادة تسمية القسم", "agentManager.section.setColor": "تعيين اللون", "agentManager.section.delete": "حذف القسم", - "agentManager.section.defaultColor": "افتراضي", "agentManager.section.defaultName": "قسم جديد", "agentManager.section.moveUp": "تحريك لأعلى", "agentManager.section.moveDown": "تحريك لأسفل", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "الفرع", "agentManager.hoverCard.base": "الأساس", "agentManager.hoverCard.sessions": "الجلسات", @@ -61,6 +58,12 @@ export const dict = { "agentManager.terminal.new": "علامة تبويب جديدة للمحطة الطرفية", "agentManager.terminal.ended": "انتهت المحطة الطرفية — أغلق علامة التبويب للإخفاء", "agentManager.terminal.connectionError": "خطأ في اتصال المحطة الطرفية", + "agentManager.terminal.kill": "إنهاء المحطة الطرفية", + "agentManager.terminal.empty": "لا توجد محطة طرفية هنا بعد", + "agentManager.terminal.start": "بدء المحطة الطرفية", + "agentManager.terminal.destination": "اختر ما الذي يفتحه زر المحطة الطرفية", + "agentManager.terminal.openInVscode": "المحطة الطرفية في VS Code", + "agentManager.terminal.openInPanel": "لوحة Agent Manager", "agentManager.terminal.errorTitle": "خطأ في المحطة الطرفية", "agentManager.setup.failed": "فشل إعداد مساحة العمل", "agentManager.setup.settingUp": "جارٍ إعداد مساحة العمل", @@ -126,7 +129,6 @@ export const dict = { "agentManager.dialog.creating": "جارٍ الإنشاء...", "agentManager.dialog.createWorktree": "إنشاء شجرة العمل", "agentManager.dialog.removeImage": "إزالة الصورة", - "agentManager.dialog.advanced": "متقدم...", "agentManager.diff.toggle": "تبديل الفرق", "agentManager.diff.openFile": "فتح الملف", @@ -135,19 +137,13 @@ export const dict = { "agentManager.diff.revertError": "فشل الاستعادة", "agentManager.open.button": "فتح", "agentManager.open.tooltip": "فتح Worktree هذا في VS Code", - "agentManager.apply.button": "تطبيق محليًا", "agentManager.apply.globalButton": "تطبيق", "agentManager.apply.tooltip": "تطبيق تغييرات شجرة العمل المحددة على الفرع المحلي", "agentManager.apply.dialogTitle": "تطبيق التغييرات على الفرع المحلي", "agentManager.apply.confirm": "تطبيق الآن", - "agentManager.apply.retry": "إعادة محاولة التطبيق", - "agentManager.apply.previewTitle": "تطبيق هذه التغييرات على الفرع المحلي؟", - "agentManager.apply.previewSummary": "{{files}} ملفات، +{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "{{selected}} من {{total}} ملفات محددة", "agentManager.apply.selectAll": "تحديد الكل", "agentManager.apply.selectNone": "إلغاء تحديد الكل", - "agentManager.apply.moreFiles": "+{{count}} المزيد", - "agentManager.apply.moreReasons": "{{reason}} (+{{count}} المزيد)", "agentManager.apply.checking": "جارٍ التحقق...", "agentManager.apply.applying": "جارٍ التطبيق...", "agentManager.apply.success": "تم تطبيق التغييرات", @@ -160,9 +156,6 @@ export const dict = { "agentManager.apply.reason.patch": "لا يمكن تطبيق التصحيح بشكل نظيف", "agentManager.apply.reason.contents": "لا يمكن قراءة محتويات الملف المحلي الحالي", "agentManager.apply.reason.unknown": "تم اكتشاف تعارض", - "agentManager.apply.inline.success": "تم التطبيق", - "agentManager.apply.inline.conflict": "تعارضات", - "agentManager.apply.inline.error": "فشل", "agentManager.shortcuts.toggleDiff": "تبديل لوحة الفرق", "agentManager.shortcuts.category.quickSwitch": "التبديل السريع", "agentManager.shortcuts.jumpToItem": "الانتقال إلى العنصر 1\u20139", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index 03efd3495c9..67cdb9bcf34 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -21,17 +21,14 @@ export const dict = { "agentManager.worktree.copyPath": "Copiar Caminho", "agentManager.worktree.openInVscode": "Abrir no VS Code", "agentManager.worktree.rename": "Renomear", - "agentManager.worktree.moveToSection": "Mover para a Seção", "agentManager.worktree.newSection": "Nova Seção", "agentManager.worktree.ungrouped": "Não Agrupado", "agentManager.section.rename": "Renomear Seção", "agentManager.section.setColor": "Definir Cor", "agentManager.section.delete": "Excluir Seção", - "agentManager.section.defaultColor": "Padrão", "agentManager.section.defaultName": "Nova Seção", "agentManager.section.moveUp": "Mover para Cima", "agentManager.section.moveDown": "Mover para Baixo", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessões", @@ -62,6 +59,12 @@ export const dict = { "agentManager.terminal.new": "Nova aba de terminal", "agentManager.terminal.ended": "terminal encerrado — feche a aba para dispensar", "agentManager.terminal.connectionError": "erro de conexão do terminal", + "agentManager.terminal.kill": "Encerrar terminal", + "agentManager.terminal.empty": "Ainda não há terminal aqui", + "agentManager.terminal.start": "Iniciar terminal", + "agentManager.terminal.destination": "Escolha o que o botão do terminal abre", + "agentManager.terminal.openInVscode": "Terminal do VS Code", + "agentManager.terminal.openInPanel": "Painel do Agent Manager", "agentManager.terminal.errorTitle": "Erro no terminal", "agentManager.setup.failed": "Falha na configuração do worktree", "agentManager.setup.settingUp": "Configurando worktree", @@ -129,7 +132,6 @@ export const dict = { "agentManager.dialog.creating": "Criando...", "agentManager.dialog.createWorktree": "Criar Worktree", "agentManager.dialog.removeImage": "Remover imagem", - "agentManager.dialog.advanced": "Avançado...", "agentManager.diff.toggle": "Alternar diff", "agentManager.diff.openFile": "Abrir arquivo", @@ -138,19 +140,13 @@ export const dict = { "agentManager.diff.revertError": "Falha ao reverter", "agentManager.open.button": "Abrir", "agentManager.open.tooltip": "Abrir este Worktree no VS Code", - "agentManager.apply.button": "Aplicar localmente", "agentManager.apply.globalButton": "Aplicar", "agentManager.apply.tooltip": "Aplicar alterações da worktree selecionada na branch local", "agentManager.apply.dialogTitle": "Aplicar alterações na branch local", "agentManager.apply.confirm": "Aplicar agora", - "agentManager.apply.retry": "Tentar aplicar novamente", - "agentManager.apply.previewTitle": "Aplicar essas alterações na branch local?", - "agentManager.apply.previewSummary": "{{files}} arquivos, +{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "{{selected}} de {{total}} arquivos selecionados", "agentManager.apply.selectAll": "Selecionar todos", "agentManager.apply.selectNone": "Desmarcar todos", - "agentManager.apply.moreFiles": "+{{count}} mais", - "agentManager.apply.moreReasons": "{{reason}} (+{{count}} mais)", "agentManager.apply.checking": "Verificando...", "agentManager.apply.applying": "Aplicando...", "agentManager.apply.success": "Alterações aplicadas", @@ -163,9 +159,6 @@ export const dict = { "agentManager.apply.reason.patch": "O patch não pode ser aplicado de forma limpa", "agentManager.apply.reason.contents": "Não é possível ler o conteúdo do arquivo local atual", "agentManager.apply.reason.unknown": "Conflito detectado", - "agentManager.apply.inline.success": "Aplicado", - "agentManager.apply.inline.conflict": "Conflitos", - "agentManager.apply.inline.error": "Falhou", "agentManager.shortcuts.toggleDiff": "Alternar painel de diff", "agentManager.shortcuts.category.quickSwitch": "Troca rápida", "agentManager.shortcuts.jumpToItem": "Ir para o item 1\u20139", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index 09f813f7d04..f6fddfd6127 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -21,17 +21,14 @@ export const dict = { "agentManager.worktree.copyPath": "Kopiraj putanju", "agentManager.worktree.openInVscode": "Otvori u VS Code", "agentManager.worktree.rename": "Preimenuj", - "agentManager.worktree.moveToSection": "Premjesti u sekciju", "agentManager.worktree.newSection": "Nova sekcija", "agentManager.worktree.ungrouped": "Bez grupe", "agentManager.section.rename": "Preimenuj sekciju", "agentManager.section.setColor": "Postavi boju", "agentManager.section.delete": "Izbriši sekciju", - "agentManager.section.defaultColor": "Zadano", "agentManager.section.defaultName": "Nova sekcija", "agentManager.section.moveUp": "Pomjeri gore", "agentManager.section.moveDown": "Pomjeri dolje", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Baza", "agentManager.hoverCard.sessions": "Sesije", @@ -61,6 +58,12 @@ export const dict = { "agentManager.terminal.new": "Nova kartica terminala", "agentManager.terminal.ended": "terminal je završen — zatvorite karticu da biste odbacili", "agentManager.terminal.connectionError": "greška u vezi terminala", + "agentManager.terminal.kill": "Prekini terminal", + "agentManager.terminal.empty": "Ovdje još nema terminala", + "agentManager.terminal.start": "Pokreni terminal", + "agentManager.terminal.destination": "Odaberite šta otvara dugme terminala", + "agentManager.terminal.openInVscode": "VS Code terminal", + "agentManager.terminal.openInPanel": "Panel Agent Managera", "agentManager.terminal.errorTitle": "Greška terminala", "agentManager.setup.failed": "Postavljanje radnog prostora neuspješno", "agentManager.setup.settingUp": "Postavljanje radnog prostora", @@ -129,7 +132,6 @@ export const dict = { "agentManager.dialog.creating": "Kreiranje...", "agentManager.dialog.createWorktree": "Kreiraj worktree", "agentManager.dialog.removeImage": "Ukloni sliku", - "agentManager.dialog.advanced": "Napredno...", "agentManager.diff.toggle": "Prebaci diff", "agentManager.diff.openFile": "Otvori datoteku", @@ -138,19 +140,13 @@ export const dict = { "agentManager.diff.revertError": "Vraćanje neuspješno", "agentManager.open.button": "Otvori", "agentManager.open.tooltip": "Otvori ovaj worktree u VS Code-u", - "agentManager.apply.button": "Primijeni lokalno", "agentManager.apply.globalButton": "Primijeni", "agentManager.apply.tooltip": "Primijeni promjene odabranog radnog stabla na lokalnu granu", "agentManager.apply.dialogTitle": "Primijeni promjene na lokalnu granu", "agentManager.apply.confirm": "Primijeni sada", - "agentManager.apply.retry": "Ponovo pokušaj primjenu", - "agentManager.apply.previewTitle": "Primijeniti ove promjene na lokalnu granu?", - "agentManager.apply.previewSummary": "{{files}} datoteka, +{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "{{selected}} od {{total}} datoteka odabrano", "agentManager.apply.selectAll": "Odaberi sve", "agentManager.apply.selectNone": "Poništi odabir", - "agentManager.apply.moreFiles": "+{{count}} više", - "agentManager.apply.moreReasons": "{{reason}} (+{{count}} više)", "agentManager.apply.checking": "Provjera...", "agentManager.apply.applying": "Primjenjivanje...", "agentManager.apply.success": "Promjene primijenjene", @@ -163,9 +159,6 @@ export const dict = { "agentManager.apply.reason.patch": "Zakrpa se ne može čisto primijeniti", "agentManager.apply.reason.contents": "Nije moguće pročitati sadržaj trenutne lokalne datoteke", "agentManager.apply.reason.unknown": "Otkriven konflikt", - "agentManager.apply.inline.success": "Primijenjeno", - "agentManager.apply.inline.conflict": "Konflikti", - "agentManager.apply.inline.error": "Neuspjelo", "agentManager.shortcuts.toggleDiff": "Prebaci panel za diff", "agentManager.shortcuts.category.quickSwitch": "Brzo prebacivanje", "agentManager.shortcuts.jumpToItem": "Idi na stavku 1\u20139", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index f7dfe7697d9..beb827b1c9d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -21,17 +21,14 @@ export const dict = { "agentManager.worktree.copyPath": "Kopier sti", "agentManager.worktree.openInVscode": "Åbn i VS Code", "agentManager.worktree.rename": "Omdøb", - "agentManager.worktree.moveToSection": "Flyt til sektion", "agentManager.worktree.newSection": "Ny sektion", "agentManager.worktree.ungrouped": "Ugrupperet", "agentManager.section.rename": "Omdøb sektion", "agentManager.section.setColor": "Indstil farve", "agentManager.section.delete": "Slet sektion", - "agentManager.section.defaultColor": "Standard", "agentManager.section.defaultName": "Ny sektion", "agentManager.section.moveUp": "Flyt op", "agentManager.section.moveDown": "Flyt ned", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessioner", @@ -63,6 +60,12 @@ export const dict = { "agentManager.terminal.new": "Ny terminalfane", "agentManager.terminal.ended": "terminal afsluttet — luk fanen for at fjerne", "agentManager.terminal.connectionError": "forbindelsesfejl til terminal", + "agentManager.terminal.kill": "Afslut terminal", + "agentManager.terminal.empty": "Ingen terminal her endnu", + "agentManager.terminal.start": "Start terminal", + "agentManager.terminal.destination": "Vælg, hvad terminalknappen åbner", + "agentManager.terminal.openInVscode": "VS Code-terminal", + "agentManager.terminal.openInPanel": "Agent Manager-panel", "agentManager.terminal.errorTitle": "Terminalfejl", "agentManager.setup.failed": "Opsætning af worktree mislykkedes", "agentManager.setup.settingUp": "Opsætter worktree", @@ -130,7 +133,6 @@ export const dict = { "agentManager.dialog.creating": "Opretter...", "agentManager.dialog.createWorktree": "Opret Worktree", "agentManager.dialog.removeImage": "Fjern billede", - "agentManager.dialog.advanced": "Avanceret...", "agentManager.diff.toggle": "Skift diff", "agentManager.diff.openFile": "Åbn fil", @@ -139,19 +141,13 @@ export const dict = { "agentManager.diff.revertError": "Gendannelse fejlede", "agentManager.open.button": "Åbn", "agentManager.open.tooltip": "Åbn dette Worktree i VS Code", - "agentManager.apply.button": "Anvend lokalt", "agentManager.apply.globalButton": "Anvend", "agentManager.apply.tooltip": "Anvend valgte worktree-ændringer på den lokale gren", "agentManager.apply.dialogTitle": "Anvend ændringer på den lokale gren", "agentManager.apply.confirm": "Anvend nu", - "agentManager.apply.retry": "Prøv at anvende igen", - "agentManager.apply.previewTitle": "Anvend disse ændringer på den lokale gren?", - "agentManager.apply.previewSummary": "{{files}} filer, +{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "{{selected}} af {{total}} filer valgt", "agentManager.apply.selectAll": "Vælg alle", "agentManager.apply.selectNone": "Fravælg alle", - "agentManager.apply.moreFiles": "+{{count}} flere", - "agentManager.apply.moreReasons": "{{reason}} (+{{count}} flere)", "agentManager.apply.checking": "Kontrollerer...", "agentManager.apply.applying": "Anvender...", "agentManager.apply.success": "Ændringer anvendt", @@ -164,9 +160,6 @@ export const dict = { "agentManager.apply.reason.patch": "Patch kan ikke anvendes rent", "agentManager.apply.reason.contents": "Kan ikke læse det aktuelle lokale filindhold", "agentManager.apply.reason.unknown": "Konflikt opdaget", - "agentManager.apply.inline.success": "Anvendt", - "agentManager.apply.inline.conflict": "Konflikter", - "agentManager.apply.inline.error": "Mislykkedes", "agentManager.shortcuts.toggleDiff": "Skift diff-panel", "agentManager.shortcuts.category.quickSwitch": "Hurtigskift", "agentManager.shortcuts.jumpToItem": "Hop til element 1\u20139", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index e116ebd4f81..b9691ecbafd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -21,17 +21,14 @@ export const dict = { "agentManager.worktree.copyPath": "Pfad kopieren", "agentManager.worktree.openInVscode": "In VS Code öffnen", "agentManager.worktree.rename": "Umbenennen", - "agentManager.worktree.moveToSection": "In Bereich verschieben", "agentManager.worktree.newSection": "Neuer Bereich", "agentManager.worktree.ungrouped": "Nicht gruppiert", "agentManager.section.rename": "Bereich umbenennen", "agentManager.section.setColor": "Farbe festlegen", "agentManager.section.delete": "Bereich löschen", - "agentManager.section.defaultColor": "Standard", "agentManager.section.defaultName": "Neuer Bereich", "agentManager.section.moveUp": "Nach oben verschieben", "agentManager.section.moveDown": "Nach unten verschieben", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Basis", "agentManager.hoverCard.sessions": "Sitzungen", @@ -62,6 +59,12 @@ export const dict = { "agentManager.terminal.new": "Neuer Terminal-Tab", "agentManager.terminal.ended": "Terminal beendet — Tab schließen zum Verwerfen", "agentManager.terminal.connectionError": "Verbindungsfehler im Terminal", + "agentManager.terminal.kill": "Terminal beenden", + "agentManager.terminal.empty": "Hier ist noch kein Terminal", + "agentManager.terminal.start": "Terminal starten", + "agentManager.terminal.destination": "Auswählen, was die Terminal-Schaltfläche öffnet", + "agentManager.terminal.openInVscode": "VS Code-Terminal", + "agentManager.terminal.openInPanel": "Agent Manager-Panel", "agentManager.terminal.errorTitle": "Terminal-Fehler", "agentManager.setup.failed": "Einrichtung des Arbeitsbereichs fehlgeschlagen", "agentManager.setup.settingUp": "Arbeitsbereich wird eingerichtet", @@ -130,7 +133,6 @@ export const dict = { "agentManager.dialog.creating": "Wird erstellt...", "agentManager.dialog.createWorktree": "Worktree erstellen", "agentManager.dialog.removeImage": "Bild entfernen", - "agentManager.dialog.advanced": "Erweitert...", "agentManager.diff.toggle": "Diff umschalten", "agentManager.diff.openFile": "Datei öffnen", @@ -139,19 +141,13 @@ export const dict = { "agentManager.diff.revertError": "Zurücksetzen fehlgeschlagen", "agentManager.open.button": "Öffnen", "agentManager.open.tooltip": "Dieses Worktree in VS Code öffnen", - "agentManager.apply.button": "Lokal anwenden", "agentManager.apply.globalButton": "Anwenden", "agentManager.apply.tooltip": "Ausgewählte Worktree-Änderungen auf den lokalen Branch anwenden", "agentManager.apply.dialogTitle": "Änderungen auf den lokalen Branch anwenden", "agentManager.apply.confirm": "Jetzt anwenden", - "agentManager.apply.retry": "Anwenden erneut versuchen", - "agentManager.apply.previewTitle": "Diese Änderungen auf den lokalen Branch anwenden?", - "agentManager.apply.previewSummary": "{{files}} Dateien, +{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "{{selected}} von {{total}} Dateien ausgewählt", "agentManager.apply.selectAll": "Alle auswählen", "agentManager.apply.selectNone": "Keine auswählen", - "agentManager.apply.moreFiles": "+{{count}} weitere", - "agentManager.apply.moreReasons": "{{reason}} (+{{count}} weitere)", "agentManager.apply.checking": "Prüfe...", "agentManager.apply.applying": "Wird angewendet...", "agentManager.apply.success": "Änderungen angewendet", @@ -164,9 +160,6 @@ export const dict = { "agentManager.apply.reason.patch": "Patch kann nicht sauber angewendet werden", "agentManager.apply.reason.contents": "Inhalt der aktuellen lokalen Datei kann nicht gelesen werden", "agentManager.apply.reason.unknown": "Konflikt erkannt", - "agentManager.apply.inline.success": "Angewendet", - "agentManager.apply.inline.conflict": "Konflikte", - "agentManager.apply.inline.error": "Fehlgeschlagen", "agentManager.shortcuts.toggleDiff": "Diff-Panel umschalten", "agentManager.shortcuts.category.quickSwitch": "Schnellwechsel", "agentManager.shortcuts.jumpToItem": "Zu Element 1\u20139 springen", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index 02bc4333cc1..236f18423a4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -22,17 +22,14 @@ export const dict = { "agentManager.worktree.copyPath": "Copy Path", "agentManager.worktree.openInVscode": "Open in VS Code", "agentManager.worktree.rename": "Rename", - "agentManager.worktree.moveToSection": "Move to Section", "agentManager.worktree.newSection": "New Section", "agentManager.worktree.ungrouped": "Ungrouped", "agentManager.section.rename": "Rename Section", "agentManager.section.setColor": "Set Color", "agentManager.section.delete": "Delete Section", - "agentManager.section.defaultColor": "Default", "agentManager.section.defaultName": "New Section", "agentManager.section.moveUp": "Move Up", "agentManager.section.moveDown": "Move Down", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Base", @@ -65,6 +62,12 @@ export const dict = { "agentManager.terminal.new": "New Terminal Tab", "agentManager.terminal.ended": "terminal ended — close tab to dismiss", "agentManager.terminal.connectionError": "terminal connection error", + "agentManager.terminal.kill": "Kill terminal", + "agentManager.terminal.empty": "No terminal here yet", + "agentManager.terminal.start": "Start terminal", + "agentManager.terminal.destination": "Choose what the terminal button opens", + "agentManager.terminal.openInVscode": "VS Code terminal", + "agentManager.terminal.openInPanel": "Agent Manager panel", "agentManager.terminal.errorTitle": "Terminal error", "agentManager.setup.failed": "Worktree setup failed", @@ -132,7 +135,6 @@ export const dict = { "agentManager.dialog.creating": "Creating...", "agentManager.dialog.createWorktree": "Create Worktree", "agentManager.dialog.removeImage": "Remove image", - "agentManager.dialog.advanced": "Advanced...", "agentManager.dialog.configureWorktree": "Configure New Worktree...", "agentManager.diff.toggle": "Toggle diff", @@ -142,19 +144,13 @@ export const dict = { "agentManager.diff.revertError": "Revert failed", "agentManager.open.button": "Open", "agentManager.open.tooltip": "Open this worktree in VS Code", - "agentManager.apply.button": "Apply to local", "agentManager.apply.globalButton": "Apply", "agentManager.apply.tooltip": "Apply selected worktree changes to local branch", "agentManager.apply.dialogTitle": "Apply changes to local branch", "agentManager.apply.confirm": "Apply now", - "agentManager.apply.retry": "Retry apply", - "agentManager.apply.previewTitle": "Apply these changes to local branch?", - "agentManager.apply.previewSummary": "{{files}} files, +{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "{{selected}} of {{total}} files selected", "agentManager.apply.selectAll": "Select all", "agentManager.apply.selectNone": "Select none", - "agentManager.apply.moreFiles": "+{{count}} more", - "agentManager.apply.moreReasons": "{{reason}} (+{{count}} more)", "agentManager.apply.checking": "Checking...", "agentManager.apply.applying": "Applying...", "agentManager.apply.success": "Changes applied", @@ -167,9 +163,6 @@ export const dict = { "agentManager.apply.reason.patch": "Patch cannot be applied cleanly", "agentManager.apply.reason.contents": "Cannot read current local file contents", "agentManager.apply.reason.unknown": "Conflict detected", - "agentManager.apply.inline.success": "Applied", - "agentManager.apply.inline.conflict": "Conflicts", - "agentManager.apply.inline.error": "Failed", "agentManager.shortcuts.toggleDiff": "Toggle diff panel", "agentManager.shortcuts.category.quickSwitch": "Quick Switch", "agentManager.shortcuts.jumpToItem": "Jump to item 1\u20139", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index b3019f0253d..a75de2ce0c2 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -21,17 +21,14 @@ export const dict = { "agentManager.worktree.copyPath": "Copiar ruta", "agentManager.worktree.openInVscode": "Abrir en VS Code", "agentManager.worktree.rename": "Renombrar", - "agentManager.worktree.moveToSection": "Mover a la sección", "agentManager.worktree.newSection": "Nueva sección", "agentManager.worktree.ungrouped": "Desagrupado", "agentManager.section.rename": "Renombrar sección", "agentManager.section.setColor": "Establecer color", "agentManager.section.delete": "Eliminar sección", - "agentManager.section.defaultColor": "Predeterminado", "agentManager.section.defaultName": "Nueva sección", "agentManager.section.moveUp": "Mover hacia arriba", "agentManager.section.moveDown": "Mover hacia abajo", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sesiones", @@ -62,6 +59,12 @@ export const dict = { "agentManager.terminal.new": "Nueva pestaña de terminal", "agentManager.terminal.ended": "terminal finalizado — cierra la pestaña para descartar", "agentManager.terminal.connectionError": "error de conexión del terminal", + "agentManager.terminal.kill": "Terminar terminal", + "agentManager.terminal.empty": "Aún no hay ningún terminal aquí", + "agentManager.terminal.start": "Iniciar terminal", + "agentManager.terminal.destination": "Elegir qué abre el botón del terminal", + "agentManager.terminal.openInVscode": "Terminal de VS Code", + "agentManager.terminal.openInPanel": "Panel de Agent Manager", "agentManager.terminal.errorTitle": "Error de terminal", "agentManager.setup.failed": "Error en la configuración del worktree", "agentManager.setup.settingUp": "Configurando worktree", @@ -129,7 +132,6 @@ export const dict = { "agentManager.dialog.creating": "Creando...", "agentManager.dialog.createWorktree": "Crear Worktree", "agentManager.dialog.removeImage": "Eliminar imagen", - "agentManager.dialog.advanced": "Avanzado...", "agentManager.diff.toggle": "Alternar diff", "agentManager.diff.openFile": "Abrir archivo", @@ -138,19 +140,13 @@ export const dict = { "agentManager.diff.revertError": "Error al revertir", "agentManager.open.button": "Abrir", "agentManager.open.tooltip": "Abrir este Worktree en VS Code", - "agentManager.apply.button": "Aplicar en local", "agentManager.apply.globalButton": "Aplicar", "agentManager.apply.tooltip": "Aplicar los cambios del worktree seleccionado a la rama local", "agentManager.apply.dialogTitle": "Aplicar cambios a la rama local", "agentManager.apply.confirm": "Aplicar ahora", - "agentManager.apply.retry": "Reintentar aplicación", - "agentManager.apply.previewTitle": "¿Aplicar estos cambios a la rama local?", - "agentManager.apply.previewSummary": "{{files}} archivos, +{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "{{selected}} de {{total}} archivos seleccionados", "agentManager.apply.selectAll": "Seleccionar todos", "agentManager.apply.selectNone": "Deseleccionar todos", - "agentManager.apply.moreFiles": "+{{count}} más", - "agentManager.apply.moreReasons": "{{reason}} (+{{count}} más)", "agentManager.apply.checking": "Verificando...", "agentManager.apply.applying": "Aplicando...", "agentManager.apply.success": "Cambios aplicados", @@ -163,9 +159,6 @@ export const dict = { "agentManager.apply.reason.patch": "El parche no se puede aplicar limpiamente", "agentManager.apply.reason.contents": "No se puede leer el contenido del archivo local actual", "agentManager.apply.reason.unknown": "Conflicto detectado", - "agentManager.apply.inline.success": "Aplicado", - "agentManager.apply.inline.conflict": "Conflictos", - "agentManager.apply.inline.error": "Falló", "agentManager.shortcuts.toggleDiff": "Alternar panel de diff", "agentManager.shortcuts.category.quickSwitch": "Cambio rápido", "agentManager.shortcuts.jumpToItem": "Ir al elemento 1\u20139", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index 8618b9a955b..d8579c9cfd0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -21,17 +21,14 @@ export const dict = { "agentManager.worktree.copyPath": "Copier le chemin", "agentManager.worktree.openInVscode": "Ouvrir dans VS Code", "agentManager.worktree.rename": "Renommer", - "agentManager.worktree.moveToSection": "Déplacer vers la section", "agentManager.worktree.newSection": "Nouvelle section", "agentManager.worktree.ungrouped": "Non groupé", "agentManager.section.rename": "Renommer la section", "agentManager.section.setColor": "Définir la couleur", "agentManager.section.delete": "Supprimer la section", - "agentManager.section.defaultColor": "Par défaut", "agentManager.section.defaultName": "Nouvelle section", "agentManager.section.moveUp": "Déplacer vers le haut", "agentManager.section.moveDown": "Déplacer vers le bas", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCHE", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessions", @@ -62,6 +59,12 @@ export const dict = { "agentManager.terminal.new": "Nouvel onglet de terminal", "agentManager.terminal.ended": "terminal terminé — fermez l'onglet pour ignorer", "agentManager.terminal.connectionError": "erreur de connexion du terminal", + "agentManager.terminal.kill": "Tuer le terminal", + "agentManager.terminal.empty": "Aucun terminal ici pour l'instant", + "agentManager.terminal.start": "Démarrer le terminal", + "agentManager.terminal.destination": "Choisir ce que le bouton Terminal ouvre", + "agentManager.terminal.openInVscode": "Terminal VS Code", + "agentManager.terminal.openInPanel": "Panneau Agent Manager", "agentManager.terminal.errorTitle": "Erreur du terminal", "agentManager.setup.failed": "Échec de la configuration de l'espace de travail", "agentManager.setup.settingUp": "Configuration de l'espace de travail", @@ -129,7 +132,6 @@ export const dict = { "agentManager.dialog.creating": "Création...", "agentManager.dialog.createWorktree": "Créer un worktree", "agentManager.dialog.removeImage": "Supprimer l'image", - "agentManager.dialog.advanced": "Avancé...", "agentManager.diff.toggle": "Basculer le diff", "agentManager.diff.openFile": "Ouvrir le fichier", @@ -138,19 +140,13 @@ export const dict = { "agentManager.diff.revertError": "Échec du rétablissement", "agentManager.open.button": "Ouvrir", "agentManager.open.tooltip": "Ouvrir ce worktree dans VS Code", - "agentManager.apply.button": "Appliquer en local", "agentManager.apply.globalButton": "Appliquer", "agentManager.apply.tooltip": "Appliquer les modifications du worktree sélectionné à la branche locale", "agentManager.apply.dialogTitle": "Appliquer les modifications à la branche locale", "agentManager.apply.confirm": "Appliquer maintenant", - "agentManager.apply.retry": "Réessayer l'application", - "agentManager.apply.previewTitle": "Appliquer ces modifications à la branche locale ?", - "agentManager.apply.previewSummary": "{{files}} fichiers, +{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "{{selected}} sur {{total}} fichiers sélectionnés", "agentManager.apply.selectAll": "Tout sélectionner", "agentManager.apply.selectNone": "Tout désélectionner", - "agentManager.apply.moreFiles": "+{{count}} de plus", - "agentManager.apply.moreReasons": "{{reason}} (+{{count}} de plus)", "agentManager.apply.checking": "Vérification...", "agentManager.apply.applying": "Application...", "agentManager.apply.success": "Modifications appliquées", @@ -164,9 +160,6 @@ export const dict = { "agentManager.apply.reason.patch": "Le patch ne peut pas être appliqué proprement", "agentManager.apply.reason.contents": "Impossible de lire le contenu du fichier local actuel", "agentManager.apply.reason.unknown": "Conflit détecté", - "agentManager.apply.inline.success": "Appliqué", - "agentManager.apply.inline.conflict": "Conflits", - "agentManager.apply.inline.error": "Échoué", "agentManager.shortcuts.toggleDiff": "Basculer le panneau de diff", "agentManager.shortcuts.category.quickSwitch": "Changement rapide", "agentManager.shortcuts.jumpToItem": "Aller à l'élément 1\u20139", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts index 10c36ee11b5..11928074249 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -22,17 +22,14 @@ export const dict = { "agentManager.worktree.copyPath": "Copia percorso", "agentManager.worktree.openInVscode": "Apri in VS Code", "agentManager.worktree.rename": "Rinomina", - "agentManager.worktree.moveToSection": "Sposta nella sezione", "agentManager.worktree.newSection": "Nuova sezione", "agentManager.worktree.ungrouped": "Non raggruppati", "agentManager.section.rename": "Rinomina sezione", "agentManager.section.setColor": "Imposta colore", "agentManager.section.delete": "Elimina sezione", - "agentManager.section.defaultColor": "Predefinito", "agentManager.section.defaultName": "Nuova sezione", "agentManager.section.moveUp": "Sposta su", "agentManager.section.moveDown": "Sposta giù", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Base", @@ -66,6 +63,12 @@ export const dict = { "agentManager.terminal.new": "Nuova scheda terminale", "agentManager.terminal.ended": "terminale terminato - chiudi la scheda per nasconderlo", "agentManager.terminal.connectionError": "errore di connessione del terminale", + "agentManager.terminal.kill": "Termina terminale", + "agentManager.terminal.empty": "Qui non c'è ancora un terminale", + "agentManager.terminal.start": "Avvia terminale", + "agentManager.terminal.destination": "Scegli cosa apre il pulsante del terminale", + "agentManager.terminal.openInVscode": "Terminale di VS Code", + "agentManager.terminal.openInPanel": "Pannello Agent Manager", "agentManager.terminal.errorTitle": "Errore terminale", "agentManager.setup.failed": "Setup del worktree non riuscito", @@ -134,7 +137,6 @@ export const dict = { "agentManager.dialog.creating": "Creazione...", "agentManager.dialog.createWorktree": "Crea worktree", "agentManager.dialog.removeImage": "Rimuovi immagine", - "agentManager.dialog.advanced": "Avanzate...", "agentManager.dialog.configureWorktree": "Configura nuovo worktree...", "agentManager.diff.toggle": "Mostra/nascondi diff", @@ -144,19 +146,13 @@ export const dict = { "agentManager.diff.revertError": "Ripristino non riuscito", "agentManager.open.button": "Apri", "agentManager.open.tooltip": "Apri questo worktree in VS Code", - "agentManager.apply.button": "Applica in locale", "agentManager.apply.globalButton": "Applica", "agentManager.apply.tooltip": "Applica le modifiche selezionate del worktree al branch locale", "agentManager.apply.dialogTitle": "Applica modifiche al branch locale", "agentManager.apply.confirm": "Applica ora", - "agentManager.apply.retry": "Riprova applicazione", - "agentManager.apply.previewTitle": "Applicare queste modifiche al branch locale?", - "agentManager.apply.previewSummary": "{{files}} file, +{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "{{selected}} di {{total}} file selezionati", "agentManager.apply.selectAll": "Seleziona tutto", "agentManager.apply.selectNone": "Deseleziona tutto", - "agentManager.apply.moreFiles": "+{{count}} altri", - "agentManager.apply.moreReasons": "{{reason}} (+{{count}} altri)", "agentManager.apply.checking": "Controllo...", "agentManager.apply.applying": "Applicazione...", "agentManager.apply.success": "Modifiche applicate", @@ -169,9 +165,6 @@ export const dict = { "agentManager.apply.reason.patch": "La patch non può essere applicata in modo pulito", "agentManager.apply.reason.contents": "Impossibile leggere il contenuto corrente del file locale", "agentManager.apply.reason.unknown": "Conflitto rilevato", - "agentManager.apply.inline.success": "Applicato", - "agentManager.apply.inline.conflict": "Conflitti", - "agentManager.apply.inline.error": "Non riuscito", "agentManager.shortcuts.toggleDiff": "Mostra/nascondi pannello diff", "agentManager.shortcuts.category.quickSwitch": "Cambio rapido", "agentManager.shortcuts.jumpToItem": "Vai all'elemento 1-9", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index 9e44ca3cfaf..d855af8e15d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -21,17 +21,14 @@ export const dict = { "agentManager.worktree.copyPath": "パスをコピー", "agentManager.worktree.openInVscode": "VS Codeで開く", "agentManager.worktree.rename": "名前を変更", - "agentManager.worktree.moveToSection": "セクションに移動", "agentManager.worktree.newSection": "新しいセクション", "agentManager.worktree.ungrouped": "グループなし", "agentManager.section.rename": "セクションの名前変更", "agentManager.section.setColor": "色の設定", "agentManager.section.delete": "セクションの削除", - "agentManager.section.defaultColor": "デフォルト", "agentManager.section.defaultName": "新しいセクション", "agentManager.section.moveUp": "上に移動", "agentManager.section.moveDown": "下に移動", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "ブランチ", "agentManager.hoverCard.base": "ベース", "agentManager.hoverCard.sessions": "セッション", @@ -62,6 +59,12 @@ export const dict = { "agentManager.terminal.new": "新しいターミナルタブ", "agentManager.terminal.ended": "ターミナルが終了しました — タブを閉じて破棄", "agentManager.terminal.connectionError": "ターミナル接続エラー", + "agentManager.terminal.kill": "ターミナルを終了", + "agentManager.terminal.empty": "ここにはまだターミナルがありません", + "agentManager.terminal.start": "ターミナルを開始", + "agentManager.terminal.destination": "ターミナルボタンで開く場所を選択", + "agentManager.terminal.openInVscode": "VS Codeのターミナル", + "agentManager.terminal.openInPanel": "Agent Managerパネル", "agentManager.terminal.errorTitle": "ターミナルエラー", "agentManager.setup.failed": "ワークスペースのセットアップに失敗しました", "agentManager.setup.settingUp": "ワークスペースをセットアップ中", @@ -128,7 +131,6 @@ export const dict = { "agentManager.dialog.creating": "作成中...", "agentManager.dialog.createWorktree": "ワークツリーを作成", "agentManager.dialog.removeImage": "画像を削除", - "agentManager.dialog.advanced": "詳細...", "agentManager.diff.toggle": "差分を切り替え", "agentManager.diff.openFile": "ファイルを開く", @@ -137,19 +139,13 @@ export const dict = { "agentManager.diff.revertError": "元に戻せませんでした", "agentManager.open.button": "開く", "agentManager.open.tooltip": "このWorktreeをVS Codeで開く", - "agentManager.apply.button": "ローカルに適用", "agentManager.apply.globalButton": "適用", "agentManager.apply.tooltip": "選択したワークツリーの変更をローカルブランチに適用", "agentManager.apply.dialogTitle": "ローカルブランチに変更を適用", "agentManager.apply.confirm": "今すぐ適用", - "agentManager.apply.retry": "適用を再試行", - "agentManager.apply.previewTitle": "これらの変更をローカルブランチに適用しますか?", - "agentManager.apply.previewSummary": "{{files}} ファイル、+{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "{{total}} ファイル中 {{selected}} 件選択", "agentManager.apply.selectAll": "すべて選択", "agentManager.apply.selectNone": "選択解除", - "agentManager.apply.moreFiles": "+{{count}} 件", - "agentManager.apply.moreReasons": "{{reason}} (+{{count}} 件)", "agentManager.apply.checking": "確認中...", "agentManager.apply.applying": "適用中...", "agentManager.apply.success": "変更が適用されました", @@ -163,9 +159,6 @@ export const dict = { "agentManager.apply.reason.patch": "パッチをクリーンに適用できません", "agentManager.apply.reason.contents": "現在のローカルファイルの内容を読み取れません", "agentManager.apply.reason.unknown": "競合が検出されました", - "agentManager.apply.inline.success": "適用済み", - "agentManager.apply.inline.conflict": "競合", - "agentManager.apply.inline.error": "失敗", "agentManager.shortcuts.toggleDiff": "差分パネルを切り替え", "agentManager.shortcuts.category.quickSwitch": "クイック切り替え", "agentManager.shortcuts.jumpToItem": "項目 1\u20139 に移動", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index 8d67919701f..ae87aaf9933 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -21,17 +21,14 @@ export const dict = { "agentManager.worktree.copyPath": "경로 복사", "agentManager.worktree.openInVscode": "VS Code에서 열기", "agentManager.worktree.rename": "이름 바꾸기", - "agentManager.worktree.moveToSection": "섹션으로 이동", "agentManager.worktree.newSection": "새 섹션", "agentManager.worktree.ungrouped": "그룹 해제됨", "agentManager.section.rename": "섹션 이름 바꾸기", "agentManager.section.setColor": "색상 설정", "agentManager.section.delete": "섹션 삭제", - "agentManager.section.defaultColor": "기본값", "agentManager.section.defaultName": "새 섹션", "agentManager.section.moveUp": "위로 이동", "agentManager.section.moveDown": "아래로 이동", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "브랜치", "agentManager.hoverCard.base": "베이스", "agentManager.hoverCard.sessions": "세션", @@ -61,6 +58,12 @@ export const dict = { "agentManager.terminal.new": "새 터미널 탭", "agentManager.terminal.ended": "터미널 종료됨 — 탭을 닫아 해제", "agentManager.terminal.connectionError": "터미널 연결 오류", + "agentManager.terminal.kill": "터미널 종료", + "agentManager.terminal.empty": "아직 여기에 터미널이 없습니다", + "agentManager.terminal.start": "터미널 시작", + "agentManager.terminal.destination": "터미널 버튼으로 열 위치 선택", + "agentManager.terminal.openInVscode": "VS Code 터미널", + "agentManager.terminal.openInPanel": "Agent Manager 패널", "agentManager.terminal.errorTitle": "터미널 오류", "agentManager.setup.failed": "워크스페이스 설정 실패", "agentManager.setup.settingUp": "워크스페이스 설정 중", @@ -127,7 +130,6 @@ export const dict = { "agentManager.dialog.creating": "생성 중...", "agentManager.dialog.createWorktree": "워크트리 생성", "agentManager.dialog.removeImage": "이미지 제거", - "agentManager.dialog.advanced": "고급...", "agentManager.diff.toggle": "차이점 전환", "agentManager.diff.openFile": "파일 열기", @@ -136,19 +138,13 @@ export const dict = { "agentManager.diff.revertError": "되돌리기 실패", "agentManager.open.button": "열기", "agentManager.open.tooltip": "이 Worktree를 VS Code에서 열기", - "agentManager.apply.button": "로컬에 적용", "agentManager.apply.globalButton": "적용", "agentManager.apply.tooltip": "선택한 워크트리 변경사항을 로컬 브랜치에 적용", "agentManager.apply.dialogTitle": "로컬 브랜치에 변경사항 적용", "agentManager.apply.confirm": "지금 적용", - "agentManager.apply.retry": "적용 재시도", - "agentManager.apply.previewTitle": "이 변경사항을 로컬 브랜치에 적용하시겠습니까?", - "agentManager.apply.previewSummary": "{{files}}개 파일, +{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "{{total}}개 파일 중 {{selected}}개 선택됨", "agentManager.apply.selectAll": "모두 선택", "agentManager.apply.selectNone": "선택 해제", - "agentManager.apply.moreFiles": "+{{count}}개 더", - "agentManager.apply.moreReasons": "{{reason}} (+{{count}}개 더)", "agentManager.apply.checking": "확인 중...", "agentManager.apply.applying": "적용 중...", "agentManager.apply.success": "변경사항이 적용되었습니다", @@ -162,9 +158,6 @@ export const dict = { "agentManager.apply.reason.patch": "패치를 깨끗하게 적용할 수 없습니다", "agentManager.apply.reason.contents": "현재 로컬 파일 내용을 읽을 수 없습니다", "agentManager.apply.reason.unknown": "충돌이 감지되었습니다", - "agentManager.apply.inline.success": "적용됨", - "agentManager.apply.inline.conflict": "충돌", - "agentManager.apply.inline.error": "실패", "agentManager.shortcuts.toggleDiff": "차이점 패널 전환", "agentManager.shortcuts.category.quickSwitch": "빠른 전환", "agentManager.shortcuts.jumpToItem": "항목 1\u20139(으)로 이동", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index 16f0c69fee0..1965204a471 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -22,17 +22,14 @@ export const dict = { "agentManager.worktree.copyPath": "Pad kopiëren", "agentManager.worktree.openInVscode": "Openen in VS Code", "agentManager.worktree.rename": "Hernoemen", - "agentManager.worktree.moveToSection": "Verplaatsen naar sectie", "agentManager.worktree.newSection": "Nieuwe sectie", "agentManager.worktree.ungrouped": "Niet gegroepeerd", "agentManager.section.rename": "Sectie hernoemen", "agentManager.section.setColor": "Kleur instellen", "agentManager.section.delete": "Sectie verwijderen", - "agentManager.section.defaultColor": "Standaard", "agentManager.section.defaultName": "Nieuwe sectie", "agentManager.section.moveUp": "Omhoog verplaatsen", "agentManager.section.moveDown": "Omlaag verplaatsen", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Basis", @@ -65,6 +62,12 @@ export const dict = { "agentManager.terminal.new": "Nieuw terminaltabblad", "agentManager.terminal.ended": "terminal beëindigd — sluit tabblad om te negeren", "agentManager.terminal.connectionError": "terminalverbindingsfout", + "agentManager.terminal.kill": "Terminal beëindigen", + "agentManager.terminal.empty": "Hier is nog geen terminal", + "agentManager.terminal.start": "Terminal starten", + "agentManager.terminal.destination": "Kies wat de terminalknop opent", + "agentManager.terminal.openInVscode": "VS Code-terminal", + "agentManager.terminal.openInPanel": "Agent Manager-paneel", "agentManager.terminal.errorTitle": "Terminalfout", "agentManager.setup.failed": "Worktree setup mislukt", @@ -134,7 +137,6 @@ export const dict = { "agentManager.dialog.creating": "Maken...", "agentManager.dialog.createWorktree": "Worktree maken", "agentManager.dialog.removeImage": "Afbeelding verwijderen", - "agentManager.dialog.advanced": "Geavanceerd...", "agentManager.diff.toggle": "Diff in-/uitschakelen", "agentManager.diff.openFile": "Bestand openen", @@ -143,19 +145,13 @@ export const dict = { "agentManager.diff.revertError": "Terugzetten mislukt", "agentManager.open.button": "Openen", "agentManager.open.tooltip": "Open deze worktree in VS Code", - "agentManager.apply.button": "Toepassen op lokaal", "agentManager.apply.globalButton": "Toepassen", "agentManager.apply.tooltip": "Geselecteerde worktree wijzigingen toepassen op lokale branch", "agentManager.apply.dialogTitle": "Wijzigingen toepassen op lokale branch", "agentManager.apply.confirm": "Nu toepassen", - "agentManager.apply.retry": "Toepassen opnieuw proberen", - "agentManager.apply.previewTitle": "Deze wijzigingen toepassen op lokale branch?", - "agentManager.apply.previewSummary": "{{files}} bestanden, +{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "{{selected}} van {{total}} bestanden geselecteerd", "agentManager.apply.selectAll": "Alles selecteren", "agentManager.apply.selectNone": "Niets selecteren", - "agentManager.apply.moreFiles": "nog +{{count}}", - "agentManager.apply.moreReasons": "{{reason}} (nog +{{count}})", "agentManager.apply.checking": "Controleren...", "agentManager.apply.applying": "Toepassen...", "agentManager.apply.success": "Wijzigingen toegepast", @@ -168,9 +164,6 @@ export const dict = { "agentManager.apply.reason.patch": "Patch kan niet netjes worden toegepast", "agentManager.apply.reason.contents": "Kan huidige lokale bestandsinhoud niet lezen", "agentManager.apply.reason.unknown": "Conflict gedetecteerd", - "agentManager.apply.inline.success": "Toegepast", - "agentManager.apply.inline.conflict": "Conflicten", - "agentManager.apply.inline.error": "Mislukt", "agentManager.shortcuts.toggleDiff": "Diff-paneel in-/uitschakelen", "agentManager.shortcuts.category.quickSwitch": "Snel wisselen", "agentManager.shortcuts.jumpToItem": "Spring naar item 1\u20139", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index b72e1842892..44efed03a51 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -21,17 +21,14 @@ export const dict = { "agentManager.worktree.copyPath": "Kopier sti", "agentManager.worktree.openInVscode": "Åpne i VS Code", "agentManager.worktree.rename": "Gi nytt navn", - "agentManager.worktree.moveToSection": "Flytt til seksjon", "agentManager.worktree.newSection": "Ny seksjon", "agentManager.worktree.ungrouped": "Ugruppert", "agentManager.section.rename": "Gi seksjon nytt navn", "agentManager.section.setColor": "Angi farge", "agentManager.section.delete": "Slett seksjon", - "agentManager.section.defaultColor": "Standard", "agentManager.section.defaultName": "Ny seksjon", "agentManager.section.moveUp": "Flytt opp", "agentManager.section.moveDown": "Flytt ned", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Økter", @@ -61,6 +58,12 @@ export const dict = { "agentManager.terminal.new": "Ny terminalfane", "agentManager.terminal.ended": "terminal avsluttet — lukk fanen for å avvise", "agentManager.terminal.connectionError": "tilkoblingsfeil for terminal", + "agentManager.terminal.kill": "Avslutt terminal", + "agentManager.terminal.empty": "Ingen terminal her ennå", + "agentManager.terminal.start": "Start terminal", + "agentManager.terminal.destination": "Velg hva terminalknappen åpner", + "agentManager.terminal.openInVscode": "VS Code-terminal", + "agentManager.terminal.openInPanel": "Agent Manager-panel", "agentManager.terminal.errorTitle": "Terminalfeil", "agentManager.setup.failed": "Oppsett av arbeidsområde mislyktes", "agentManager.setup.settingUp": "Setter opp arbeidsområde", @@ -128,7 +131,6 @@ export const dict = { "agentManager.dialog.creating": "Oppretter...", "agentManager.dialog.createWorktree": "Opprett worktree", "agentManager.dialog.removeImage": "Fjern bilde", - "agentManager.dialog.advanced": "Avansert...", "agentManager.diff.toggle": "Veksle diff", "agentManager.diff.openFile": "Åpne fil", @@ -137,19 +139,13 @@ export const dict = { "agentManager.diff.revertError": "Tilbakestilling feilet", "agentManager.open.button": "Åpne", "agentManager.open.tooltip": "Åpne dette Worktree-et i VS Code", - "agentManager.apply.button": "Bruk lokalt", "agentManager.apply.globalButton": "Bruk", "agentManager.apply.tooltip": "Bruk valgte worktree-endringer på den lokale grenen", "agentManager.apply.dialogTitle": "Bruk endringer på den lokale grenen", "agentManager.apply.confirm": "Bruk nå", - "agentManager.apply.retry": "Prøv å bruke på nytt", - "agentManager.apply.previewTitle": "Bruke disse endringene på den lokale grenen?", - "agentManager.apply.previewSummary": "{{files}} filer, +{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "{{selected}} av {{total}} filer valgt", "agentManager.apply.selectAll": "Velg alle", "agentManager.apply.selectNone": "Velg ingen", - "agentManager.apply.moreFiles": "+{{count}} flere", - "agentManager.apply.moreReasons": "{{reason}} (+{{count}} flere)", "agentManager.apply.checking": "Sjekker...", "agentManager.apply.applying": "Bruker...", "agentManager.apply.success": "Endringer brukt", @@ -162,9 +158,6 @@ export const dict = { "agentManager.apply.reason.patch": "Patch kan ikke brukes rent", "agentManager.apply.reason.contents": "Kan ikke lese innholdet i gjeldende lokal fil", "agentManager.apply.reason.unknown": "Konflikt oppdaget", - "agentManager.apply.inline.success": "Brukt", - "agentManager.apply.inline.conflict": "Konflikter", - "agentManager.apply.inline.error": "Mislyktes", "agentManager.shortcuts.toggleDiff": "Veksle diff-panel", "agentManager.shortcuts.category.quickSwitch": "Hurtigbytte", "agentManager.shortcuts.jumpToItem": "Hopp til element 1\u20139", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index 3a6e0fda56d..137bf34132f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -21,17 +21,14 @@ export const dict = { "agentManager.worktree.copyPath": "Kopiuj ścieżkę", "agentManager.worktree.openInVscode": "Otwórz w VS Code", "agentManager.worktree.rename": "Zmień nazwę", - "agentManager.worktree.moveToSection": "Przenieś do sekcji", "agentManager.worktree.newSection": "Nowa sekcja", "agentManager.worktree.ungrouped": "Niezgrupowane", "agentManager.section.rename": "Zmień nazwę sekcji", "agentManager.section.setColor": "Ustaw kolor", "agentManager.section.delete": "Usuń sekcję", - "agentManager.section.defaultColor": "Domyślny", "agentManager.section.defaultName": "Nowa sekcja", "agentManager.section.moveUp": "Przenieś w górę", "agentManager.section.moveDown": "Przenieś w dół", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Baza", "agentManager.hoverCard.sessions": "Sesje", @@ -62,6 +59,12 @@ export const dict = { "agentManager.terminal.new": "Nowa karta terminala", "agentManager.terminal.ended": "terminal zakończony — zamknij kartę, aby zamknąć", "agentManager.terminal.connectionError": "błąd połączenia terminala", + "agentManager.terminal.kill": "Zakończ terminal", + "agentManager.terminal.empty": "Nie ma tu jeszcze terminala", + "agentManager.terminal.start": "Uruchom terminal", + "agentManager.terminal.destination": "Wybierz, co otwiera przycisk terminala", + "agentManager.terminal.openInVscode": "Terminal VS Code", + "agentManager.terminal.openInPanel": "Panel Agent Manager", "agentManager.terminal.errorTitle": "Błąd terminala", "agentManager.setup.failed": "Konfiguracja worktree nie powiodła się", "agentManager.setup.settingUp": "Konfigurowanie worktree", @@ -128,7 +131,6 @@ export const dict = { "agentManager.dialog.creating": "Tworzenie...", "agentManager.dialog.createWorktree": "Utwórz Worktree", "agentManager.dialog.removeImage": "Usuń obraz", - "agentManager.dialog.advanced": "Zaawansowane...", "agentManager.diff.toggle": "Przełącz diff", "agentManager.diff.openFile": "Otwórz plik", @@ -137,19 +139,13 @@ export const dict = { "agentManager.diff.revertError": "Cofanie nie powiodło się", "agentManager.open.button": "Otwórz", "agentManager.open.tooltip": "Otwórz ten Worktree w VS Code", - "agentManager.apply.button": "Zastosuj lokalnie", "agentManager.apply.globalButton": "Zastosuj", "agentManager.apply.tooltip": "Zastosuj wybrane zmiany worktree na lokalnej gałęzi", "agentManager.apply.dialogTitle": "Zastosuj zmiany na lokalnej gałęzi", "agentManager.apply.confirm": "Zastosuj teraz", - "agentManager.apply.retry": "Ponów zastosowanie", - "agentManager.apply.previewTitle": "Zastosować te zmiany na lokalnej gałęzi?", - "agentManager.apply.previewSummary": "{{files}} plików, +{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "{{selected}} z {{total}} plików wybranych", "agentManager.apply.selectAll": "Zaznacz wszystkie", "agentManager.apply.selectNone": "Odznacz wszystkie", - "agentManager.apply.moreFiles": "+{{count}} więcej", - "agentManager.apply.moreReasons": "{{reason}} (+{{count}} więcej)", "agentManager.apply.checking": "Sprawdzanie...", "agentManager.apply.applying": "Stosowanie...", "agentManager.apply.success": "Zmiany zastosowane", @@ -162,9 +158,6 @@ export const dict = { "agentManager.apply.reason.patch": "Łatka nie może być zastosowana czysto", "agentManager.apply.reason.contents": "Nie można odczytać zawartości bieżącego pliku lokalnego", "agentManager.apply.reason.unknown": "Wykryto konflikt", - "agentManager.apply.inline.success": "Zastosowano", - "agentManager.apply.inline.conflict": "Konflikty", - "agentManager.apply.inline.error": "Błąd", "agentManager.shortcuts.toggleDiff": "Przełącz panel diff", "agentManager.shortcuts.category.quickSwitch": "Szybkie przełączanie", "agentManager.shortcuts.jumpToItem": "Przejdź do elementu 1\u20139", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index d2f230a1cd7..c0df2ab3c94 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -21,17 +21,14 @@ export const dict = { "agentManager.worktree.copyPath": "Копировать путь", "agentManager.worktree.openInVscode": "Открыть в VS Code", "agentManager.worktree.rename": "Переименовать", - "agentManager.worktree.moveToSection": "Переместить в раздел", "agentManager.worktree.newSection": "Новый раздел", "agentManager.worktree.ungrouped": "Без группы", "agentManager.section.rename": "Переименовать раздел", "agentManager.section.setColor": "Задать цвет", "agentManager.section.delete": "Удалить раздел", - "agentManager.section.defaultColor": "По умолчанию", "agentManager.section.defaultName": "Новый раздел", "agentManager.section.moveUp": "Переместить вверх", "agentManager.section.moveDown": "Переместить вниз", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "ВЕТКА", "agentManager.hoverCard.base": "Основа", "agentManager.hoverCard.sessions": "Сессии", @@ -62,6 +59,12 @@ export const dict = { "agentManager.terminal.new": "Новая вкладка терминала", "agentManager.terminal.ended": "терминал завершен — закройте вкладку, чтобы скрыть", "agentManager.terminal.connectionError": "ошибка подключения к терминалу", + "agentManager.terminal.kill": "Завершить терминал", + "agentManager.terminal.empty": "Здесь пока нет терминала", + "agentManager.terminal.start": "Запустить терминал", + "agentManager.terminal.destination": "Выберите, где будет открываться терминал", + "agentManager.terminal.openInVscode": "Терминал VS Code", + "agentManager.terminal.openInPanel": "Панель Agent Manager", "agentManager.terminal.errorTitle": "Ошибка терминала", "agentManager.setup.failed": "Не удалось настроить рабочее пространство", "agentManager.setup.settingUp": "Настройка рабочего пространства", @@ -129,7 +132,6 @@ export const dict = { "agentManager.dialog.creating": "Создание...", "agentManager.dialog.createWorktree": "Создать worktree", "agentManager.dialog.removeImage": "Удалить изображение", - "agentManager.dialog.advanced": "Дополнительно...", "agentManager.diff.toggle": "Переключить diff", "agentManager.diff.openFile": "Открыть файл", @@ -138,19 +140,13 @@ export const dict = { "agentManager.diff.revertError": "Ошибка отката", "agentManager.open.button": "Открыть", "agentManager.open.tooltip": "Открыть этот Worktree в VS Code", - "agentManager.apply.button": "Применить локально", "agentManager.apply.globalButton": "Применить", "agentManager.apply.tooltip": "Применить изменения выбранного рабочего дерева к локальной ветке", "agentManager.apply.dialogTitle": "Применить изменения к локальной ветке", "agentManager.apply.confirm": "Применить сейчас", - "agentManager.apply.retry": "Повторить применение", - "agentManager.apply.previewTitle": "Применить эти изменения к локальной ветке?", - "agentManager.apply.previewSummary": "{{files}} файлов, +{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "{{selected}} из {{total}} файлов выбрано", "agentManager.apply.selectAll": "Выбрать все", "agentManager.apply.selectNone": "Снять выделение", - "agentManager.apply.moreFiles": "+{{count}} ещё", - "agentManager.apply.moreReasons": "{{reason}} (+{{count}} ещё)", "agentManager.apply.checking": "Проверка...", "agentManager.apply.applying": "Применение...", "agentManager.apply.success": "Изменения применены", @@ -163,9 +159,6 @@ export const dict = { "agentManager.apply.reason.patch": "Патч не может быть применён чисто", "agentManager.apply.reason.contents": "Не удаётся прочитать содержимое текущего локального файла", "agentManager.apply.reason.unknown": "Обнаружен конфликт", - "agentManager.apply.inline.success": "Применено", - "agentManager.apply.inline.conflict": "Конфликты", - "agentManager.apply.inline.error": "Ошибка", "agentManager.shortcuts.toggleDiff": "Переключить панель diff", "agentManager.shortcuts.category.quickSwitch": "Быстрое переключение", "agentManager.shortcuts.jumpToItem": "Перейти к элементу 1\u20139", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index af8b8e11d5c..012b3776ba3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -21,17 +21,14 @@ export const dict = { "agentManager.worktree.copyPath": "คัดลอกเส้นทาง", "agentManager.worktree.openInVscode": "เปิดใน VS Code", "agentManager.worktree.rename": "เปลี่ยนชื่อ", - "agentManager.worktree.moveToSection": "ย้ายไปที่ส่วน", "agentManager.worktree.newSection": "ส่วนใหม่", "agentManager.worktree.ungrouped": "ไม่ได้จัดกลุ่ม", "agentManager.section.rename": "เปลี่ยนชื่อส่วน", "agentManager.section.setColor": "ตั้งค่าสี", "agentManager.section.delete": "ลบส่วน", - "agentManager.section.defaultColor": "ค่าเริ่มต้น", "agentManager.section.defaultName": "ส่วนใหม่", "agentManager.section.moveUp": "เลื่อนขึ้น", "agentManager.section.moveDown": "เลื่อนลง", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "ฐาน", "agentManager.hoverCard.sessions": "เซสชัน", @@ -61,6 +58,12 @@ export const dict = { "agentManager.terminal.new": "แท็บเทอร์มินัลใหม่", "agentManager.terminal.ended": "เทอร์มินัลสิ้นสุด — ปิดแท็บเพื่อยกเลิก", "agentManager.terminal.connectionError": "ข้อผิดพลาดการเชื่อมต่อเทอร์มินัล", + "agentManager.terminal.kill": "หยุดเทอร์มินัล", + "agentManager.terminal.empty": "ยังไม่มีเทอร์มินัลที่นี่", + "agentManager.terminal.start": "เริ่มเทอร์มินัล", + "agentManager.terminal.destination": "เลือกว่าปุ่มเทอร์มินัลจะเปิดอะไร", + "agentManager.terminal.openInVscode": "เทอร์มินัล VS Code", + "agentManager.terminal.openInPanel": "แผง Agent Manager", "agentManager.terminal.errorTitle": "ข้อผิดพลาดเทอร์มินัล", "agentManager.setup.failed": "ตั้งค่า Worktree ล้มเหลว", "agentManager.setup.settingUp": "กำลังตั้งค่า Worktree", @@ -124,7 +127,6 @@ export const dict = { "agentManager.dialog.creating": "กำลังสร้าง...", "agentManager.dialog.createWorktree": "สร้าง Worktree", "agentManager.dialog.removeImage": "ลบรูปภาพ", - "agentManager.dialog.advanced": "ขั้นสูง...", "agentManager.diff.toggle": "สลับ diff", "agentManager.diff.openFile": "เปิดไฟล์", @@ -133,19 +135,13 @@ export const dict = { "agentManager.diff.revertError": "ย้อนกลับล้มเหลว", "agentManager.open.button": "เปิด", "agentManager.open.tooltip": "เปิด Worktree นี้ใน VS Code", - "agentManager.apply.button": "นำไปใช้ในเครื่อง", "agentManager.apply.globalButton": "นำไปใช้", "agentManager.apply.tooltip": "นำการเปลี่ยนแปลงของ worktree ที่เลือกไปใช้กับสาขาในเครื่อง", "agentManager.apply.dialogTitle": "นำการเปลี่ยนแปลงไปใช้กับสาขาในเครื่อง", "agentManager.apply.confirm": "นำไปใช้ตอนนี้", - "agentManager.apply.retry": "ลองนำไปใช้อีกครั้ง", - "agentManager.apply.previewTitle": "นำการเปลี่ยนแปลงเหล่านี้ไปใช้กับสาขาในเครื่อง?", - "agentManager.apply.previewSummary": "{{files}} ไฟล์, +{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "เลือก {{selected}} จาก {{total}} ไฟล์", "agentManager.apply.selectAll": "เลือกทั้งหมด", "agentManager.apply.selectNone": "ยกเลิกการเลือกทั้งหมด", - "agentManager.apply.moreFiles": "+{{count}} เพิ่มเติม", - "agentManager.apply.moreReasons": "{{reason}} (+{{count}} เพิ่มเติม)", "agentManager.apply.checking": "กำลังตรวจสอบ...", "agentManager.apply.applying": "กำลังนำไปใช้...", "agentManager.apply.success": "นำการเปลี่ยนแปลงไปใช้แล้ว", @@ -158,9 +154,6 @@ export const dict = { "agentManager.apply.reason.patch": "ไม่สามารถนำ patch ไปใช้ได้อย่างสะอาด", "agentManager.apply.reason.contents": "ไม่สามารถอ่านเนื้อหาของไฟล์ในเครื่องปัจจุบัน", "agentManager.apply.reason.unknown": "ตรวจพบข้อขัดแย้ง", - "agentManager.apply.inline.success": "นำไปใช้แล้ว", - "agentManager.apply.inline.conflict": "ข้อขัดแย้ง", - "agentManager.apply.inline.error": "ล้มเหลว", "agentManager.shortcuts.toggleDiff": "สลับแผง diff", "agentManager.shortcuts.category.quickSwitch": "สลับด่วน", "agentManager.shortcuts.jumpToItem": "ข้ามไปยังรายการ 1\u20139", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index ba9711f61db..e18f88694b3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -22,17 +22,14 @@ export const dict = { "agentManager.worktree.copyPath": "Yolu Kopyala", "agentManager.worktree.openInVscode": "VS Code'da Aç", "agentManager.worktree.rename": "Yeniden Adlandır", - "agentManager.worktree.moveToSection": "Bölüme Taşı", "agentManager.worktree.newSection": "Yeni Bölüm", "agentManager.worktree.ungrouped": "Gruplandırılmamış", "agentManager.section.rename": "Bölümü Yeniden Adlandır", "agentManager.section.setColor": "Renk Ayarla", "agentManager.section.delete": "Bölümü Sil", - "agentManager.section.defaultColor": "Varsayılan", "agentManager.section.defaultName": "Yeni Bölüm", "agentManager.section.moveUp": "Yukarı Taşı", "agentManager.section.moveDown": "Aşağı Taşı", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "DAL", "agentManager.hoverCard.base": "Temel", @@ -66,6 +63,12 @@ export const dict = { "agentManager.terminal.new": "Yeni Terminal Sekmesi", "agentManager.terminal.ended": "terminal sona erdi — kapatmak için sekmeyi kapatın", "agentManager.terminal.connectionError": "terminal bağlantı hatası", + "agentManager.terminal.kill": "Terminali sonlandır", + "agentManager.terminal.empty": "Burada henüz terminal yok", + "agentManager.terminal.start": "Terminali başlat", + "agentManager.terminal.destination": "Terminal düğmesinin ne açacağını seçin", + "agentManager.terminal.openInVscode": "VS Code terminali", + "agentManager.terminal.openInPanel": "Agent Manager paneli", "agentManager.terminal.errorTitle": "Terminal hatası", "agentManager.setup.failed": "Worktree kurulumu başarısız", @@ -135,7 +138,6 @@ export const dict = { "agentManager.dialog.creating": "Oluşturuluyor...", "agentManager.dialog.createWorktree": "Worktree Oluştur", "agentManager.dialog.removeImage": "Görüntüyü kaldır", - "agentManager.dialog.advanced": "Gelişmiş...", "agentManager.diff.toggle": "diff'i aç/kapat", "agentManager.diff.openFile": "Dosyayı aç", @@ -144,19 +146,13 @@ export const dict = { "agentManager.diff.revertError": "Geri alma başarısız", "agentManager.open.button": "Aç", "agentManager.open.tooltip": "Bu worktree'yi VS Code'da aç", - "agentManager.apply.button": "Yerele uygula", "agentManager.apply.globalButton": "Uygula", "agentManager.apply.tooltip": "Seçilen worktree değişikliklerini yerel dala uygula", "agentManager.apply.dialogTitle": "Değişiklikleri yerel dala uygula", "agentManager.apply.confirm": "Şimdi uygula", - "agentManager.apply.retry": "Yeniden uygula", - "agentManager.apply.previewTitle": "Bu değişiklikler yerel dala uygulansın mı?", - "agentManager.apply.previewSummary": "{{files}} dosya, +{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "{{total}} dosyadan {{selected}} tanesi seçildi", "agentManager.apply.selectAll": "Tümünü seç", "agentManager.apply.selectNone": "Hiçbirini seçme", - "agentManager.apply.moreFiles": "+{{count}} tane daha", - "agentManager.apply.moreReasons": "{{reason}} (+{{count}} tane daha)", "agentManager.apply.checking": "Kontrol ediliyor...", "agentManager.apply.applying": "Uygulanıyor...", "agentManager.apply.success": "Değişiklikler uygulandı", @@ -170,9 +166,6 @@ export const dict = { "agentManager.apply.reason.patch": "Yama (patch) sorunsuz uygulanamıyor", "agentManager.apply.reason.contents": "Geçerli yerel dosya içerikleri okunamıyor", "agentManager.apply.reason.unknown": "Çakışma algılandı", - "agentManager.apply.inline.success": "Uygulandı", - "agentManager.apply.inline.conflict": "Çakışmalar", - "agentManager.apply.inline.error": "Başarısız oldu", "agentManager.shortcuts.toggleDiff": "diff panelini aç/kapat", "agentManager.shortcuts.category.quickSwitch": "Hızlı Geçiş", "agentManager.shortcuts.jumpToItem": "1\u20139 öğesine atla", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index 2ce90dccfff..1c1c1cbf22e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -22,17 +22,14 @@ export const dict = { "agentManager.worktree.copyPath": "Копіювати шлях", "agentManager.worktree.openInVscode": "Відкрити у VS Code", "agentManager.worktree.rename": "Перейменувати", - "agentManager.worktree.moveToSection": "Перемістити до розділу", "agentManager.worktree.newSection": "Новий розділь", "agentManager.worktree.ungrouped": "Без групи", "agentManager.section.rename": "Перейменувати розділ", "agentManager.section.setColor": "Встановити колір", "agentManager.section.delete": "Видалити розділ", - "agentManager.section.defaultColor": "За замовчуванням", "agentManager.section.defaultName": "Новий розділ", "agentManager.section.moveUp": "Перемістити вгору", "agentManager.section.moveDown": "Перемістити вниз", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "ГІЛКА", "agentManager.hoverCard.base": "База", @@ -66,6 +63,12 @@ export const dict = { "agentManager.terminal.new": "Нова вкладка термінала", "agentManager.terminal.ended": "термінал завершено — закрийте вкладку, щоб відхилити", "agentManager.terminal.connectionError": "помилка з'єднання термінала", + "agentManager.terminal.kill": "Завершити термінал", + "agentManager.terminal.empty": "Тут ще немає термінала", + "agentManager.terminal.start": "Запустити термінал", + "agentManager.terminal.destination": "Виберіть, що відкриватиме кнопка термінала", + "agentManager.terminal.openInVscode": "Термінал VS Code", + "agentManager.terminal.openInPanel": "Панель Agent Manager", "agentManager.terminal.errorTitle": "Помилка термінала", "agentManager.setup.failed": "Налаштування робочого дерева не вдалося", @@ -136,7 +139,6 @@ export const dict = { "agentManager.dialog.creating": "Створення...", "agentManager.dialog.createWorktree": "Створити робоче дерево", "agentManager.dialog.removeImage": "Видалити зображення", - "agentManager.dialog.advanced": "Розширено...", "agentManager.diff.toggle": "Показати або приховати diff", "agentManager.diff.openFile": "Відкрити файл", @@ -145,19 +147,13 @@ export const dict = { "agentManager.diff.revertError": "Не вдалося відновити", "agentManager.open.button": "Відкрити", "agentManager.open.tooltip": "Відкрити це робоче дерево у VS Code", - "agentManager.apply.button": "Застосувати локально", "agentManager.apply.globalButton": "Застосувати", "agentManager.apply.tooltip": "Застосувати вибрані зміни робочого дерева до локальної гілки", "agentManager.apply.dialogTitle": "Застосувати зміни до локальної гілки", "agentManager.apply.confirm": "Застосувати зараз", - "agentManager.apply.retry": "Повторити застосування", - "agentManager.apply.previewTitle": "Застосувати ці зміни до локальної гілки?", - "agentManager.apply.previewSummary": "{{files}} файлів, +{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "{{selected}} з {{total}} файлів вибрано", "agentManager.apply.selectAll": "Вибрати все", "agentManager.apply.selectNone": "Зняти вибір", - "agentManager.apply.moreFiles": "+{{count}} ще", - "agentManager.apply.moreReasons": "{{reason}} (+{{count}} ще)", "agentManager.apply.checking": "Перевірка...", "agentManager.apply.applying": "Застосування...", "agentManager.apply.success": "Зміни застосовано", @@ -171,9 +167,6 @@ export const dict = { "agentManager.apply.reason.patch": "Патч неможливо застосувати без конфліктів", "agentManager.apply.reason.contents": "Неможливо прочитати вміст поточного локального файлу", "agentManager.apply.reason.unknown": "Виявлено конфлікт", - "agentManager.apply.inline.success": "Застосовано", - "agentManager.apply.inline.conflict": "Конфлікти", - "agentManager.apply.inline.error": "Не вдалося", "agentManager.shortcuts.toggleDiff": "Показати або приховати панель diff", "agentManager.shortcuts.category.quickSwitch": "Швидке перемикання", "agentManager.shortcuts.jumpToItem": "Перейти до елемента 1\u20139", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index fb46b8f06aa..2e0c7f5568c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -21,17 +21,14 @@ export const dict = { "agentManager.worktree.copyPath": "复制路径", "agentManager.worktree.openInVscode": "在 VS Code 中打开", "agentManager.worktree.rename": "重命名", - "agentManager.worktree.moveToSection": "移动到分区", "agentManager.worktree.newSection": "新分区", "agentManager.worktree.ungrouped": "未分组", "agentManager.section.rename": "重命名分区", "agentManager.section.setColor": "设置颜色", "agentManager.section.delete": "删除分区", - "agentManager.section.defaultColor": "默认", "agentManager.section.defaultName": "新分区", "agentManager.section.moveUp": "上移", "agentManager.section.moveDown": "下移", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "分支", "agentManager.hoverCard.base": "基础", "agentManager.hoverCard.sessions": "会话", @@ -61,6 +58,12 @@ export const dict = { "agentManager.terminal.new": "新建终端标签页", "agentManager.terminal.ended": "终端已结束 — 关闭标签页以消除", "agentManager.terminal.connectionError": "终端连接错误", + "agentManager.terminal.kill": "终止终端", + "agentManager.terminal.empty": "此处尚无终端", + "agentManager.terminal.start": "启动终端", + "agentManager.terminal.destination": "选择终端按钮的打开目标", + "agentManager.terminal.openInVscode": "VS Code 终端", + "agentManager.terminal.openInPanel": "Agent Manager 面板", "agentManager.terminal.errorTitle": "终端错误", "agentManager.setup.failed": "工作区设置失败", "agentManager.setup.settingUp": "正在设置工作区", @@ -123,7 +126,6 @@ export const dict = { "agentManager.dialog.creating": "创建中...", "agentManager.dialog.createWorktree": "创建工作树", "agentManager.dialog.removeImage": "移除图片", - "agentManager.dialog.advanced": "高级...", "agentManager.diff.toggle": "切换差异", "agentManager.diff.openFile": "打开文件", @@ -132,19 +134,13 @@ export const dict = { "agentManager.diff.revertError": "还原失败", "agentManager.open.button": "打开", "agentManager.open.tooltip": "在 VS Code 中打开此 Worktree", - "agentManager.apply.button": "应用到本地", "agentManager.apply.globalButton": "应用", "agentManager.apply.tooltip": "将所选工作树的更改应用到本地分支", "agentManager.apply.dialogTitle": "将更改应用到本地分支", "agentManager.apply.confirm": "立即应用", - "agentManager.apply.retry": "重试应用", - "agentManager.apply.previewTitle": "将这些更改应用到本地分支?", - "agentManager.apply.previewSummary": "{{files}} 个文件,+{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "已选择 {{selected}}/{{total}} 个文件", "agentManager.apply.selectAll": "全选", "agentManager.apply.selectNone": "取消全选", - "agentManager.apply.moreFiles": "+{{count}} 更多", - "agentManager.apply.moreReasons": "{{reason}}(+{{count}} 更多)", "agentManager.apply.checking": "检查中...", "agentManager.apply.applying": "应用中...", "agentManager.apply.success": "更改已应用", @@ -157,9 +153,6 @@ export const dict = { "agentManager.apply.reason.patch": "补丁无法干净地应用", "agentManager.apply.reason.contents": "无法读取当前本地文件内容", "agentManager.apply.reason.unknown": "检测到冲突", - "agentManager.apply.inline.success": "已应用", - "agentManager.apply.inline.conflict": "冲突", - "agentManager.apply.inline.error": "失败", "agentManager.shortcuts.toggleDiff": "切换差异面板", "agentManager.shortcuts.category.quickSwitch": "快速切换", "agentManager.shortcuts.jumpToItem": "跳转到项目 1\u20139", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index b1655b23ee1..e4403c5aaff 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -21,17 +21,14 @@ export const dict = { "agentManager.worktree.copyPath": "複製路徑", "agentManager.worktree.openInVscode": "在 VS Code 中打開", "agentManager.worktree.rename": "重新命名", - "agentManager.worktree.moveToSection": "移動到分區", "agentManager.worktree.newSection": "新分區", "agentManager.worktree.ungrouped": "未分組", "agentManager.section.rename": "重新命名分區", "agentManager.section.setColor": "設定顏色", "agentManager.section.delete": "刪除分區", - "agentManager.section.defaultColor": "預設", "agentManager.section.defaultName": "新分區", "agentManager.section.moveUp": "上移", "agentManager.section.moveDown": "下移", - "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "分支", "agentManager.hoverCard.base": "基底", "agentManager.hoverCard.sessions": "工作階段", @@ -61,6 +58,12 @@ export const dict = { "agentManager.terminal.new": "新增終端分頁", "agentManager.terminal.ended": "終端已結束 — 關閉分頁以消除", "agentManager.terminal.connectionError": "終端連線錯誤", + "agentManager.terminal.kill": "終止終端機", + "agentManager.terminal.empty": "此處尚無終端機", + "agentManager.terminal.start": "啟動終端機", + "agentManager.terminal.destination": "選擇終端機按鈕的開啟目標", + "agentManager.terminal.openInVscode": "VS Code 終端機", + "agentManager.terminal.openInPanel": "Agent Manager 面板", "agentManager.terminal.errorTitle": "終端錯誤", "agentManager.setup.failed": "工作區設定失敗", "agentManager.setup.settingUp": "正在設定工作區", @@ -123,7 +126,6 @@ export const dict = { "agentManager.dialog.creating": "建立中...", "agentManager.dialog.createWorktree": "建立工作樹", "agentManager.dialog.removeImage": "移除圖片", - "agentManager.dialog.advanced": "進階...", "agentManager.diff.toggle": "切換差異", "agentManager.diff.openFile": "開啟檔案", @@ -132,19 +134,13 @@ export const dict = { "agentManager.diff.revertError": "還原失敗", "agentManager.open.button": "開啟", "agentManager.open.tooltip": "在 VS Code 中開啟此 Worktree", - "agentManager.apply.button": "套用到本地", "agentManager.apply.globalButton": "套用", "agentManager.apply.tooltip": "將所選工作樹的變更套用到本地分支", "agentManager.apply.dialogTitle": "將變更套用到本地分支", "agentManager.apply.confirm": "立即套用", - "agentManager.apply.retry": "重試套用", - "agentManager.apply.previewTitle": "將這些變更套用到本地分支?", - "agentManager.apply.previewSummary": "{{files}} 個檔案,+{{additions}} -{{deletions}}", "agentManager.apply.selectionSummary": "已選取 {{selected}}/{{total}} 個檔案", "agentManager.apply.selectAll": "全選", "agentManager.apply.selectNone": "取消全選", - "agentManager.apply.moreFiles": "+{{count}} 更多", - "agentManager.apply.moreReasons": "{{reason}}(+{{count}} 更多)", "agentManager.apply.checking": "檢查中...", "agentManager.apply.applying": "套用中...", "agentManager.apply.success": "變更已套用", @@ -157,9 +153,6 @@ export const dict = { "agentManager.apply.reason.patch": "修補程式無法乾淨地套用", "agentManager.apply.reason.contents": "無法讀取目前本地檔案內容", "agentManager.apply.reason.unknown": "偵測到衝突", - "agentManager.apply.inline.success": "已套用", - "agentManager.apply.inline.conflict": "衝突", - "agentManager.apply.inline.error": "失敗", "agentManager.shortcuts.toggleDiff": "切換差異面板", "agentManager.shortcuts.category.quickSwitch": "快速切換", "agentManager.shortcuts.jumpToItem": "跳至項目 1\u20139", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx new file mode 100644 index 00000000000..bf42185b831 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx @@ -0,0 +1,85 @@ +/** + * Right-side terminal panel for the Agent Manager inspector. + * + * Lives inside the shared `.am-diff-panel-wrapper` host next to the diff + * and PR panels, so all three inspector modes share one resize handle + * and one width. The header intentionally reuses the `.am-diff-header` + * structure and metrics so switching modes does not shift the chrome. + * + * Visibility is opacity-based, never unmount: the xterm render loop + * dies when its subtree leaves the paint tree (see `render.tsx`). + */ + +import type { Accessor, Component } from "solid-js" +import { Show, createEffect } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { Button } from "@kilocode/kilo-ui/button" +import { Spinner } from "@kilocode/kilo-ui/spinner" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import { useLanguage } from "../../src/context/language" +import { renderSideTerminalLayer } from "./render" +import type { TerminalStateControls } from "./state" + +interface Props { + state: TerminalStateControls + /** Context the panel currently shows (`state.sideKey`). */ + contextKey: Accessor + /** True while the inspector is in terminal mode. */ + visible: Accessor + /** Kill the terminal (or cancel its create) and hide. */ + onClose: () => void + /** Empty-state action: create a side terminal for this context. */ + onStart: () => void +} + +export const SideTerminalPanel: Component = (props) => { + const { t } = useLanguage() + let panel!: HTMLElement + createEffect(() => { + panel.inert = !props.visible() + }) + const side = () => props.state.side() + const pending = () => props.state.pendingSide(props.contextKey()) !== undefined + return ( +
+
+
+ + {side()?.title ?? t("agentManager.tab.terminal")} +
+
+ + + +
+
+ {renderSideTerminalLayer({ state: props.state, contextKey: props.contextKey, visible: props.visible })} + +
+ + {t("common.loading")} +
+
+ +
+ {t("agentManager.terminal.empty")} + +
+
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalDestinationButton.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalDestinationButton.tsx new file mode 100644 index 00000000000..a0ae02bee2d --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalDestinationButton.tsx @@ -0,0 +1,65 @@ +/** + * Terminal destination split button for the Agent Manager tab toolbar. + * + * The primary action opens whatever the user picked (VS Code integrated + * terminal or the embedded side panel); the dropdown switches between + * them. Markup mirrors the `+` new-tab split button in + * `tab-rendering.tsx` so both share the same split-button styling. + */ + +import type { Accessor, Component } from "solid-js" +import { Show } from "solid-js" +import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu" +import { Icon } from "@kilocode/kilo-ui/icon" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" +import { useLanguage } from "../../src/context/language" +import type { TerminalDestination } from "../../src/types/messages/agent-manager" + +interface Props { + destination: Accessor + /** True while the embedded terminal panel is showing. */ + active: Accessor + keybind: Accessor + onOpen: () => void + onChoose: (destination: TerminalDestination) => void +} + +export const TerminalDestinationButton: Component = (props) => { + const { t } = useLanguage() + const item = (destination: TerminalDestination, label: string) => ( + props.onChoose(destination)}> + + + + + + {label} + + ) + return ( +
+ + + + + + + + + + {item("vscode", t("agentManager.terminal.openInVscode"))} + {item("agentManager", t("agentManager.terminal.openInPanel"))} + + + +
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx index 82669255354..792d2385866 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx @@ -38,6 +38,14 @@ interface Props { * an xterm re-paint when the slot transitions back to visible after * sitting behind an occluding layer. */ active: boolean + /** Serial of the latest explicit focus request for this terminal + * (`state.focusRequest()`), consumed so re-requesting focus on an + * already-visible terminal still re-focuses it. */ + focusSerial?: number + /** Reports DOM focus entering or leaving the xterm host. The state + * layer tracks this as `focusedId` so `Cmd+W` can target the + * terminal that actually has the cursor. */ + onFocusChange?: (focused: boolean) => void } /** How long the ResizeObserver waits after the last size change before @@ -188,6 +196,17 @@ export const TerminalTab: Component = (props) => { // ⌘T / ⌘W / ⌘⌥← etc. still work while the terminal is focused. term.attachCustomKeyEventHandler((event) => !isAgentManagerShortcut(event)) + // Track DOM focus so the state layer knows which terminal holds the + // cursor (drives Cmd+W targeting). focusout is ignored when focus + // moves within the same host (xterm shuffles inner nodes). + const onFocusIn = () => props.onFocusChange?.(true) + const onFocusOut = (event: FocusEvent) => { + if (event.relatedTarget instanceof Node && host.contains(event.relatedTarget)) return + props.onFocusChange?.(false) + } + host.addEventListener("focusin", onFocusIn) + host.addEventListener("focusout", onFocusOut) + const ws = new WebSocket(props.wsUrl) ws.binaryType = "arraybuffer" let closed = false @@ -260,7 +279,12 @@ export const TerminalTab: Component = (props) => { // — historically "press Enter to wake it up". Forcing a // `fit + refresh(0, rows-1)` once per activation reclaims the paint // priority; from then on the browser keeps the canvas live. + // + // Focus is opt-in per repaint (`shouldFocus`): repaints triggered by + // resizes or font changes must not yank the cursor out of the chat + // input, only explicit activation / focus requests may. let pendingFrame: number | null = null + let shouldFocus = false const isRenderable = () => { if (!host.isConnected) return false const rect = host.getBoundingClientRect() @@ -278,9 +302,11 @@ export const TerminalTab: Component = (props) => { log("repaint fit() threw", err) } term.refresh(0, Math.max(0, term.rows - 1)) - if (document.hasFocus()) term.focus() + if (shouldFocus && document.hasFocus()) term.focus() + shouldFocus = false } - const scheduleRepaint = () => { + const scheduleRepaint = (focus = false) => { + shouldFocus ||= focus if (pendingFrame !== null) return pendingFrame = requestAnimationFrame(runRepaint) } @@ -309,26 +335,37 @@ export const TerminalTab: Component = (props) => { scheduleRepaint() }) - let wasActive = props.active + // Activation and explicit focus requests focus the terminal; + // deactivation blurs it so keystrokes never land in a hidden xterm. + // `wasActive` starts false so a terminal mounted already-active (the + // create-and-activate flow) still gets its initial focus repaint. + let wasActive = false + let focusSerial = -1 createEffect(() => { const now = props.active - if (now && !wasActive) scheduleRepaint() + const serial = props.focusSerial ?? 0 + if (now && (!wasActive || serial !== focusSerial)) scheduleRepaint(true) + if (!now && wasActive) term.blur() wasActive = now + focusSerial = serial }) // Also recover when the user returns from an external window or the // OS-level window manager (alt-tab, browser → VS Code, etc.) — the // browser often suspends canvas paint while the window is in the // background, and the Solid `active` prop alone doesn't see that. - // Gated on `props.active` so inactive tabs don't do needless work. + // Gated on `props.active` so inactive tabs don't do needless work, + // and on the terminal already owning focus so returning to the + // window never steals the cursor back from the chat input. + const ownsFocus = () => host.contains(document.activeElement) const onVisibilityChange = () => { if (document.hidden) return - if (!props.active) return - scheduleRepaint() + if (!props.active || !ownsFocus()) return + scheduleRepaint(true) } const onWindowFocus = () => { - if (!props.active) return - scheduleRepaint() + if (!props.active || !ownsFocus()) return + scheduleRepaint(true) } document.addEventListener("visibilitychange", onVisibilityChange) window.addEventListener("focus", onWindowFocus) @@ -350,6 +387,8 @@ export const TerminalTab: Component = (props) => { if (pendingFrame !== null) cancelAnimationFrame(pendingFrame) document.removeEventListener("visibilitychange", onVisibilityChange) window.removeEventListener("focus", onWindowFocus) + host.removeEventListener("focusin", onFocusIn) + host.removeEventListener("focusout", onFocusOut) fontSub() themeObserver.disconnect() clearTimeout(resizeTimer) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts index 22dd8038a15..6bac7cab9bb 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts @@ -20,6 +20,9 @@ export { createTerminalMessageHandler, } from "./state" export type { TerminalTabState, TerminalStateControls, TerminalHandlerDeps } from "./state" -export { renderTerminalTab, renderTerminalLayer } from "./render" +export { renderTerminalTab, renderTerminalLayer, renderSideTerminalLayer } from "./render" +export { SideTerminalPanel } from "./SideTerminalPanel" +export { TerminalDestinationButton } from "./TerminalDestinationButton" +export { createSideTerminal } from "./side" export { TerminalTab } from "./TerminalTab" export { SortableTerminalTab } from "./SortableTerminalTab" diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx index ad6fced8aa6..df304f59627 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx @@ -7,11 +7,18 @@ */ import { For, Show } from "solid-js" -import type { JSX } from "solid-js" +import type { Accessor, JSX } from "solid-js" import { SortableTerminalTab } from "./SortableTerminalTab" import { TerminalTab } from "./TerminalTab" import type { TerminalStateControls } from "./state" +/** Serial of the latest focus request addressed to `id`, or 0. Read + * inside JSX so the effect re-runs when a request lands. */ +function focusSerial(state: TerminalStateControls, id: string): number { + const request = state.focusRequest() + return request?.id === id ? request.serial : 0 +} + export interface TerminalTabRenderDeps { id: string terms: TerminalStateControls @@ -94,8 +101,15 @@ export function renderTerminalLayer(props: { state: TerminalStateControls }): JS {(term) => { const visible = () => slotVisible(term.id, term.contextKey) return ( -
- +
+ props.state.setFocusedId(focused ? term.id : undefined)} + />
) }} @@ -104,3 +118,40 @@ export function renderTerminalLayer(props: { state: TerminalStateControls }): JS ) } + +/** + * Render the side-panel terminal layer inside the right-hand inspector. + * + * Same paint-tree invariant as `renderTerminalLayer`: every side + * terminal stays mounted, visibility is toggled via `opacity` / + * `pointer-events` / `inert` only. The layer is scoped to + * `contextKey` — side terminals from other contexts stay composed in + * the background and never refit. + */ +export function renderSideTerminalLayer(props: { + state: TerminalStateControls + contextKey: Accessor + visible: Accessor +}): JSX.Element { + return ( +
+ + {(term) => { + const active = () => props.visible() && term.contextKey === props.contextKey() + return ( +
+ props.state.setFocusedId(focused ? term.id : undefined)} + /> +
+ ) + }} +
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts new file mode 100644 index 00000000000..3c7fe391450 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts @@ -0,0 +1,94 @@ +/** + * Right-side terminal wiring for the Agent Manager webview. + * + * Extracted from AgentManagerApp.tsx to keep that file under the + * `max-lines` lint cap. Owns the destination preference plus the toggle + * semantics of the toolbar button / `Cmd/Ctrl+/` shortcut, so the + * embedded terminal behaves like the diff panel: press once to reveal, + * press again to hide. Hiding never kills the terminal — only the + * explicit close action (or `Cmd+W` while it holds focus) does. + */ + +import { createSignal } from "solid-js" +import type { Accessor } from "solid-js" +import type { TerminalDestination } from "../../src/types/messages/agent-manager" + +interface Handlers { + requestSide(): void + closeSide(): boolean +} + +export interface SideTerminalDeps { + handlers: Handlers + /** True while the right-side inspector shows the terminal. */ + visible: Accessor + /** True while the side terminal itself holds DOM focus. */ + focused: Accessor + /** Leave terminal mode; the terminal stays alive in the background. */ + hide: () => void + /** Move focus back to the chat composer. */ + refocus: () => void + postMessage: (msg: unknown) => void + track: (button: string, surface: string, properties: Record) => void + /** Open or focus the VS Code integrated terminal for the active context. */ + openVscode: () => void +} + +export function createSideTerminal(deps: SideTerminalDeps) { + const [destination, setDestination] = createSignal("vscode") + + /** + * Hiding while the terminal holds focus would strand the cursor on + * , so hand it to the chat composer — the common flow is + * type → Cmd+/ → run command → Cmd+/ → keep typing. When the user + * was anywhere else (chat, diff, another tab), focus stays put. + */ + const handoff = (wasFocused: boolean) => { + if (wasFocused) deps.refocus() + } + + const toggle = () => { + if (deps.visible()) { + const was = deps.focused() + deps.hide() + handoff(was) + return + } + deps.handlers.requestSide() + } + + /** Kill the current context's side terminal (or cancel its in-flight + * create) and hide the panel. */ + const close = (): boolean => { + const was = deps.focused() + const done = deps.handlers.closeSide() + if (done) handoff(was) + return done + } + + /** Toolbar button and `Cmd/Ctrl+/`: follow the user's destination. */ + const openPreferred = (trigger: "keyboard_shortcut" | "tab_toolbar") => { + const target = destination() + deps.track("terminal", trigger, { destination: target }) + if (target === "agentManager") { + toggle() + return + } + deps.openVscode() + } + + /** + * Dropdown pick. Applied locally right away so the button reacts + * without a round trip, then persisted as a VS Code setting; the + * extension echoes it back via `terminal.destinationChanged`. + * The key is relative to the `kilo-code.new` section, matching every + * other `updateSetting` sender. + */ + const choose = (target: TerminalDestination) => { + deps.track("terminal_destination", "tab_toolbar", { destination: target }) + setDestination(target) + deps.postMessage({ type: "updateSetting", key: "agentManager.terminalButtonDestination", value: target }) + } + + return { destination, setDestination, toggle, close, openPreferred, choose } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts index f472623b287..3e9fba7af14 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts @@ -5,13 +5,18 @@ * `max-lines` lint cap. Owns the per-context terminal list, the * `activeTerminalId` focus signal, and a small set of imperative * helpers the main component composes with its existing tab logic. + * + * Main terminal tabs and right-side terminals share the same PTY + * transport, but their UI state is intentionally separate: tab + * activation replaces the chat, while a side terminal lives in the + * right-hand inspector and keeps the current session visible. */ -import { createMemo, createSignal } from "solid-js" +import { createSignal } from "solid-js" import type { Accessor } from "solid-js" import { LOCAL } from "../navigate" import type { ExtensionMessage } from "../../src/types/messages/extension-messages" -import type { TerminalFont } from "../../src/types/messages/agent-manager" +import type { TerminalDestination, TerminalFont, TerminalPlacement } from "../../src/types/messages/agent-manager" export type { TerminalFont } @@ -26,6 +31,7 @@ export interface TerminalTabState { title: string wsUrl: string font: TerminalFont + placement: TerminalPlacement } /** Terminal row enriched with the sidebar context it belongs to. Used by @@ -35,26 +41,59 @@ export interface TerminalTabStateWithContext extends TerminalTabState { contextKey: string } +/** Explicit focus demand, consumed by `TerminalTab` via the render layer. + * The serial lets repeated requests for the same terminal retrigger the + * focus effect. */ +export interface TerminalFocusRequest { + id: string + serial: number +} + +/** A create request for a side terminal that has not been answered yet. + * `cancelled` is set when the user closes the panel while the PTY is + * still starting; the late `created` answer is then closed again. */ +interface SideRequest { + contextKey: string + cancelled: boolean +} + export interface TerminalStateControls { /** Record received from `terminal.created`. */ add(worktreeId: string | null, term: TerminalTabState): void - /** Drop a terminal from its context (location resolved automatically). */ - remove(terminalId: string): string | undefined + /** Drop a terminal from its context (location resolved automatically). + * Returns the removed record so callers can react to placement. */ + remove(terminalId: string): TerminalTabStateWithContext | undefined /** Resolve the context key a terminal lives in, if any. */ contextFor(terminalId: string): string | undefined - /** All terminals for the given sidebar selection. */ + /** All tab terminals for the given sidebar selection. */ forSelection(selection: string | null): TerminalTabStateWithContext[] /** Map of { id -> tab state } for O(1) lookup. */ lookup: Accessor> - /** All terminals for the currently selected context. */ + /** All tab terminals for the currently selected context. */ current: Accessor - /** Every terminal across every context (for the persistent render layer). */ + /** Every tab terminal across every context (for the persistent render layer). */ all: Accessor + /** Every side terminal across every context (for the side-panel layer). */ + sides: Accessor + /** The side terminal of the current context, if any. */ + side: Accessor + /** The side terminal of an arbitrary context, if any. */ + sideForContext(contextKey: string): TerminalTabStateWithContext | undefined /** Context key for the current sidebar selection, or `undefined` when nothing is selected. */ currentKey: Accessor + /** Context key for the side panel: like `currentKey` but unassigned + * sessions (null selection) share the LOCAL workspace-root terminal. */ + sideKey: Accessor /** Active terminal id signal + setter. */ activeId: Accessor setActiveId: (id: string | undefined) => void + /** The terminal that currently holds DOM focus, if any. Set by + * `TerminalTab` focus listeners; drives `Cmd+W` targeting. */ + focusedId: Accessor + setFocusedId: (id: string | undefined) => void + /** Latest explicit focus demand. */ + focusRequest: Accessor + requestFocus(id: string): void /** True when the given remembered tab id points to a live terminal for the given selection. */ hasRemembered(selection: string | null, remembered: string | undefined): boolean /** @@ -70,49 +109,96 @@ export interface TerminalStateControls { * was applied, false otherwise so the caller can fall through. */ reorderDrag(from: string, to: string): boolean + /** Request id of the in-flight side-terminal create for a context. */ + pendingSide(contextKey: string): string | undefined + /** Mark a side-terminal create as in flight for a context. */ + beginSide(contextKey: string, createId: string): void + /** Cancel the in-flight create; returns true when one was pending. */ + cancelSide(contextKey: string): boolean + /** Settle a create request; returns it so the caller can validate. */ + completeSide(createId: string): SideRequest | undefined } /** Wire up reactive state for terminal tabs. The caller passes the current - * `selection()` accessor so memos can key by the right context. + * `selection()` accessor so the accessors below key by the right context. * * ## Reference stability * * Terminals are stored as `TerminalTabStateWithContext` (contextKey - * baked in) so the reactive accessors below can return them *by - * reference* without ever allocating a new object per terminal. That - * matters because Solid's `` uses element reference equality to - * decide whether a child is "the same" across renders. If `all()` - * created `{...t, contextKey}` each time (the original bug), adding - * a new terminal to context A would rewrite every object in every - * context — `` would then unmount + remount every live xterm - * across the whole app, destroying instances and losing canvas state. + * baked in) so the accessors below can return them *by reference* + * without ever allocating a new object per terminal. That matters + * because Solid's `` uses element reference equality to decide + * whether a child is "the same" across renders. If `all()` created + * `{...t, contextKey}` each time (the original bug), adding a new + * terminal to context A would rewrite every object in every context — + * `` would then unmount + remount every live xterm across the + * whole app, destroying instances and losing canvas state. + * + * ## Plain accessors, not memos + * + * The derived accessors are plain functions rather than `createMemo`. + * Signal reads inside them are still tracked by whatever computation + * calls them, and `` identity comes from the stored records above + * (not from the array), so behavior is identical — while the module + * stays unit-testable: bun resolves `solid-js` to its server build, + * where a memo never recomputes after a signal write. Re-filtering a + * handful of terminals per read is far cheaper than an xterm frame. */ export function createTerminalState(selection: Accessor): TerminalStateControls { const [terminalsByContext, setTerminalsByContext] = createSignal>({}) const [activeId, setActiveId] = createSignal() + const [focusedId, setFocusedId] = createSignal() + const [focusRequest, setFocusRequest] = createSignal() + let focusSerial = 0 + // In-flight side-terminal creates, keyed both ways: per context (what + // the panel shows) and per request id (what the answer carries). + const [pending, setPending] = createSignal>({}) + const requests = new Map() - const currentKey = createMemo((): string | undefined => { + const currentKey = (): string | undefined => { const sel = selection() if (sel === null) return undefined return sel === LOCAL ? LOCAL : sel - }) + } - const current = createMemo((): TerminalTabStateWithContext[] => { + const sideKey = (): string => { + const sel = selection() + if (sel === null || sel === LOCAL) return LOCAL + return sel + } + + const current = (): TerminalTabStateWithContext[] => { const key = currentKey() if (!key) return [] - return terminalsByContext()[key] ?? [] - }) + return (terminalsByContext()[key] ?? []).filter((t) => t.placement === "tab") + } - const all = createMemo((): TerminalTabStateWithContext[] => { + const all = (): TerminalTabStateWithContext[] => { const map = terminalsByContext() // Concat existing per-context arrays without spreading their // elements, so the same record references flow through to . const out: TerminalTabStateWithContext[] = [] - for (const list of Object.values(map)) out.push(...list) + for (const list of Object.values(map)) { + for (const t of list) if (t.placement === "tab") out.push(t) + } return out - }) + } - const lookup = createMemo(() => new Map(current().map((t) => [t.id, t]))) + const sides = (): TerminalTabStateWithContext[] => { + const map = terminalsByContext() + // Same reference-stability rule as `all` — the side render layer is + // a over live xterm instances too. + const out: TerminalTabStateWithContext[] = [] + for (const list of Object.values(map)) { + for (const t of list) if (t.placement === "side") out.push(t) + } + return out + } + + const sideForContext = (key: string) => terminalsByContext()[key]?.find((t) => t.placement === "side") + const side = () => sideForContext(sideKey()) + + const lookup = () => new Map(current().map((t) => [t.id, t])) const contextFor = (terminalId: string): string | undefined => { for (const [key, terms] of Object.entries(terminalsByContext())) { @@ -124,7 +210,7 @@ export function createTerminalState(selection: Accessor): Termina const forSelection = (sel: string | null): TerminalTabStateWithContext[] => { if (sel === null) return [] const key = sel === LOCAL ? LOCAL : sel - return terminalsByContext()[key] ?? [] + return (terminalsByContext()[key] ?? []).filter((t) => t.placement === "tab") } const add = (worktreeId: string | null, term: TerminalTabState) => { @@ -132,14 +218,18 @@ export function createTerminalState(selection: Accessor): Termina setTerminalsByContext((prev) => { const list = prev[key] ?? [] if (list.some((t) => t.id === term.id)) return prev + // One side terminal per context; the message handler dedupes via + // pending requests, this guard covers stale double answers. + if (term.placement === "side" && list.some((t) => t.placement === "side")) return prev const enriched: TerminalTabStateWithContext = { ...term, contextKey: key } return { ...prev, [key]: [...list, enriched] } }) } - const remove = (terminalId: string): string | undefined => { + const remove = (terminalId: string): TerminalTabStateWithContext | undefined => { const key = contextFor(terminalId) if (!key) return undefined + const removed = terminalsByContext()[key]?.find((t) => t.id === terminalId) setTerminalsByContext((prev) => { const list = (prev[key] ?? []).filter((t) => t.id !== terminalId) const next = { ...prev } @@ -147,7 +237,13 @@ export function createTerminalState(selection: Accessor): Termina else next[key] = list return next }) - return key + if (focusedId() === terminalId) setFocusedId(undefined) + return removed + } + + const requestFocus = (id: string) => { + focusSerial++ + setFocusRequest({ id, serial: focusSerial }) } const hasRemembered = (sel: string | null, remembered: string | undefined): boolean => { @@ -159,7 +255,11 @@ export function createTerminalState(selection: Accessor): Termina setTerminalsByContext((prev) => { const list = prev[key] if (!list || list.length === 0) return prev - const byId = new Map(list.map((t) => [t.id, t])) + // Tab order only covers tab terminals; side terminals never join + // the tab strip and keep their position at the end of the list. + const tabs = list.filter((t) => t.placement === "tab") + const side = list.filter((t) => t.placement === "side") + const byId = new Map(tabs.map((t) => [t.id, t])) const next: TerminalTabStateWithContext[] = [] for (const id of orderedIds) { const t = byId.get(id) @@ -172,9 +272,10 @@ export function createTerminalState(selection: Accessor): Termina // appeared between drag start and commit) at their original tail // position — simpler than merging and matches the existing // `applyTabOrder` semantics used elsewhere in the app. - for (const t of list) if (byId.has(t.id)) next.push(t) - if (next.length === list.length && next.every((t, i) => t.id === list[i]!.id)) return prev - return { ...prev, [key]: next } + for (const t of tabs) if (byId.has(t.id)) next.push(t) + const ordered = [...next, ...side] + if (ordered.length === list.length && ordered.every((t, i) => t.id === list[i]!.id)) return prev + return { ...prev, [key]: ordered } }) } @@ -187,7 +288,7 @@ export function createTerminalState(selection: Accessor): Termina const reorderDrag = (from: string, to: string): boolean => { const key = currentKey() if (!key) return false - const order = (terminalsByContext()[key] ?? []).map((t) => t.id) + const order = current().map((t) => t.id) const fi = order.indexOf(from) const ti = order.indexOf(to) if (fi === -1 || ti === -1 || fi === ti) return false @@ -198,6 +299,39 @@ export function createTerminalState(selection: Accessor): Termina return true } + const pendingSide = (key: string) => pending()[key] + + const beginSide = (key: string, createId: string) => { + requests.set(createId, { contextKey: key, cancelled: false }) + setPending((prev) => ({ ...prev, [key]: createId })) + } + + const cancelSide = (key: string): boolean => { + const id = pending()[key] + if (!id) return false + const request = requests.get(id) + if (request) request.cancelled = true + setPending((prev) => { + const next = { ...prev } + delete next[key] + return next + }) + return true + } + + const completeSide = (createId: string): SideRequest | undefined => { + const request = requests.get(createId) + if (!request) return undefined + requests.delete(createId) + setPending((prev) => { + if (prev[request.contextKey] !== createId) return prev + const next = { ...prev } + delete next[request.contextKey] + return next + }) + return request + } + return { add, remove, @@ -206,12 +340,24 @@ export function createTerminalState(selection: Accessor): Termina lookup, current, all, + sides, + side, + sideForContext, currentKey, + sideKey, activeId, setActiveId, + focusedId, + setFocusedId, + focusRequest, + requestFocus, hasRemembered, reorder, reorderDrag, + pendingSide, + beginSide, + cancelSide, + completeSide, } } @@ -228,6 +374,10 @@ export interface TerminalHandlerDeps { findTab: (id: string) => { id: string } | undefined postMessage: (msg: unknown) => void onRemove?: () => void + /** Reveal the right-side inspector in terminal mode. */ + onShowSide: (contextKey: string) => void + /** Leave terminal mode without killing the terminal. */ + onHideSide: () => void /** Resolve the current sidebar selection for the new-terminal helper. */ getSelection: () => string | null /** Sentinel value for the LOCAL sidebar selection. */ @@ -235,6 +385,12 @@ export interface TerminalHandlerDeps { REVIEW_TAB_ID: string } +/** Correlation ids for terminal create requests. */ +function newId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID() + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` +} + /** * Build the close-terminal handler the main component wires to the * close button. Picks the next visible tab before dropping the entry @@ -253,7 +409,38 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { const requestNew = () => { const sel = deps.getSelection() if (sel === null) return - deps.postMessage({ type: "agentManager.terminal.create", worktreeId: sel === deps.LOCAL ? null : sel }) + deps.postMessage({ + type: "agentManager.terminal.create", + createId: newId(), + placement: "tab", + worktreeId: sel === deps.LOCAL ? null : sel, + }) + } + + /** + * Reveal the side panel and create-or-focus the context's side + * terminal. Reuses the existing terminal when one is alive, dedupes + * against an in-flight create, and never touches the tab strip or + * the chat session. + */ + const requestSide = () => { + const key = deps.state.sideKey() + deps.onShowSide(key) + const existing = deps.state.sideForContext(key) + if (existing) { + deps.state.requestFocus(existing.id) + return + } + if (deps.state.pendingSide(key)) return + const id = newId() + deps.state.beginSide(key, id) + const sel = deps.getSelection() + deps.postMessage({ + type: "agentManager.terminal.create", + createId: id, + placement: "side", + worktreeId: sel === null || sel === deps.LOCAL ? null : sel, + }) } const closeTerminal = (terminalId: string) => { @@ -289,6 +476,20 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { deps.postMessage({ type: "agentManager.terminal.close", terminalId }) } + /** + * Kill the current context's side terminal and hide the panel. With + * a create still in flight, cancels it instead — the late answer is + * closed by the message handler. + */ + const closeSide = () => { + const term = deps.state.side() + deps.onHideSide() + if (!term) return deps.state.cancelSide(deps.state.sideKey()) + deps.state.remove(term.id) + deps.postMessage({ type: "agentManager.terminal.close", terminalId: term.id }) + return true + } + const middleClick = (terminalId: string, e: MouseEvent) => { if (e.button !== 1) return e.preventDefault() @@ -303,7 +504,7 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { return true } - return { closeTerminal, middleClick, activate, deactivate, requestNew, closeActive } + return { closeTerminal, closeSide, middleClick, activate, deactivate, requestNew, requestSide, closeActive } } export interface TerminalMessageHandlerDeps { @@ -312,6 +513,7 @@ export interface TerminalMessageHandlerDeps { saveTabMemory: () => void setSelection: (sel: string | typeof LOCAL) => void showError: (message: string) => void + postMessage: (message: unknown) => void /** * Called with the context key ("local" or worktree id) and the new * terminal id once a `terminal.created` message lands. The main @@ -320,39 +522,85 @@ export interface TerminalMessageHandlerDeps { * than wherever `tabIds()`'s base composition happens to put it. */ onCreated?: (contextKey: string, terminalId: string) => void + /** Side terminal for a context finished creating. */ + onSideCreated?: (contextKey: string, terminalId: string) => void + /** Side terminal create failed for a context. */ + onSideError?: (contextKey: string) => void + /** Side terminal was closed (locally or by the extension). */ + onSideClosed?: (contextKey: string) => void + /** The destination setting changed (live settings sync). */ + onDestinationChanged?: (destination: TerminalDestination) => void +} + +type CreatedMessage = Extract + +function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { + const contextKey = msg.worktreeId === null ? LOCAL : msg.worktreeId + const term = { + id: msg.terminalId, + title: msg.title, + wsUrl: msg.wsUrl, + font: msg.font, + placement: msg.placement, + } + if (msg.placement === "side") { + // Side terminals are answered to a specific pending request. A + // missing, cancelled, or context-mismatched request means the user + // already moved on — close the PTY again instead of leaking it. + const request = deps.state.completeSide(msg.createId) + if (!request || request.cancelled || request.contextKey !== contextKey) { + deps.postMessage({ type: "agentManager.terminal.close", terminalId: msg.terminalId }) + return + } + deps.state.add(msg.worktreeId, term) + deps.onSideCreated?.(contextKey, msg.terminalId) + return + } + deps.state.add(msg.worktreeId, term) + deps.onCreated?.(contextKey, msg.terminalId) + deps.saveTabMemory() + deps.setSelection(contextKey) + deps.activate(msg.terminalId) } /** - * Wire handlers for the three inbound terminal messages. Returns a - * dispatcher that accepts each message type and returns true if it - * handled the payload. Keeps all the terminal-specific routing logic - * out of the main webview component. + * Wire handlers for the inbound terminal messages. Returns a dispatcher + * that accepts each message type and returns true if it handled the + * payload. Keeps all the terminal-specific routing logic out of the + * main webview component. */ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) { return (msg: ExtensionMessage): boolean => { if (msg.type === "agentManager.terminal.created") { - const contextKey = msg.worktreeId === null ? LOCAL : msg.worktreeId - deps.state.add(msg.worktreeId, { - id: msg.terminalId, - title: msg.title, - wsUrl: msg.wsUrl, - font: msg.font, - }) - deps.onCreated?.(contextKey, msg.terminalId) - deps.saveTabMemory() - deps.setSelection(contextKey) - deps.activate(msg.terminalId) + handleCreated(deps, msg) return true } if (msg.type === "agentManager.terminal.closed") { - deps.state.remove(msg.terminalId) + const removed = deps.state.remove(msg.terminalId) if (deps.state.activeId() === msg.terminalId) deps.state.setActiveId(undefined) + if (removed?.placement === "side") deps.onSideClosed?.(removed.contextKey) return true } if (msg.type === "agentManager.terminal.error") { + const request = msg.createId ? deps.state.completeSide(msg.createId) : undefined + // Errors for requests the user already cancelled are noise. + if (request?.cancelled) return true + if (request) deps.onSideError?.(request.contextKey) deps.showError(msg.message) return true } + if (msg.type === "agentManager.terminal.destinationChanged") { + deps.onDestinationChanged?.(msg.destination) + return true + } + // The initial destination rides along on the state message. Claimed + // here so the main webview handler stays free of terminal settings, + // but reported as unhandled because the rest of that payload belongs + // to the other subscribers. + if (msg.type === "agentManager.state" && msg.terminalDestination) { + deps.onDestinationChanged?.(msg.terminalDestination) + return false + } return false } } diff --git a/packages/kilo-vscode/webview-ui/kiloclaw/kiloclaw.css b/packages/kilo-vscode/webview-ui/kiloclaw/kiloclaw.css index cd595c9c76f..8cc42876aa7 100644 --- a/packages/kilo-vscode/webview-ui/kiloclaw/kiloclaw.css +++ b/packages/kilo-vscode/webview-ui/kiloclaw/kiloclaw.css @@ -61,37 +61,6 @@ color: var(--vscode-descriptionForeground); } -/* Chat panel */ -.kiloclaw-chat { - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; - overflow: hidden; -} - -/* Chat header */ -.kiloclaw-chat-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 16px; - border-bottom: 1px solid var(--vscode-panel-border); - flex-shrink: 0; -} - -.kiloclaw-chat-header-left { - display: flex; - align-items: center; - gap: 8px; -} - -.kiloclaw-chat-header-title { - font-size: var(--kilo-font-size-13); - font-weight: 500; - color: var(--vscode-foreground); -} - /* Status dots */ .kiloclaw-dot { display: inline-block; @@ -117,16 +86,6 @@ background: var(--vscode-editorError-foreground, #f14c4c); } -/* Messages area */ -.kiloclaw-messages { - flex: 1; - overflow-y: auto; - padding: 16px; - display: flex; - flex-direction: column; - gap: 4px; -} - .kiloclaw-empty { color: var(--vscode-descriptionForeground); font-size: var(--kilo-font-size-13); @@ -458,17 +417,6 @@ white-space: nowrap; } -/* ── Dot states ────────────────────────────────────────────────────── */ - -.kiloclaw-dot-idle { - background: var(--vscode-editorWarning-foreground, #cca700); -} - -.kiloclaw-dot-unknown { - background: var(--vscode-descriptionForeground, #888); - opacity: 0.5; -} - /* ── Message bubble ───────────────────────────────────────────────── * * Layout mirrors the web UI (cloud/apps/web/src/app/(app)/claw/kilo-chat/ diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 1ac8a5dc7cc..1aa67688238 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -42,6 +42,7 @@ import { FeedbackProvider } from "./context/feedback" import { KiloEmbeddingModelsProvider } from "./context/kilo-embedding-models" import { ImageModelsProvider } from "./context/image-models" import type { Message as SDKMessage, Part as SDKPart } from "@kilocode/sdk/v2" +import { cycleAgent as cycle } from "./context/session-agent" import "./styles/chat.css" type ViewType = "newTask" | "history" | "profile" | "settings" | "subAgentViewer" @@ -276,14 +277,14 @@ const AppContent: Component = () => { } const cycleAgent = (direction: 1 | -1) => { - const available = session.agents().filter((a) => a.mode !== "subagent" && !a.hidden) - if (available.length <= 1) return - const current = session.selectedAgent() - const idx = available.findIndex((a) => a.name === current) - const raw = idx + direction - const next = raw < 0 ? available.length - 1 : raw >= available.length ? 0 : raw - const agent = available[next] - if (agent) session.selectAgent(agent.name) + const id = session.currentSessionID() ?? tabs?.pending() ?? session.draftSessionID() + cycle({ + agents: session.agents(), + scope: id, + direction, + selected: session.selectedAgent, + select: session.selectAgent, + }) } const handleForked = (message: { type?: string; sessionID?: string; forkedFromID?: string }) => { diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 7f3cd86583a..d63b0d6a219 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -1433,7 +1433,7 @@ export const PromptInput: Component = (props) => { - +
- + + + +
) } - - const row = node.row - if (!row) return null - if (row.kind === "clear") { - return ( -
pickClear()} - onMouseMove={() => setPointer(true)} - onMouseEnter={() => { - if (pointer()) setSelectedKey(CLEAR_KEY) - }} - > - - {props.clearLabel ?? language.t("dialog.model.notSet")} - -
- ) - } - if (!row.model) return null - - const model = row.model - const hovered = () => isSelected(row.key) - const preActive = () => isPreActive(row.key) - const starred = () => favoriteKeys().has(modelKey(model.providerID, model.id)) - const showProvider = () => row.kind === "favorite" - const showSelect = () => expanded() && preActive() && !isActive(model) - const starLabel = () => - `${starred() ? language.t("model.favorite.remove") : language.t("model.favorite.add")}: ${sanitizeName(model.name)}` - return ( - - ) } - } - - -
+ +
+ - -
- -
- +
+
+ + + +
-
- ) - }} - + ) + }} + + ) } diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx index fd03cdcb408..f49c649a9d1 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx @@ -151,6 +151,7 @@ export const ThinkingSelectorBase: Component = (props
} placement="top" + openDelay={0} > = (props) => { }) return ( - +