diff --git a/.changeset/adaptive-opus-five.md b/.changeset/adaptive-opus-five.md deleted file mode 100644 index 9d8cf6c1b3..0000000000 --- a/.changeset/adaptive-opus-five.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Support adaptive thinking levels for Claude Opus and Sonnet 5 and later. diff --git a/.changeset/agent-manager-diff-base-override.md b/.changeset/agent-manager-diff-base-override.md new file mode 100644 index 0000000000..5d38f0e6cb --- /dev/null +++ b/.changeset/agent-manager-diff-base-override.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Apply the Agent Manager base branch picker selection to the active diff immediately. Changing the base branch now refreshes the diff against the new base instead of keeping the previous comparison until the scope or session changed. diff --git a/.changeset/agent-manager-diff-scope-selector.md b/.changeset/agent-manager-diff-scope-selector.md new file mode 100644 index 0000000000..341e5ee768 --- /dev/null +++ b/.changeset/agent-manager-diff-scope-selector.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add a scope selector and base branch picker to the Agent Manager diff review. The side panel and full-screen review now let you switch between Branch, Staged, Unstaged, and Session scopes for the selected worktree, and the Branch scope's base branch can be overridden from a picker next to it. Branch stays the default, so existing review behavior is unchanged. diff --git a/.changeset/agent-manager-side-terminal.md b/.changeset/agent-manager-side-terminal.md deleted file mode 100644 index 533da3233c..0000000000 --- a/.changeset/agent-manager-side-terminal.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"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/agent-manager-terminal-shortcut-focus.md b/.changeset/agent-manager-terminal-shortcut-focus.md new file mode 100644 index 0000000000..8d0b718198 --- /dev/null +++ b/.changeset/agent-manager-terminal-shortcut-focus.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Make Cmd/Ctrl+/ toggle the Agent Manager terminal even when the webview keybinding forwarding drops the key while the prompt input is focused, and stop it from triggering the Agent Manager terminal while the Kilo sidebar is focused. diff --git a/.changeset/agent-manager-terminal-shortcut-platform.md b/.changeset/agent-manager-terminal-shortcut-platform.md new file mode 100644 index 0000000000..dbd2a126cb --- /dev/null +++ b/.changeset/agent-manager-terminal-shortcut-platform.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Match the Agent Manager terminal shortcut fallback to the platform modifier (Cmd on macOS, Ctrl elsewhere) and consume the extension echo once per keypress so unrelated invocations are no longer swallowed. diff --git a/.changeset/atomic-session-revert.md b/.changeset/atomic-session-revert.md deleted file mode 100644 index ed922d1a6e..0000000000 --- a/.changeset/atomic-session-revert.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Keep conversations and workspace files unchanged when a checkpoint cannot be fully restored. diff --git a/.changeset/calm-cursors-fit.md b/.changeset/calm-cursors-fit.md new file mode 100644 index 0000000000..d35d8ee2d1 --- /dev/null +++ b/.changeset/calm-cursors-fit.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep the Agent Manager terminal cursor visible on the bottom row. diff --git a/.changeset/calm-run-terminals.md b/.changeset/calm-run-terminals.md new file mode 100644 index 0000000000..df2a4ace3d --- /dev/null +++ b/.changeset/calm-run-terminals.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Run Agent Manager project scripts in the terminal selected by the existing toolbar dropdown. Agent Manager panel uses the named side terminal, while VS Code terminal retains the integrated task flow. diff --git a/.changeset/cli-startup-lazy-loading.md b/.changeset/cli-startup-lazy-loading.md new file mode 100644 index 0000000000..38571e5838 --- /dev/null +++ b/.changeset/cli-startup-lazy-loading.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-telemetry": patch +--- + +Reduce CLI startup time by deferring Kilo-specific module loading until commands actually run, caching the telemetry profile lookup across invocations, and uploading telemetry in the background so process exit is not delayed by a network round trip diff --git a/.changeset/config-unset-propagation.md b/.changeset/config-unset-propagation.md new file mode 100644 index 0000000000..82418a5635 --- /dev/null +++ b/.changeset/config-unset-propagation.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix settings snapping back to their previous value after being cleared to "Not set" when multiple config files exist (e.g. both `kilo.json` and `kilo.jsonc`) diff --git a/.changeset/console-headless-credentials.md b/.changeset/console-headless-credentials.md deleted file mode 100644 index 6c6102f467..0000000000 --- a/.changeset/console-headless-credentials.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"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/enable-websearch-config.md b/.changeset/enable-websearch-config.md new file mode 100644 index 0000000000..cd47a9e9fb --- /dev/null +++ b/.changeset/enable-websearch-config.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Allow users to enable web search for models from all providers through Kilo configuration, VS Code settings, and Kilo Console settings. diff --git a/.changeset/fix-nix-bun-pin.md b/.changeset/fix-nix-bun-pin.md deleted file mode 100644 index 5facef0667..0000000000 --- a/.changeset/fix-nix-bun-pin.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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 deleted file mode 100644 index d71009746d..0000000000 --- a/.changeset/fix-scoped-mode-cycling.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"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 deleted file mode 100644 index c7ed178ad9..0000000000 --- a/.changeset/fix-vscode-settings-save.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix settings changes sometimes failing to save and apply in VS Code. diff --git a/.changeset/fresh-rc-upgrades.md b/.changeset/fresh-rc-upgrades.md new file mode 100644 index 0000000000..61a54ea4e2 --- /dev/null +++ b/.changeset/fresh-rc-upgrades.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep RC installations up to date when a newer stable CLI release is published. diff --git a/.changeset/fuzzy-pandas-listen.md b/.changeset/fuzzy-pandas-listen.md new file mode 100644 index 0000000000..c2d8334aec --- /dev/null +++ b/.changeset/fuzzy-pandas-listen.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Persist MCP server enable and disable changes from VS Code settings across window reloads. diff --git a/.changeset/fuzzy-tildes-smile.md b/.changeset/fuzzy-tildes-smile.md deleted file mode 100644 index 39c76209f8..0000000000 --- a/.changeset/fuzzy-tildes-smile.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Preserve parenthesized tilde expressions as literal text in rendered chat messages. diff --git a/.changeset/headless-run-honest-exit.md b/.changeset/headless-run-honest-exit.md new file mode 100644 index 0000000000..37d00a8493 --- /dev/null +++ b/.changeset/headless-run-honest-exit.md @@ -0,0 +1,13 @@ +--- +"@kilocode/cli": patch +--- + +Non-interactive `kilo run` no longer reports success for runs that did not complete. A plain +headless run (neither `--auto` nor `--dangerously-skip-permissions`) in which the CLI +auto-rejected at least one permission ask now exits 1 with a stderr diagnostic naming the cause, +and a run whose session errors mid-stream now prints that diagnostic to stderr under +`--format json` as well (previously the JSON branch swallowed it). Runs that complete their turn +with no auto-rejected permission still exit 0. Under `--format json` the auto-reject path adds a +new `error` event to the stream; existing event shapes are unchanged. The same exit-1 rule applies +to a plain non-interactive `--attach` run that auto-rejects an ask (that run was equally crippled); +interactive mode is untouched. diff --git a/.changeset/ingest-shutdown-flush.md b/.changeset/ingest-shutdown-flush.md deleted file mode 100644 index 94ff86a5b1..0000000000 --- a/.changeset/ingest-shutdown-flush.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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 deleted file mode 100644 index d34f46dc04..0000000000 --- a/.changeset/instant-prompt-tooltips.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"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 deleted file mode 100644 index d0239ff176..0000000000 --- a/.changeset/jetbrains-bundled-cli.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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 deleted file mode 100644 index b22d8c8353..0000000000 --- a/.changeset/jetbrains-queued-prompts.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Allow sending prompts while a session is busy and show queued prompts with a remove action. diff --git a/.changeset/long-session-prompt-navigation.md b/.changeset/long-session-prompt-navigation.md new file mode 100644 index 0000000000..967dde901f --- /dev/null +++ b/.changeset/long-session-prompt-navigation.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Navigate long conversations from a compact prompt rail that loads earlier history as you scroll. diff --git a/.changeset/opencode-v1-17-5-to-v1-17-9.md b/.changeset/opencode-v1-17-5-to-v1-17-9.md deleted file mode 100644 index 5a019637c3..0000000000 --- a/.changeset/opencode-v1-17-5-to-v1-17-9.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@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/past-chats-worktree-family.md b/.changeset/past-chats-worktree-family.md new file mode 100644 index 0000000000..395bee2b5a --- /dev/null +++ b/.changeset/past-chats-worktree-family.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix the `@` "Past chats" picker in Agent Manager showing only the current session's directory. It now lists previous sessions across the whole worktree family — the local workspace and every Agent Manager worktree — each labeled with its worktree name, matching the Agent Manager session search. Any listed session can be attached as context, including chats from other worktrees of the same repository. diff --git a/.changeset/persian-language.md b/.changeset/persian-language.md new file mode 100644 index 0000000000..9e07d9e8b8 --- /dev/null +++ b/.changeset/persian-language.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add Persian (Farsi) as a UI language, including right-to-left layout. Contributed by Babak Safabahar. diff --git a/.changeset/pwsh-permission-fail-closed.md b/.changeset/pwsh-permission-fail-closed.md deleted file mode 100644 index cb140f33cd..0000000000 --- a/.changeset/pwsh-permission-fail-closed.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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 deleted file mode 100644 index bc7f513506..0000000000 --- a/.changeset/quiet-json-events.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Emit each agent event once from `kilo run --format json`. diff --git a/.changeset/quiet-terminals-switch.md b/.changeset/quiet-terminals-switch.md new file mode 100644 index 0000000000..0306eb3475 --- /dev/null +++ b/.changeset/quiet-terminals-switch.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Open an embedded terminal automatically when switching to a worktree without one. diff --git a/.changeset/quiet-vscode-watchers.md b/.changeset/quiet-vscode-watchers.md deleted file mode 100644 index 0f44b7cfda..0000000000 --- a/.changeset/quiet-vscode-watchers.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@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 deleted file mode 100644 index 566918cf93..0000000000 --- a/.changeset/reliable-vscode-message-copy.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Keep message and response copy buttons working after switching focus away from VS Code. diff --git a/.changeset/ripgrep-error-detail.md b/.changeset/ripgrep-error-detail.md new file mode 100644 index 0000000000..6afe556432 --- /dev/null +++ b/.changeset/ripgrep-error-detail.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Include the underlying reason in search execution failures instead of showing a bare "ripgrep execution failed" message. diff --git a/.changeset/safe-windows-snapshot-diffs.md b/.changeset/safe-windows-snapshot-diffs.md deleted file mode 100644 index 55dd9e61bc..0000000000 --- a/.changeset/safe-windows-snapshot-diffs.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@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/side-terminal-tab-parity.md b/.changeset/side-terminal-tab-parity.md new file mode 100644 index 0000000000..715a1bfe21 --- /dev/null +++ b/.changeset/side-terminal-tab-parity.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Make Agent Manager panel terminals behave like session tabs: right-click Close and Close Others, arrow-key tab navigation, overflow scrolling with edge fades, and stable tab widths while closing. The new-terminal button now sits directly next to the last terminal tab instead of the far edge of the panel. diff --git a/.changeset/skill-shell-execution.md b/.changeset/skill-shell-execution.md new file mode 100644 index 0000000000..8689613673 --- /dev/null +++ b/.changeset/skill-shell-execution.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Support executing shell commands embedded in skill files. Commands written as `` !`command` `` in a SKILL.md run and their output is inlined into the skill. Only trusted skills can run commands and `KILO_DISABLE_SKILL_SHELL` disables the behavior; when the model loads a skill, the commands are shown in a single up-front approval before running. diff --git a/.changeset/stalled-provider-first-byte.md b/.changeset/stalled-provider-first-byte.md deleted file mode 100644 index 2d11921fd6..0000000000 --- a/.changeset/stalled-provider-first-byte.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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 deleted file mode 100644 index e95a9d4c73..0000000000 --- a/.changeset/steady-cli-subprocess-tests.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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 deleted file mode 100644 index 84e5f2b0b5..0000000000 --- a/.changeset/steady-editor-tabs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"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 deleted file mode 100644 index 85c3ca1581..0000000000 --- a/.changeset/tidy-am-i18n-keys.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"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/tidy-mice-report.md b/.changeset/tidy-mice-report.md new file mode 100644 index 0000000000..3ba5fbb6e9 --- /dev/null +++ b/.changeset/tidy-mice-report.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-telemetry": patch +--- + +Include the host operating system name, version, and architecture in telemetry events. diff --git a/.changeset/tui-variant-shortcut-hint.md b/.changeset/tui-variant-shortcut-hint.md deleted file mode 100644 index 8891876bdb..0000000000 --- a/.changeset/tui-variant-shortcut-hint.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"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 777c3a2929..167656eda3 100644 --- a/.github/docs-sync/edit.mjs +++ b/.github/docs-sync/edit.mjs @@ -82,8 +82,13 @@ Batch specifics for this run: the PRs to handle are in the attached ${batchFile} break } + // Headless `kilo run` auto-rejects every permission ask; without --auto the + // agent cannot run shell commands. SECURITY: --auto grants unrestricted bash + // to an agent steered by external PR content. Hardening deferred: a scoped + // permission.bash map via KILO_CONFIG_CONTENT should replace --auto once the + // required shell patterns are stable (see PR #12605 review thread). const result = runKilo({ - args: ["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile], + args: ["run", "--auto", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile], timeoutMs: Math.min(BATCH_TIMEOUT_MS, left), streamStdout: true, label: `edit batch ${index} attempt ${attempt}`, diff --git a/.github/docs-sync/lib.mjs b/.github/docs-sync/lib.mjs index 67c13354d8..6aaf9dc6c3 100644 --- a/.github/docs-sync/lib.mjs +++ b/.github/docs-sync/lib.mjs @@ -167,13 +167,53 @@ export function sleepSync(ms) { const STDERR_TAIL_LINES = 20 const STDERR_TAIL_CHARS = 4_000 +// CSI sequences (colour, cursor moves, erases). kilo renders its TUI to stderr, +// so an unstripped tail lands in the rolling PR's pending table as +// "^[[0m→ ^[[0mRead packages/..." and the cause is unreadable. Stripped before +// the line/char slice so escapes do not eat the budget. The persisted +// docs-sync-out/kilo-stderr-*.log stays raw — that is the debugging record. +// eslint-disable-next-line no-control-regex +const ANSI_CSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g + function tailText(text, { lines = STDERR_TAIL_LINES, chars = STDERR_TAIL_CHARS } = {}) { - const s = String(text ?? "").trim() + const s = String(text ?? "") + .replace(ANSI_CSI, "") + .trim() if (!s) return "" const lastLines = s.split("\n").slice(-lines).join("\n") return lastLines.length > chars ? lastLines.slice(-chars) : lastLines } +/** + * Artifact files are raw: GitHub masks secret values in log streams only, and the runner + * env holds long-lived secrets (KILO_API_KEY), so exact values of secret-looking env vars + * are redacted before stdout/stderr is persisted or printed. + * Matching is exact-substring and case-sensitive on values — JSON-escaped, base64'd, or + * line-wrapped renderings and values shorter than 8 chars survive (same limitation as + * GitHub's own log masking); this is defense-in-depth, not a guarantee the logs are clean. + */ +export function redactEnvSecrets(text) { + let out = String(text ?? "") + // Also match CREDENTIAL/PASSWORD/ORG_ID/_PAT (e.g. KILO_ORG_ID, GH_PAT) beyond KEY|TOKEN|SECRET. + const nameRe = /KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|ORG_ID|_PAT$/i + const candidates = [] + for (const [name, value] of Object.entries(process.env)) { + if (!nameRe.test(name)) continue + if (typeof value !== "string" || value.length < 8) continue + candidates.push(value) + } + // Longer values first so a shorter secret that is a prefix of a longer one cannot leave a remainder. + candidates.sort((a, b) => b.length - a.length) + for (const value of candidates) { + if (!out.includes(value)) continue + out = out.split(value).join("***") + } + return out +} + +/** Max bytes of child stderr persisted to docs-sync-out/ (full buffer, not the console tail). */ +const STDERR_LOG_MAX_CHARS = 8 * 1024 * 1024 + /** * Run `kilo` via spawnSync so stderr is always recoverable — including when * the child exits 0 after writing a diagnostic (execFileSync cannot return @@ -181,6 +221,10 @@ function tailText(text, { lines = STDERR_TAIL_LINES, chars = STDERR_TAIL_CHARS } * * streamStdout:true → inherit fd 1 (edit live log); false → capture stdout * (triage parses it). stderr is always buffered. + * + * Always writes the full captured stderr to + * docs-sync-out/kilo-stderr-.log (unconditional — success and + * failure). The console return value still uses the short tailText. */ export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo" }) { const result = spawnSync("kilo", args, { @@ -193,16 +237,30 @@ export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo" const timedOut = Boolean(result.error && result.error.code === "ETIMEDOUT") const exitCode = typeof result.status === "number" ? result.status : timedOut ? null : result.status === null ? null : result.status - const stderrTail = tailText(result.stderr) - const stdout = streamStdout ? "" : String(result.stdout ?? "") + const stderrRaw = String(result.stderr ?? "") + const stderrSafe = redactEnvSecrets(stderrRaw) + const stderrTail = tailText(stderrSafe) + const stdoutSafe = streamStdout ? "" : redactEnvSecrets(String(result.stdout ?? "")) // ok is "process finished without OS-level failure". Callers still treat a // missing summary / unparseable output as failure even when ok is true — // exit 0 is not success for the docs-sync bot. const ok = !result.error && result.status === 0 + // Persist full stderr on every call (not gated on ok/exitCode/summary). Cap is + // generous (megabytes) so long batch dumps keep auto-rejecting lines; console + // still uses the short tail above. + try { + fs.mkdirSync("docs-sync-out", { recursive: true }) + const safe = label.replace(/[^A-Za-z0-9._-]/g, "-") + const body = stderrSafe.length > STDERR_LOG_MAX_CHARS ? stderrSafe.slice(-STDERR_LOG_MAX_CHARS) : stderrSafe + fs.writeFileSync(`docs-sync-out/kilo-stderr-${safe}.log`, body) + } catch (err) { + console.warn(`${label}: failed to write kilo-stderr log: ${err.message}`) + } + if (result.error && !timedOut) { console.warn(`${label}: spawn error: ${result.error.message}`) } - return { ok, stdout, stderrTail, exitCode, timedOut } + return { ok, stdout: stdoutSafe, stderrTail, exitCode, timedOut } } diff --git a/.github/docs-sync/redact-stream.mjs b/.github/docs-sync/redact-stream.mjs new file mode 100644 index 0000000000..341aea34ad --- /dev/null +++ b/.github/docs-sync/redact-stream.mjs @@ -0,0 +1,25 @@ +// kilocode_change - new file + +/** + * Line-wise stdin→stdout filter that redacts secret-looking env values. + * Used in the docs-sync workflow so kilo stdout piped to edit-log.txt is safe. + * Env values contain no newlines, so line-wise processing never splits a value. + */ + +import { redactEnvSecrets } from "./lib.mjs" + +let carry = "" + +process.stdin.setEncoding("utf8") +process.stdin.on("data", (chunk) => { + carry += chunk + let idx + while ((idx = carry.indexOf("\n")) !== -1) { + const line = carry.slice(0, idx + 1) + carry = carry.slice(idx + 1) + process.stdout.write(redactEnvSecrets(line)) + } +}) +process.stdin.on("end", () => { + if (carry.length > 0) process.stdout.write(redactEnvSecrets(carry)) +}) diff --git a/.github/docs-sync/selftest.mjs b/.github/docs-sync/selftest.mjs index fe2cb1faec..b720ba67cb 100644 --- a/.github/docs-sync/selftest.mjs +++ b/.github/docs-sync/selftest.mjs @@ -55,7 +55,7 @@ function writeExecutable(filePath, body) { function makeStubKiloDir({ mode, callLog, stderrText = "event stream disconnected" }) { const dir = mktemp("docs-sync-kilo-") const kiloPath = path.join(dir, "kilo") - // mode: "stderr-exit0" | "record" | "partial-triage" | "mixed-triage" + // mode: "stderr-exit0" | "record" | "partial-triage" | "mixed-triage" | "write-edit-summary" const script = `#!/usr/bin/env node const fs = require("node:fs"); const path = require("node:path"); @@ -81,6 +81,22 @@ let chunk = []; if (fileArg && fs.existsSync(fileArg)) { try { chunk = JSON.parse(fs.readFileSync(fileArg, "utf8")); } catch { chunk = []; } } +if (mode === "write-edit-summary") { + // Success path: write the batch summary so edit.mjs returns true, while still + // emitting stderr so selftest can assert runKilo persisted it unconditionally. + process.stderr.write(stderrText + "\\n"); + const m = fileArg && String(fileArg).match(/edit-batch-(\\d+)\\.json/); + const index = m ? m[1] : "0"; + const summary = chunk.map((d) => ({ + pr: d.number, + url: d.url, + action: "skipped", + reason: "selftest stub", + })); + fs.mkdirSync("docs-sync-out", { recursive: true }); + fs.writeFileSync("docs-sync-out/edit-summary-" + index + ".json", JSON.stringify(summary)); + process.exit(0); +} if (mode === "partial-triage") { // Classify only a proper subset (first URL) of the chunk. const owned = chunk.slice(0, Math.max(0, chunk.length - 1)); @@ -116,6 +132,21 @@ if (mode === "mixed-triage") { process.stdout.write(JSON.stringify(entries) + "\\n"); process.exit(0); } +if (mode === "triage-embed-env-secret") { + // Valid triage JSON with a secret env value embedded in a string field + // (stdout is persisted to triage-raw-*.txt; must be redacted at capture). + const secret = process.env.KILO_API_KEY || "missing-secret"; + const entries = chunk.map((d) => ({ + pr: d.number, + url: d.url, + docs_worthy: true, + reason: "needs docs; diagnostic=" + secret, + target_sections: ["overview"], + priority: "high", + })); + process.stdout.write(JSON.stringify(entries) + "\\n"); + process.exit(0); +} process.stderr.write("unknown stub mode\\n"); process.exit(1); ` @@ -129,7 +160,9 @@ function gitIn(cwd, args, env = {}) { env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", - }).toString().trim() + }) + .toString() + .trim() } function makeGitRunner(cwd, env = {}) { @@ -247,14 +280,19 @@ function case1_mergeOrFallback() { env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", - }).toString().trim() + }) + .toString() + .trim() - assert.throws(() => mergeOrFallback({ branch: DEFAULT_BRANCH, git }), (err) => { - // Must throw the original merge error, not a merge --abort failure - const msg = String(err?.stderr ?? err?.message ?? err) - assert.ok(!/no merge to abort/i.test(msg), `should not reach merge --abort: ${msg}`) - return true - }) + assert.throws( + () => mergeOrFallback({ branch: DEFAULT_BRANCH, git }), + (err) => { + // Must throw the original merge error, not a merge --abort failure + const msg = String(err?.stderr ?? err?.message ?? err) + assert.ok(!/no merge to abort/i.test(msg), `should not reach merge --abort: ${msg}`) + return true + }, + ) } } @@ -372,6 +410,450 @@ function case2_defectB() { } } +// --------------------------------------------------------------------------- +// Case 2b — AC4a: every docs-sync kilo run argv carries --auto +// --------------------------------------------------------------------------- +/** Slice `args: [` … matching `]` from source (newlines allowed inside). */ +function extractArgsArraySlice(source) { + const start = source.indexOf("args: [") + assert.ok(start >= 0, "args: [ not found in source") + let i = start + "args: ".length + assert.equal(source[i], "[") + let depth = 0 + for (; i < source.length; i++) { + const ch = source[i] + if (ch === "[") depth++ + else if (ch === "]") { + depth-- + if (depth === 0) return source.slice(start, i + 1) + } + } + throw new assert.AssertionError({ message: "unclosed args: [ array in source" }) +} + +/** Label → kilo-stderr filename rule (must match lib.mjs runKilo). */ +function kiloStderrLogName(label) { + return `kilo-stderr-${String(label).replace(/[^A-Za-z0-9._-]/g, "-")}.log` +} + +function case2b_autoFlag() { + console.log("case 2b: AC4a (--auto on every docs-sync kilo run)") + + // (i) region-scoped static check on triage.mjs / edit.mjs argv arrays + for (const name of ["triage.mjs", "edit.mjs"]) { + const src = fs.readFileSync(path.join(HERE, name), "utf8") + const slice = extractArgsArraySlice(src) + assert.ok(slice.includes('"--auto"'), `${name} args array must contain "--auto"; got:\n${slice}`) + } + + // (ii) Fix verify failures step: join the run: | block and require --auto on kilo run + { + const yml = fs.readFileSync(path.join(HERE, "..", "workflows", "docs-sync.yml"), "utf8") + const stepIdx = yml.indexOf("Fix verify failures") + assert.ok(stepIdx >= 0, "Fix verify failures step missing") + const afterStep = yml.slice(stepIdx) + const runIdx = afterStep.indexOf("run: |") + assert.ok(runIdx >= 0, "run: | missing after Fix verify failures") + const blockStart = stepIdx + runIdx + "run: |".length + const rest = yml.slice(blockStart) + // Block ends at next unindented step key or EOF — collect indented lines + const lines = [] + for (const line of rest.split("\n")) { + if (line === "") { + lines.push(line) + continue + } + // stop at next top-level list item under steps (two-space + "- ") + if (/^ {0,6}- name:/.test(line) || (/^\S/.test(line) && lines.length > 0)) break + lines.push(line) + } + // Join continuation backslashes then collapse whitespace for the kilo run line + const joined = lines + .map((l) => l.replace(/^\s+/, "")) + .join("\n") + .replace(/\\\n/g, " ") + .replace(/\s+/g, " ") + assert.match(joined, /kilo run\b/, `expected kilo run in Fix verify block:\n${joined}`) + const kiloCmd = joined.match(/kilo run\b[^|]*/)?.[0] ?? "" + assert.ok( + /\s--auto\b/.test(kiloCmd) || /kilo run\s+--auto\b/.test(kiloCmd), + `Fix verify kilo run must contain --auto; got: ${kiloCmd}`, + ) + + // The step runs under `set -o pipefail` + the default `bash -e`, so an + // unguarded kilo pipeline aborts the block before verify2.log is written + // once the CLI exits nonzero on a mid-stream error. The rebuild must decide + // this step's outcome, not the agent's exit code. + // Window is the end of the kilo pipeline → the rebuild, so a comment + // elsewhere in the block cannot satisfy the guard assertion. + const teeIdx = joined.indexOf("tee -a docs-sync-out/edit-log.txt") + assert.ok(teeIdx >= 0, `expected the kilo pipeline to tee edit-log.txt:\n${joined}`) + const kiloPipeline = joined.slice(teeIdx, joined.indexOf("bun run", teeIdx)) + assert.match( + kiloPipeline, + /\|\|\s*(echo|true)\b/, + `Fix verify kilo pipeline must be guarded (|| echo/true) so bash -e cannot skip the rebuild; got: ${kiloPipeline}`, + ) + assert.match(joined, /verify2\.log/, "Fix verify block must still write verify2.log") + } + + // (iii) authoritative: real stub invocations with callLog — every argv has --auto + { + const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n)) + const worthy = prs + const triage = prs.map((p) => ({ + pr: p.number, + url: p.url, + docs_worthy: true, + reason: "needs docs", + target_sections: ["overview"], + priority: "high", + })) + const cwd = setupEditCwd(worthy, triage) + const callLog = path.join(cwd, "kilo-calls.log") + const stderrText = "event stream disconnected DIAG-AUTO" + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText, callLog }) + + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + EDIT_BUDGET_MINUTES: "5", + EDIT_BATCH_TIMEOUT_MINUTES: "1", + }, + }) + assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`) + + assert.ok(fs.existsSync(callLog), "callLog must be written (stub was invoked)") + const lines = fs.readFileSync(callLog, "utf8").trim().split("\n").filter(Boolean) + assert.ok(lines.length > 0, "callCount > 0 required (vacuous empty log forbidden)") + for (const line of lines) { + const { argv } = JSON.parse(line) + assert.ok( + Array.isArray(argv) && argv.includes("--auto"), + `every kilo argv must include --auto; got ${JSON.stringify(argv)}`, + ) + } + } +} + +// --------------------------------------------------------------------------- +// Case 2c — full child stderr always written (success and failure paths) +// --------------------------------------------------------------------------- +function case2c_stderrLogAlways() { + console.log("case 2c: unconditional kilo-stderr-*.log") + + const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n)) + const worthy = prs + const triage = prs.map((p) => ({ + pr: p.number, + url: p.url, + docs_worthy: true, + reason: "needs docs", + target_sections: ["overview"], + priority: "high", + })) + + // Failure path: stub exits 0 without summary (same mode as case 2) + { + const cwd = setupEditCwd(worthy, triage) + const stderrText = "FAILPATH-STDERR-MARKER" + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText }) + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + EDIT_BUDGET_MINUTES: "5", + EDIT_BATCH_TIMEOUT_MINUTES: "1", + }, + }) + assert.equal(result.status, 0, result.output) + const logName = kiloStderrLogName("edit batch 0 attempt 1") + const logPath = path.join(cwd, "docs-sync-out", logName) + assert.equal(logName, "kilo-stderr-edit-batch-0-attempt-1.log") + assert.ok(fs.existsSync(logPath), `expected ${logPath} on failure path`) + assert.match(fs.readFileSync(logPath, "utf8"), /FAILPATH-STDERR-MARKER/) + } + + // Success path: stub writes summary (today's path that discarded stderr) + { + const cwd = setupEditCwd(worthy, triage) + const stderrText = "SUCCESSPATH-STDERR-MARKER" + const kiloDir = makeStubKiloDir({ mode: "write-edit-summary", stderrText }) + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + EDIT_BUDGET_MINUTES: "5", + EDIT_BATCH_TIMEOUT_MINUTES: "1", + }, + }) + assert.equal(result.status, 0, result.output) + assert.ok( + fs.existsSync(path.join(cwd, "docs-sync-out", "edit-summary-0.json")), + "stub must write summary (success path)", + ) + const logName = kiloStderrLogName("edit batch 0 attempt 1") + const logPath = path.join(cwd, "docs-sync-out", logName) + assert.ok(fs.existsSync(logPath), `expected ${logPath} on success path`) + assert.match(fs.readFileSync(logPath, "utf8"), /SUCCESSPATH-STDERR-MARKER/) + } +} + +// --------------------------------------------------------------------------- +// Case 2d — redact secret env values from captured kilo stderr (artifact-safe) +// --------------------------------------------------------------------------- +function case2d_redactEnvSecrets() { + console.log("case 2d: redact env secrets from kilo stderr capture") + + const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n)) + const worthy = prs + const triage = prs.map((p) => ({ + pr: p.number, + url: p.url, + docs_worthy: true, + reason: "needs docs", + target_sections: ["overview"], + priority: "high", + })) + const cwd = setupEditCwd(worthy, triage) + const secret = "selftest-secret-value-12345" + const stderrText = `leak before ${secret} after` + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText }) + + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + EDIT_BUDGET_MINUTES: "5", + EDIT_BATCH_TIMEOUT_MINUTES: "1", + KILO_API_KEY: secret, + }, + }) + assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`) + + const logName = kiloStderrLogName("edit batch 0 attempt 1") + const logPath = path.join(cwd, "docs-sync-out", logName) + assert.ok(fs.existsSync(logPath), `expected ${logPath}`) + const logBody = fs.readFileSync(logPath, "utf8") + assert.ok(!logBody.includes(secret), `persisted stderr must not contain secret; got: ${logBody}`) + assert.ok(logBody.includes("leak before *** after"), `persisted stderr must redact to exact line; got: ${logBody}`) + + // Console stderr-tail region must also be redacted (not only the artifact file). + const tailIdx = result.output.indexOf("stderr tail:") + assert.ok(tailIdx >= 0, `expected stderr tail: in output; got: ${result.output}`) + const tailRegion = result.output.slice(tailIdx) + assert.ok(!tailRegion.includes(secret), `console stderr tail must not contain secret; got: ${tailRegion}`) +} + +// --------------------------------------------------------------------------- +// Case 2e — longer secret first when a shorter env value is a prefix +// --------------------------------------------------------------------------- +function case2e_prefixSecretOrdering() { + console.log("case 2e: prefix-secret ordering (longer value redacted first)") + + const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n)) + const worthy = prs + const triage = prs.map((p) => ({ + pr: p.number, + url: p.url, + docs_worthy: true, + reason: "needs docs", + target_sections: ["overview"], + priority: "high", + })) + const cwd = setupEditCwd(worthy, triage) + const shortSecret = "abcdefgh" + const longSecret = "abcdefghIJKL-tail" + const stderrText = `leak: ${longSecret} end` + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText }) + + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + EDIT_BUDGET_MINUTES: "5", + EDIT_BATCH_TIMEOUT_MINUTES: "1", + A_KEY: shortSecret, + B_TOKEN: longSecret, + }, + }) + assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`) + + const logName = kiloStderrLogName("edit batch 0 attempt 1") + const logPath = path.join(cwd, "docs-sync-out", logName) + assert.ok(fs.existsSync(logPath), `expected ${logPath}`) + const logBody = fs.readFileSync(logPath, "utf8") + assert.ok(!logBody.includes("IJKL-tail"), `must not leak prefix remainder; got: ${logBody}`) + assert.ok(logBody.includes("leak: *** end"), `expected full long secret redacted; got: ${logBody}`) +} + +// --------------------------------------------------------------------------- +// Case 2f — redact secret values from captured kilo stdout (triage-raw artifact) +// --------------------------------------------------------------------------- +function case2f_redactStdout() { + console.log("case 2f: redact env secrets from kilo stdout (triage-raw)") + + const digest = [samplePr(501), samplePr(502)] + const cwd = setupTriageCwd(digest) + const secret = "selftest-stdout-secret-99999" + const kiloDir = makeStubKiloDir({ mode: "triage-embed-env-secret" }) + const summaryFile = path.join(cwd, "step-summary.md") + fs.writeFileSync(summaryFile, "") + + const result = runNodeScript(TRIAGE_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + TRIAGE_BUDGET_MINUTES: "30", + GITHUB_STEP_SUMMARY: summaryFile, + KILO_API_KEY: secret, + }, + }) + assert.equal(result.status, 0, `triage.mjs exit: ${result.output}`) + + const rawFiles = fs.readdirSync(path.join(cwd, "docs-sync-out")).filter((f) => f.startsWith("triage-raw-")) + assert.ok(rawFiles.length > 0, "expected triage-raw-*.txt artifact") + for (const f of rawFiles) { + const body = fs.readFileSync(path.join(cwd, "docs-sync-out", f), "utf8") + assert.ok(!body.includes(secret), `triage-raw must not contain secret; ${f}: ${body}`) + } + + const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8")) + assert.ok(triage.length >= 1, "triage must still parse after redaction") + assert.ok( + triage.some((e) => e.docs_worthy === true || e.pending === true || e.docs_worthy === false), + "triage entries must be structured", + ) +} + +// --------------------------------------------------------------------------- +// Case 2g — redact-stream.mjs line-wise filter (including partial last line) +// --------------------------------------------------------------------------- +function case2g_redactStream() { + console.log("case 2g: redact-stream.mjs stdin filter") + + const secret = "stream-secret-value-xyz" + const filterPath = path.join(HERE, "redact-stream.mjs") + assert.ok(fs.existsSync(filterPath), `expected ${filterPath}`) + + const input = `leak ${secret} after\npartial-${secret}` + const result = spawnSync(process.execPath, [filterPath], { + env: { ...process.env, KILO_API_KEY: secret }, + input, + encoding: "utf8", + timeout: 10_000, + }) + assert.equal(result.status, 0, `redact-stream exit: ${result.stderr || result.error}`) + assert.equal(result.stdout, "leak *** after\npartial-***") +} + +// --------------------------------------------------------------------------- +// Case 2h — pending causes reach the rolling PR free of ANSI escapes +// --------------------------------------------------------------------------- +function case2h_pendingCauseIsReadable() { + console.log("case 2h: pending cause has no ANSI escapes") + + const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n)) + const worthy = prs + const triage = prs.map((p) => ({ + pr: p.number, + url: p.url, + docs_worthy: true, + reason: "needs docs", + target_sections: ["overview"], + priority: "high", + })) + const cwd = setupEditCwd(worthy, triage) + // Verbatim shape of a real kilo TUI stderr line (see PR #12521's pending table). + const ESC = "\u001b" + const stderrText = `${ESC}[0m→ ${ESC}[0mRead packages/kilo-docs/AGENTS.md${ESC}[2K${ESC}[1G done` + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText }) + + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + EDIT_BUDGET_MINUTES: "5", + EDIT_BATCH_TIMEOUT_MINUTES: "1", + }, + }) + assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`) + + const summary = JSON.parse(fs.readFileSync(path.join(cwd, ".docs-sync-summary.json"), "utf8")) + assert.equal(summary.length, 5) + for (const e of summary) { + assert.equal(e.action, "pending", `expected pending, got ${JSON.stringify(e)}`) + assert.ok(!e.reason.includes(ESC), `pending reason must not contain ANSI escapes: ${JSON.stringify(e.reason)}`) + // Non-vacuous: the diagnostic text itself must survive the strip. + assert.match(e.reason, /Read packages\/kilo-docs\/AGENTS\.md/) + } + + // The raw artifact log keeps the escapes — it is the debugging record. + const rawLog = fs.readFileSync(path.join(cwd, "docs-sync-out", "kilo-stderr-edit-batch-0-attempt-1.log"), "utf8") + assert.ok(rawLog.includes(ESC), "persisted stderr log must stay raw") +} + +// --------------------------------------------------------------------------- +// Case 2i — wall-clock budgets can actually fit work +// --------------------------------------------------------------------------- +/** + * The pre-unit gates in triage.mjs/edit.mjs refuse to start a chunk/batch unless + * a whole per-unit timeout remains, so a budget below that timeout silently runs + * ZERO units and defers every PR. Run 30306629290 hit the weaker form of this: + * 8 of 11 chunks and 4 of 11 batches ran, the rest deferred untried. Assert the + * workflow sets both budgets and that each fits at least two units. + */ +function case2i_budgetsFitWork() { + console.log("case 2i: triage/edit budgets fit at least two units") + + const yml = fs.readFileSync(path.join(HERE, "..", "workflows", "docs-sync.yml"), "utf8") + const readEnvNumber = (key) => { + const m = yml.match(new RegExp(`^\\s*${key}:\\s*"?(\\d+)"?\\s*$`, "m")) + assert.ok(m, `${key} must be set in docs-sync.yml (default is too small to drain a backlog)`) + return Number(m[1]) + } + + // Per-unit timeouts are script constants, not workflow env; read them from source. + const triageSrc = fs.readFileSync(path.join(HERE, "triage.mjs"), "utf8") + const chunkMin = Number(triageSrc.match(/CHUNK_TIMEOUT_MS = (\d+) \* 60 \* 1000/)?.[1]) + assert.ok(Number.isFinite(chunkMin), "could not read CHUNK_TIMEOUT_MS from triage.mjs") + + const editSrc = fs.readFileSync(path.join(HERE, "edit.mjs"), "utf8") + const batchMin = Number(editSrc.match(/EDIT_BATCH_TIMEOUT_MINUTES\) \|\| (\d+)/)?.[1]) + assert.ok(Number.isFinite(batchMin), "could not read EDIT_BATCH_TIMEOUT_MINUTES default from edit.mjs") + + const triageBudget = readEnvNumber("TRIAGE_BUDGET_MINUTES") + const editBudget = readEnvNumber("EDIT_BUDGET_MINUTES") + assert.ok( + triageBudget >= 2 * chunkMin, + `TRIAGE_BUDGET_MINUTES=${triageBudget} must be >= 2x chunk timeout (${chunkMin}m)`, + ) + assert.ok(editBudget >= 2 * batchMin, `EDIT_BUDGET_MINUTES=${editBudget} must be >= 2x batch timeout (${batchMin}m)`) + + // The job timeout must outlast both budgets plus the non-LLM steps. + const jobTimeout = Number(yml.match(/^\s*timeout-minutes:\s*(\d+)\s*$/m)?.[1]) + assert.ok(Number.isFinite(jobTimeout), "could not read job timeout-minutes") + assert.ok( + jobTimeout > triageBudget + editBudget, + `job timeout-minutes=${jobTimeout} must exceed triage+edit budgets (${triageBudget}+${editBudget})`, + ) +} + // --------------------------------------------------------------------------- // Case 3 — watermark invariant // --------------------------------------------------------------------------- @@ -611,14 +1093,8 @@ function case4_routing() { triage: [], uncovered: [], }) - assert.ok( - !forgedRows.skippedRows[0].includes(""), - "clean() must strip --> from reasons", - ) + assert.ok(!forgedRows.skippedRows[0].includes(""), "clean() must strip --> from reasons") const forgedBody = renderBody({ date: "2026-07-27", since: "s", @@ -934,6 +1410,14 @@ function main() { const cases = [ case1_mergeOrFallback, case2_defectB, + case2b_autoFlag, + case2c_stderrLogAlways, + case2d_redactEnvSecrets, + case2e_prefixSecretOrdering, + case2f_redactStdout, + case2g_redactStream, + case2h_pendingCauseIsReadable, + case2i_budgetsFitWork, case3_watermark, case4_routing, case5_recollection, diff --git a/.github/docs-sync/triage.mjs b/.github/docs-sync/triage.mjs index eb075980b5..a01eef0a99 100644 --- a/.github/docs-sync/triage.mjs +++ b/.github/docs-sync/triage.mjs @@ -71,8 +71,13 @@ function triageChunk(chunk, index, budgetDeadline) { break } + // Headless `kilo run` auto-rejects every permission ask; without --auto the + // agent cannot run shell commands. SECURITY: --auto grants unrestricted bash + // to an agent steered by external PR content. Hardening deferred: a scoped + // permission.bash map via KILO_CONFIG_CONTENT should replace --auto once the + // required shell patterns are stable (see PR #12605 review thread). const result = runKilo({ - args: ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile], + args: ["run", "--auto", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile], timeoutMs: Math.min(CHUNK_TIMEOUT_MS, left), streamStdout: false, label: `triage chunk ${index} attempt ${attempt}`, diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml index 5a32e87698..147cde8a47 100644 --- a/.github/workflows/docs-sync.yml +++ b/.github/workflows/docs-sync.yml @@ -66,8 +66,13 @@ jobs: sync: if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request' runs-on: blacksmith-4vcpu-ubuntu-2404 - # Budget: 3 setup/collect + 35 triage + 50 edit + 2 verify + 10 fix + 2 upsert = 102 min, 18-minute reserve. - timeout-minutes: 120 + # Budget: 4 setup/collect + 90 triage + 120 edit + 2 verify + 10 fix + 2 upsert = 228 min, 12-minute reserve. + # These are ceilings, not costs: a caught-up run triages ~2 chunks and edits + # ~1 batch and finishes in ~25 min. The old 35/50 pair was the binding + # constraint on backlog drain — run 30306629290 deferred 54 PRs untriaged and + # 31 unedited purely on budget, with no attempt made. See the throughput note + # in the PR description for the arithmetic. + timeout-minutes: 240 env: # Both are required: without KILO_ORG_ID the gateway bills the key # owner's personal balance (402 "Add credits") instead of the org. @@ -116,6 +121,9 @@ jobs: if: steps.collect.outputs.count != '0' env: SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }} + # Default 35 fit only 8 of 11 chunks on a 254-PR window. Headroom for + # --auto making chunks slower now that the agent really runs commands. + TRIAGE_BUDGET_MINUTES: "90" run: node .github/docs-sync/triage.mjs - name: Filter docs-worthy PRs @@ -153,6 +161,11 @@ jobs: - name: Update docs (Kilo CLI, batched) if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true continue-on-error: true + env: + # Default 50 fit only 4 of 11 batches. A healthy --auto batch is ~8 min, + # and edit.mjs will not start a batch without EDIT_BATCH_TIMEOUT_MINUTES + # (15) left, so 120 covers 14 batches = 70 PRs against ~5 worthy/day. + EDIT_BUDGET_MINUTES: "120" run: node .github/docs-sync/edit.mjs - name: Verify docs build and tests @@ -174,9 +187,19 @@ jobs: NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }} run: | set -o pipefail - kilo run "The docs build or tests failed. Read the attached docs-sync-out/verify.log and fix the packages/kilo-docs changes so they pass. Do not revert doc edits; fix them. Do not modify anything outside packages/kilo-docs." \ + # Headless kilo run auto-rejects every permission ask; the runner has no + # user config granting bash, so without --auto the agent cannot run ordinary + # shell commands against the repository. + kilo run --auto "The docs build or tests failed. Read the attached docs-sync-out/verify.log and fix the packages/kilo-docs changes so they pass. Do not revert doc edits; fix them. Do not modify anything outside packages/kilo-docs." \ -m "$EDIT_MODEL" --dir "$GITHUB_WORKSPACE" -f docs-sync-out/verify.log \ - | tee -a docs-sync-out/edit-log.txt + | node .github/docs-sync/redact-stream.mjs \ + | tee -a docs-sync-out/edit-log.txt \ + || echo "::warning::kilo fix pass exited nonzero; re-verifying anyway" + # The rebuild below decides this step's outcome, not the agent's exit code. + # Without the guard above, `set -o pipefail` + the default `bash -e` would + # abort here once the CLI half of this PR ships: a mid-stream session error + # (or an auto-rejected ask) exits 1, verify2.log is never written, and + # `Re-verify status` reports VERIFIED=false even when the docs build fine. { bun run --filter @kilocode/kilo-docs build && bun run --filter @kilocode/kilo-docs test; } 2>&1 | tee docs-sync-out/verify2.log - name: Re-verify status diff --git a/bun.lock b/bun.lock index 49d5521b85..75496c6208 100644 --- a/bun.lock +++ b/bun.lock @@ -56,7 +56,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.4.16", + "version": "7.4.17", "bin": { "opencode": "./bin/opencode", }, @@ -154,7 +154,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -168,7 +168,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "effect": "catalog:", }, @@ -180,7 +180,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -214,7 +214,7 @@ }, "packages/kilo-console": { "name": "@kilocode/kilo-console", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/kilo-indexing": "workspace:*", "@kilocode/kilo-web-ui": "workspace:*", @@ -237,7 +237,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -267,7 +267,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -303,7 +303,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.4.16", + "version": "7.4.17", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -313,7 +313,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -349,7 +349,7 @@ }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "effect": "catalog:", "zod": "catalog:", @@ -363,7 +363,7 @@ }, "packages/kilo-sandbox": { "name": "@kilocode/sandbox", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@anthropic-ai/sandbox-runtime": "catalog:", "effect": "catalog:", @@ -378,7 +378,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -392,7 +392,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "0.13.11", @@ -429,7 +429,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-gateway": "workspace:*", @@ -498,7 +498,7 @@ }, "packages/kilo-web-ui": { "name": "@kilocode/kilo-web-ui", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/kilo-ui": "workspace:*", "@kobalte/core": "catalog:", @@ -515,7 +515,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -534,7 +534,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.4.16", + "version": "7.4.17", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -703,7 +703,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@ai-sdk/provider": "3.0.8", "@kilocode/sdk": "workspace:*", @@ -732,7 +732,7 @@ }, "packages/plugin-atomic-chat": { "name": "@kilocode/plugin-atomic-chat", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/plugin": "workspace:*", }, @@ -771,7 +771,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "semver": "^7.6.3", }, @@ -797,7 +797,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "cross-spawn": "catalog:", }, @@ -812,7 +812,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -871,7 +871,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.4.16", + "version": "7.4.17", "devDependencies": { "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -895,7 +895,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", @@ -922,7 +922,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", diff --git a/package.json b/package.json index 7618290a91..12ea5fff16 100644 --- a/package.json +++ b/package.json @@ -172,6 +172,6 @@ "pacote@21.5.1": "patches/pacote@21.5.1.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch" }, - "version": "7.4.16", + "version": "7.4.17", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index 934146e1a9..593b01ac9e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.16", + "version": "7.4.17", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/core/src/kilocode/pty/termination.ts b/packages/core/src/kilocode/pty/termination.ts new file mode 100644 index 0000000000..bb1fd30be7 --- /dev/null +++ b/packages/core/src/kilocode/pty/termination.ts @@ -0,0 +1,179 @@ +import { spawn } from "child_process" +import { setTimeout as sleep } from "node:timers/promises" +import type { Proc } from "../../pty/pty" +import { Log } from "../../util/log" + +const log = Log.create({ service: "pty.termination" }) +const GRACE_MS = 200 +const SPAWN_TIMEOUT_MS = 5_000 + +export type Process = Pick + +export type Runtime = { + readonly platform: NodeJS.Platform + readonly taskkill: ( + file: string, + args: string[], + opts: { stdio: "ignore"; windowsHide: true; timeout: number }, + ) => Promise + readonly tree: () => Promise> + readonly alive: (pid: number) => boolean + readonly signal: (pid: number, signal: "SIGTERM" | "SIGKILL") => void + readonly sleep: (ms: number) => Promise +} + +const runtime: Runtime = { + platform: process.platform, + taskkill, + tree, + alive: (pid) => { + try { + process.kill(pid, 0) + return true + } catch { + return false + } + }, + signal: (pid, signal) => process.kill(pid, signal), + sleep, +} + +function direct(proc: Process, signal?: "SIGTERM" | "SIGKILL") { + try { + proc.kill(signal) + } catch (err) { + log.warn("failed to kill PTY directly", { err, pid: proc.pid, signal }) + } +} + +function descendants(root: number, rows: Array<{ pid: number; parent: number }>) { + const children = new Map() + for (const row of rows) { + const list = children.get(row.parent) ?? [] + list.push(row.pid) + children.set(row.parent, list) + } + const seen = new Set() + const collect = (pid: number): number[] => { + const result: number[] = [] + for (const child of children.get(pid) ?? []) { + if (seen.has(child)) continue + seen.add(child) + result.push(...collect(child), child) + } + return result + } + return collect(root) +} + +async function family(root: number, input: Runtime) { + const rows = await input.tree().catch((err) => { + log.debug("failed to inspect PTY process tree", { err, pid: root }) + return [] + }) + return [...descendants(root, rows), root] +} + +function signal(proc: Process, pids: number[], value: "SIGTERM" | "SIGKILL", input: Runtime) { + for (const pid of pids) { + let sent = false + for (const target of [-pid, pid]) { + try { + input.signal(target, value) + sent = true + } catch (err) { + log.debug("failed to signal PTY process", { err, pid: target, signal: value }) + } + } + if (pid === proc.pid && !sent) direct(proc, value) + } +} + +async function tree(file: string = "ps", args: string[] = ["-axo", "pid=,ppid="]) { + return await new Promise>((resolve) => { + try { + const child = spawn(file, args, { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + timeout: SPAWN_TIMEOUT_MS, + killSignal: "SIGKILL", + }) + const chunks: Buffer[] = [] + child.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk)) + child.once("error", () => resolve([])) + child.once("close", (code) => { + if (code !== 0) return resolve([]) + const rows = Buffer.concat(chunks) + .toString("utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => line.trim().split(/\s+/).map(Number)) + .filter(([pid, parent]) => Number.isSafeInteger(pid) && Number.isSafeInteger(parent)) + .map(([pid, parent]) => ({ pid: pid!, parent: parent! })) + resolve(rows) + }) + } catch { + resolve([]) + } + }) +} + +async function taskkill( + file: string, + args: string[], + opts: { stdio: "ignore"; windowsHide: true; timeout: number }, +) { + return await new Promise((resolve) => { + try { + const child = spawn(file, args, opts) + child.once("exit", (code) => resolve(code === 0)) + child.once("error", (err) => { + log.warn("taskkill failed", { err }) + resolve(false) + }) + } catch (err) { + log.warn("failed to start taskkill", { err }) + resolve(false) + } + }) +} + +export async function terminate(proc: Process, input: Runtime = runtime): Promise { + const state = { exited: false } + const listener = proc.onExit(() => { + state.exited = true + }) + try { + if (!proc.pid) { + direct(proc) + if (!state.exited) await input.sleep(GRACE_MS) + return + } + + if (input.platform === "win32") { + const killed = await input.taskkill("taskkill", ["/pid", String(proc.pid), "/f", "/t"], { + stdio: "ignore", + windowsHide: true, + timeout: SPAWN_TIMEOUT_MS, + }) + if (!killed && !state.exited) direct(proc) + if (!state.exited) await input.sleep(GRACE_MS) + return + } + + const initial = await family(proc.pid, input) + signal(proc, initial, "SIGTERM", input) + await input.sleep(GRACE_MS) + const remaining = new Set(initial.filter(input.alive)) + if (input.alive(proc.pid)) for (const pid of await family(proc.pid, input)) remaining.add(pid) + if (remaining.size > 0) { + signal(proc, [...remaining], "SIGKILL", input) + await input.sleep(GRACE_MS) + } + } finally { + listener.dispose() + } +} + +export * as KiloPtyTermination from "./termination" diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index f6aff8d123..44d95cb508 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -12,6 +12,7 @@ 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 +import { KiloPtyTermination } from "./kilocode/pty/termination" // kilocode_change const BUFFER_LIMIT = 1024 * 1024 * 2 // Exited sessions stay observable (status, exit code, retained output) until removed explicitly. @@ -36,6 +37,7 @@ type Active = { cursor: number subscribers: Map listeners: Disp[] + stopping: boolean // kilocode_change } // kilocode_change - the Kilo `sessionID` field now lives on the canonical shared schema (see @@ -65,6 +67,8 @@ export type AttachInput = { 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 + // Canonical routes can replay retained output after exit; legacy callers retain the former error. + readonly allowExited?: boolean // kilocode_change } export type Attachment = { @@ -122,23 +126,25 @@ const layer = Layer.effect( session.subscribers.clear() } - function teardown(session: Active) { + // kilocode_change start - terminate the complete PTY tree before reporting removal. + async function teardown(session: Active) { + session.stopping = true + if (session.info.status === "running") await KiloPtyTermination.terminate(session.process) for (const listener of session.listeners) listener.dispose() session.listeners.length = 0 - if (session.info.status === "running") { - try { - session.process.kill() - } catch {} - } - notifyEnd(session, {}) + notifyEnd(session, session.info.status === "exited" ? { exitCode: session.info.exitCode } : {}) } + // kilocode_change end - yield* Effect.addFinalizer(() => - Effect.sync(() => { - for (const session of sessions.values()) teardown(session) - sessions.clear() - exitOrder.length = 0 - }), + yield* Effect.addFinalizer( + () => + // kilocode_change start - wait for process-tree termination during async service teardown. + Effect.promise(async () => { + await Promise.all(Array.from(sessions.values()).map(teardown)) + sessions.clear() + exitOrder.length = 0 + }), + // kilocode_change end ) const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) { @@ -148,14 +154,18 @@ const layer = Layer.effect( }) const removeSession = Effect.fnUntraced(function* (id: PtyID) { - const session = sessions.get(id) - 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 }) + // kilocode_change start - removal and its deleted event are one uninterruptible lifecycle transition. + yield* Effect.gen(function* () { + const session = sessions.get(id) + if (!session) return + yield* Effect.logInfo("removing session", { id }) + yield* Effect.promise(() => teardown(session)) + sessions.delete(id) + const index = exitOrder.indexOf(id) + if (index !== -1) exitOrder.splice(index, 1) + yield* events.publish(Event.Deleted, { id: session.info.id }) + }).pipe(Effect.uninterruptible) + // kilocode_change end }) const remove = Effect.fn("Pty.remove")(function* (id: PtyID) { @@ -179,9 +189,10 @@ const layer = Layer.effect( args: input.args ? [...input.args] : undefined, cwd: input.cwd, }) + const implicit = !resolved.command 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 args = implicit && Shell.login(command) ? [...base, "-l"] : [...base] const cwd = resolved.cwd || location.directory // kilocode_change end const env = { @@ -221,6 +232,7 @@ const layer = Layer.effect( cursor: 0, subscribers: new Map(), listeners: [], + stopping: false, // kilocode_change } sessions.set(id, session) session.listeners.push( @@ -244,7 +256,7 @@ const layer = Layer.effect( session.bufferCursor += excess }), proc.onExit(({ exitCode }) => { - if (session.info.status === "exited") return + if (session.info.status === "exited" || session.stopping) return // kilocode_change session.info.status = "exited" session.info.exitCode = exitCode notifyEnd(session, { exitCode }) @@ -284,7 +296,7 @@ const layer = Layer.effect( 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 }) + if (session.info.status !== "running" && !input.allowExited) return yield* new ExitedError({ ptyID: id }) // kilocode_change yield* Effect.logInfo("client attached to session", { id, directory: location.directory }) const token = {} const subscriber: Subscriber = { @@ -293,6 +305,7 @@ const layer = Layer.effect( active: false, detached: false, pending: [], + end: session.info.status === "exited" ? { exitCode: session.info.exitCode } : undefined, // kilocode_change } session.subscribers.set(token, subscriber) const start = session.bufferCursor diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 07cfcd0965..5361c083a4 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -163,11 +163,13 @@ const layer = Layer.effect( ) const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program return abortable.pipe( - Effect.mapError((cause) => - cause instanceof Error || cause instanceof InvalidPatternError - ? cause - : failure("ripgrep execution failed", cause), - ), + // kilocode_change start - surface the underlying reason instead of a bare wrapper message + Effect.mapError((cause) => { + if (cause instanceof Error || cause instanceof InvalidPatternError) return cause + const detail = cause instanceof globalThis.Error && cause.message.trim() ? `: ${cause.message.trim()}` : "" + return failure(`ripgrep execution failed${detail}`, cause) + }), + // kilocode_change end ) } diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 233f623837..77f5d55160 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -229,6 +229,9 @@ export const Info = Schema.Struct({ layout: Schema.optional(ConfigLayoutV1.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), permission: Schema.optional(ConfigPermissionV1.Info), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + web_search: Schema.optional(Schema.Boolean).annotate({ + description: "Make web search available to models from all providers (default: false)", + }), // kilocode_change attachment: Schema.optional(ConfigAttachmentV1.Info).annotate({ description: "Attachment processing configuration, including image size limits and resizing behavior", }), diff --git a/packages/core/test/kilocode/pty-termination.test.ts b/packages/core/test/kilocode/pty-termination.test.ts new file mode 100644 index 0000000000..a6162cfbe0 --- /dev/null +++ b/packages/core/test/kilocode/pty-termination.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test" +import { KiloPtyTermination } from "../../src/kilocode/pty/termination" + +function fake(pid = 123) { + const calls: Array = [] + const proc: KiloPtyTermination.Process = { + pid, + onExit: () => ({ dispose() {} }), + kill: (signal) => calls.push(signal), + } + return { proc, calls } +} + +function runtime( + platform: NodeJS.Platform, + input: { + taskkill?: boolean + signal?: "throw" + tree?: Array<{ pid: number; parent: number }> + } = {}, +) { + const tasks: Array<{ + file: string + args: string[] + opts: { stdio: "ignore"; windowsHide: true; timeout: number } + }> = [] + const signals: Array<{ pid: number; signal: "SIGTERM" | "SIGKILL" }> = [] + const sleeps: number[] = [] + const value: KiloPtyTermination.Runtime = { + platform, + taskkill: async (file, args, opts) => { + tasks.push({ file, args, opts }) + return input.taskkill ?? true + }, + tree: async () => input.tree ?? [], + alive: () => true, + signal: (pid, signal) => { + signals.push({ pid, signal }) + if (input.signal === "throw") throw new Error("process group unavailable") + }, + sleep: async (ms) => { + sleeps.push(ms) + }, + } + return { value, tasks, signals, sleeps } +} + +describe("pty process-tree termination", () => { + test("uses hidden taskkill for Windows process trees", async () => { + const item = fake(42) + const input = runtime("win32") + + await KiloPtyTermination.terminate(item.proc, input.value) + + expect(input.tasks).toEqual([ + { + file: "taskkill", + args: ["/pid", "42", "/f", "/t"], + opts: { stdio: "ignore", windowsHide: true, timeout: 5_000 }, + }, + ]) + expect(input.signals).toEqual([]) + expect(item.calls).toEqual([]) + expect(input.sleeps).toEqual([200]) + }) + + test("signals POSIX process groups before escalating", async () => { + const item = fake(42) + const input = runtime("linux") + + await KiloPtyTermination.terminate(item.proc, input.value) + + expect(input.signals).toEqual([ + { pid: -42, signal: "SIGTERM" }, + { pid: 42, signal: "SIGTERM" }, + { pid: -42, signal: "SIGKILL" }, + { pid: 42, signal: "SIGKILL" }, + ]) + expect(item.calls).toEqual([]) + expect(input.sleeps).toEqual([200, 200]) + }) + + test("falls back to direct PTY signals when a process group is unavailable", async () => { + const item = fake(42) + const input = runtime("darwin", { signal: "throw" }) + + await KiloPtyTermination.terminate(item.proc, input.value) + + expect(item.calls).toEqual(["SIGTERM", "SIGKILL"]) + }) + + test("signals descendants that run in separate process groups", async () => { + const item = fake(42) + const input = runtime("linux", { + tree: [ + { pid: 43, parent: 42 }, + { pid: 44, parent: 43 }, + ], + }) + + await KiloPtyTermination.terminate(item.proc, input.value) + + expect(input.signals).toContainEqual({ pid: -44, signal: "SIGTERM" }) + expect(input.signals).toContainEqual({ pid: 44, signal: "SIGKILL" }) + expect(input.signals).toContainEqual({ pid: -43, signal: "SIGTERM" }) + expect(input.signals).toContainEqual({ pid: 43, signal: "SIGKILL" }) + }) +}) diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts index 9b41c98fb4..38ffe809f3 100644 --- a/packages/core/test/pty/pty-session.test.ts +++ b/packages/core/test/pty/pty-session.test.ts @@ -128,6 +128,43 @@ describe("pty", () => { }), ) + // kilocode_change start - explicit commands must not acquire implicit login-shell arguments. + ptyTest("preserves explicit command arguments", () => + Effect.gen(function* () { + const args = ["-c", 'printf "<%s>" "$0"; sleep 5'] + const info = yield* createPty("sh", args) + expect(info.args).toEqual(args) + + const attached = yield* attachCollecting(info.id) + expect(yield* waitForOutput(attached.output, "")).toContain("") + }), + ) + + ptyTest("terminates background descendants outside the shell process group", () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const info = yield* createPty("sh", ["-c", 'sleep 30 & printf "" "$!"; wait']) + const attached = yield* attachCollecting(info.id) + const output = yield* waitForOutput(attached.output, ">") + const match = output.match(//) + expect(match?.[1]).toBeDefined() + const pid = Number(match?.[1]) + + yield* pty.remove(info.id) + yield* Effect.sleep("100 millis") + const alive = yield* Effect.sync(() => { + try { + process.kill(pid, 0) + return true + } catch { + return false + } + }) + expect(alive).toBe(false) + }), + ) + // kilocode_change end + ptyTest("replays buffered output and streams live output to attachments", () => Effect.gen(function* () { const pty = yield* Pty.Service @@ -202,6 +239,31 @@ describe("pty", () => { expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.ExitedError", ptyID: info.id }) }), ) + + // kilocode_change start - canonical attachments replay retained exited output, then end without accepting input. + ptyTest("replays exited output and ends when enabled", () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const events = yield* subscribePtyEvents() + const info = yield* createPty("sh", ["-c", 'printf "replayed"; exit 7']) + expect(yield* waitForEvents(events, info.id, 2)).toEqual(["created", "exited"]) + + const ended = yield* Deferred.make<{ exitCode?: number }>() + const attachment = yield* pty.attach(info.id, { + allowExited: true, + onData: () => {}, + onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)), + }) + expect(attachment.replay).toContain("replayed") + + attachment.write("ignored") + yield* pty.remove(info.id) + attachment.activate() + expect(yield* Deferred.await(ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 7 }) + attachment.detach() + }), + ) + // kilocode_change end }) const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash") diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts index c31cc293af..d819f8f2f8 100644 --- a/packages/core/test/ripgrep.test.ts +++ b/packages/core/test/ripgrep.test.ts @@ -62,4 +62,18 @@ describe("Ripgrep", () => { (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ), ) + + // kilocode_change start - surfaced error keeps the underlying reason + it.live("includes the underlying reason in execution failures", () => + Effect.gen(function* () { + const ripgrep = yield* Ripgrep.Service + const controller = new AbortController() + controller.abort() + const error = yield* ripgrep + .find({ cwd: process.cwd(), pattern: "*", limit: 1, signal: controller.signal }) + .pipe(Effect.flip) + expect(error.message).toMatch(/^ripgrep execution failed: .+/) + }), + ) + // kilocode_change end }) diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 920418a989..bdc5267013 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.16", + "version": "7.4.17", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 66a2274823..c5863bfad0 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.16", + "version": "7.4.17", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index d31c75a3d8..a912fd5940 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.4.16" +version = "7.4.17" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 70bd905405..b2a33bdbca 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.16", + "version": "7.4.17", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index 82ed878f47..53ac288500 100755 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-console", - "version": "7.4.16", + "version": "7.4.17", "private": true, "type": "module", "scripts": { diff --git a/packages/kilo-console/src/routes/config/ToolsRoute.tsx b/packages/kilo-console/src/routes/config/ToolsRoute.tsx index 1aca8a16b8..1dd1001e66 100644 --- a/packages/kilo-console/src/routes/config/ToolsRoute.tsx +++ b/packages/kilo-console/src/routes/config/ToolsRoute.tsx @@ -1,14 +1,18 @@ import { createMemo, createSignal, For, Show } from "solid-js" import { ConfigRow, SectionTitle, StatusTag } from "@kilocode/kilo-web-ui/console" +import { Button } from "@kilocode/kilo-web-ui/button" +import { Card } from "@kilocode/kilo-web-ui/card" import { SearchField } from "../../components/SearchField" import { useConfig } from "../../context/config" import { toolCapabilities, toolName } from "../../shared/utils" -import { ConfigCountTag as CountTag, ConfigPage } from "./ConfigPage" +import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage" export function ToolsRoute() { const ctx = useConfig() const [search, setSearch] = createSignal("") const snap = () => ctx.data() + const websearch = createMemo(() => snap()?.overlay.fields.web_search) + const searchEnabled = createMemo(() => websearch()?.value === true) const rows = createMemo(() => { const data = snap() if (!data) return [] @@ -52,6 +56,48 @@ export function ToolsRoute() { } description="Built-in tools available to agents, including file access, terminal execution, search, fetch, and orchestration tools." > + +
+
+

Web search

+

Control web search availability for models from providers that do not enable it by default.

+
+ + + +
+
+ +
+
+ ("generateProviderIcons") { 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 9b1e517ac6..d032d281cc 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 @@ -722,7 +722,10 @@ class SessionController( LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id" } cs.launch { try { - if (!autoApprove) { + // Skill-shell batches must be answered by a human: the server refuses + // non-interactive approvals, so auto-approve must show the card (whose + // manual reply sets interactive=true) rather than send a machine reply. + if (!autoApprove || restore().meta.raw["skillShell"] == "true") { edt { if (disposed) return@edt model.setState(SessionState.AwaitingPermission(restore())) @@ -759,9 +762,16 @@ class SessionController( try { val permissions = sessions.pendingPermissions(directory).filter { it.sessionID in ids && it.id !in skip } val count = replyAll(permissions) - if (count == 0) return@launch + // Skill-shell requests are skipped by replyAll; surface one as a card so it + // isn't stranded (never machine-approved, never shown). + val card = skillShellCard(permissions)?.let { toPermission(it) } + if (count == 0 && card == null) return@launch runEdt { if (disposed) return@runEdt + if (card != null) { + updateModel { model.setState(SessionState.AwaitingPermission(card)) } + return@runEdt + } val current = model.state if (current is SessionState.AwaitingPermission && current.permission.sessionId in ids) { model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) @@ -777,6 +787,8 @@ class SessionController( var count = 0 for (request in permissions) { if (!autoApprove) return count + // Skill-shell batches need a human; skip them here (callers surface the card). + if (request.metadata["skillShell"] == "true") continue sessions.replyPermission(request.id, directory, PermissionReplyDto("once")) capture("Permission Auto Approved", sessionProps(request.sessionID) + mapOf("tool" to request.permission, "source" to "drain")) count++ @@ -784,6 +796,11 @@ class SessionController( return count } + // A skill-shell request is never machine-approved (the server refuses non-interactive + // approvals); after draining, callers must surface one as a card so a human can answer. + private fun skillShellCard(permissions: List): PermissionRequestDto? = + permissions.lastOrNull { it.metadata["skillShell"] == "true" } + private fun updatePermission(id: String, state: PermissionRequestState, message: String? = null) { assertEdt() val current = model.state @@ -1156,11 +1173,15 @@ class SessionController( val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == child } if (permissions.isEmpty()) return LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child permissions=${permissions.size}" } - if (autoApprove) { + // A skill-shell request must surface as a card even under auto-approve (replyAll + // skips it); prefer it over the last pending so a human can answer. + val show = if (autoApprove) { replyAll(permissions) - return + skillShellCard(permissions) ?: return + } else { + skillShellCard(permissions) ?: permissions.last() } - val last = toPermission(permissions.last()) + val last = toPermission(show) runEdt { if (disposed) return@runEdt if (child !in childIds) return@runEdt @@ -1201,9 +1222,12 @@ class SessionController( val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == id } val questions = sessions.pendingQuestions(directory).filter { it.sessionID == id } val status = sessions.statuses.value[id] + // replyAll auto-approves the ordinary permissions and skips skill-shell ones. A + // skill-shell request must then fall through to a human card rather than go Busy. + val skillCard = skillShellCard(permissions) if (permissions.isNotEmpty() && autoApprove) { val count = replyAll(permissions) - if (count > 0) { + if (count > 0 && skillCard == null) { runEdt { if (disposed) return@runEdt if (sid != id) return@runEdt @@ -1226,7 +1250,8 @@ class SessionController( if (sid != id) return@runEdt updateModel { if (permissions.isNotEmpty()) { - model.setState(SessionState.AwaitingPermission(toPermission(permissions.last()))) + // Prefer a skill-shell request (needs a human) over the last pending. + model.setState(SessionState.AwaitingPermission(toPermission(skillCard ?: permissions.last()))) } else if (questions.isNotEmpty()) { model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last()))) } else if (status != null) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt index d5323dd58a..0ae649ef54 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt @@ -356,7 +356,7 @@ class PermissionView( card.setActionEnabled(ID_RUN, false) card.setActionEnabled(ID_DENY, false) rules.setControlsEnabled(false) - reply(id, PermissionReplyDto(reply = "once"), rulePayload()) + reply(id, PermissionReplyDto(reply = "once", interactive = true), rulePayload()) } @RequiresEdt 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 9d5e4225e1..768c9e36b3 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 @@ -254,6 +254,23 @@ class PromptLifecycleTest : SessionControllerTestBase() { ) } + fun `test auto approve does not machine-reply a skill shell batch`() { + val (m, _, _) = prompted() + + edt { m.setAutoApprove(true) } + // skill-shell batches must be answered by a human; auto-approve must show the card + // instead of sending a non-interactive reply the server would refuse. + emit( + ChatEventDto.PermissionAsked( + "ses_test", + permission("perm1").copy(metadata = mapOf("skillShell" to "true")), + ), + ) + + assertTrue(rpc.permissionReplies.isEmpty()) + assertTrue(m.model.state is SessionState.AwaitingPermission) + } + fun `test disabling auto approve before reply restores awaiting permission`() { val (m, _, _) = prompted() @@ -310,6 +327,31 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertEquals("once", rpc.permissionReplies[0].third.reply) } + fun `test enabling auto approve surfaces a pending skill shell as a card`() { + val (m, _, _) = prompted() + rpc.pendingPermissionList.add(permission("perm_skill").copy(metadata = mapOf("skillShell" to "true"))) + + edt { m.setAutoApprove(true) } + flush() + + // skill-shell must not be machine-approved; it surfaces as a human card instead + assertTrue(rpc.permissionReplies.isEmpty()) + assertTrue(m.model.state is SessionState.AwaitingPermission) + } + + fun `test recovery surfaces a pending skill shell as a card under auto approve`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + rpc.pendingPermissionList.add(permission("perm_skill").copy(metadata = mapOf("skillShell" to "true"))) + edt { KiloPluginSettings.setAutoApprove(true) } + + val m = controller("ses_test") + flush() + + assertTrue(rpc.permissionReplies.isEmpty()) + assertTrue(m.model.state is SessionState.AwaitingPermission) + } + fun `test auto approve drains pending permissions during recovery`() { appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) projectRpc.state.value = workspaceReady() diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index 15b48416fe..182e9b77c4 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -5,6 +5,9 @@ intellij-rpc-plugin = "2.3.20-RC2-0.1" kotlin-jvm-plugin = "2.3.20" kotlin-serialization-plugin = "2.3.20" kotlin-serialization = "1.11.0" +kotlinx-coroutines = "1.10.2" +junit = "4.13.2" +junit-vintage = "5.11.4" okhttp = "4.12.0" openapi-generator = "7.21.0" detekt = "1.23.8" @@ -22,7 +25,9 @@ okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" } -kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.10.2" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +junit = { module = "junit:junit", version.ref = "junit" } +junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit-vintage" } zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "commons-compress" } diff --git a/packages/kilo-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 edbbed7b02..f4e222bbb2 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 @@ -328,6 +328,8 @@ data class ToolRefDto( data class PermissionReplyDto( val reply: String, val message: String? = null, + // Set when a human answered the prompt; the CLI ignores machine approvals of skill-shell batches. + val interactive: Boolean = false, ) @Serializable diff --git a/packages/kilo-memory/package.json b/packages/kilo-memory/package.json index df2adc3325..d48bcabe87 100644 --- a/packages/kilo-memory/package.json +++ b/packages/kilo-memory/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-memory", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "description": "Project memory storage, indexing, recall, and command helpers for Kilo Code", diff --git a/packages/kilo-sandbox/package.json b/packages/kilo-sandbox/package.json index ff29edd756..b57728afed 100644 --- a/packages/kilo-sandbox/package.json +++ b/packages/kilo-sandbox/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sandbox", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "private": true, diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 7b9e81497a..36243e82c9 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-telemetry/src/__tests__/identity.test.ts b/packages/kilo-telemetry/src/__tests__/identity.test.ts new file mode 100644 index 0000000000..3754c047f0 --- /dev/null +++ b/packages/kilo-telemetry/src/__tests__/identity.test.ts @@ -0,0 +1,89 @@ +import { mkdtempSync, readFileSync, existsSync, statSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { describe, test, expect, beforeEach, mock, afterEach } from "bun:test" +import { createHash } from "node:crypto" + +let profileCalls = 0 +mock.module("@kilocode/kilo-gateway", () => ({ + fetchProfile: async (token: string) => { + profileCalls++ + if (token === "bad-token") return null + return { email: `user-${token}@example.com` } + }, +})) + +const { Identity } = await import("../identity.js") + +function digest(token: string) { + return createHash("sha256").update(token).digest("hex") +} + +let dir: string + +beforeEach(() => { + profileCalls = 0 + dir = mkdtempSync(path.join(tmpdir(), "kilo-telemetry-identity-")) + Identity.reset() + Identity.setDataPath(dir) +}) + +afterEach(() => { + Identity.setDataPath("") +}) + +describe("Identity.updateFromKiloAuth profile cache", () => { + test("fetches profile and writes cache keyed by token hash", async () => { + await Identity.updateFromKiloAuth("token-a") + expect(Identity.getUserId()).toBe("user-token-a@example.com") + expect(profileCalls).toBe(1) + + const file = path.join(dir, "telemetry-profile.json") + expect(existsSync(file)).toBe(true) + const cache = JSON.parse(readFileSync(file, "utf8")) + expect(cache.token).toBe(digest("token-a")) + expect(cache.email).toBe("user-token-a@example.com") + expect(cache.token).not.toBe("token-a") + // The cache stores an email and a token verifier, so it must be owner-only. + // POSIX only: Windows reports default mode bits and enforces access via ACLs. + if (process.platform !== "win32") expect(statSync(file).mode & 0o777).toBe(0o600) + }) + + test("uses cached email without a network request on later invocations", async () => { + await Identity.updateFromKiloAuth("token-a") + expect(profileCalls).toBe(1) + + // Simulate a fresh process: identity state resets, cache file persists. + Identity.reset() + await Identity.updateFromKiloAuth("token-a") + expect(Identity.getUserId()).toBe("user-token-a@example.com") + expect(profileCalls).toBe(1) + }) + + test("refetches when the token changes", async () => { + await Identity.updateFromKiloAuth("token-a") + Identity.reset() + await Identity.updateFromKiloAuth("token-b") + expect(Identity.getUserId()).toBe("user-token-b@example.com") + expect(profileCalls).toBe(2) + }) + + test("clears identity when token is null", async () => { + await Identity.updateFromKiloAuth("token-a") + Identity.reset() + await Identity.updateFromKiloAuth(null) + expect(Identity.getUserId()).toBeNull() + expect(profileCalls).toBe(1) + }) + + test("ignores a cache file for a different token", async () => { + await Identity.updateFromKiloAuth("token-a") + const file = path.join(dir, "telemetry-profile.json") + const cache = JSON.parse(readFileSync(file, "utf8")) + expect(cache.token).toBe(digest("token-a")) + + Identity.reset() + await Identity.updateFromKiloAuth("token-b") + expect(Identity.getUserId()).toBe("user-token-b@example.com") + }) +}) diff --git a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts index ff87bf6909..939bb5dd24 100644 --- a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts +++ b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts @@ -1,4 +1,6 @@ -import { describe, test, expect, beforeEach } from "bun:test" +import { arch, platform, release } from "node:os" +import { describe, test, expect, beforeEach, spyOn } from "bun:test" +import { Client } from "../client.js" import { Identity } from "../identity.js" import { TelemetryEvent } from "../events.js" import { Telemetry } from "../telemetry.js" @@ -83,6 +85,22 @@ describe("TelemetryEvent", () => { }) describe("Telemetry", () => { + test("includes host OS properties", () => { + const capture = spyOn(Client, "capture").mockImplementation(() => {}) + + Telemetry.track(TelemetryEvent.CLI_START) + + expect(capture).toHaveBeenCalledWith( + TelemetryEvent.CLI_START, + expect.objectContaining({ + os_name: platform(), + os_version: release(), + os_arch: arch(), + }), + ) + capture.mockRestore() + }) + test("indexing helpers are exposed", () => { expect(typeof Telemetry.trackIndexingStarted).toBe("function") expect(typeof Telemetry.trackIndexingCompleted).toBe("function") @@ -96,4 +114,3 @@ describe("Telemetry", () => { expect(typeof Telemetry.trackSuggestionAccepted).toBe("function") }) }) - diff --git a/packages/kilo-telemetry/src/client.ts b/packages/kilo-telemetry/src/client.ts index 24c902b543..f5baa1ec54 100644 --- a/packages/kilo-telemetry/src/client.ts +++ b/packages/kilo-telemetry/src/client.ts @@ -82,4 +82,25 @@ export namespace Client { } } } + + // Flush queued events in the background without blocking the caller. The + // flush is delayed slightly so commands that exit immediately pay only the + // single shutdown() flush instead of an in-flight flush plus a follow-up + // flush for CLI_EXIT. For commands that outlive the delay, the upload + // overlaps with execution, so by the time shutdown() runs the queue is + // usually empty (or the connection is still warm) and process exit is not + // delayed by a network round trip. The unref'd timer never keeps a process + // alive on its own. The authoritative, error-handled flush still happens in + // shutdown(); failures here are retried there, so they are only surfaced + // when debug logging is on. + export function flushInBackground(delayMs = 300): void { + if (!enabled || !client) return + const timer = setTimeout(() => { + if (!client) return + client.flush().catch((err) => { + if (process.env.KILO_PRINT_LOGS) console.warn("telemetry background flush failed", err) + }) + }, delayMs) + timer.unref?.() + } } diff --git a/packages/kilo-telemetry/src/identity.ts b/packages/kilo-telemetry/src/identity.ts index c75fac4d29..974a32120d 100644 --- a/packages/kilo-telemetry/src/identity.ts +++ b/packages/kilo-telemetry/src/identity.ts @@ -1,4 +1,6 @@ import * as path from "path" +import { createHash } from "crypto" +import { writeFile, chmod, rename, rm } from "fs/promises" import { fetchProfile } from "@kilocode/kilo-gateway" export namespace Identity { @@ -7,6 +9,21 @@ export namespace Identity { let organizationId: string | null = null let dataPath = "" + // Cache the email resolved from the auth token so CLI startup does not block on + // a profile request for every invocation. Keyed by token hash; refreshed when + // the token changes. Stale entries (older than a week) are still used for the + // current run and refreshed on a best-effort basis for a later run: the + // background refresh is not awaited, so short-lived invocations may exit before + // it completes and simply retry next time. + const CACHE_FILE = "telemetry-profile.json" + const CACHE_TTL = 7 * 24 * 60 * 60 * 1000 + + interface Cache { + token: string + email: string + fetchedAt: number + } + export function setDataPath(p: string) { dataPath = p } @@ -51,6 +68,45 @@ export namespace Identity { organizationId = orgId } + function digest(token: string): string { + return createHash("sha256").update(token).digest("hex") + } + + async function read(): Promise { + if (!dataPath) return null + const file = Bun.file(path.join(dataPath, CACHE_FILE)) + if (!(await file.exists())) return null + const parsed = await file.json().catch(() => null) + if (!parsed || typeof parsed.token !== "string" || typeof parsed.email !== "string") return null + if (typeof parsed.fetchedAt !== "number") return null + return parsed as Cache + } + + async function write(cache: Cache): Promise { + if (!dataPath) return + const filepath = path.join(dataPath, CACHE_FILE) + // The cache stores the user's email and a token verifier, so keep it + // readable only by the owner, including when replacing an existing file. + // Write to a temp file and rename so concurrent invocations or a mid-write + // kill cannot leave a truncated cache behind (POSIX rename is atomic). + const tmp = `${filepath}.${process.pid}.tmp` + try { + await writeFile(tmp, JSON.stringify(cache), { mode: 0o600 }) + await chmod(tmp, 0o600) + await rename(tmp, filepath) + } catch (err) { + await rm(tmp, { force: true }).catch((rmErr) => { + if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile cache temp cleanup failed", rmErr) + }) + if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile cache write failed", err) + } + } + + async function refresh(token: string, tokenHash: string): Promise { + const profile = await fetchProfile(token).catch(() => null) + if (profile?.email) await write({ token: tokenHash, email: profile.email, fetchedAt: Date.now() }) + } + export async function updateFromKiloAuth(token: string | null, accountId?: string): Promise { organizationId = accountId || null @@ -59,8 +115,21 @@ export namespace Identity { return } + const tokenHash = digest(token) + const cached = await read() + if (cached && cached.token === tokenHash) { + userId = cached.email + if (Date.now() - cached.fetchedAt > CACHE_TTL) { + refresh(token, tokenHash).catch((err) => { + if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile refresh failed", err) + }) + } + return + } + const profile = await fetchProfile(token).catch(() => null) userId = profile?.email || null + if (profile?.email) await write({ token: tokenHash, email: profile.email, fetchedAt: Date.now() }) } export function reset() { diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 3ac0d42e39..496bf1ad4c 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -1,3 +1,4 @@ +import { release } from "node:os" import { Client } from "./client.js" import { Identity } from "./identity.js" import { TelemetryEvent } from "./events.js" @@ -6,6 +7,9 @@ export interface TelemetryProperties { appName: string appVersion: string platform: string + os_name: string + os_version: string + os_arch: string editorName?: string vscodeVersion?: string } @@ -58,6 +62,9 @@ export namespace Telemetry { appName: "kilo-cli", appVersion: "unknown", platform: process.platform, + os_name: process.platform, + os_version: release(), + os_arch: process.arch, } export async function init(options: { dataPath: string; version: string; enabled: boolean }): Promise { @@ -109,6 +116,9 @@ export namespace Telemetry { appName: props.appName, appVersion: props.appVersion, platform: props.platform, + os_name: props.os_name, + os_version: props.os_version, + os_arch: props.os_arch, }) // Link the anonymous machineId to the authenticated email @@ -125,6 +135,12 @@ export namespace Telemetry { track(TelemetryEvent.CLI_START) } + // Upload queued events without blocking. Call after bootstrap so the flush + // overlaps with command execution and shutdown() stays fast (#10242). + export function flushInBackground() { + Client.flushInBackground() + } + export function trackCliExit(exitCode?: number) { track(TelemetryEvent.CLI_EXIT, { duration: Date.now() - startTime, diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 3043aedbab..25e78d8ac0 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index d41e4a8c94..8e0e1d920c 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,78 @@ # kilo-code +## 7.4.17 + +### Minor Changes + +- [#12631](https://github.com/Kilo-Org/kilocode/pull/12631) [`3321216`](https://github.com/Kilo-Org/kilocode/commit/3321216c0157e1a8a1829b0c8e0a1cae8d2f2ad2) - Show the ⌘1-9 (Ctrl+1-9 on Windows/Linux) shortcut badges on every Agent Manager sidebar card while the modifier key is held, making it easy to see which number jumps to which worktree before pressing it. + +- [#12598](https://github.com/Kilo-Org/kilocode/pull/12598) [`c6711fc`](https://github.com/Kilo-Org/kilocode/commit/c6711fcf6cea9fdbe78a04b95276d09a2faabfa7) - 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. + +- [#12633](https://github.com/Kilo-Org/kilocode/pull/12633) [`23039c0`](https://github.com/Kilo-Org/kilocode/commit/23039c0fb1e5b32704119ddde10a6a28ccd6bff3) - Support multiple Agent Manager side-panel terminals per context. The panel header is now a tab strip that reuses the main tab bar's terminal tabs: click to switch, drag to reorder, X to close a single terminal, and + to open another one. Terminal numbers fill gaps left by closed terminals, and tabs pick up the live title from the shell or running program (OSC escape codes), so a dev server or build names its own tab. + +- [#12632](https://github.com/Kilo-Org/kilocode/pull/12632) [`0d853df`](https://github.com/Kilo-Org/kilocode/commit/0d853df3ec338ac99e025939f74136dec6d9daa1) - Add a prompt navigator rail to the chat transcript. A thin rail of ticks on the left edge shows one tick per prompt you sent; hovering or focusing it expands a card listing those prompts with a short preview of the answer, and clicking jumps the transcript to that turn. It appears in the sidebar, Kilo editor tabs, the sub-agent viewer, and Agent Manager, and never changes the readable width of the chat. + +### Patch Changes + +- [#12629](https://github.com/Kilo-Org/kilocode/pull/12629) [`0a1c140`](https://github.com/Kilo-Org/kilocode/commit/0a1c14073a4bf14f8ad4e3c8295dc6ae6bfbfdaf) - Keep each Agent Manager panel's terminal destination consistent. A dropdown pick is now remembered per panel and no longer flips when another window rewrites the shared terminal destination setting, so the terminal shortcut keeps opening the terminal type that panel is actually using. The shortcut also no longer dead-ends on worktrees without an active session, and terminals left over from a reloaded webview are cleaned up instead of leaking. + +- [#12587](https://github.com/Kilo-Org/kilocode/pull/12587) [`16f8e7e`](https://github.com/Kilo-Org/kilocode/commit/16f8e7ef7fbd47755395539e7df54af3baae0c63) - Keep conversations and workspace files unchanged when a checkpoint cannot be fully restored. + +- [#12333](https://github.com/Kilo-Org/kilocode/pull/12333) [`290a5af`](https://github.com/Kilo-Org/kilocode/commit/290a5af56e6ddccd8b4a459883625e30f2ae0344) Thanks [@IamCoder18](https://github.com/IamCoder18)! - 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. + +- [#12630](https://github.com/Kilo-Org/kilocode/pull/12630) [`a7f972f`](https://github.com/Kilo-Org/kilocode/commit/a7f972f63bc948d70b73a36a5beab6d694316037) - Open Agent Manager terminals faster and avoid delayed shell prompts. + +- [#12560](https://github.com/Kilo-Org/kilocode/pull/12560) [`65c5e9d`](https://github.com/Kilo-Org/kilocode/commit/65c5e9d2c03cea152b140710228075edf9156def) - Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs. + +- [#12561](https://github.com/Kilo-Org/kilocode/pull/12561) [`44f5963`](https://github.com/Kilo-Org/kilocode/commit/44f596366931d5336f1cd4dfdd97ef54e0f2fa4c) - Fix settings changes sometimes failing to save and apply in VS Code. + +- [#12540](https://github.com/Kilo-Org/kilocode/pull/12540) [`2da8949`](https://github.com/Kilo-Org/kilocode/commit/2da89498138e49f857c354924fdecac85337e742) Thanks [@Githubguy132010](https://github.com/Githubguy132010)! - Preserve parenthesized tilde expressions as literal text in rendered chat messages. + +- [#12591](https://github.com/Kilo-Org/kilocode/pull/12591) [`625d2b9`](https://github.com/Kilo-Org/kilocode/commit/625d2b974de1381b4d475808c9215becb263d1f0) - Show prompt input toggle tooltips instantly on hover instead of after a delay. + +- [#12460](https://github.com/Kilo-Org/kilocode/pull/12460) [`51d8031`](https://github.com/Kilo-Org/kilocode/commit/51d8031c9997bd5478bcde715562169f732d04d4) - 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. + +- [#11984](https://github.com/Kilo-Org/kilocode/pull/11984) [`26dac19`](https://github.com/Kilo-Org/kilocode/commit/26dac197fe28294c391c8d437abf06e18e2d22bd) Thanks [@sylwester-liljegren](https://github.com/sylwester-liljegren)! - Mention `@` file references in the chat input placeholder so users know they can add file mentions. Translated across all supported languages. + +- [#12593](https://github.com/Kilo-Org/kilocode/pull/12593) [`160b066`](https://github.com/Kilo-Org/kilocode/commit/160b06661acc5f04b21221ab6578c468325f64c5) - Prevent the VS Code backend from eagerly starting native file watchers for every Agent Manager worktree. + +- [#12123](https://github.com/Kilo-Org/kilocode/pull/12123) [`3075d35`](https://github.com/Kilo-Org/kilocode/commit/3075d35f13ba9738446ac28fa2eebf054097f2f5) Thanks [@mjnaderi](https://github.com/mjnaderi)! - Keep message and response copy buttons working after switching focus away from VS Code. + +- [#12583](https://github.com/Kilo-Org/kilocode/pull/12583) [`1310c12`](https://github.com/Kilo-Org/kilocode/commit/1310c1200ab613b316f27fe4fd59e23e262df02f) Thanks [@noobezlol](https://github.com/noobezlol)! - Keep Windows snapshot diffs parseable and preserve valid files when a stored patch is malformed. + +- [#12410](https://github.com/Kilo-Org/kilocode/pull/12410) [`85d65a3`](https://github.com/Kilo-Org/kilocode/commit/85d65a3137ecadfcbda8255bfb4a40daf1f155fb) - Open Kilo chats, settings, and files as tabs in the selected editor pane without creating, locking, or resizing editor panes. + +- [#12639](https://github.com/Kilo-Org/kilocode/pull/12639) [`8a47d8b`](https://github.com/Kilo-Org/kilocode/commit/8a47d8b78885fa8fd14c73b3aecdb57e1fc96c9c) - Stop flashing a "Turn interrupted" warning when a follow-up message is queued while the assistant is still working. The running turn now closes with a dedicated "superseded" reason instead of "interrupted" when it hands off to the queued prompt, so the premature-stop warning only appears for real interruptions. + +- [#12602](https://github.com/Kilo-Org/kilocode/pull/12602) [`5d87ca5`](https://github.com/Kilo-Org/kilocode/commit/5d87ca598c4c66328f0e476cb1be3fc9b26d05aa) - 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. + +- [#12463](https://github.com/Kilo-Org/kilocode/pull/12463) [`1f3383c`](https://github.com/Kilo-Org/kilocode/commit/1f3383cf3de37327b02e0fc2a1c5ac176ca9134f) Thanks [@IamCoder18](https://github.com/IamCoder18)! - 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 + +- [#12634](https://github.com/Kilo-Org/kilocode/pull/12634) [`4c5c242`](https://github.com/Kilo-Org/kilocode/commit/4c5c2428927f26c4c818f23a650cfaf5723b7641) - Show the worktree directory name on the Agent Manager worktree hover card + ## 7.4.16 ### Minor Changes diff --git a/packages/kilo-vscode/eslint.config.mjs b/packages/kilo-vscode/eslint.config.mjs index d19df68b45..97181f7f60 100644 --- a/packages/kilo-vscode/eslint.config.mjs +++ b/packages/kilo-vscode/eslint.config.mjs @@ -43,17 +43,13 @@ export default [ }, { files: ["webview-ui/agent-manager/AgentManagerApp.tsx"], - // Raised from 3100 → 3200 for the experimental terminal tabs feature. - // ~600 lines of terminal logic were extracted to ./terminal/* and - // ./tab-rendering.tsx; the remaining ~75 lines are signal bindings, - // a stacking-container wrapper required by the hydration invariant - // (canvases must never leave the paint tree — see render.tsx), and - // render-call wiring that must live at the top of - // `AgentManagerContent` alongside the existing selection/session state. - // Raised from 3200 → 3210 for the per-message feedback `FeedbackProvider` - // wiring, which sits inside the provider chain and cannot be extracted - // without adding an intermediate wrapper component. - rules: { complexity: ["error", 74], "max-lines": ["error", 3210] }, + // Complexity stays exempt: the top of `AgentManagerContent` wires many + // selection/session handlers that can't be split without threading shared + // reactive state. Line count needs no override — the apply-to-local + // workflow is extracted to ./apply-to-local.tsx, keeping the file under + // the global 3000-line default. Do not add a max-lines override back; + // extract cohesive domains out of the file instead. + rules: { complexity: ["error", 74] }, }, { files: ["src/agent-manager/AgentManagerProvider.ts"], diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 45d6fbeb52..381f28c257 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.4.16", + "version": "7.4.17", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -678,7 +678,7 @@ "command": "kilo-code.new.agentManager.showTerminal", "key": "ctrl+/", "mac": "cmd+/", - "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' && !kilo-code.new.sidebarFocused" }, { "command": "kilo-code.new.agentManager.runScript", @@ -868,7 +868,8 @@ "tr", "nl", "uk", - "it" + "it", + "fa" ], "enumDescriptions": [ "Auto (VS Code language)", @@ -891,7 +892,8 @@ "Türkçe", "Nederlands", "Українська", - "Italiano" + "Italiano", + "فارسی" ] }, "kilo-code.new.languageCommitMessage": { @@ -919,7 +921,8 @@ "tr", "nl", "uk", - "it" + "it", + "fa" ], "enumDescriptions": [ "跟随界面语言 (Sync with UI language)", @@ -942,7 +945,8 @@ "Türkçe", "Nederlands", "Українська", - "Italiano" + "Italiano", + "فارسی" ] }, "kilo-code.new.model.providerID": { @@ -1028,7 +1032,7 @@ "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." + "description": "Default destination for the Agent Manager terminal button and Focus Terminal keyboard shortcut. The terminal button's dropdown remembers its own choice per Agent Manager panel; this setting only applies to panels that never picked one." }, "kilo-code.new.indexing.showButtonWhenDisabled": { "type": "boolean", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 48817cf901..83c41aaf09 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -161,6 +161,7 @@ import { AnacondaDesktopBridge } from "./anaconda-desktop/bridge" import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models" import type { Agent } from "@kilocode/sdk/v2/client" import { configFeatures } from "./features" +import { fetchSnapshot } from "./kilo-provider/config-snapshot" import { createAutoApproveBridge } from "./kilo-provider/auto-approve" import type { KiloProviderOptions } from "./kilo-provider/options" import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway" @@ -2562,24 +2563,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } try { - const workspaceDir = this.getWorkspaceDirectory() - const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([ - retry(() => this.client!.config.get({ directory: workspaceDir }, { throwOnError: true })), - this.client.global.config.get({ throwOnError: true }), - this.client.config.overlay({ directory: workspaceDir, scope: "project" }, { throwOnError: true }), - ]) - this.cachedGlobalConfig = global ?? null - - const message = { - type: "configLoaded", - config, - globalConfig: global, - projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, - features: configFeatures(config), - } - this.cachedConfigMessage = message - this.postMessage(message) + await this.refreshConfig("configLoaded") } catch (error) { console.error("[Kilo New] KiloProvider: Failed to fetch config:", error) } @@ -2673,29 +2657,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private async fetchAndSendConfigUpdated(): Promise { if (!this.client || this.connectionState !== "connected") return try { - const dir = this.getWorkspaceDirectory() - const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([ - retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })), - this.client.global.config.get({ throwOnError: true }), - this.client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }), - ]) - this.cachedGlobalConfig = global ?? null - this.cachedConfigMessage = { - type: "configLoaded", - config, - globalConfig: global, - projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, - features: configFeatures(config), - } - this.postMessage({ - type: "configUpdated", - config, - globalConfig: global, - projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, - features: configFeatures(config), - }) + await this.refreshConfig("configUpdated") } catch (error) { console.error("[Kilo New] KiloProvider: Failed to fetch config after update:", error) } @@ -3064,28 +3026,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } try { - const [{ data: merged }, { data: global }, { data: overlay }] = await Promise.all([ - retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })), - this.client.global.config.get({ throwOnError: true }), - this.client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }), - ]) - this.cachedGlobalConfig = global ?? null - this.cachedConfigMessage = { - type: "configLoaded", - config: merged, - globalConfig: global, - projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, - features: configFeatures(merged), - } - this.postMessage({ - type: "configUpdated", - config: merged, - globalConfig: global, - projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, - features: configFeatures(merged), - }) + await this.refreshConfig("configUpdated", dir) this.requirements.clear() await Promise.all([ refreshProviders ? this.fetchAndSendProviders() : Promise.resolve(), @@ -3113,6 +3054,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.pending-- } } + + private async refreshConfig(type: "configLoaded" | "configUpdated", dir = this.getWorkspaceDirectory()) { + const snapshot = await fetchSnapshot(this.client!, dir, () => ({ + maxCost: this.maxCostSetting(), + languageCommitMessage: this.commitMessageLanguageSetting(), + })) + this.cachedGlobalConfig = snapshot.globalConfig ?? null + this.cachedConfigMessage = { type: "configLoaded", ...snapshot } + this.postMessage({ type, ...snapshot }) + } + private postConfigFailure(error: unknown): void { console.error("[Kilo New] KiloProvider: Failed to update config:", error) this.postMessage({ diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index bf72af74ab..5dd9b1987c 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -4,10 +4,12 @@ import type { KiloClient, Session } from "@kilocode/sdk/v2/client" import type { KiloConnectionService } from "../services/cli-backend" import { getErrorMessage } from "../kilo-provider-utils" import { resolveLocalDiffTarget } from "../diff/shared/target" +import { DiffSourceCatalog } from "../diff/sources/catalog" import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings" import { isAbsolutePath } from "../path-utils" import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager" import { remoteRef, WorktreeStateManager, type Worktree } from "./WorktreeStateManager" +import { composeDiffId, normalizeScope } from "./diff-scope" import { handleSection } from "./section-handler" import { normalizeBaseBranch } from "./base-branch" import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller" @@ -22,9 +24,9 @@ import { SessionTerminalManager } from "./SessionTerminalManager" import { createTerminalHost } from "./terminal-host" import { TerminalRouter } from "./terminal-routing" import { executeVscodeTask } from "./task-runner" -import { startVscodeRunTask } from "./run/task" import { RunController } from "./run/controller" import { handleRunMessage } from "./run/message" +import { createRunController, createScriptTerminalRuntime } from "./script-terminal-runtime" import { forkSession } from "./fork-session" import { AgentManagerVisiblePresence } from "./am-visible-presence" import { continueInWorktree } from "./continue-in-worktree" @@ -64,6 +66,7 @@ export class AgentManagerProvider implements Disposable { private importer: WorktreeImporter private terminalManager: SessionTerminalManager private terminalRouter: TerminalRouter + private scripts: ReturnType private run: RunController private stateReady: Promise | undefined private statsPoller: GitStatsPoller @@ -71,6 +74,7 @@ export class AgentManagerProvider implements Disposable { private orchestration: AgentManagerOrchestrationBridge private gitOps: GitOps private diffs: WorktreeDiffController + private diffCatalog: DiffSourceCatalog private naming: BranchNamingController private staleWorktreeIds = new Set() private toolRequests = new Set() @@ -103,6 +107,7 @@ export class AgentManagerProvider implements Disposable { ) this.terminalRouter = new TerminalRouter({ getClient: () => this.connectionService.getClient(), + getClientAsync: () => this.connectionService.getClientAsync(this.getRoot()), getServerConfig: () => this.connectionService.getServerConfig() ?? undefined, getRoot: () => this.getRoot(), getWorktreePath: (id) => this.getStateManager()?.getWorktree(id)?.path, @@ -110,19 +115,25 @@ export class AgentManagerProvider implements Disposable { post: (msg) => this.postToWebview(msg), getTerminalFont: () => readTerminalFont(), }) + this.scripts = createScriptTerminalRuntime({ + connection: this.connectionService, + output: this.outputChannel, + post: (message) => this.postToWebview(message), + }) this.unsubFont = watchTerminalFont((font) => { this.postToWebview({ type: "agentManager.terminal.fontChanged", font }) + this.scripts.manager.snapshot() }) this.unsubDestination = watchTerminalDestination((destination) => { this.postToWebview({ type: "agentManager.terminal.destinationChanged", destination }) }) - this.run = new RunController({ + this.run = createRunController({ + manager: this.scripts.manager, root: () => this.getRoot(), state: () => this.getStateManager(), open: (file) => this.host.openDocument(file), - start: startVscodeRunTask, - post: (status) => this.postToWebview({ type: "agentManager.runStatus", ...status }), - error: (message) => this.postToWebview({ type: "error", message }), + trusted: () => this.host.isTrusted(), + post: (message) => this.postToWebview(message), log: (msg) => this.outputChannel.appendLine(`[RunScript] ${msg}`), refresh: () => this.pushState(), }) @@ -148,12 +159,13 @@ export class AgentManagerProvider implements Disposable { log: (msg) => this.log(msg), }) const local = createLocalDiff(this.gitOps, (...args) => this.log(...args)) + this.diffCatalog = new DiffSourceCatalog(this.connectionService) this.diffs = new WorktreeDiffController({ getState: () => this.getStateManager(), getRoot: () => this.getRoot(), getStateReady: () => this.stateReady, + catalog: this.diffCatalog, git: this.gitOps, - localDiff: local.summary, localDiffFile: local.file, post: (msg) => this.postToWebview(msg), log: (...args) => this.log(...args), @@ -411,6 +423,7 @@ export class AgentManagerProvider implements Disposable { if (diff !== undefined) return diff const bridge = this.onBridgeMessage(m) if (bridge !== undefined) return bridge + if (this.scripts.manager.intercept(m)) return null if (this.terminalRouter.handle(m)) return null return msg @@ -593,6 +606,10 @@ export class AgentManagerProvider implements Disposable { this.terminalManager.showLocalTerminal() return null } + if (m.type === "agentManager.showWorktreeTerminal") { + this.terminalManager.showWorktreeTerminal(m.worktreeId, this.state) + return null + } if (m.type === "agentManager.openWorktree") { this.openWorktreeDirectory(m.worktreeId) return null @@ -679,11 +696,11 @@ export class AgentManagerProvider implements Disposable { private onDiffMessage(m: AgentManagerInMessage): Record | null | undefined { if (m.type === "agentManager.requestWorktreeDiff") { - void this.diffs.request(m.sessionId) + void this.diffs.request(composeDiffId(m.sessionId, normalizeScope(m.scope))) return null } if (m.type === "agentManager.requestWorktreeDiffFile") { - void this.diffs.requestFile(m.sessionId, m.file) + void this.diffs.requestFile(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.file) return null } if (m.type === "agentManager.applyWorktreeDiff") { @@ -691,23 +708,53 @@ export class AgentManagerProvider implements Disposable { return null } if (m.type === "agentManager.revertWorktreeFile") { - void this.diffs.revert(m.sessionId, m.file) + void this.diffs.revert(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.file) return null } if (m.type === "agentManager.startDiffWatch") { - this.diffs.start(m.sessionId) + this.diffs.start(composeDiffId(m.sessionId, normalizeScope(m.scope))) return null } if (m.type === "agentManager.stopDiffWatch") { this.diffs.stop() return null } + if (m.type === "agentManager.requestDiffBranches") { + void this.sendDiffBranches(m.sessionId, m.scope) + return null + } + if (m.type === "agentManager.setDiffBaseBranch") { + void this.diffs + .setBase(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.branch) + .catch((err) => this.log("Failed to set diff base:", err instanceof Error ? err.message : String(err))) + .then(() => void this.sendDiffBranches(m.sessionId, m.scope)) + return null + } if (m.type === "agentManager.openFile") { this.openWorktreeFile(m.sessionId, m.filePath, m.line, m.column) return null } } + private async sendDiffBranches(sessionId: string, scope?: string): Promise { + const id = composeDiffId(sessionId, normalizeScope(scope)) + const result = await this.diffs.branches(id).catch((err) => { + this.log("Failed to list diff branches:", err instanceof Error ? err.message : String(err)) + return undefined + }) + if (!result) return + this.postToWebview({ + type: "agentManager.diffBranches", + sessionId: id, + branches: result.branches, + defaultBranch: result.defaultBranch, + autoBase: result.autoBase, + currentBase: result.currentBase, + isAuto: result.isAuto, + currentBranch: result.currentBranch, + }) + } + private onBridgeMessage(m: AgentManagerInMessage): Record | null | undefined { if (m.type !== "openFile") return undefined @@ -720,6 +767,14 @@ export class AgentManagerProvider implements Disposable { } private onRequestState(): void { + // requestState fires from the webview's onMount, and a freshly mounted + // webview has no terminal records — any PTYs the router still tracks + // belong to a previous webview instance (reload or crash) and are + // unreachable orphans. Kill them here rather than leaking shells until + // the panel itself is disposed. In-flight creates from the dying + // instance are reaped by the router's generation guard. + void this.terminalRouter.dispose() + this.scripts.manager.snapshot() void this.stateReady ?.then(() => { // When the folder is not a git repo (or has no folder open), @@ -906,8 +961,9 @@ export class AgentManagerProvider implements Disposable { case "agentManager.toggleSectionCollapsed": case "agentManager.moveToSection": case "agentManager.moveSection": - case "agentManager.terminal.create": return true + case "agentManager.terminal.create": + return m.worktreeId !== null default: return false } @@ -1010,11 +1066,16 @@ export class AgentManagerProvider implements Disposable { this.log(`Worktree ${worktreeId} not found in state`) return null } + this.statsPoller.skipWorktree(worktreeId) + await this.run.remove(worktreeId) + if (!(await this.scripts.manager.clear("run", worktreeId))) { + this.statsPoller.unskipWorktree(worktreeId) + this.postToWebview({ type: "error", message: "Failed to stop the Run script before deleting the worktree" }) + return null + } // Remove from state BEFORE disk removal so pollers immediately stop targeting this worktree. // Pre-emptive skip covers any in-flight poll that already captured getWorktrees(). - this.statsPoller.skipWorktree(worktreeId) this.prBridge.remove(worktreeId) - this.run.remove(worktreeId) this.naming.forget(worktreeId) const orphaned = state.removeWorktree(worktreeId) if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) { @@ -1049,6 +1110,11 @@ export class AgentManagerProvider implements Disposable { return null } + await this.run.remove(worktreeId) + if (!(await this.scripts.manager.clear("run", worktreeId))) { + this.postToWebview({ type: "error", message: "Failed to stop the Run script before removing the worktree" }) + return null + } this.naming.forget(worktreeId) const orphaned = state.removeWorktree(worktreeId) if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) { @@ -1911,9 +1977,11 @@ export class AgentManagerProvider implements Disposable { this.unsubStatus?.() this.unsubFont?.() this.unsubDestination?.() + await this.scripts.dispose() this.orchestration.dispose() this.visiblePresence.clear() this.diffs.stop() + this.diffCatalog.dispose() this.naming.dispose() this.statsPoller.stop() this.gitOps.dispose() diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index ce386a858a..405557d50d 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -102,6 +102,10 @@ export class GitStatsPoller { this.skipWorktreeIds.add(id) } + unskipWorktree(id: string): void { + this.skipWorktreeIds.delete(id) + } + setEnabled(enabled: boolean): void { if (enabled) { if (this.active) return diff --git a/packages/kilo-vscode/src/agent-manager/ScriptTerminalManager.ts b/packages/kilo-vscode/src/agent-manager/ScriptTerminalManager.ts new file mode 100644 index 0000000000..91450ef452 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/ScriptTerminalManager.ts @@ -0,0 +1,395 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" +import type { TerminalFont } from "./terminal-font" +import type { RunHandle } from "./run/manager" + +type ScriptTerminalKind = "run" +type ScriptTerminalState = "running" | "stopping" | "exited" | "failed" + +interface ScriptTerminalConfig { + worktreeId: string + command: string + args: string[] + cwd: string + env: Record +} + +interface ScriptTerminalExit { + exitCode?: number + stopped?: boolean + error?: string +} + +export interface ScriptTerminalView { + terminalId: string + /** null for the LOCAL workspace; RunController retains its internal "local" key. */ + worktreeId: string | null + kind: ScriptTerminalKind + title: "Run" + wsUrl: string + state: ScriptTerminalState + exitCode?: number + font: TerminalFont +} + +interface ScriptTerminalDeps { + getClient(): KiloClient + getClientAsync(directory: string): Promise + buildWsUrl(ptyID: string, cwd: string): string + getTerminalFont(): TerminalFont + emit(terminals: ScriptTerminalView[]): void + closed(terminalId: string): void + log(msg: string): void +} + +interface Entry { + key: string + kind: ScriptTerminalKind + terminalId: string + ptyID: string + worktreeId: string + cwd: string + wsUrl: string + state: ScriptTerminalState + exitCode?: number + done: (exit: ScriptTerminalExit) => void + finished: boolean + closing?: Promise +} + +interface TerminalMessage { + type: string + terminalId?: unknown + cols?: unknown + rows?: unknown +} + +function message(error: unknown): string { + if (error instanceof Error) return error.message + return String(error) +} + +function missing(error: unknown): boolean { + if (!error || typeof error !== "object") return false + const value = error as Record + if (value.status === 404 || value._tag === "PtyNotFoundError") return true + if (!value.data || typeof value.data !== "object") return false + const data = value.data as Record + return data.status === 404 || data._tag === "PtyNotFoundError" +} + +function key(kind: ScriptTerminalKind, worktreeId: string): string { + return `${kind}:${worktreeId}` +} + +function terminalId(): string { + return `script:${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` +} + +/** + * Owns extension-host script PTYs independently from webview terminal routing. + * Exited records stay available for output replay until the user closes them. + */ +export class ScriptTerminalManager { + private readonly entries = new Map() + private readonly terminals = new Map() + private readonly ptys = new Map() + + constructor(private readonly deps: ScriptTerminalDeps) {} + + async start( + kind: ScriptTerminalKind, + config: ScriptTerminalConfig, + done: (exit: ScriptTerminalExit) => void, + ): Promise { + const id = key(kind, config.worktreeId) + const prior = this.entries.get(id) + if (prior) { + if (prior.state === "running" || prior.state === "stopping") throw new Error("Run terminal is already active") + await this.remove(prior, false) + if (this.entries.has(id)) throw new Error("Failed to remove previous Run terminal") + } + + const client = await this.deps.getClientAsync(config.cwd).catch((error) => { + const detail = message(error) + this.deps.log(`Run terminal create failed: ${detail}`) + throw new Error(detail) + }) + const created = await client.v2.pty + .create({ + location: { directory: config.cwd }, + command: config.command, + args: config.args, + cwd: config.cwd, + env: config.env, + title: "Run", + }) + .catch((error) => { + const detail = message(error) + this.deps.log(`Run terminal create failed: ${detail}`) + throw new Error(detail) + }) + const pty = created.data?.data + if (created.error || !pty) { + const detail = message(created.error ?? "unknown error") + this.deps.log(`Run terminal create failed: ${detail}`) + throw new Error(`Failed to create Run terminal: ${detail}`) + } + + const wsUrl = await this.url(client, pty.id, config.cwd) + const entry: Entry = { + key: id, + kind, + terminalId: terminalId(), + ptyID: pty.id, + worktreeId: config.worktreeId, + cwd: config.cwd, + wsUrl, + state: "running", + done, + finished: false, + } + this.entries.set(entry.key, entry) + this.terminals.set(entry.terminalId, entry) + this.ptys.set(entry.ptyID, entry) + this.emit() + + await this.reconcile(entry, client) + + return { + stop: () => this.stop(entry), + } + } + + /** Return true only for close/resize messages owned by a script terminal. */ + intercept(msg: TerminalMessage): boolean { + const id = msg.terminalId + if (typeof id !== "string" || !this.terminals.has(id)) return false + if (msg.type === "agentManager.terminal.close") { + void this.close(id).then((closed) => { + if (closed) this.deps.closed(id) + }) + return true + } + if (msg.type !== "agentManager.terminal.resize") return false + if (typeof msg.cols !== "number" || typeof msg.rows !== "number") return true + void this.resize(id, msg.cols, msg.rows) + return true + } + + exited(ptyID: string, exitCode: number): void { + const entry = this.ptys.get(ptyID) + if (!entry) return + this.finishExited(entry, exitCode) + } + + deleted(ptyID: string): void { + const entry = this.ptys.get(ptyID) + if (!entry) return + const state = entry.state + this.drop(entry) + this.emit() + if (state === "stopping") { + this.done(entry, { stopped: true }) + return + } + if (state === "running") this.done(entry, { error: "Run terminal was removed before it exited" }) + } + + snapshot(): void { + this.emit() + } + + owns(ptyID: string): boolean { + return this.ptys.has(ptyID) + } + + async sync(): Promise { + await Promise.all( + [...this.entries.values()].map(async (entry) => { + const client = await this.deps.getClientAsync(entry.cwd).catch((error) => { + this.deps.log(`Failed to reconnect Run terminal: ${message(error)}`) + return undefined + }) + if (client) await this.reconcile(entry, client) + }), + ) + } + + async clear(kind: ScriptTerminalKind, worktreeId: string): Promise { + const entry = this.entries.get(key(kind, worktreeId)) + if (!entry) return true + return this.close(entry.terminalId) + } + + async close(terminalId: string): Promise { + const entry = this.terminals.get(terminalId) + if (!entry) return true + if (entry.state === "running") { + await this.stop(entry) + return !this.terminals.has(terminalId) + } + if (entry.state === "stopping") { + await entry.closing + return !this.terminals.has(terminalId) + } + await this.remove(entry, false) + return !this.terminals.has(terminalId) + } + + async resize(terminalId: string, cols: number, rows: number): Promise { + const entry = this.terminals.get(terminalId) + if (!entry) return + try { + const client = this.deps.getClient() + const result = await client.v2.pty.update({ + ptyID: entry.ptyID, + location: { directory: entry.cwd }, + size: { cols, rows }, + }) + if (!result.error) return + this.deps.log(`Run terminal resize failed (${terminalId}): ${message(result.error)}`) + } catch (error) { + this.deps.log(`Run terminal resize failed (${terminalId}): ${message(error)}`) + } + } + + async dispose(): Promise { + await Promise.all([...this.terminals.keys()].map((terminalId) => this.close(terminalId))) + } + + private async reconcile(entry: Entry, client: KiloClient): Promise { + if (!this.current(entry)) return + try { + const result = await client.v2.pty.get({ ptyID: entry.ptyID, location: { directory: entry.cwd } }) + const pty = result.data?.data + if (result.error || !pty) { + this.missing(entry, `Run terminal is no longer available: ${message(result.error ?? "unknown error")}`) + return + } + if (pty.status === "exited") this.finishExited(entry, pty.exitCode ?? 0) + } catch (error) { + this.deps.log(`Failed to read Run terminal: ${message(error)}`) + } + } + + private async stop(entry: Entry): Promise { + if (!this.current(entry)) return + if (entry.state === "stopping") { + await entry.closing + return + } + if (entry.state === "exited" || entry.state === "failed") { + await this.remove(entry, false) + return + } + entry.state = "stopping" + this.emit() + await this.remove(entry, true) + } + + private remove(entry: Entry, stopped: boolean): Promise { + if (entry.closing) return entry.closing + const task = this.removeEntry(entry, stopped) + entry.closing = task + void task.finally(() => { + if (this.current(entry) && entry.closing === task) entry.closing = undefined + }) + return task + } + + private async removeEntry(entry: Entry, stopped: boolean): Promise { + try { + const client = await this.deps.getClientAsync(entry.cwd) + const result = await client.v2.pty.remove({ ptyID: entry.ptyID, location: { directory: entry.cwd } }) + if (result.error) { + if (missing(result.error)) { + this.drop(entry) + this.emit() + if (stopped) this.done(entry, { stopped: true }) + return + } + this.failed(entry, `Failed to remove Run terminal: ${message(result.error)}`) + return + } + this.drop(entry) + this.emit() + if (stopped) this.done(entry, { stopped: true }) + } catch (error) { + this.failed(entry, `Failed to remove Run terminal: ${message(error)}`) + } + } + + private async url(client: KiloClient, ptyID: string, cwd: string): Promise { + try { + return this.deps.buildWsUrl(ptyID, cwd) + } catch (error) { + this.deps.log(`Failed to build Run terminal URL: ${message(error)}`) + try { + const result = await client.v2.pty.remove({ ptyID, location: { directory: cwd } }) + if (result.error) this.deps.log(`Failed to remove Run terminal after URL failure: ${message(result.error)}`) + } catch (cleanup) { + this.deps.log(`Failed to remove Run terminal after URL failure: ${message(cleanup)}`) + } + throw error + } + } + + private finishExited(entry: Entry, exitCode: number): void { + if (!this.current(entry) || entry.state === "exited") return + entry.state = "exited" + entry.exitCode = exitCode + this.emit() + this.done(entry, { exitCode }) + } + + private failed(entry: Entry, error: string): void { + if (!this.current(entry)) return + this.deps.log(error) + entry.state = "failed" + this.emit() + this.done(entry, { error }) + } + + private missing(entry: Entry, error: string): void { + if (!this.current(entry)) return + this.deps.log(error) + this.drop(entry) + this.emit() + this.done(entry, { error }) + } + + private done(entry: Entry, exit: ScriptTerminalExit): void { + if (entry.finished) return + entry.finished = true + entry.done(exit) + } + + private drop(entry: Entry): void { + if (!this.current(entry)) return + this.entries.delete(entry.key) + this.terminals.delete(entry.terminalId) + this.ptys.delete(entry.ptyID) + } + + private current(entry: Entry): boolean { + return this.entries.get(entry.key) === entry + } + + private emit(): void { + const terminals: ScriptTerminalView[] = [] + for (const entry of this.entries.values()) { + const terminal: ScriptTerminalView = { + terminalId: entry.terminalId, + worktreeId: entry.worktreeId === "local" ? null : entry.worktreeId, + kind: entry.kind, + title: "Run", + wsUrl: entry.wsUrl, + state: entry.state, + font: this.deps.getTerminalFont(), + } + if (entry.exitCode !== undefined) terminal.exitCode = entry.exitCode + terminals.push(terminal) + } + this.deps.emit(terminals) + } +} diff --git a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts index 6120ba6a1c..3fc41c1170 100644 --- a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts +++ b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts @@ -34,6 +34,8 @@ export interface Disposable { */ export class SessionTerminalManager { private static readonly LOCAL_KEY = "__local__" + private static readonly SESSION_PREFIX = "session:" + private static readonly WORKTREE_PREFIX = "worktree:" private terminals = new Map() private disposables: Disposable[] = [] @@ -47,10 +49,10 @@ export class SessionTerminalManager { ) { this.disposables.push( host.onTerminalClosed((terminal) => { - for (const [sessionId, entry] of this.terminals) { + for (const [key, entry] of this.terminals) { if (entry.terminal !== terminal) continue - this.terminals.delete(sessionId) - this.log(`Removed terminal mapping for session ${sessionId} (terminal closed)`) + this.terminals.delete(key) + this.log(`Removed terminal mapping for ${key} (terminal closed)`) break } this.updateContextKey() @@ -86,7 +88,8 @@ export class SessionTerminalManager { */ showTerminal(sessionId: string, state: WorktreeStateManager | undefined): void { // If terminal already exists, just focus it - if (this.showExisting(sessionId, false)) return + const key = SessionTerminalManager.sessionKey(sessionId) + if (this.showExistingKey(key, false)) return const repoPath = this.host.repoPath() const worktreePath = state?.directoryFor(sessionId) @@ -102,7 +105,7 @@ export class SessionTerminalManager { const worktree = session?.worktreeId ? state?.getWorktree(session.worktreeId) : undefined const name = worktree ? `Agent: ${worktree.branch}` : "Agent: local" - this.showOrCreate(sessionId, cwd, name) + this.showOrCreate(key, cwd, name) } /** @@ -110,7 +113,7 @@ export class SessionTerminalManager { * Used when the user triggers a terminal in local mode without an active session. */ showLocalTerminal(): void { - if (this.showExisting(SessionTerminalManager.LOCAL_KEY, false)) return + if (this.showExistingKey(SessionTerminalManager.LOCAL_KEY, false)) return const cwd = this.host.repoPath() if (!cwd) { @@ -122,11 +125,32 @@ export class SessionTerminalManager { this.showOrCreate(SessionTerminalManager.LOCAL_KEY, cwd, "Agent: local") } + /** + * Show (or create) a terminal rooted at a worktree directory. Used when + * the worktree has no session to key the terminal off (e.g. all of its + * sessions were closed) so the shortcut never dead-ends on a sessionless + * worktree. + */ + showWorktreeTerminal(worktreeId: string, state: WorktreeStateManager | undefined): void { + const key = SessionTerminalManager.worktreeKey(worktreeId) + if (this.showExistingKey(key, false)) return + + const worktree = state?.getWorktree(worktreeId) + const cwd = worktree?.path ?? this.host.repoPath() + if (!cwd) { + this.log(`showWorktreeTerminal: no cwd resolved for worktree ${worktreeId}`) + this.host.showWarning("Open a folder that contains a git repository to use worktrees") + return + } + + this.showOrCreate(key, cwd, worktree ? `Agent: ${worktree.branch}` : "Agent: worktree") + } + /** * Show the existing local terminal if one was previously created (used on context switch). */ showExistingLocal(): boolean { - return this.showExisting(SessionTerminalManager.LOCAL_KEY) + return this.showExistingKey(SessionTerminalManager.LOCAL_KEY) } /** @@ -159,34 +183,38 @@ export class SessionTerminalManager { * Pass preserveFocus=true to keep focus on the current editor (default for session switching). */ showExisting(sessionId: string, preserveFocus = true): boolean { - const entry = this.terminals.get(sessionId) + return this.showExistingKey(SessionTerminalManager.sessionKey(sessionId), preserveFocus) + } + + private showExistingKey(key: string, preserveFocus = true): boolean { + const entry = this.terminals.get(key) if (!entry) return false if (entry.terminal.exitStatus !== undefined) { - this.terminals.delete(sessionId) - this.log(`showExisting: terminal exited for session ${sessionId}, clearing`) + this.terminals.delete(key) + this.log(`showExisting: terminal exited for ${key}, clearing`) return false } entry.terminal.show(preserveFocus) this.panelOpen = true - this.log(`showExisting: revealed terminal for session ${sessionId}`) + this.log(`showExisting: revealed terminal for ${key}`) return true } - activeSession(): string | undefined { + private activeKey(): string | undefined { const active = this.host.activeTerminal() if (!active) return undefined - for (const [id, entry] of this.terminals) { - if (entry.terminal === active && entry.terminal.exitStatus === undefined) return id + for (const [key, entry] of this.terminals) { + if (entry.terminal === active && entry.terminal.exitStatus === undefined) return key } return undefined } prepareContext(sessionId: string): boolean { if (this.showExisting(sessionId)) return true - const active = this.activeSession() - return !active || active === sessionId + const active = this.activeKey() + return !active || active === SessionTerminalManager.sessionKey(sessionId) } dispose(): void { @@ -254,32 +282,40 @@ export class SessionTerminalManager { void this.host.setContext("kilo-code.agentTerminalFocus", managed) } - private showOrCreate(sessionId: string, cwd: string, name: string): void { - let entry = this.terminals.get(sessionId) + private showOrCreate(key: string, cwd: string, name: string): void { + let entry = this.terminals.get(key) // Clean up exited terminals if (entry && entry.terminal.exitStatus !== undefined) { - this.terminals.delete(sessionId) + this.terminals.delete(key) entry = undefined } // Recreate if CWD changed if (entry && entry.cwd !== cwd) { entry.terminal.dispose() - this.terminals.delete(sessionId) + this.terminals.delete(key) entry = undefined - this.log(`showTerminal: cwd changed for session ${sessionId}, recreating`) + this.log(`showTerminal: cwd changed for ${key}, recreating`) } if (!entry) { const terminal = this.host.createTerminal({ cwd, name }) entry = { terminal, cwd } - this.terminals.set(sessionId, entry) - this.log(`showTerminal: created terminal for session ${sessionId} (cwd=${cwd})`) + this.terminals.set(key, entry) + this.log(`showTerminal: created terminal for ${key} (cwd=${cwd})`) } entry.terminal.show(false) this.panelOpen = true this.updateContextKey() } + + private static sessionKey(id: string): string { + return `${SessionTerminalManager.SESSION_PREFIX}${id}` + } + + private static worktreeKey(id: string): string { + return `${SessionTerminalManager.WORKTREE_PREFIX}${id}` + } } 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 045a108faf..92c6c1a661 100644 --- a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts +++ b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts @@ -39,6 +39,7 @@ vi.mock("../SessionTerminalManager", () => ({ SessionTerminalManager: class { showTerminal() {} showLocalTerminal() {} + showWorktreeTerminal() {} syncLocalOnSessionSwitch() {} syncOnSessionSwitch() { return false @@ -70,6 +71,7 @@ function createMockHost(): Host { return { openPanel: vi.fn(), workspacePath: () => "/repo", + isTrusted: () => true, autoBranchNaming: () => ({ enabled: true, prefix: "" }), showError: vi.fn(), openDocument: vi.fn().mockResolvedValue(undefined), @@ -77,7 +79,10 @@ function createMockHost(): Host { openFolder: vi.fn(), createOutput: () => ({ appendLine: vi.fn(), dispose: vi.fn() }) as OutputHandle, extensionKeybindings: () => [], + copyToClipboard: vi.fn(), capture: vi.fn(), + openExternal: vi.fn(), + refreshGit: vi.fn(), dispose: vi.fn(), } } @@ -101,6 +106,7 @@ function createHarness() { prBridge: { handleMessage: ReturnType } activeSessionId: string | undefined naming: { prompt: ReturnType } + scripts: { intercept: ReturnType; snapshot: ReturnType } terminalRouter: { handle: ReturnType } stateReady: Promise | undefined contextTarget: ReturnType @@ -124,6 +130,7 @@ function createHarness() { manager.prBridge = { handleMessage: vi.fn().mockReturnValue(false) } manager.activeSessionId = undefined manager.naming = { prompt: vi.fn() } + manager.scripts = { intercept: vi.fn().mockReturnValue(false), snapshot: vi.fn() } manager.terminalRouter = { handle: vi.fn().mockReturnValue(false) } manager.stateReady = Promise.resolve() manager.contextTarget = vi.fn() @@ -216,6 +223,20 @@ describe("AgentManagerProvider worktree creation", () => { expect(manager.createWorktreeOnDisk).toHaveBeenCalledTimes(1) }) + it("disposes orphaned terminals when a freshly mounted webview requests state", async () => { + const manager = createHarness() + const dispose = vi.fn().mockResolvedValue(undefined) + manager.terminalRouter = { handle: vi.fn().mockReturnValue(false), dispose } as unknown as { + handle: ReturnType + } + // Avoid the vscode-backed pushEmptyState; the disposal under test is synchronous. + ;(manager as unknown as Record).pushEmptyState = vi.fn() + + await manager.onMessage({ type: "agentManager.requestState" }) + + expect(dispose).toHaveBeenCalledTimes(1) + }) + it("routes file search through the active worktree session", async () => { const manager = createHarness() manager.activeSessionId = "session-wt" diff --git a/packages/kilo-vscode/src/agent-manager/diff-scope.ts b/packages/kilo-vscode/src/agent-manager/diff-scope.ts new file mode 100644 index 0000000000..21d2e00793 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/diff-scope.ts @@ -0,0 +1,57 @@ +/** + * Composite diff-source keying for Agent Manager. + * + * Agent Manager keys diff sources by *context* (a session id, or the `local` + * workspace pseudo-context) while the standalone Changes viewer keys by + * *scope* (branch / staged / unstaged / session). To expose scopes in Agent + * Manager we compose the two into a single id the SourceController can build. + * + * ctx = "local" | "" + * scope = "branch" | "staged" | "unstaged" | "session" + * id = `${ctx}#${scope}` + * + * `ctx#branch` is the default and reproduces the pre-scope behavior exactly. + */ + +export type DiffScope = "branch" | "staged" | "unstaged" | "session" + +export const DEFAULT_DIFF_SCOPE: DiffScope = "branch" + +const SEP = "#" + +export function composeDiffId(ctx: string, scope: DiffScope): string { + return `${ctx}${SEP}${scope}` +} + +/** + * Split a composite id back into context and scope. Tolerates a bare context + * id (no separator) by assuming the default branch scope, which keeps the + * pre-scope messages working unchanged. + */ +export function parseDiffId(id: string): { ctx: string; scope: DiffScope } { + const idx = id.lastIndexOf(SEP) + if (idx === -1) return { ctx: id, scope: DEFAULT_DIFF_SCOPE } + const scope = id.slice(idx + SEP.length) + if (isDiffScope(scope)) return { ctx: id.slice(0, idx), scope } + return { ctx: id, scope: DEFAULT_DIFF_SCOPE } +} + +export function isDiffScope(value: string): value is DiffScope { + return value === "branch" || value === "staged" || value === "unstaged" || value === "session" +} + +export function normalizeScope(value: unknown): DiffScope { + return typeof value === "string" && isDiffScope(value) ? value : DEFAULT_DIFF_SCOPE +} + +/** + * Map a scope to the underlying standalone-viewer source id the catalog knows + * how to build. `branch` maps to the workspace source; `session` is handled + * separately because it needs the session id embedded in the source id. + */ +export function scopeToSourceId(scope: DiffScope, ctx: string): string { + if (scope === "staged") return "staged" + if (scope === "unstaged") return "unstaged" + if (scope === "session") return `session:${ctx}` + return "workspace" +} diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index ed8b1f2f63..2916efbf62 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -104,6 +104,9 @@ export interface Host { /** Get the workspace/project root path. */ workspacePath(): string | undefined + /** Whether the workspace permits executing configured scripts. */ + isTrusted(): boolean + /** Read the user's automatic branch naming preferences. */ autoBranchNaming(): { enabled: boolean; prefix: string } diff --git a/packages/kilo-vscode/src/agent-manager/run/controller.ts b/packages/kilo-vscode/src/agent-manager/run/controller.ts index b5db0320e7..614436975a 100644 --- a/packages/kilo-vscode/src/agent-manager/run/controller.ts +++ b/packages/kilo-vscode/src/agent-manager/run/controller.ts @@ -4,8 +4,10 @@ import { getShellEnvironment } from "../shell-env" import { RunScriptManager, type RunHandle, type RunStatus } from "./manager" import { RunScriptService } from "./service" import type { WorktreeStateManager } from "../WorktreeStateManager" +import type { RunTerminalDestination } from "./destination" export interface RunTaskConfig { + destination: RunTerminalDestination worktreeId: string branch: string command: string @@ -14,11 +16,13 @@ export interface RunTaskConfig { env: Record } -interface TaskExit { +export interface RunTaskExit { exitCode?: number + stopped?: boolean + error?: string } -type StartTask = (config: RunTaskConfig, done: (exit: TaskExit) => void) => Promise +export type StartTask = (config: RunTaskConfig, done: (exit: RunTaskExit) => void) => Promise interface Options { root: () => string | undefined @@ -60,7 +64,7 @@ export class RunController { this.opts.refresh?.() } - async run(worktreeId: string): Promise { + async run(worktreeId: string, destination: RunTerminalDestination): Promise { const status = this.manager.status(worktreeId) if (status.state !== "idle") { this.stop(worktreeId) @@ -109,18 +113,19 @@ export class RunController { } const start = () => - this.opts.start({ worktreeId, branch, command: script.command, args: script.args, cwd, env }, (exit) => - this.manager.finish(worktreeId, { exitCode: exit.exitCode }), + this.opts.start( + { destination, worktreeId, branch, command: script.command, args: script.args, cwd, env }, + (exit) => this.manager.finish(worktreeId, exit), ) await this.manager.start(worktreeId, start) } stop(worktreeId: string): void { - this.manager.stop(worktreeId) + void this.manager.stop(worktreeId) } - remove(worktreeId: string): void { - this.manager.remove(worktreeId) + remove(worktreeId: string): Promise { + return this.manager.remove(worktreeId) } dispose(): void { diff --git a/packages/kilo-vscode/src/agent-manager/run/destination.ts b/packages/kilo-vscode/src/agent-manager/run/destination.ts new file mode 100644 index 0000000000..5945da16b7 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/run/destination.ts @@ -0,0 +1,16 @@ +/** + * Where the Agent Manager Run button executes the project run script. + * + * The Agent Manager terminal dropdown owns this choice per panel. + * "agentManager" runs through the canonical PTY service in the embedded + * side terminal. "vscode" is the legacy integrated terminal task path, + * kept for comparison while the embedded path proves itself. Remove the + * "vscode" dropdown option, `run/task.ts`, and the integrated branch below + * together once the embedded path is the only one. + */ + +export type RunTerminalDestination = "agentManager" | "vscode" + +export function pickRunStart(destination: RunTerminalDestination, embedded: T, integrated: T): T { + return destination === "vscode" ? integrated : embedded +} diff --git a/packages/kilo-vscode/src/agent-manager/run/manager.ts b/packages/kilo-vscode/src/agent-manager/run/manager.ts index a1b92e35c9..60af10cfa5 100644 --- a/packages/kilo-vscode/src/agent-manager/run/manager.ts +++ b/packages/kilo-vscode/src/agent-manager/run/manager.ts @@ -4,6 +4,7 @@ export interface RunStatus { worktreeId: string state: RunState exitCode?: number + stopped?: boolean signal?: string startedAt?: string finishedAt?: string @@ -11,17 +12,21 @@ export interface RunStatus { } export interface RunHandle { - stop(): void + stop(): void | Promise dispose?(): void } interface Entry { status: RunStatus handle?: RunHandle + task?: Promise + released?: boolean + stopping?: Promise } interface FinishOptions { exitCode?: number + stopped?: boolean signal?: string error?: string } @@ -42,6 +47,7 @@ export class RunScriptManager { ) {} async start(worktreeId: string, start: () => Promise): Promise { + this.removed.delete(worktreeId) const current = this.entries.get(worktreeId) if (current && current.status.state !== "idle") return false @@ -56,21 +62,25 @@ export class RunScriptManager { this.emit(entry.status) try { - const handle = await start() + const task = start() + entry.task = task + const handle = await task const latest = this.entries.get(worktreeId) if (latest !== entry) { - handle.dispose?.() + await this.release(worktreeId, entry, handle, this.removed.has(worktreeId)) return true } entry.handle = handle - if (entry.status.state === "stopping") handle.stop() + if (entry.status.state === "stopping") { + void this.halt(worktreeId, entry, handle) + } } catch (error) { this.finish(worktreeId, { error: message(error) }) } return true } - stop(worktreeId: string): void { + async stop(worktreeId: string): Promise { const entry = this.entries.get(worktreeId) if (!entry || entry.status.state === "idle" || entry.status.state === "stopping") return @@ -81,11 +91,7 @@ export class RunScriptManager { this.emit(entry.status) if (!entry.handle) return - try { - entry.handle.stop() - } catch (error) { - this.log(`Failed to stop run script for ${worktreeId}: ${message(error)}`) - } + await this.halt(worktreeId, entry, entry.handle) } finish(worktreeId: string, opts: FinishOptions = {}): void { @@ -100,6 +106,7 @@ export class RunScriptManager { } if (entry?.status.startedAt) status.startedAt = entry.status.startedAt if (opts.exitCode !== undefined) status.exitCode = opts.exitCode + if (opts.stopped) status.stopped = true if (opts.signal) status.signal = opts.signal if (opts.error) status.error = opts.error @@ -115,24 +122,57 @@ export class RunScriptManager { return [...this.entries.values()].map((entry) => entry.status) } - remove(worktreeId: string): void { + async remove(worktreeId: string): Promise { const entry = this.entries.get(worktreeId) - if (entry?.status.state !== "idle") this.stop(worktreeId) - this.entries.delete(worktreeId) this.removed.add(worktreeId) + this.entries.delete(worktreeId) + const handle = + entry?.handle ?? + (entry?.task + ? await entry.task.catch((error) => { + this.log(`Failed to start removed run script for ${worktreeId}: ${message(error)}`) + return undefined + }) + : undefined) + if (!entry || !handle) return + if (entry.status.state !== "idle") { + await this.release(worktreeId, entry, handle, true) + return + } + await this.release(worktreeId, entry, handle, false) } dispose(): void { - for (const entry of this.entries.values()) { - if (entry.status.state !== "idle") { - try { - entry.handle?.stop() - } catch (error) { - this.log(`Failed to stop run script during dispose: ${message(error)}`) - } - } - entry.handle?.dispose?.() + for (const [id, entry] of this.entries) { + this.removed.add(id) + if (!entry.handle || entry.released) continue + entry.released = true + if (entry.status.state !== "idle") void this.halt(id, entry, entry.handle) + entry.handle.dispose?.() } this.entries.clear() } + + private async release(worktreeId: string, entry: Entry, handle: RunHandle, stop: boolean): Promise { + if (entry.released) return + entry.released = true + if (stop) await this.halt(worktreeId, entry, handle) + handle.dispose?.() + } + + private halt(worktreeId: string, entry: Entry, handle: RunHandle): Promise { + if (entry.stopping) return entry.stopping + const task = (() => { + try { + return Promise.resolve(handle.stop()) + .then(() => undefined) + .catch((error) => this.log(`Failed to stop run script for ${worktreeId}: ${message(error)}`)) + } catch (error) { + this.log(`Failed to stop run script for ${worktreeId}: ${message(error)}`) + return Promise.resolve() + } + })() + entry.stopping = task + return task + } } diff --git a/packages/kilo-vscode/src/agent-manager/run/message.ts b/packages/kilo-vscode/src/agent-manager/run/message.ts index 1e8f21a936..6b51050a3e 100644 --- a/packages/kilo-vscode/src/agent-manager/run/message.ts +++ b/packages/kilo-vscode/src/agent-manager/run/message.ts @@ -7,7 +7,7 @@ export function handleRunMessage(run: RunController, msg: AgentManagerInMessage) return true } if (msg.type === "agentManager.runScript") { - void run.run(msg.worktreeId) + void run.run(msg.worktreeId, msg.destination) return true } if (msg.type === "agentManager.stopRunScript") { diff --git a/packages/kilo-vscode/src/agent-manager/run/task.ts b/packages/kilo-vscode/src/agent-manager/run/task.ts index 0835b216af..664baf4753 100644 --- a/packages/kilo-vscode/src/agent-manager/run/task.ts +++ b/packages/kilo-vscode/src/agent-manager/run/task.ts @@ -1,3 +1,11 @@ +/** + * Legacy integrated terminal Run adapter. + * + * Kept while the Agent Manager terminal dropdown offers the "VS Code + * terminal" option so both execution paths can be compared. Remove this + * file together with that dropdown option and the integrated `pickRunStart` + * branch. + */ import * as vscode from "vscode" import type { RunHandle } from "./manager" diff --git a/packages/kilo-vscode/src/agent-manager/script-terminal-runtime.ts b/packages/kilo-vscode/src/agent-manager/script-terminal-runtime.ts new file mode 100644 index 0000000000..da2f3be9be --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/script-terminal-runtime.ts @@ -0,0 +1,81 @@ +import type { KiloConnectionService } from "../services/cli-backend" +import type { OutputHandle } from "./host" +import { ScriptTerminalManager } from "./ScriptTerminalManager" +import { buildScriptTerminalWsUrl } from "./script-terminal-url" +import { readTerminalFont } from "./terminal-font" +import type { AgentManagerOutMessage } from "./types" +import type { WorktreeStateManager } from "./WorktreeStateManager" +import { RunController } from "./run/controller" +import { pickRunStart } from "./run/destination" +import { startVscodeRunTask } from "./run/task" + +interface Input { + connection: KiloConnectionService + output: OutputHandle + post(message: AgentManagerOutMessage): void +} + +export function createScriptTerminalRuntime(input: Input) { + const manager = new ScriptTerminalManager({ + getClient: () => input.connection.getClient(), + getClientAsync: (directory) => input.connection.getClientAsync(directory), + buildWsUrl: (ptyID, cwd) => { + const config = input.connection.getServerConfig() + if (!config) throw new Error("Not connected to CLI backend") + return buildScriptTerminalWsUrl(config, ptyID, cwd) + }, + getTerminalFont: () => readTerminalFont(), + emit: (terminals) => input.post({ type: "agentManager.scriptTerminals", terminals }), + closed: (terminalId) => input.post({ type: "agentManager.terminal.closed", terminalId }), + log: (msg) => input.output.appendLine(`[RunScript] ${msg}`), + }) + const event = input.connection.onEventFiltered( + (value) => (value.type === "pty.exited" || value.type === "pty.deleted") && manager.owns(value.properties.id), + (value) => { + if (value.type === "pty.exited") manager.exited(value.properties.id, value.properties.exitCode) + if (value.type === "pty.deleted") manager.deleted(value.properties.id) + }, + ) + const connection = input.connection.onStateChange((state) => { + if (state === "connected") void manager.sync() + }) + return { + manager, + dispose: async () => { + event() + connection() + await manager.dispose() + }, + } +} + +interface RunInput { + manager: ScriptTerminalManager + root(): string | undefined + state(): WorktreeStateManager | undefined + open(path: string): Promise + trusted(): boolean + post(message: AgentManagerOutMessage): void + log(message: string): void + refresh(): void +} + +export function createRunController(input: RunInput) { + return new RunController({ + root: input.root, + state: input.state, + open: input.open, + start: async (config, done) => { + if (!input.trusted()) throw new Error("Trust the workspace before running scripts") + return pickRunStart( + config.destination, + (cfg, cb) => input.manager.start("run", cfg, cb), + startVscodeRunTask, + )(config, done) + }, + post: (status) => input.post({ type: "agentManager.runStatus", ...status }), + error: (message) => input.post({ type: "error", message }), + log: input.log, + refresh: input.refresh, + }) +} diff --git a/packages/kilo-vscode/src/agent-manager/script-terminal-url.ts b/packages/kilo-vscode/src/agent-manager/script-terminal-url.ts new file mode 100644 index 0000000000..8151b460ee --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/script-terminal-url.ts @@ -0,0 +1,17 @@ +export interface PtyServerConfig { + baseUrl: string + password: string +} + +/** Build the canonical authenticated PTY WebSocket URL for script terminals. */ +export function buildScriptTerminalWsUrl(config: PtyServerConfig, ptyID: string, cwd: string): string { + const base = config.baseUrl.replace(/^http/i, "ws").replace(/\/$/, "") + const token = Buffer.from(`kilo:${config.password}`).toString("base64") + const query = new URLSearchParams({ + "location[directory]": cwd, + cursor: "0", + replayExited: "1", + auth_token: token, + }) + return `${base}/api/pty/${encodeURIComponent(ptyID)}/connect?${query.toString()}` +} diff --git a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts index c1662d92b3..9f3d030728 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts @@ -113,6 +113,16 @@ export class TerminalManager { } } + /** Titles of every live terminal in a context — used by the router to + * pick the lowest free "Terminal N" ordinal. */ + titles(worktreeId: string | null): string[] { + const out: string[] = [] + for (const entry of this.entries.values()) { + if (entry.worktreeId === worktreeId) out.push(entry.title) + } + return out + } + /** Kill a single terminal. Best-effort — we always drop our bookkeeping. * The SDK's `pty.remove` returns `{ data, error }` without throwing * on 4xx/5xx, so we have to check `error` ourselves; otherwise a diff --git a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts index 566952f9b2..6ca6491c7a 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts @@ -26,6 +26,8 @@ interface ServerConfig { export interface TerminalRoutingDeps { /** Shared SDK client. Throws when the CLI backend is not connected. */ getClient(): KiloClient + /** Shared SDK client, connecting the CLI backend when needed. */ + getClientAsync(): Promise /** Loopback URL + basic-auth password for the running `kilo serve`. */ getServerConfig(): ServerConfig | undefined /** Workspace root — used as cwd fallback when no worktree is selected (LOCAL). */ @@ -53,7 +55,9 @@ function isTerminalMessage( export class TerminalRouter { private manager: TerminalManager - private readonly ordinals = new Map() + /** Ordinals reserved by in-flight creates, per context — prevents two + * concurrent creates from grabbing the same "Terminal N" title. */ + private readonly reserved = new Map>() private generation = 0 constructor(private readonly deps: TerminalRoutingDeps) { @@ -100,6 +104,7 @@ export class TerminalRouter { this.generation++ const manager = this.manager this.manager = this.createManager() + this.reserved.clear() return manager.dispose() } @@ -117,8 +122,12 @@ export class TerminalRouter { }) return } - const title = `Terminal ${this.nextOrdinal(worktreeId)}` + const ordinal = this.reserveOrdinal(worktreeId) + const title = `Terminal ${ordinal}` try { + // Join the shared backend connection instead of racing its synchronous + // client accessor when this is the first Kilo action in the window. + await this.deps.getClientAsync() const created = await manager.create({ worktreeId, cwd, title }) if (generation !== this.generation) { await manager.close(created.terminalId) @@ -139,6 +148,11 @@ export class TerminalRouter { const message = err instanceof Error ? err.message : String(err) this.deps.log(`Terminal create failed: ${message}`) this.deps.post({ type: "agentManager.terminal.error", createId, message }) + } finally { + // Only a current-generation create may release: dispose() already + // cleared this create's reservation, and releasing here would + // delete a *new* panel's reservation for the same number. + if (generation === this.generation) this.releaseOrdinal(worktreeId, ordinal) } } @@ -156,15 +170,40 @@ export class TerminalRouter { return this.deps.getWorktreePath(worktreeId) } - /** Per-context counter so default titles are "Terminal 1", "Terminal 2"… - * Not persisted; a webview reload resets counts. */ - private nextOrdinal(worktreeId: string | null): number { + /** + * Pick the lowest "Terminal N" ordinal not used by a live terminal or + * an in-flight create in this context, and reserve it until the + * create settles. Gap-filling keeps numbering consistent: closing + * "Terminal 1" of three frees 1 for the next terminal, instead of + * drifting to ever-higher numbers. Not persisted; a webview reload + * resets the live set. + */ + private reserveOrdinal(worktreeId: string | null): number { const key = worktreeId ?? "__local__" - const next = (this.ordinals.get(key) ?? 0) + 1 - this.ordinals.set(key, next) + const used = new Set() + for (const title of this.manager.titles(worktreeId)) { + const match = /^Terminal (\d+)$/.exec(title) + if (match) used.add(Number(match[1])) + } + const pending = this.reserved.get(key) + if (pending) for (const n of pending) used.add(n) + let next = 1 + while (used.has(next)) next++ + const set = pending ?? new Set() + set.add(next) + this.reserved.set(key, set) return next } + /** Return an in-flight create's reservation. */ + private releaseOrdinal(worktreeId: string | null, ordinal: number) { + const key = worktreeId ?? "__local__" + const set = this.reserved.get(key) + if (!set) return + set.delete(ordinal) + if (set.size === 0) this.reserved.delete(key) + } + /** * Build the WebSocket URL for a given PTY. * @@ -182,6 +221,9 @@ export class TerminalRouter { const token = Buffer.from(`kilo:${config.password}`).toString("base64") const dir = encodeURIComponent(cwd) const auth = encodeURIComponent(token) - return `${base}/pty/${encodeURIComponent(ptyID)}/connect?directory=${dir}&cursor=-1&auth_token=${auth}` + // A new terminal has one initial attachment. Replay its retained startup + // bytes so xterm can answer shell capability queries emitted before the + // WebSocket connected; tailing from -1 can make shells wait for a timeout. + return `${base}/pty/${encodeURIComponent(ptyID)}/connect?directory=${dir}&cursor=0&auth_token=${auth}` } } diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 8950c68d22..aaf3fb0fb6 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -16,6 +16,7 @@ import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import" import type { RunStatus } from "./run/manager" import type { TerminalFont } from "./terminal-font" import type { TerminalDestination } from "./terminal-destination" +import type { ScriptTerminalView } from "./ScriptTerminalManager" export type { TerminalFont } @@ -177,6 +178,11 @@ interface TerminalFontChangedMessage { font: TerminalFont } +interface ScriptTerminalsMessage { + type: "agentManager.scriptTerminals" + terminals: ScriptTerminalView[] +} + interface ErrorOutMessage { type: "error" message: string @@ -286,6 +292,18 @@ interface RevertWorktreeFileResultMessage { message: string } +/** Branch picker data for a context's diff directory. */ +interface DiffBranchesMessage { + type: "agentManager.diffBranches" + sessionId: string + branches: BranchListItem[] + defaultBranch: string + autoBase?: string + currentBase?: string + isAuto: boolean + currentBranch?: string +} + interface PRStatusOutMessage { type: "agentManager.prStatus" worktreeId: string @@ -324,6 +342,7 @@ export type AgentManagerOutMessage = | WorktreeDiffMessage | WorktreeDiffFileMessage | RevertWorktreeFileResultMessage + | DiffBranchesMessage | PRStatusOutMessage | ActionOutMessage | RunStatusMessage @@ -332,6 +351,7 @@ export type AgentManagerOutMessage = | TerminalErrorMessage | TerminalDestinationChangedMessage | TerminalFontChangedMessage + | ScriptTerminalsMessage // --------------------------------------------------------------------------- // Webview → Extension messages (onMessage) @@ -398,6 +418,7 @@ interface ConfigureRunScriptIn { interface RunScriptIn { type: "agentManager.runScript" worktreeId: string + destination: TerminalDestination } interface StopRunScriptIn { @@ -414,6 +435,11 @@ interface ShowLocalTerminalIn { type: "agentManager.showLocalTerminal" } +interface ShowWorktreeTerminalIn { + type: "agentManager.showWorktreeTerminal" + worktreeId: string +} + interface OpenWorktreeIn { type: "agentManager.openWorktree" worktreeId: string @@ -512,6 +538,7 @@ interface ImportFromPRIn { interface RequestWorktreeDiffIn { type: "agentManager.requestWorktreeDiff" sessionId: string + scope?: string } interface ApplyWorktreeDiffIn { @@ -524,11 +551,13 @@ interface RequestWorktreeDiffFileIn { type: "agentManager.requestWorktreeDiffFile" sessionId: string file: string + scope?: string } interface StartDiffWatchIn { type: "agentManager.startDiffWatch" sessionId: string + scope?: string } interface StopDiffWatchIn { @@ -539,6 +568,20 @@ interface RevertWorktreeFileIn { type: "agentManager.revertWorktreeFile" sessionId: string file: string + scope?: string +} + +interface RequestDiffBranchesIn { + type: "agentManager.requestDiffBranches" + sessionId: string + scope?: string +} + +interface SetDiffBaseBranchIn { + type: "agentManager.setDiffBaseBranch" + sessionId: string + scope?: string + branch?: string } interface RefreshPRIn { @@ -780,6 +823,7 @@ export type AgentManagerInMessage = | StopRunScriptIn | ShowTerminalIn | ShowLocalTerminalIn + | ShowWorktreeTerminalIn | OpenWorktreeIn | CopyToClipboardIn | ShowExistingLocalTerminalIn @@ -803,6 +847,8 @@ export type AgentManagerInMessage = | StartDiffWatchIn | StopDiffWatchIn | RevertWorktreeFileIn + | RequestDiffBranchesIn + | SetDiffBaseBranchIn | RefreshPRIn | OpenPRIn | OpenSessionsIn diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index fc5dfd0107..4280c064a3 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -173,6 +173,10 @@ export class VscodeHost implements Host { return getWorkspaceRoot() } + isTrusted(): boolean { + return vscode.workspace.isTrusted + } + autoBranchNaming(): { enabled: boolean; prefix: string } { const cfg = vscode.workspace.getConfiguration("kilo-code.new.agentManager") return { diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index 75d538cc35..9a95ae5f96 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -1,12 +1,13 @@ import { SourceController } from "../diff/SourceController" import { resolveLocalDiffTarget } from "../diff/shared/target" import { WorktreeDiffReverter, type StatusResolver } from "../diff/shared/reverter" -import type { DiffFile } from "../diff/types" -import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "../diff/sources/types" +import type { DiffFile, PanelContext } from "../diff/types" +import type { DiffSource } from "../diff/sources/types" +import type { DiffSourceCatalog } from "../diff/sources/catalog" import type { ApplyConflict, GitOps } from "./GitOps" import { shouldStopDiffPolling } from "./delete-worktree" -import { Semaphore } from "./semaphore" import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager" +import { parseDiffId, scopeToSourceId } from "./diff-scope" import type { AgentManagerOutMessage, WorktreeDiffEntry } from "./types" const LOCAL_DIFF_ID = "local" as const @@ -19,14 +20,11 @@ export interface WorktreeDiffControllerContext { getState: () => WorktreeStateManager | undefined getRoot: () => string | undefined getStateReady: () => Promise | undefined - /** - * In-process diff paths deliberately bypass the SDK client to keep git spawns - * out of the Bun `kilo serve` process (see oven-sh/bun#18265). - */ + /** Builds the underlying per-scope diff sources (workspace/staged/unstaged/session). */ + catalog: DiffSourceCatalog + /** Shared git ops, injected into sources so they don't spawn their own channels. */ git: GitOps - /** In-process diff summary (replaces client.worktree.diffSummary). */ - localDiff: (dir: string, base: string) => Promise - /** In-process single-file diff (replaces client.worktree.diffFile). */ + /** In-process single-file diff (replaces client.worktree.diffFile). Used by revert. */ localDiffFile: (dir: string, base: string, file: string) => Promise post: (msg: AgentManagerOutMessage) => void log: (...args: unknown[]) => void @@ -34,13 +32,16 @@ export interface WorktreeDiffControllerContext { export class WorktreeDiffController { private readonly controller: SourceController - private readonly details = new Semaphore(3) private target: Target | undefined private applying: string | undefined + /** Intended watch mode for the active context; isPolling lags the initial fetch. */ + private poll = false + /** Ephemeral per-context base override, keyed by context id. */ + private baseOverrides = new Map() constructor(private readonly ctx: WorktreeDiffControllerContext) { this.controller = new SourceController( - (id) => this.source(id), + (id, ctx) => this.source(id, ctx), () => [], (msg) => this.ctx.post(msg as AgentManagerOutMessage), { @@ -80,7 +81,11 @@ export class WorktreeDiffController { } public shouldStopForWorktree(path: string, sessions: ManagedSession[]): boolean { - return shouldStopDiffPolling(path, sessions, this.target, this.controller.currentId) + // Pass the parsed context id, not the composite id, so the orphaned-session + // check matches real session ids. + const current = this.controller.currentId + const ctxId = current ? parseDiffId(current).ctx : undefined + return shouldStopDiffPolling(path, sessions, this.target, ctxId) } public async apply(worktreeId: string, value?: unknown): Promise { @@ -144,131 +149,161 @@ export class WorktreeDiffController { } } - public async revert(sessionId: string, file: string): Promise { + public async revert(id: string, file: string): Promise { if (!file) return - if (this.controller.currentId !== sessionId) { - const result = await this.revertFile(sessionId, file) - this.postRevertResult(sessionId, file, result) + if (this.controller.currentId !== id) { + const result = await this.revertFile(id, file) + this.postRevertResult(id, file, result) return } await this.controller.revertFile(file) } - public async request(sessionId: string): Promise { - if (this.controller.currentId !== sessionId) { - await this.activate(sessionId, false, true) + public async request(id: string): Promise { + if (this.controller.currentId !== id) { + await this.activate(id, false, true) return } this.target = undefined await this.controller.refresh() } - public async requestFile(sessionId: string, file: string): Promise { + public async requestFile(id: string, file: string): Promise { if (!file) return - if (this.controller.currentId !== sessionId) { - this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: null }) + if (this.controller.currentId !== id) { + this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId: id, file, diff: null }) return } await this.controller.requestFile(file) } - public start(sessionId: string): void { - if (this.controller.isPolling && this.controller.currentId === sessionId) return - this.ctx.log(`Starting diff polling for session ${sessionId}`) - void this.activate(sessionId, true, true) + public start(id: string): void { + if (this.controller.isPolling && this.controller.currentId === id) return + this.ctx.log(`Starting diff polling for ${id}`) + void this.activate(id, true, true) } public stop(): void { this.controller.stop() this.target = undefined + this.poll = false } - private async activate(sessionId: string, poll: boolean, fetch: boolean): Promise { + /** + * Set or clear an ephemeral base override for a context (worktree or local), + * then re-activate the current source so it refetches against the new base. + * Passing undefined clears the override and falls back to the recorded parent. + */ + public async setBase(id: string, branch: string | undefined): Promise { + const { ctx } = parseDiffId(id) + if (branch) this.baseOverrides.set(ctx, branch) + else this.baseOverrides.delete(ctx) + // Nothing to rebuild when the context isn't active; the override is + // picked up the next time start()/request() resolves it. + if (this.controller.currentId !== id) return + // Route through activate() so the base is re-resolved and pushed via + // setContext() — SourceController.reactivate() alone would rebuild the + // source against the stale context captured by the last activate(). The + // recorded poll intent preserves watch mode even when the initial fetch + // is still in flight (isPolling only turns true once it resolves). + await this.activate(id, this.poll, true) + } + + /** Branch picker data for a context's directory, using any active override. */ + public async branches(id: string) { + await this.ready("stateReady rejected, continuing diff branches resolve:") + const { ctx } = parseDiffId(id) + const target = await this.resolve(ctx) + if (!target) return undefined + return await this.ctx.catalog.listWorkspaceBranches(this.baseOverrides.get(ctx), target.directory) + } + + private async activate(id: string, poll: boolean, fetch: boolean): Promise { this.target = undefined - this.controller.setContext({ workspaceRoot: this.ctx.getRoot() }) - await this.controller.activate(sessionId, { poll, fetch }) + this.poll = poll + await this.ready("stateReady rejected, continuing diff activate:") + const { ctx } = parseDiffId(id) + const resolved = await this.resolve(ctx) + this.target = resolved ? { sessionId: id, ...resolved } : undefined + this.controller.setContext({ + workspaceRoot: this.ctx.getRoot(), + dir: resolved?.directory, + // The resolved base already bakes in any ephemeral override (see + // resolve()), so pass it as the explicit base and leave + // baseBranchOverride unset to avoid double resolution. + baseBranch: resolved?.baseBranch, + // Agent Manager always knows its intended directory (LOCAL resolves to + // the root). Never fall back to the workspace root for an unresolvable + // worktree context — return an empty diff instead. + strictDir: true, + git: this.ctx.git, + log: (...args) => this.ctx.log(...args), + }) + await this.controller.activate(id, { poll, fetch }) } - private async resolve(sessionId: string): Promise<{ directory: string; baseBranch: string } | undefined> { - if (sessionId === LOCAL_DIFF_ID) return await this.resolveLocal() + private async resolve(ctxId: string): Promise<{ directory: string; baseBranch: string } | undefined> { + if (ctxId === LOCAL_DIFF_ID) return await this.resolveLocal() const state = this.ctx.getState() if (!state) { - this.ctx.log(`resolveDiffTarget: no state manager for session ${sessionId}`) + this.ctx.log(`resolveDiffTarget: no state manager for context ${ctxId}`) return undefined } - const session = state.getSession(sessionId) + const session = state.getSession(ctxId) if (!session) { this.ctx.log( - `resolveDiffTarget: session ${sessionId} not found in state (${state.getSessions().length} total sessions)`, + `resolveDiffTarget: session ${ctxId} not found in state (${state.getSessions().length} total sessions)`, ) return undefined } if (!session.worktreeId) { - this.ctx.log(`resolveDiffTarget: session ${sessionId} has no worktreeId (local session)`) + this.ctx.log(`resolveDiffTarget: session ${ctxId} has no worktreeId (local session)`) return undefined } const worktree = state.getWorktree(session.worktreeId) if (!worktree) { - this.ctx.log(`resolveDiffTarget: worktree ${session.worktreeId} not found for session ${sessionId}`) + this.ctx.log(`resolveDiffTarget: worktree ${session.worktreeId} not found for session ${ctxId}`) return undefined } - return { directory: worktree.path, baseBranch: remoteRef(worktree) } + const base = this.baseOverrides.get(ctxId) ?? remoteRef(worktree) + return { directory: worktree.path, baseBranch: base } } private async resolveLocal(): Promise<{ directory: string; baseBranch: string } | undefined> { - return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), this.ctx.getRoot()) + const root = this.ctx.getRoot() + if (!root) return undefined + const override = this.baseOverrides.get(LOCAL_DIFF_ID) + if (override) { + return { directory: root, baseBranch: override } + } + return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), root) } private async ready(msg: string): Promise { await this.ctx.getStateReady()?.catch((err) => this.ctx.log(msg, err)) } - private source(sessionId: string): DiffSource { - const descriptor: DiffSourceDescriptor = { - id: sessionId, - type: "workspace", - group: "Git", - capabilities: { revert: true, comments: true }, - } - + /** + * Build the active source for a composite id by delegating to the catalog. + * The composite id (ctx#scope) is preserved as the descriptor id so the + * webview keys diff data by context+scope. Context resolution (dir/base) + * already happened in activate() and is carried by the PanelContext. + */ + private source(id: string, panelCtx: PanelContext): DiffSource { + const { ctx, scope } = parseDiffId(id) + const built = this.ctx.catalog.build(scopeToSourceId(scope, ctx), panelCtx) return { - descriptor, - fetch: () => this.fetch(sessionId), - fetchFile: (file) => this.fetchFile(sessionId, file), - revert: (file) => this.revertFile(sessionId, file), + ...built, + descriptor: { ...built.descriptor, id }, } } - private async fetch(sessionId: string): Promise { - await this.ready("stateReady rejected, continuing diff resolve:") - const target = await this.ensureTarget(sessionId) - if (!target) return { diffs: [], stopPolling: true } - - const files = await this.ctx.localDiff(target.directory, target.baseBranch) - this.ctx.log(`Worktree diff returned ${files.length} file(s) for session ${sessionId}`) - return { diffs: files as AgentManagerDiffFile[] } - } - - private async fetchFile(sessionId: string, file: string): Promise { - await this.ready("stateReady rejected, continuing diff detail resolve:") - return this.details.run(async () => { - const target = await this.ensureTarget(sessionId) - if (!target) return null - - try { - return (await this.ctx.localDiffFile(target.directory, target.baseBranch, file)) as AgentManagerDiffFile | null - } catch (error) { - this.ctx.log("Failed to fetch worktree diff file:", error) - return null - } - }) - } - - private async revertFile(sessionId: string, file: string): Promise<{ ok: boolean; message: string }> { + private async revertFile(id: string, file: string): Promise<{ ok: boolean; message: string }> { await this.ready("stateReady rejected, continuing revert resolve:") - const target = await this.resolveTarget(sessionId) + const { ctx } = parseDiffId(id) + const target = await this.resolve(ctx) if (!target) return { ok: false, message: "Could not resolve diff target" } try { @@ -285,19 +320,6 @@ export class WorktreeDiffController { } } - private async ensureTarget(sessionId: string): Promise { - if (this.controller.currentId !== sessionId) return undefined - if (this.target?.sessionId === sessionId) return this.target - return await this.resolveTarget(sessionId) - } - - private async resolveTarget(sessionId: string): Promise { - const target = await this.resolve(sessionId) - if (!target) return undefined - this.target = { sessionId, ...target } - return this.target - } - private postRevertResult(sessionId: string, file: string, result: { ok: boolean; message: string }): void { this.ctx.post({ type: "agentManager.revertWorktreeFileResult", diff --git a/packages/kilo-vscode/src/agent-manager/worktree-importer.ts b/packages/kilo-vscode/src/agent-manager/worktree-importer.ts index d840f67eb5..f589a7c4eb 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-importer.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-importer.ts @@ -56,63 +56,14 @@ export class WorktreeImporter { } async branch(branch: string): Promise { - 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 - } - if (this.busy()) return - - this.importing = true - try { - this.host.post({ - type: "agentManager.worktreeSetup", - status: "creating", - message: "Creating worktree from branch...", - }) - const result = await manager.createWorktree({ existingBranch: branch }) - const worktree = state.addWorktree({ - branch: result.branch, - path: result.path, - parentBranch: result.parentBranch, - remote: result.remote, - branchOwned: false, - }) - this.host.push() - - try { - this.host.post({ - type: "agentManager.worktreeSetup", - status: "creating", - message: "Running setup script...", - branch: result.branch, - worktreeId: worktree.id, - }) - await this.host.setup(result.path, result.branch, worktree.id) - - const session = await this.host.session(result.path, result.branch, worktree.id) - if (!session) throw new Error("Failed to create session") - - state.addSession(session.id, worktree.id) - this.host.register(session.id, result.path) - this.host.ready(session.id, result, worktree.id) - this.host.post({ type: "agentManager.importResult", success: true, message: `Opened branch ${branch}` }) - this.host.log(`Imported branch ${branch} as worktree ${worktree.id}`) - } catch (error) { - state.removeWorktree(worktree.id) - await manager.removeWorktree(result.path) - this.host.push() - throw error - } - } catch (error) { - this.importError(error, `Branch "${branch}" is already checked out in another worktree`) - } finally { - this.importing = false - } + await this.run({ branch }) } async pr(url: string): Promise { + await this.run({ url }) + } + + private async run(target: { branch: string } | { url: string }): Promise { const manager = this.host.manager() const state = this.host.state() if (!manager || !state) { @@ -120,11 +71,21 @@ export class WorktreeImporter { return } if (this.busy()) return - this.importing = true + const branch = "branch" in target + const creating = branch ? "Creating worktree from branch..." : "Resolving PR..." + const setup = branch ? "Running setup script..." : "Setting up worktree..." + const duplicate = branch + ? `Branch "${target.branch}" is already checked out in another worktree` + : "This PR's branch is already checked out in another worktree" try { - this.host.post({ type: "agentManager.worktreeSetup", status: "creating", message: "Resolving PR..." }) - const result = await manager.createFromPR(url) + const progress = { type: "agentManager.worktreeSetup", status: "creating" } as const + this.host.post({ ...progress, message: creating }) + const result = branch + ? await manager.createWorktree({ existingBranch: target.branch }) + : await manager.createFromPR(target.url) + const success = branch ? `Opened branch ${target.branch}` : `Opened PR branch ${result.branch}` + const log = branch ? `Imported branch ${target.branch}` : `Imported PR ${target.url}` const worktree = state.addWorktree({ branch: result.branch, path: result.path, @@ -133,29 +94,16 @@ export class WorktreeImporter { branchOwned: false, }) this.host.push() - try { - this.host.post({ - type: "agentManager.worktreeSetup", - status: "creating", - message: "Setting up worktree...", - branch: result.branch, - worktreeId: worktree.id, - }) + this.host.post({ ...progress, message: setup, branch: result.branch, worktreeId: worktree.id }) await this.host.setup(result.path, result.branch, worktree.id) - const session = await this.host.session(result.path, result.branch, worktree.id) if (!session) throw new Error("Failed to create session") - state.addSession(session.id, worktree.id) this.host.register(session.id, result.path) this.host.ready(session.id, result, worktree.id) - this.host.post({ - type: "agentManager.importResult", - success: true, - message: `Opened PR branch ${result.branch}`, - }) - this.host.log(`Imported PR ${url} as worktree ${worktree.id}`) + this.host.post({ type: "agentManager.importResult", success: true, message: success }) + this.host.log(`${log} as worktree ${worktree.id}`) } catch (error) { state.removeWorktree(worktree.id) await manager.removeWorktree(result.path) @@ -163,7 +111,7 @@ export class WorktreeImporter { throw error } } catch (error) { - this.importError(error, "This PR's branch is already checked out in another worktree") + this.importError(error, duplicate) } finally { this.importing = false } diff --git a/packages/kilo-vscode/src/diff/sources/catalog.ts b/packages/kilo-vscode/src/diff/sources/catalog.ts index fa36aaecf2..8eb2d0ffdf 100644 --- a/packages/kilo-vscode/src/diff/sources/catalog.ts +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -90,12 +90,17 @@ export class DiffSourceCatalog implements vscode.Disposable { } build(id: string, ctx: PanelContext): DiffSource { + const opts = { dir: () => ctx.dir, strictDir: ctx.strictDir, git: ctx.git, log: ctx.log } if (id === WORKSPACE_SOURCE_ID) { - return createWorktreeDiffSource({ baseBranchOverride: ctx.baseBranchOverride }) + return createWorktreeDiffSource({ + ...opts, + baseBranchOverride: ctx.baseBranchOverride, + baseBranch: ctx.baseBranch, + }) } - if (id === STAGED_SOURCE_ID) return createStagedDiffSource() - if (id === UNSTAGED_SOURCE_ID) return createUnstagedDiffSource() + if (id === STAGED_SOURCE_ID) return createStagedDiffSource(opts) + if (id === UNSTAGED_SOURCE_ID) return createUnstagedDiffSource(opts) if (id.startsWith(TURN_PREFIX)) { const [sessionId, messageId] = id.slice(TURN_PREFIX.length).split(":") @@ -108,14 +113,22 @@ export class DiffSourceCatalog implements vscode.Disposable { if (id.startsWith(SESSION_PREFIX)) { const sessionId = id.slice(SESSION_PREFIX.length) if (!sessionId) throw new Error(`DiffSourceCatalog.build: empty session id in "${id}"`) - return createSessionDiffSource(sessionId, this.sessionFetch, ctx.workspaceRoot, this.checkSnapshotsEnabled) + return createSessionDiffSource( + sessionId, + this.sessionFetch, + ctx.dir ?? ctx.workspaceRoot, + this.checkSnapshotsEnabled, + ) } throw new Error(`DiffSourceCatalog.build: unknown source id "${id}"`) } - async listWorkspaceBranches(override: string | undefined): Promise { - const root = getWorkspaceRoot() + async listWorkspaceBranches( + override: string | undefined, + dir?: string, + ): Promise { + const root = dir ?? getWorkspaceRoot() if (!root) return undefined const git = this.ensureBranchGit() diff --git a/packages/kilo-vscode/src/diff/sources/staged.ts b/packages/kilo-vscode/src/diff/sources/staged.ts index d21a508902..8cd250743c 100644 --- a/packages/kilo-vscode/src/diff/sources/staged.ts +++ b/packages/kilo-vscode/src/diff/sources/staged.ts @@ -34,17 +34,39 @@ function stamp(entry: FileEntry, before: string, after: string): FileEntry { return { ...entry, stamp: `${entry.status}:${before}:${after}` } } +export interface StagedDiffSourceOptions { + /** + * Resolve the directory to diff. Defaults to the VS Code workspace root. + * Agent Manager passes a worktree path so the source diffs inside the + * worktree rather than the main checkout. + */ + dir?: () => string | undefined + /** + * When true, a `dir` that resolves to undefined yields an empty diff rather + * than falling back to the workspace root. + */ + strictDir?: boolean + /** Shared GitOps / log so sources don't each spawn their own channel. */ + git?: GitOps + log?: (...args: unknown[]) => void +} + /** * Diff between the git index and HEAD — what `git diff --cached` would show. * Polls on the standard interval; revert isn't supported (use `git reset` from * a real git client). Read-only view. */ -export function createStagedDiffSource(): DiffSource { - const output = vscode.window.createOutputChannel("Kilo Diff: Staged") - const log = (...args: unknown[]) => appendOutput(output, "StagedDiffSource", ...args) - const git = new GitOps({ log }) +export function createStagedDiffSource(opts: StagedDiffSourceOptions = {}): DiffSource { + const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Staged") + const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "StagedDiffSource", ...args)) + const git = opts.git ?? new GitOps({ log }) - const root = (): string | undefined => getWorkspaceRoot() + const root = (): string | undefined => { + const dir = opts.dir?.() + if (dir) return dir + if (opts.strictDir) return undefined + return getWorkspaceRoot() + } const listEntries = async (dir: string): Promise => { const [nameStatus, numstat, raw] = await Promise.all([ @@ -150,8 +172,10 @@ export function createStagedDiffSource(): DiffSource { }, dispose(): void { - git.dispose() - output.dispose() + // Only dispose resources we own (created here). Injected git/log are + // owned by the caller. + if (!opts.git) git.dispose() + output?.dispose() }, } } diff --git a/packages/kilo-vscode/src/diff/sources/unstaged.ts b/packages/kilo-vscode/src/diff/sources/unstaged.ts index b4d0c99616..5fcb03793d 100644 --- a/packages/kilo-vscode/src/diff/sources/unstaged.ts +++ b/packages/kilo-vscode/src/diff/sources/unstaged.ts @@ -40,17 +40,39 @@ function stamp(entry: FileEntry, before: string, after: string): FileEntry { return { ...entry, stamp: `${entry.status}:${before}:${after}` } } +export interface UnstagedDiffSourceOptions { + /** + * Resolve the directory to diff. Defaults to the VS Code workspace root. + * Agent Manager passes a worktree path so the source diffs inside the + * worktree rather than the main checkout. + */ + dir?: () => string | undefined + /** + * When true, a `dir` that resolves to undefined yields an empty diff rather + * than falling back to the workspace root. + */ + strictDir?: boolean + /** Shared GitOps / log so sources don't each spawn their own channel. */ + git?: GitOps + log?: (...args: unknown[]) => void +} + /** * Diff between the working tree and the index — what `git diff` shows for * tracked files, plus untracked files (treated as fully-added). Read-only; * polls on the standard interval. */ -export function createUnstagedDiffSource(): DiffSource { - const output = vscode.window.createOutputChannel("Kilo Diff: Unstaged") - const log = (...args: unknown[]) => appendOutput(output, "UnstagedDiffSource", ...args) - const git = new GitOps({ log }) +export function createUnstagedDiffSource(opts: UnstagedDiffSourceOptions = {}): DiffSource { + const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Unstaged") + const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "UnstagedDiffSource", ...args)) + const git = opts.git ?? new GitOps({ log }) - const root = (): string | undefined => getWorkspaceRoot() + const root = (): string | undefined => { + const dir = opts.dir?.() + if (dir) return dir + if (opts.strictDir) return undefined + return getWorkspaceRoot() + } const listTracked = async (dir: string): Promise => { const [nameStatus, numstat, raw] = await Promise.all([ @@ -192,8 +214,10 @@ export function createUnstagedDiffSource(): DiffSource { }, dispose(): void { - git.dispose() - output.dispose() + // Only dispose resources we own (created here). Injected git/log are + // owned by the caller. + if (!opts.git) git.dispose() + output?.dispose() }, } } diff --git a/packages/kilo-vscode/src/diff/sources/worktree.ts b/packages/kilo-vscode/src/diff/sources/worktree.ts index 1f3d8ee25a..89e750a1c8 100644 --- a/packages/kilo-vscode/src/diff/sources/worktree.ts +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -23,6 +23,28 @@ export interface WorktreeDiffSourceOptions { * the current branch — only the comparison target changes. Reset on dispose. */ baseBranchOverride?: string + /** + * Resolve the directory to diff. Defaults to the VS Code workspace root. + * Agent Manager passes a worktree path so the source diffs inside the + * worktree rather than the main checkout. + */ + dir?: () => string | undefined + /** + * When true, a `dir` that resolves to undefined yields an empty diff rather + * than falling back to the workspace root. Prevents an unresolvable + * worktree context from silently diffing the main checkout. + */ + strictDir?: boolean + /** + * Explicit base branch to diff against. When set, the source skips + * auto-resolution (tracking → default) and diffs against this ref directly. + * Agent Manager passes the worktree's recorded parent so a worktree always + * compares against its own base even when the workspace default differs. + */ + baseBranch?: string + /** Shared GitOps / log so sources don't each spawn their own channel. */ + git?: GitOps + log?: (...args: unknown[]) => void } /** @@ -32,9 +54,16 @@ export interface WorktreeDiffSourceOptions { * extension host — no `kilo serve` round-trip. */ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): DiffSource { - const output = vscode.window.createOutputChannel("Kilo Diff: Workspace") - const log = (...args: unknown[]) => appendOutput(output, "WorktreeDiffSource", ...args) - const git = new GitOps({ log }) + const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Workspace") + const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "WorktreeDiffSource", ...args)) + const git = opts.git ?? new GitOps({ log }) + + const root = (): string | undefined => { + const dir = opts.dir?.() + if (dir) return dir + if (opts.strictDir) return undefined + return getWorkspaceRoot() + } // Cached between fetches so repeated polling doesn't re-resolve the base // branch every tick. Reset only on dispose (when the source is swapped out). @@ -42,22 +71,32 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): const resolveTarget = async (): Promise => { if (target) return target + if (opts.baseBranch) { + const dir = root() + if (!dir) { + log("Local diff: no directory (explicit base mode)") + return + } + target = { directory: dir, baseBranch: opts.baseBranch } + log(`Local diff: using explicit base=${opts.baseBranch} dir=${dir}`) + return target + } if (opts.baseBranchOverride) { - const root = getWorkspaceRoot() - if (!root) { + const dir = root() + if (!dir) { log("Local diff: no workspace root (override mode)") return } - const resolved = await resolveOverrideRef(git, root, opts.baseBranchOverride, log) + const resolved = await resolveOverrideRef(git, dir, opts.baseBranchOverride, log) if (!resolved) { log(`Local diff: override base="${opts.baseBranchOverride}" could not be resolved, falling back to auto`) } else { - target = { directory: root, baseBranch: resolved } + target = { directory: dir, baseBranch: resolved } log(`Local diff: using override base=${resolved}`) return target } } - target = await resolveLocalDiffTarget(git, log, getWorkspaceRoot()) + target = await resolveLocalDiffTarget(git, log, root()) return target } @@ -109,8 +148,10 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): }, dispose(): void { - git.dispose() - output.dispose() + // Only dispose resources we own (created here). Injected git/log are + // owned by the caller. + if (!opts.git) git.dispose() + output?.dispose() target = undefined }, } diff --git a/packages/kilo-vscode/src/diff/types.ts b/packages/kilo-vscode/src/diff/types.ts index 913d4560a3..3ff72904e1 100644 --- a/packages/kilo-vscode/src/diff/types.ts +++ b/packages/kilo-vscode/src/diff/types.ts @@ -10,6 +10,27 @@ export interface PanelContext { hidePicker?: boolean /** User-picked base branch for the workspace source. Undefined = auto. */ baseBranchOverride?: string + /** + * Explicit directory to diff inside, overriding the workspace root lookup. + * Agent Manager passes a worktree path so its sources operate in the + * worktree rather than the main checkout. + */ + dir?: string + /** + * When true, a source whose `dir` resolves to undefined returns an empty + * diff instead of falling back to the workspace root. Agent Manager sets + * this so an unresolvable worktree context never silently diffs the main + * checkout. + */ + strictDir?: boolean + /** + * Explicit base ref for the workspace source, skipping auto-resolution. + * Agent Manager passes the worktree's recorded parent ref. + */ + baseBranch?: string + /** Shared GitOps / log injected by Agent Manager to avoid per-source channels. */ + git?: import("../agent-manager/GitOps").GitOps + log?: (...args: unknown[]) => void } export type DiffImageError = "too-large" | "unreadable" diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index e5dbd40e44..c9825cb771 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -408,7 +408,7 @@ export type WebviewMessage = message: Record } | { type: "sessionStatus"; sessionID: string; status: string; attempt?: number; message?: string; next?: number } - | { type: "sessionTurnClosed"; sessionID: string; reason: "completed" | "error" | "interrupted" } + | { type: "sessionTurnClosed"; sessionID: string; reason: "completed" | "error" | "interrupted" | "superseded" } | { type: "permissionRequest" permission: { diff --git a/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts new file mode 100644 index 0000000000..4b5ef9d5e9 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts @@ -0,0 +1,21 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { configFeatures } from "../features" +import { retry } from "../services/cli-backend/retry" + +type Client = Pick +type Settings = { maxCost: number; languageCommitMessage: string } +export async function fetchSnapshot(client: Client, dir: string, settings: () => Settings) { + const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([ + retry(() => client.config.get({ directory: dir }, { throwOnError: true })), + client.global.config.get({ throwOnError: true }), + client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }), + ]) + return { + config, + globalConfig: global, + projectConfig: overlay?.project, + collections: overlay?.collections, + settings: settings(), + features: configFeatures(config), + } +} diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts b/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts index 2b6e3608ca..52008fdab4 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts @@ -106,7 +106,7 @@ export async function handlePermissionResponse( } const replyResult = await ctx.client.permission - .reply({ requestID: permissionId, reply: response, directory: dir }, { throwOnError: true }) + .reply({ requestID: permissionId, reply: response, directory: dir, interactive: true }, { throwOnError: true }) .then(() => "ok" as const) .catch((error: unknown) => { if (isNotFoundError(error)) return "stale" as const diff --git a/packages/kilo-vscode/src/kilo-provider/session-search.ts b/packages/kilo-vscode/src/kilo-provider/session-search.ts index 7552fc6802..e525b5a854 100644 --- a/packages/kilo-vscode/src/kilo-provider/session-search.ts +++ b/packages/kilo-vscode/src/kilo-provider/session-search.ts @@ -4,6 +4,7 @@ type Item = { id: string title: string updated: number + worktreeName?: string } type Message = { @@ -22,11 +23,14 @@ type Input = { } /** - * Past-chat mention search. Lists root sessions for the directory the current - * chat runs in (workspace root for the sidebar, the worktree for Agent Manager - * sessions) — the same directory-scoped `session.list` the session history and - * Agent Manager search are built on. Fuzzy title filtering happens in the - * webview (same mechanism as the Agent Manager sidebar search). + * Past-chat mention search. Lists root sessions across the current directory's + * worktree family (the repo root and its sibling worktrees for git projects, + * just the directory itself otherwise) — the same family-wide listing the + * Agent Manager session search and the CLI's past-chat picker are built on. + * Every session in the family shares the project, so any of them can be + * attached regardless of which worktree the current chat runs in. Fuzzy title + * filtering happens in the webview (same mechanism as the Agent Manager + * sidebar search). */ export async function handleSessionSearch(input: Input): Promise { const client = input.client @@ -39,10 +43,18 @@ export async function handleSessionSearch(input: Input): Promise { const dir = input.dir(id) try { - const res = await client.session.list({ directory: dir, roots: true, limit: 50 }, { throwOnError: true }) + const res = await client.experimental.session.list( + { worktrees: true, roots: true, directory: dir, limit: 50 }, + { throwOnError: true }, + ) const sessions: Item[] = res.data .filter((session) => session.id !== input.exclude && session.title) - .map((session) => ({ id: session.id, title: session.title, updated: session.time.updated })) + .map((session) => ({ + id: session.id, + title: session.title, + updated: session.time.updated, + worktreeName: session.worktreeName, + })) input.post({ type: "sessionSearchResult", sessions, requestId: input.message.requestId }) } catch (err) { console.error("[Kilo New] Session search failed:", err) diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/fa.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/fa.ts new file mode 100644 index 0000000000..3aa4dc7483 --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/fa.ts @@ -0,0 +1,6 @@ +export const dict = { + "server.processExited": "فرآیند CLI با کد {{code}} قبل از راه‌اندازی سرور خاتمه یافت", + "server.startupTimeout": "زمان راه‌اندازی سرور پس از {{seconds}} ثانیه به پایان رسید", + "remote.connected": "Kilo Remote: متصل شد", + "remote.connecting": "Kilo Remote: در حال اتصال…", +} as const diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts index 82c8c9358a..d41b11bd2c 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts @@ -6,6 +6,7 @@ import { dict as da } from "./da" import { dict as de } from "./de" import { dict as en } from "./en" import { dict as es } from "./es" +import { dict as fa } from "./fa" import { dict as fr } from "./fr" import { dict as it } from "./it" import { dict as ja } from "./ja" @@ -29,6 +30,7 @@ const bundles: Record> = { de, en, es, + fa, fr, it, ja, diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/fa.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/fa.ts new file mode 100644 index 0000000000..593c89eb68 --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/fa.ts @@ -0,0 +1,27 @@ +// English runtime translations for autocomplete (kilocode:autocomplete.* namespace) +// Source: src/i18n/locales/en/kilocode.json → "autocomplete" section + +export const dict = { + "kilocode:autocomplete.statusBar.enabled": "$(kilo-logo) تکمیل خودکار", + "kilocode:autocomplete.statusBar.snoozed": "به تعویق افتاده", + "kilocode:autocomplete.statusBar.warning": "$(warning) تکمیل خودکار", + "kilocode:autocomplete.statusBar.tooltip.basic": "تکمیل خودکار Kilo Code", + "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": + "**هیچ مدل تکمیل خودکاری پیکربندی نشده است**\n\nبرای فعال‌سازی تکمیل خودکار، یک پروفایل با یکی از ارائه‌دهندگان پشتیبانی‌شده زیر اضافه کنید: {{providers}}.\n\n[باز کردن تنظیمات]({{command}})", + "kilocode:autocomplete.statusBar.tooltip.completionSummary": + "{{count}} تکمیل بین {{startTime}} و {{endTime}} انجام شد، با هزینه کل {{cost}}.", + "kilocode:autocomplete.statusBar.tooltip.providerInfo": + "تکمیل خودکار توسط {{model}} از طریق {{provider}} ارائه می‌شود.", + "kilocode:autocomplete.statusBar.cost.zero": "۰.۰۰$", + "kilocode:autocomplete.statusBar.cost.lessThanCent": "<۰.۰۱$", + "kilocode:autocomplete.codeAction.title": "Kilo Code: ویرایش‌های پیشنهادی", + "kilocode:autocomplete.incompatibilityExtensionPopup.message": + "تکمیل خودکار Kilo Code به دلیل تعارض با GitHub Copilot مسدود شده است. برای رفع این مشکل، باید پیشنهادات درون‌خطی Copilot را غیرفعال کنید.", + "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "غیرفعال کردن Copilot", + "kilocode:autocomplete.incompatibilityExtensionPopup.disableInlineAssist": "غیرفعال کردن تکمیل خودکار", + "kilocode:autocomplete.creditsExhausted.message": + "تکمیل خودکار Kilo Code متوقف شده است. دلایل احتمالی: حساب Kilo شما اعتبار کافی ندارد، یا کلید API پیکربندی‌شده (BYOK) به سقف مجاز خود رسیده است. برای از سرگیری تکمیل خودکار، اعتبار Kilo اضافه کنید یا تنظیمات کلید API خود را بررسی کنید.", + "kilocode:autocomplete.creditsExhausted.addCredits": "افزودن اعتبار", + "kilocode:autocomplete.authError.message": + "تکمیل خودکار Kilo Code به دلیل مشکل احراز هویت متوقف شده است. دلایل احتمالی: وارد Kilo نشده‌اید، یا کلید API (BYOK) شما نامعتبر یا وارد نشده است. لطفاً دوباره وارد شوید یا تنظیمات کلید API ارائه‌دهنده خود را بررسی کنید.", +} diff --git a/packages/kilo-vscode/src/services/i18n/fa.ts b/packages/kilo-vscode/src/services/i18n/fa.ts new file mode 100644 index 0000000000..e8daa62e10 --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/fa.ts @@ -0,0 +1,7 @@ +import { dict as autocompleteDict } from "./autocomplete/fa" + +export { autocompleteDict } + +export const dict = { + ...autocompleteDict, +} as const diff --git a/packages/kilo-vscode/src/services/i18n/index.ts b/packages/kilo-vscode/src/services/i18n/index.ts index 4da8dda292..1090b84b39 100644 --- a/packages/kilo-vscode/src/services/i18n/index.ts +++ b/packages/kilo-vscode/src/services/i18n/index.ts @@ -6,6 +6,7 @@ import { dict as de } from "./de" import { dict as en } from "./en" import { type dict as enDict } from "./en" import { dict as es } from "./es" +import { dict as fa } from "./fa" import { dict as fr } from "./fr" import { dict as it } from "./it" import { dict as ja } from "./ja" @@ -28,6 +29,7 @@ const bundles: Record> = { de, en, es, + fa, fr, it, ja, diff --git a/packages/kilo-vscode/tests/package.json b/packages/kilo-vscode/tests/package.json index 38105c8a8f..52e607fbd4 100644 --- a/packages/kilo-vscode/tests/package.json +++ b/packages/kilo-vscode/tests/package.json @@ -1,6 +1,6 @@ { "type": "module", - "version": "7.4.16", + "version": "7.4.17", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-vscode/tests/settings-accessibility.spec.ts b/packages/kilo-vscode/tests/settings-accessibility.spec.ts index 03073ea4c5..5e2b62082f 100644 --- a/packages/kilo-vscode/tests/settings-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/settings-accessibility.spec.ts @@ -6,7 +6,7 @@ const NAMES = [ "Providers", "Agent Behaviour", "Auto-Approve", - "Browser", + "Web Tools", "Checkpoints", "Display", "Autocomplete", diff --git a/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts b/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts index 0820dc818e..796a2d0eeb 100644 --- a/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts @@ -1,11 +1,52 @@ import { describe, expect, it } from "bun:test" import { + mcpConfigScope, + mcpEnabledPatch, selectedAgentNumberOverrideValue, selectedAgentTextOverrideValue, selectedDefaultAgentValue, shouldClearDefaultAgentWhenAgentBecomesUnavailable, } from "../../webview-ui/src/components/settings/agent-behaviour-patches" +describe("mcpEnabledPatch", () => { + it("returns only the enabled-state patch", () => { + expect(mcpEnabledPatch("docs", false)).toEqual({ + mcp: { + docs: { + enabled: false, + }, + }, + }) + }) + + it("routes project-defined servers to project config", () => { + expect( + mcpConfigScope("docs", { + mcp: [{ key: "docs", source: "project" }], + }), + ).toBe("project") + }) + + it("routes global servers to global config", () => { + const collections = { + mcp: [{ key: "docs", source: "global" as const }], + } + expect(mcpConfigScope("docs", collections)).toBe("global") + }) + + it("keeps system, default, and unknown servers runtime-only", () => { + const collections = { + mcp: [ + { key: "legacy", source: "system" as const }, + { key: "builtin", source: "default" as const }, + ], + } + expect(mcpConfigScope("legacy", collections)).toBeUndefined() + expect(mcpConfigScope("builtin", collections)).toBeUndefined() + expect(mcpConfigScope("unknown", collections)).toBeUndefined() + }) +}) + describe("selectedAgentTextOverrideValue", () => { it("maps an empty text field value to a null delete sentinel", () => { expect(selectedAgentTextOverrideValue("")).toBeNull() diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 804471f9bb..7ba0115d1a 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect } from "bun:test" import fs from "node:fs" import path from "node:path" import { Project, SyntaxKind } from "ts-morph" +import { WorktreeImporter } from "../../src/agent-manager/worktree-importer" const ROOT = path.resolve(import.meta.dir, "../..") const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") @@ -60,6 +61,10 @@ const IMPORTER_FILE = path.join(ROOT, "src/agent-manager/worktree-importer.ts") const SETUP_SCRIPT_RUNNER_FILE = path.join(ROOT, "src/agent-manager/SetupScriptRunner.ts") const RUN_MESSAGE_FILE = path.join(ROOT, "src/agent-manager/run/message.ts") const TERMINAL_ROUTING_FILE = path.join(ROOT, "src/agent-manager/terminal-routing.ts") +const SCRIPT_TERMINAL_FILE = path.join(ROOT, "src/agent-manager/ScriptTerminalManager.ts") +const SCRIPT_TERMINAL_RUNTIME_FILE = path.join(ROOT, "src/agent-manager/script-terminal-runtime.ts") +const RUN_TASK_FILE = path.join(ROOT, "src/agent-manager/run/task.ts") +const RUN_DESTINATION_FILE = path.join(ROOT, "src/agent-manager/run/destination.ts") function readAllCss(): string { return CSS_FILES.map((f) => fs.readFileSync(f, "utf-8")).join("\n") @@ -411,6 +416,7 @@ describe("Agent Manager Provider — onMessage routing", () => { "agentManager.stopRunScript", "agentManager.showTerminal", "agentManager.showLocalTerminal", + "agentManager.showWorktreeTerminal", "agentManager.showExistingLocalTerminal", "agentManager.requestRepoInfo", "agentManager.requestState", @@ -453,6 +459,52 @@ describe("Agent Manager Provider — onMessage routing", () => { expect(text).not.toContain("agentManager.requestState") }) + it("routes script terminal close and resize messages before user terminals", () => { + const text = body("onMessage") + expect(text.indexOf("this.scripts.manager.intercept(m)")).toBeLessThan( + text.indexOf("this.terminalRouter.handle(m)"), + ) + }) + + it("runs scripts through the vscode-free canonical PTY manager", () => { + const text = fs.readFileSync(SCRIPT_TERMINAL_FILE, "utf-8") + expect(text).toMatch(/client\.v2\.pty\s*\.create/) + expect(text).toContain("client.v2.pty.get") + expect(text).toContain("client.v2.pty.update") + expect(text).toContain("client.v2.pty.remove") + expect(text).not.toContain("vscode") + }) + + it("selects the Run adapter from the panel dropdown message", () => { + const text = fs.readFileSync(SCRIPT_TERMINAL_RUNTIME_FILE, "utf-8") + expect(text).toContain("pickRunStart") + expect(text).toContain("config.destination") + expect(text).not.toContain("readRunTerminalDestination") + expect(text.indexOf("pickRunStart")).toBeLessThan(text.indexOf("config.destination")) + }) + + it("keeps the legacy integrated Run adapter isolated and removable", () => { + const task = fs.readFileSync(RUN_TASK_FILE, "utf-8") + expect(task).toContain("vscode.tasks.executeTask") + expect(task).toContain("Remove this") + const dest = fs.readFileSync(RUN_DESTINATION_FILE, "utf-8") + expect(dest).not.toContain('from "vscode"') + expect(dest).toContain("pickRunStart") + expect(dest).not.toContain("getConfiguration") + }) + + it("clears retained Run terminals before removing worktree state", () => { + for (const name of ["onDeleteWorktree", "onRemoveStaleWorktree"]) { + const text = body(name) + expect(text).toContain('this.scripts.manager.clear("run", worktreeId)') + expect(text.indexOf('this.scripts.manager.clear("run", worktreeId)')).toBeLessThan( + text.indexOf("state.removeWorktree"), + ) + } + const deleted = body("onDeleteWorktree") + expect(deleted.indexOf("statsPoller.skipWorktree")).toBeLessThan(deleted.indexOf("this.run.remove")) + }) + // -- onDeleteWorktree invariants ------------------------------------------- /** @@ -570,18 +622,70 @@ describe("Agent Manager Provider — onMessage routing", () => { expect(text).toContain("class WorktreeDiffController") expect(text).toContain("buildWorktreePatch") expect(text).toContain("revertFile") - expect(text).toContain("diffSummary") + // Summary/detail diff data comes from the shared DiffSourceCatalog sources + // (workspace/staged/unstaged/session), not a bespoke in-controller pipeline. + expect(text).toContain("catalog.build") expect(text).toContain("shouldStopDiffPolling") expect(providerText).toContain("this.diffs") }) it("worktree import behavior lives in the cohesive importer", () => { const text = importer() - const providerText = body("onImportMessage") - expect(text).toContain("class WorktreeImporter") - expect(text).toContain("createFromPR") - expect(text).toContain("createWorktree") - expect(providerText).toContain("this.importer") + for (const value of ["createFromPR", "createWorktree", "this.busy()"]) expect(text).toContain(value) + expect(body("onImportMessage")).toContain("this.importer") + }) + + it("preserves branch and PR import ordering and rollback", async () => { + const run = async (kind: "branch" | "pr", fail?: "setup" | "duplicate") => { + const events: string[] = [] + const create = async () => { + events.push("create") + if (fail === "duplicate") throw new Error("already checked out") + return { branch: "topic", path: "/repo/topic", parentBranch: "main" } + } + const importer = new WorktreeImporter({ + manager: () => + ({ createWorktree: create, createFromPR: create, removeWorktree: async () => events.push("disk") }) as never, + state: () => + ({ + addWorktree: (input: { branchOwned: boolean }) => ({ + id: events.push(`add:${input.branchOwned}`) ? "worktree" : "", + }), + addSession: () => events.push("state-session"), + removeWorktree: () => events.push("state-remove"), + }) as never, + post: (msg) => events.push("message" in msg ? String(msg.message) : msg.type), + push: () => events.push("push"), + setup: async () => { + events.push("setup") + if (fail === "setup") throw new Error("setup failed") + }, + session: async () => (events.push("session"), { id: "session" }) as never, + register: () => events.push("register"), + ready: () => events.push("ready"), + log: () => events.push("log"), + }) + const action = () => (kind === "branch" ? importer.branch("topic") : importer.pr("https://example.test/pull/1")) + await action() + if (fail === "setup") await action() + return events.join("|") + } + for (const kind of ["branch", "pr"] as const) { + const branch = kind === "branch" + const creating = branch ? "Creating worktree from branch..." : "Resolving PR..." + const setup = branch ? "Running setup script..." : "Setting up worktree..." + const success = branch ? "Opened branch topic" : "Opened PR branch topic" + expect(await run(kind)).toBe( + `${creating}|create|add:false|push|${setup}|setup|session|state-session|register|ready|${success}|log`, + ) + expect(await run(kind, "setup")).toBe( + `${creating}|create|add:false|push|${setup}|setup|state-remove|disk|push|setup failed|setup failed|${creating}|create|add:false|push|${setup}|setup|state-remove|disk|push|setup failed|setup failed`, + ) + const duplicate = branch + ? 'Branch "topic" is already checked out in another worktree' + : "This PR's branch is already checked out in another worktree" + expect(await run(kind, "duplicate")).toBe(`${creating}|create|${duplicate}|${duplicate}`) + } }) }) @@ -790,9 +894,6 @@ const VSCODE_ALLOWED: Record = { "task-runner.ts": { note: "vscode adapter for SetupScriptRunner", }, - "run/task.ts": { - note: "vscode adapter for Agent Manager run scripts", - }, // Reads terminal.integrated.* and editor.font* config for xterm font settings "terminal-font.ts": { note: "vscode config reader for integrated terminal font settings", @@ -891,70 +992,73 @@ describe("Agent Manager — VS Code import boundary", () => { }) }) -// --------------------------------------------------------------------------- -// Provider chain parity — sidebar App.tsx vs AgentManagerApp.tsx -// -// The agent manager reuses ChatView (and therefore MessageList, etc.) from the -// sidebar. Any context provider that ChatView's tree may call useXxx() on must -// also be present in the agent manager's provider chain. A missing provider -// crashes the entire SolidJS component tree silently. -// -// Regression: PR #7473 moved KiloNotifications into MessageList. It calls -// useNotifications(), but NotificationsProvider was only in App.tsx — the agent -// manager rendered a blank screen. -// --------------------------------------------------------------------------- - const APP_FILE = path.join(ROOT, "webview-ui/src/App.tsx") const AGENT_MANAGER_APP_FILE = path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx") +const PROVIDER_SHELL_FILE = path.join(ROOT, "webview-ui/src/context/provider-shell.tsx") -describe("Agent Manager — provider chain parity with sidebar", () => { - /** - * Extract provider component names used as JSX elements in a file. - * Matches `` patterns, returning the names. - */ - function extractProviders(content: string): string[] { - const matches = [...content.matchAll(/<(\w+Provider)\b/g)] - return [...new Set(matches.map((m) => m[1]!))] +describe("Shared webview provider shell", () => { + function ordered(source: string, names: string[]) { + const positions = names.map((name) => source.indexOf(`<${name}`)) + expect( + positions.every((position) => position >= 0), + `Missing provider from ${names.join(" -> ")}`, + ).toBe(true) + expect(positions).toEqual([...positions].sort((a, b) => a - b)) } - /** - * Providers that the agent manager intentionally omits because it does not - * use the components that depend on them. If a shared component (ChatView, - * MessageList, etc.) starts using one of these, the test will fail and - * force the developer to add the provider to AgentManagerApp.tsx. - */ - const KNOWN_EXCLUSIONS: string[] = [ - // These are wrapped by LanguageBridge and DataBridge respectively, - // which the agent manager already includes in its provider chain. - "LanguageProvider", - "DataProvider", - // Agent Manager owns its local session tabs and ChatView only reads this - // optional context in the standard sidebar/editor webview. - "LocalTabsProvider", - // Work-style onboarding is injected only into the sidebar empty state. - "WorkStyleProvider", - ] + it("owns the common provider order and bridges", () => { + const source = fs.readFileSync(PROVIDER_SHELL_FILE, "utf-8") + ordered(source, [ + "ThemeProvider", + "DialogProvider", + "VSCodeProvider", + "MermaidDownloadBridge", + "ServerProvider", + "LanguageBridge", + "MarkedProvider", + "DiffComponentProvider", + "CodeComponentProvider", + "FileComponentProvider", + "ProviderProvider", + "ConfigProvider", + "SpeechToTextPrewarm", + "DisplayProvider", + "IndexingProvider", + "KiloEmbeddingModelsProvider", + "ImageModelsProvider", + "NotificationsProvider", + "SessionProvider", + "AgentRequirementsProvider", + "MemoryProvider", + "FeedbackProvider", + ]) + expect(source.indexOf("")) + }) - it("agent manager includes all context providers from sidebar App.tsx", () => { - const sidebar = fs.readFileSync(APP_FILE, "utf-8") - const agent = fs.readFileSync(AGENT_MANAGER_APP_FILE, "utf-8") + it("keeps sidebar-only providers in the sidebar root", () => { + const source = fs.readFileSync(APP_FILE, "utf-8") + ordered(source, [ + "ProviderShell.Root", + "WorkStyleProvider", + "ProviderShell.Session", + "LocalTabsProvider", + "ProviderShell.Chat", + "DataBridge", + "AppContent", + ]) + expect(fs.readFileSync(PROVIDER_SHELL_FILE, "utf-8")).not.toMatch(/WorkStyleProvider|LocalTabsProvider/) + }) - const sidebarProviders = extractProviders(sidebar) - const agentProviders = extractProviders(agent) - const agentSet = new Set(agentProviders) - const excluded = new Set(KNOWN_EXCLUSIONS) - - const missing = sidebarProviders.filter((p) => !agentSet.has(p) && !excluded.has(p)) - - expect( - missing, - `These providers are in App.tsx but missing from AgentManagerApp.tsx.\n` + - `The agent manager reuses ChatView — any provider that ChatView's component\n` + - `tree depends on must be present in both provider chains.\n\n` + - `Missing providers:\n` + - missing.map((p) => ` - ${p}`).join("\n") + - `\n\nFix: add the missing <${missing[0]}> to AgentManagerApp.tsx's provider chain,\n` + - `or add it to KNOWN_EXCLUSIONS with a justification if it's truly unused.`, - ).toEqual([]) + it("keeps worktree mode in the Agent Manager root", () => { + const source = fs.readFileSync(AGENT_MANAGER_APP_FILE, "utf-8") + ordered(source, [ + "ProviderShell.Root", + "ProviderShell.Session", + "ProviderShell.Chat", + "WorktreeModeProvider", + "DataBridge", + "AgentManagerContent", + ]) + expect(fs.readFileSync(PROVIDER_SHELL_FILE, "utf-8")).not.toContain("WorktreeModeProvider") }) }) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts index ab0965fa7e..3b7477aafc 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts @@ -39,6 +39,7 @@ import { dict as amTr } from "../../webview-ui/agent-manager/i18n/tr" import { dict as amNl } from "../../webview-ui/agent-manager/i18n/nl" import { dict as amUk } from "../../webview-ui/agent-manager/i18n/uk" import { dict as amIt } from "../../webview-ui/agent-manager/i18n/it" +import { dict as amFa } from "../../webview-ui/agent-manager/i18n/fa" const PREFIX = "agentManager." @@ -63,6 +64,7 @@ const locales = { nl: amNl, uk: amUk, it: amIt, + fa: amFa, } const appLocales = { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-chrome.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-chrome.test.ts new file mode 100644 index 0000000000..eff96950cd --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-chrome.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test" +import { terminalChrome } from "../../webview-ui/agent-manager/terminal/chrome" + +describe("Agent Manager Run terminal chrome", () => { + it("keeps the console icon for user terminals", () => { + expect(terminalChrome("Terminal 1", undefined)).toEqual({ icon: "console", tooltip: "Terminal 1" }) + }) + + it("renders compact status icons with accessible Run status details", () => { + expect(terminalChrome("Run", { state: "running" })).toEqual({ icon: "spinner", tooltip: "Run (Running)" }) + expect(terminalChrome("Run", { state: "stopping" })).toEqual({ icon: "spinner", tooltip: "Run (Stopping)" }) + expect(terminalChrome("Run", { state: "exited", exitCode: 0 })).toEqual({ + icon: "success", + tooltip: "Run (Exited, code 0)", + }) + expect(terminalChrome("Run", { state: "exited", exitCode: 1 })).toEqual({ + icon: "failure", + tooltip: "Run (Exited, code 1)", + }) + expect(terminalChrome("Run", { state: "failed" })).toEqual({ icon: "failure", tooltip: "Run (Failed)" }) + }) +}) 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 64504d2600..02b7fffe27 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 @@ -53,6 +53,7 @@ describe("Agent Manager terminal font", () => { const message = new Promise((resolve) => { const router = new TerminalRouter({ getClient: () => client, + getClientAsync: async () => client, getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), getRoot: () => "/workspace", getWorktreePath: () => undefined, @@ -77,6 +78,7 @@ describe("Agent Manager terminal font", () => { expect(created.font).toEqual(font) expect(created.worktreeId).toBeNull() expect(created.wsUrl).toContain("/pty/pty-1/connect") + expect(created.wsUrl).toContain("cursor=0") }) it("keeps the created font in terminal state", () => { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts new file mode 100644 index 0000000000..f30ba800e5 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +const css = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/agent-manager.css"), "utf8") + +test("xterm owns the padding used by FitAddon", () => { + const host = css.match(/\.am-terminal-host\s*\{([^}]*)\}/)?.[1] + const term = css.match(/\.am-terminal-host \[class~="xterm"\]\s*\{([^}]*)\}/)?.[1] + + expect(host).toBeDefined() + expect(term).toBeDefined() + expect(host).not.toMatch(/\bpadding\s*:/) + expect(term).toMatch(/\bpadding\s*:\s*8px\s*;/) +}) 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 index bfa243d1fc..ef9bfcd6c4 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts @@ -21,6 +21,7 @@ describe("Agent Manager terminal routing", () => { } as unknown as KiloClient const router = new TerminalRouter({ getClient: () => client, + getClientAsync: async () => client, getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), getRoot: () => "/workspace", getWorktreePath: (id) => (id === "wt-1" ? "/workspace/wt-1" : undefined), @@ -78,6 +79,7 @@ describe("Agent Manager terminal routing", () => { } as unknown as KiloClient const router = new TerminalRouter({ getClient: () => client, + getClientAsync: async () => client, getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), getRoot: () => "/workspace", getWorktreePath: () => undefined, @@ -108,4 +110,174 @@ describe("Agent Manager terminal routing", () => { await router.dispose() expect(removed).toContain("pty-new") }) + + it("fills numbering gaps left by closed terminals", async () => { + const messages: AgentManagerOutMessage[] = [] + const titles: string[] = [] + let seq = 0 + const client = { + pty: { + create: async ({ title }: { title: string }) => { + titles.push(title) + seq++ + return { data: { id: `pty-${seq}`, title } } + }, + remove: async () => ({ data: true }), + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => client, + getClientAsync: async () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + log: () => undefined, + post: (message) => messages.push(message), + getTerminalFont: () => font, + }) + const create = (createId: string) => + router.handle({ type: "agentManager.terminal.create", createId, placement: "side", worktreeId: null }) + + create("one") + await wait() + create("two") + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 2"]) + + // Close "Terminal 1"; the next create reuses the freed number. + const first = messages.find((m) => m.type === "agentManager.terminal.created" && m.createId === "one") + if (first?.type !== "agentManager.terminal.created") throw new Error("missing created message") + router.handle({ type: "agentManager.terminal.close", terminalId: first.terminalId }) + await wait() + + create("three") + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 2", "Terminal 1"]) + await router.dispose() + }) + + it("hands out distinct numbers to concurrent creates", async () => { + const messages: AgentManagerOutMessage[] = [] + const titles: string[] = [] + const resolvers: Array<(value: { data: { id: string; title: string } }) => void> = [] + const client = { + pty: { + create: ({ title }: { title: string }) => + new Promise<{ data: { id: string; title: string } }>((resolve) => { + titles.push(title) + resolvers.push(resolve) + }), + remove: async () => ({ data: true }), + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => client, + getClientAsync: async () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + log: () => undefined, + post: (message) => messages.push(message), + getTerminalFont: () => font, + }) + + // Two creates before either settles must not share an ordinal. The + // backend-connection await defers the PTY creates to a microtask. + router.handle({ type: "agentManager.terminal.create", createId: "a", placement: "side", worktreeId: null }) + router.handle({ type: "agentManager.terminal.create", createId: "b", placement: "side", worktreeId: null }) + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 2"]) + resolvers[0]?.({ data: { id: "pty-a", title: titles[0]! } }) + resolvers[1]?.({ data: { id: "pty-b", title: titles[1]! } }) + await wait() + + // A failed create releases its reservation for the next attempt. + await router.dispose() + }) + + it("does not let a stale create release a new generation's reservation", async () => { + const titles: string[] = [] + const resolvers: Array<(value: { data: { id: string; title: string } }) => void> = [] + const client = { + pty: { + create: ({ title }: { title: string }) => + new Promise<{ data: { id: string; title: string } }>((resolve) => { + titles.push(title) + resolvers.push(resolve) + }), + remove: async () => ({ data: true }), + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => client, + getClientAsync: async () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + log: () => undefined, + post: () => undefined, + getTerminalFont: () => font, + }) + + // Create A starts before the panel is recreated; its reservation dies + // with dispose(). Create B of the new generation reserves the same + // free number. When A's late completion settles, its release must not + // wipe B's reservation — otherwise create C would duplicate B's title. + router.handle({ type: "agentManager.terminal.create", createId: "a", placement: "side", worktreeId: null }) + await router.dispose() + router.handle({ type: "agentManager.terminal.create", createId: "b", placement: "side", worktreeId: null }) + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 1"]) + resolvers[0]?.({ data: { id: "pty-a", title: titles[0]! } }) + await wait() + router.handle({ type: "agentManager.terminal.create", createId: "c", placement: "side", worktreeId: null }) + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 1", "Terminal 2"]) + await router.dispose() + }) + + it("awaits the shared backend connection before creating a terminal", async () => { + let connected = false + 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 messages: AgentManagerOutMessage[] = [] + const router = new TerminalRouter({ + getClient: () => { + if (!connected) throw new Error("Not connected") + return client + }, + getClientAsync: async () => { + await wait() + connected = true + return 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: "real", + placement: "tab", + worktreeId: null, + }) + expect(messages).toHaveLength(0) + await wait() + await wait() + + expect(messages[0]).toMatchObject({ type: "agentManager.terminal.created", createId: "real" }) + await router.dispose() + }) }) 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 index 199d3adca2..70e245f2cc 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts @@ -1,32 +1,48 @@ import { describe, expect, it } from "bun:test" -import { createSideTerminal } from "../../webview-ui/agent-manager/terminal/side" +import { + createSideTerminal, + readSavedDestination, + resolveRunScriptRequest, + resolveVscodeTerminalRequest, +} from "../../webview-ui/agent-manager/terminal/side" -function scene(opts: { destination?: "vscode" | "agentManager"; visible?: boolean; focused?: boolean } = {}) { +function scene( + opts: { + destination?: "vscode" | "agentManager" + saved?: "vscode" | "agentManager" + visible?: boolean + focusedId?: string + mac?: boolean + } = {}, +) { const calls = { requestSide: 0, - closeSide: 0, + ensureSide: 0, + closed: [] as string[], hide: 0, refocus: 0, openVscode: 0, + persisted: [] as string[], posted: [] as Array>, tracked: [] as string[], } let visible = opts.visible ?? false - let focused = opts.focused ?? false + let focusedId = opts.focusedId as string | undefined const ctl = createSideTerminal({ handlers: { requestSide: () => { calls.requestSide++ visible = true }, - closeSide: () => { - calls.closeSide++ - visible = false + ensureSide: () => calls.ensureSide++, + closeSide: (terminalId) => { + calls.closed.push(terminalId) + focusedId = undefined return true }, }, visible: () => visible, - focused: () => focused, + focusedId: () => focusedId, hide: () => { calls.hide++ visible = false @@ -35,19 +51,22 @@ function scene(opts: { destination?: "vscode" | "agentManager"; visible?: boolea postMessage: (msg) => calls.posted.push(msg as Record), track: (button) => calls.tracked.push(button), openVscode: () => calls.openVscode++, + saved: opts.saved, + save: (destination) => calls.persisted.push(destination), + mac: opts.mac, }) - if (opts.destination) ctl.setDestination(opts.destination) + if (opts.destination) ctl.syncDefault(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 }) + const focused = scene({ destination: "agentManager", visible: true, focusedId: "terminal:side" }) focused.ctl.toggle() expect(focused.calls.hide).toBe(1) expect(focused.calls.refocus).toBe(1) - const elsewhere = scene({ destination: "agentManager", visible: true, focused: false }) + const elsewhere = scene({ destination: "agentManager", visible: true }) elsewhere.ctl.toggle() expect(elsewhere.calls.hide).toBe(1) expect(elsewhere.calls.refocus).toBe(0) @@ -58,15 +77,41 @@ describe("Agent Manager side terminal controller", () => { 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) + it("ensures an open terminal panel has a terminal after switching contexts", async () => { + const visible = scene({ visible: true }) + visible.ctl.syncContext("wt-2", "wt-1") + await Promise.resolve() + expect(visible.calls.ensureSide).toBe(1) - const elsewhere = scene({ focused: false }) - expect(elsewhere.ctl.close()).toBe(true) - expect(elsewhere.calls.refocus).toBe(0) + visible.ctl.syncContext("wt-2", "wt-2") + visible.ctl.syncContext("wt-2", undefined) + await Promise.resolve() + expect(visible.calls.ensureSide).toBe(2) + expect(visible.calls.requestSide).toBe(0) + + const hidden = scene() + hidden.ctl.syncContext("wt-2", "wt-1") + expect(hidden.calls.ensureSide).toBe(0) + + const closed = scene({ visible: true }) + closed.ctl.syncContext("wt-2", "wt-1") + closed.ctl.toggle() + await Promise.resolve() + expect(closed.calls.ensureSide).toBe(0) + }) + + it("kills the focused terminal and refocuses the chat", () => { + const focused = scene({ focusedId: "terminal:two" }) + expect(focused.ctl.close()).toBe(true) + expect(focused.calls.closed).toEqual(["terminal:two"]) + expect(focused.calls.refocus).toBe(1) + }) + + it("does nothing on close without a focused terminal", () => { + const item = scene() + expect(item.ctl.close()).toBe(false) + expect(item.calls.closed).toEqual([]) + expect(item.calls.refocus).toBe(0) }) it("routes the primary action by destination", () => { @@ -81,6 +126,70 @@ describe("Agent Manager side terminal controller", () => { expect(panelFirst.calls.openVscode).toBe(0) }) + it("handles the platform terminal shortcut locally and dedupes the extension echo", () => { + const press = (opts: Partial = {}) => + ({ key: "/", metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...opts }) as KeyboardEvent + + // macOS: the workbench binding is Cmd+/, so only Cmd is accepted. + const mac = scene({ destination: "agentManager", mac: true }) + expect(mac.ctl.press(press({ metaKey: true }))).toBe(true) + expect(mac.calls.requestSide).toBe(1) + expect(mac.ctl.press(press({ ctrlKey: true }))).toBe(false) + expect(mac.ctl.press(press({ metaKey: true, ctrlKey: true }))).toBe(false) + expect(mac.calls.requestSide).toBe(1) + + // Windows/Linux: the workbench binding is Ctrl+/, so only Ctrl is accepted. + const win = scene({ destination: "agentManager", mac: false }) + expect(win.ctl.press(press({ ctrlKey: true }))).toBe(true) + expect(win.calls.requestSide).toBe(1) + expect(win.ctl.press(press({ metaKey: true }))).toBe(false) + expect(win.calls.requestSide).toBe(1) + + // Unrelated keys and modifier combinations are not the shortcut. + expect(win.ctl.press(press({ key: "?" }))).toBe(false) + expect(win.ctl.press(press({ ctrlKey: true, shiftKey: true }))).toBe(false) + expect(win.ctl.press(press({ ctrlKey: true, altKey: true }))).toBe(false) + expect(win.calls.requestSide).toBe(1) + + // The extension echoes each locally handled keypress back as an action + // message; one echo is consumed per press, then invocations run again. + expect(mac.ctl.echo()).toBe(true) + expect(mac.ctl.echo()).toBe(false) + }) + + it("consumes one echo per press, even for rapid repeated presses", () => { + const item = scene({ destination: "agentManager", mac: true }) + const press = () => item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent) + press() + press() + // Two presses toggled the panel open and closed again; both echoes + // must still be consumed so neither press toggles a third time. + expect(item.calls.requestSide).toBe(1) + expect(item.calls.hide).toBe(1) + expect(item.ctl.echo()).toBe(true) + expect(item.ctl.echo()).toBe(true) + expect(item.ctl.echo()).toBe(false) + }) + + it("drops a never-arriving echo after the timeout safety valve", async () => { + const item = scene({ destination: "agentManager", mac: true }) + item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent) + await new Promise((resolve) => setTimeout(resolve, 550)) + expect(item.ctl.echo()).toBe(false) + expect(item.ctl.echo()).toBe(false) + }) + + it("expires a dropped echo's backlog at the next spaced press", async () => { + const item = scene({ destination: "agentManager", mac: true }) + // First press's echo never arrives (dropped forwarding); its backlog + // must not outlive the echo window into the next press. + item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent) + await new Promise((resolve) => setTimeout(resolve, 550)) + item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent) + expect(item.ctl.echo()).toBe(true) + expect(item.ctl.echo()).toBe(false) + }) + it("persists the picked destination with a section-relative settings key", () => { const item = scene() item.ctl.choose("agentManager") @@ -88,5 +197,99 @@ describe("Agent Manager side terminal controller", () => { expect(item.calls.posted).toEqual([ { type: "updateSetting", key: "agentManager.terminalButtonDestination", value: "agentManager" }, ]) + expect(item.calls.persisted).toEqual(["agentManager"]) + }) + + it("follows the remote default while the panel has no explicit choice", () => { + const item = scene() + item.ctl.syncDefault("agentManager") + expect(item.ctl.destination()).toBe("agentManager") + item.ctl.syncDefault("vscode") + expect(item.ctl.destination()).toBe("vscode") + }) + + it("keeps the panel's explicit choice when another window rewrites the shared setting", () => { + const item = scene() + item.ctl.choose("agentManager") + // Echo of the application-scoped setting being rewritten elsewhere: + // worktree window B picked the VS Code terminal, which must not flip + // this panel's routing. + item.ctl.syncDefault("vscode") + expect(item.ctl.destination()).toBe("agentManager") + item.ctl.openPreferred("keyboard_shortcut") + expect(item.calls.requestSide).toBe(1) + expect(item.calls.openVscode).toBe(0) + }) + + it("restores a saved panel choice and ignores remote defaults", () => { + const item = scene({ saved: "agentManager" }) + expect(item.ctl.destination()).toBe("agentManager") + item.ctl.syncDefault("vscode") + expect(item.ctl.destination()).toBe("agentManager") + }) +}) + +describe("readSavedDestination", () => { + it("reads a valid choice and rejects anything else", () => { + expect(readSavedDestination({ terminalDestination: "agentManager" })).toBe("agentManager") + expect(readSavedDestination({ terminalDestination: "vscode" })).toBe("vscode") + expect(readSavedDestination({ terminalDestination: "bogus" })).toBeUndefined() + expect(readSavedDestination({})).toBeUndefined() + expect(readSavedDestination(undefined)).toBeUndefined() + }) +}) + +describe("resolveRunScriptRequest", () => { + it("carries the current panel dropdown destination with every Run request", () => { + expect(resolveRunScriptRequest("wt-1", "agentManager")).toEqual({ + type: "agentManager.runScript", + worktreeId: "wt-1", + destination: "agentManager", + }) + expect(resolveRunScriptRequest("local", "vscode")).toEqual({ + type: "agentManager.runScript", + worktreeId: "local", + destination: "vscode", + }) + }) +}) + +describe("resolveVscodeTerminalRequest", () => { + const sessions = new Map([ + ["wt-1", "session-a"], + ["wt-2", "session-b"], + ]) + const forWorktree = (id: string) => sessions.get(id) + + it("prefers the current session", () => { + expect(resolveVscodeTerminalRequest("wt-1", "session-current", forWorktree)).toEqual({ + type: "agentManager.showTerminal", + sessionId: "session-current", + }) + }) + + it("falls back to a session of the selected worktree when the current session is cleared", () => { + // Terminal tab activation clears the current session; the shortcut + // must still open a terminal for the worktree, not dead-end. + expect(resolveVscodeTerminalRequest("wt-2", undefined, forWorktree)).toEqual({ + type: "agentManager.showTerminal", + sessionId: "session-b", + }) + }) + + it("opens a worktree-rooted terminal for sessionless worktrees", () => { + expect(resolveVscodeTerminalRequest("wt-3", undefined, forWorktree)).toEqual({ + type: "agentManager.showWorktreeTerminal", + worktreeId: "wt-3", + }) + }) + + it("opens the local terminal for the local context and unassigned selections", () => { + expect(resolveVscodeTerminalRequest("local", undefined, forWorktree)).toEqual({ + type: "agentManager.showLocalTerminal", + }) + expect(resolveVscodeTerminalRequest(null, undefined, forWorktree)).toEqual({ + type: "agentManager.showLocalTerminal", + }) }) }) 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 index 432eed1fb6..37a17a7e45 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts @@ -5,6 +5,7 @@ import { createTerminalHandlers, createTerminalMessageHandler, createTerminalState, + isTerminalTabId, } from "../../webview-ui/agent-manager/terminal/state" import type { ExtensionMessage } from "../../webview-ui/src/types/messages/extension-messages" @@ -14,7 +15,14 @@ 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 events = { + activated: [] as string[], + selected: [] as string[], + saved: 0, + shown: [] as string[], + errors: 0, + running: [] as Array<{ contextKey: string; terminalId: string }>, + } const tabs = () => state.current().map((term) => term.id) const handlers = createTerminalHandlers({ state, @@ -27,7 +35,6 @@ function scene(initial: string | null = LOCAL) { 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", @@ -40,12 +47,48 @@ function scene(initial: string | null = LOCAL) { events.selected.push(value) setSelection(value) }, - showError: () => undefined, + showError: () => events.errors++, postMessage: (message) => posted.push(message as Record), + onScriptRunning: (contextKey, terminalId) => events.running.push({ contextKey, terminalId }), }) return { state, selection, setSelection, posted, events, handlers, dispatch } } +function createdSide(createId: string, terminalId: string, title = "Terminal 1") { + return { + type: "agentManager.terminal.created", + createId, + placement: "side", + worktreeId: null, + terminalId, + title, + wsUrl: `ws://${terminalId}`, + font, + } satisfies ExtensionMessage +} + +function script( + terminalId: string, + state: "running" | "stopping" | "exited" | "failed" = "running", + exitCode?: number, +) { + return { + type: "agentManager.scriptTerminals", + terminals: [ + { + terminalId, + worktreeId: null, + kind: "run", + title: "Run", + wsUrl: `ws://${terminalId}`, + state, + ...(exitCode === undefined ? {} : { exitCode }), + font, + }, + ], + } satisfies ExtensionMessage +} + describe("Agent Manager terminal state", () => { it("keeps side terminals out of the tab state and shares root context with unassigned sessions", () => { createRoot((dispose) => { @@ -64,21 +107,63 @@ describe("Agent Manager terminal state", () => { font, placement: "side", }) + item.state.setSideActive(LOCAL, "terminal: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") + expect(item.state.sideActiveFor(LOCAL)).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") + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side") dispose() }) }) - it("deduplicates side creation and reuses the terminal without tab side effects", () => { + it("hydrates complete Run snapshots without create ids and preserves mounted terminal records", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:user", title: "Terminal 1", wsUrl: "ws://user", font, placement: "side" }) + const user = item.state.sidesForContext(LOCAL)[0]! + + expect(item.dispatch(script("script:run"))).toBe(true) + const run = item.state.sidesForContext(LOCAL).find((term) => term.id === "script:run") + expect(run).toMatchObject({ title: "Run", placement: "side", kind: "run", contextKey: LOCAL }) + expect(item.events.running).toEqual([{ contextKey: LOCAL, terminalId: "script:run" }]) + expect(item.state.scriptStatus("script:run")).toEqual({ state: "running" }) + expect(isTerminalTabId("script:run")).toBe(true) + + item.state.setTitle("script:run", "npm test") + expect(item.state.title("script:run")).toBe("Run") + + item.dispatch(script("script:run", "exited", 0)) + expect(item.state.sidesForContext(LOCAL).find((term) => term.id === "script:run")).toBe(run) + expect(item.state.scriptStatus("script:run")).toEqual({ state: "exited", exitCode: 0 }) + expect(item.state.sidesForContext(LOCAL).find((term) => term.id === "terminal:user")).toBe(user) + // Existing snapshots update status only; they do not re-open the inspector. + expect(item.events.running).toEqual([{ contextKey: LOCAL, terminalId: "script:run" }]) + + item.dispatch({ type: "agentManager.scriptTerminals", terminals: [] } satisfies ExtensionMessage) + expect(item.state.sidesForContext(LOCAL)).toEqual([user]) + expect(item.state.scriptStatus("script:run")).toBeUndefined() + dispose() + }) + }) + + it("maps Local Run snapshots to LOCAL and does not reveal exited terminals", () => { + createRoot((dispose) => { + const item = scene() + item.dispatch(script("script:exit", "exited", 2)) + + expect(item.state.sidesForContext(LOCAL)[0]).toMatchObject({ id: "script:exit", contextKey: LOCAL }) + expect(item.events.running).toEqual([]) + dispose() + }) + }) + + it("deduplicates an in-flight reveal and focuses the active terminal on repeat", () => { createRoot((dispose) => { const item = scene() item.handlers.requestSide() @@ -88,18 +173,8 @@ describe("Agent Manager terminal state", () => { 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.dispatch(createdSide(createId, "terminal:side"))).toBe(true) + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side") expect(item.events.activated).toEqual([]) expect(item.events.selected).toEqual([]) expect(item.events.saved).toBe(0) @@ -111,6 +186,135 @@ describe("Agent Manager terminal state", () => { }) }) + it("ensures a side terminal without revealing the panel", () => { + createRoot((dispose) => { + const item = scene("wt-1") + item.handlers.ensureSide() + item.handlers.ensureSide() + + expect(item.events.shown).toEqual([]) + expect(item.posted).toHaveLength(1) + expect(item.posted[0]).toMatchObject({ + type: "agentManager.terminal.create", + placement: "side", + worktreeId: "wt-1", + }) + dispose() + }) + }) + + it("supports several side terminals per context with newest active", () => { + createRoot((dispose) => { + const item = scene() + item.handlers.addSide() + item.handlers.addSide() + expect(item.posted).toHaveLength(2) + const first = String(item.posted[0]!.createId) + const second = String(item.posted[1]!.createId) + + item.dispatch(createdSide(first, "terminal:one", "Terminal 1")) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one"]) + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one") + + item.dispatch(createdSide(second, "terminal:two", "Terminal 2")) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one", "terminal:two"]) + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two") + dispose() + }) + }) + + it("switches the active side terminal on select", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" }) + item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" }) + item.state.setSideActive(LOCAL, "terminal:two") + + item.handlers.selectSide("terminal:one") + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one") + expect(item.state.focusRequest()?.id).toBe("terminal:one") + dispose() + }) + }) + + it("moves activation to the last remaining side terminal on close", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" }) + item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" }) + item.state.setSideActive(LOCAL, "terminal:two") + + expect(item.handlers.closeSide("terminal:two")).toBe(true) + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one") + expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:two" }]) + + expect(item.handlers.closeSide("terminal:one")).toBe(true) + expect(item.state.sideActiveFor(LOCAL)).toBeUndefined() + expect(item.state.sidesForContext(LOCAL)).toEqual([]) + + // Closing an unknown or non-side id is a no-op. + expect(item.handlers.closeSide("terminal:gone")).toBe(false) + expect(item.posted).toHaveLength(2) + dispose() + }) + }) + + it("keeps only the target side terminal on close others", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" }) + item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" }) + item.state.add(null, { id: "terminal:three", title: "Terminal 3", wsUrl: "ws://three", font, placement: "side" }) + // Another context must survive untouched: "others" is per context. + item.state.add("wt-1", { id: "terminal:other", title: "Other", wsUrl: "ws://other", font, placement: "side" }) + item.state.setSideActive(LOCAL, "terminal:one") + + item.handlers.closeSideOthers("terminal:two") + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:two"]) + expect(item.state.sidesForContext("wt-1").map((term) => term.id)).toEqual(["terminal:other"]) + // The survivor becomes visible and focused, like selecting its tab. + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two") + expect(item.state.focusRequest()?.id).toBe("terminal:two") + expect(item.posted).toEqual([ + { type: "agentManager.terminal.close", terminalId: "terminal:one" }, + { type: "agentManager.terminal.close", terminalId: "terminal:three" }, + ]) + dispose() + }) + }) + + it("waits for Run closure confirmation while user terminal closes stay optimistic", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:user", title: "Terminal 1", wsUrl: "ws://user", font, placement: "side" }) + item.dispatch(script("script:run")) + + expect(item.handlers.closeSide("script:run")).toBe(true) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:user", "script:run"]) + expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "script:run" }]) + + expect(item.handlers.closeSide("terminal:user")).toBe(true) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["script:run"]) + expect(item.posted).toEqual([ + { type: "agentManager.terminal.close", terminalId: "script:run" }, + { type: "agentManager.terminal.close", terminalId: "terminal:user" }, + ]) + dispose() + }) + }) + + it("closes a stale side answer whose create request is unknown", () => { + createRoot((dispose) => { + const item = scene() + // A created message for a createId the webview never sent (e.g. it + // reloaded while the PTY was starting) must not leak the PTY. + item.dispatch(createdSide("stale-id", "terminal:stale")) + expect(item.state.sidesForContext(LOCAL)).toEqual([]) + expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:stale" }]) + dispose() + }) + }) + it("creates explicit terminal tabs independently of the side destination", () => { createRoot((dispose) => { const item = scene("wt-1") @@ -124,44 +328,91 @@ describe("Agent Manager terminal state", () => { }) }) - it("cancels a side terminal that is still starting", () => { + it("routes side creates of a worktree context to that worktree", () => { 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), + const item = scene("wt-1") + item.handlers.addSide() + expect(item.posted[0]).toMatchObject({ + type: "agentManager.terminal.create", placement: "side", - worktreeId: null, - terminalId: "terminal:late", - title: "Terminal 1", - wsUrl: "ws://late", - font, + worktreeId: "wt-1", }) - 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", () => { + it("tracks OSC titles per terminal without touching the terminal records", () => { 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) + item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" }) + const before = item.state.sidesForContext(LOCAL)[0]! + + item.state.setTitle("terminal:one", "npm run dev") + expect(item.state.title("terminal:one")).toBe("npm run dev") + // Reference stability: the stored record is untouched so does + // not remount the xterm instance on a title change. + expect(item.state.sidesForContext(LOCAL)[0]).toBe(before) + + // Empty titles are ignored; removal drops the override. + item.state.setTitle("terminal:one", " ") + expect(item.state.title("terminal:one")).toBe("npm run dev") + item.state.remove("terminal:one") + expect(item.state.title("terminal:one")).toBeUndefined() + dispose() + }) + }) + + it("reorders side terminals within their context via drag", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" }) + item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" }) + item.state.add(null, { id: "terminal:three", title: "Terminal 3", wsUrl: "ws://three", font, placement: "side" }) + item.state.add(null, { id: "terminal:tab", title: "Terminal 4", wsUrl: "ws://tab", font, placement: "tab" }) + + // Drag the first side terminal onto the third position. + expect(item.state.reorderSideDrag(LOCAL, "terminal:one", "terminal:three")).toBe(true) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([ + "terminal:two", + "terminal:three", + "terminal:one", + ]) + // Tab terminals are untouched. + expect(item.state.current().map((term) => term.id)).toEqual(["terminal:tab"]) + + // The order survives switching to another context and back. + item.setSelection("wt-1") + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([ + "terminal:two", + "terminal:three", + "terminal:one", + ]) + item.setSelection(LOCAL) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([ + "terminal:two", + "terminal:three", + "terminal:one", + ]) + + // Unknown ids, tab-placement ids, and foreign contexts are rejected. + expect(item.state.reorderSideDrag(LOCAL, "terminal:gone", "terminal:two")).toBe(false) + expect(item.state.reorderSideDrag(LOCAL, "terminal:tab", "terminal:two")).toBe(false) + expect(item.state.reorderSideDrag("wt-1", "terminal:two", "terminal:three")).toBe(false) + dispose() + }) + }) + + it("reports the focused side terminal only for the current context", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:side", title: "Terminal 1", wsUrl: "ws://side", font, placement: "side" }) + item.state.add(null, { id: "terminal:tab", title: "Terminal 2", wsUrl: "ws://tab", font, placement: "tab" }) + + expect(item.state.sideFocusedId()).toBeUndefined() + item.state.setFocusedId("terminal:tab") + expect(item.state.sideFocusedId()).toBeUndefined() + item.state.setFocusedId("terminal:side") + expect(item.state.sideFocusedId()).toBe("terminal:side") dispose() }) }) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts new file mode 100644 index 0000000000..5d2e5bc3e1 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "bun:test" +import { createRoot } from "solid-js" +import { createWorktreeDiffs } from "../../webview-ui/agent-manager/worktree-diffs" +import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages" + +const diff = (file: string, additions = 1): WorktreeFileDiff => ({ + file, + before: "", + after: "", + additions, + deletions: 0, +}) + +interface Sent { + type: string + sessionId?: string + file?: string +} + +// Only `postMessage` is exercised by the diff workflow, so a recording stub is +// enough — the signals and merge/pending logic under test are the real thing. +const vscode = (sent: Sent[]) => + ({ postMessage: (msg: Sent) => sent.push(msg) }) as unknown as Parameters[0] + +const withDiffs = (fn: (diffs: ReturnType, sent: Sent[]) => void) => { + createRoot((dispose) => { + const sent: Sent[] = [] + fn(createWorktreeDiffs(vscode(sent)), sent) + dispose() + }) +} + +describe("createWorktreeDiffs", () => { + it("stores full diffs per session", () => { + withDiffs((diffs) => { + diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] }) + expect(diffs.diffDatas()["s1"]).toHaveLength(1) + }) + }) + + it("does not replace state when an update produces an identical diff list", () => { + withDiffs((diffs) => { + diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] }) + const before = diffs.diffDatas() + diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] }) + expect(diffs.diffDatas()).toBe(before) + }) + }) + + it("replaces a single file on a diffFile message and clears its pending flag", () => { + withDiffs((diffs) => { + diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts", 1)] }) + diffs.onWorktreeDiffFile({ + type: "agentManager.worktreeDiffFile", + sessionId: "s1", + file: "a.ts", + diff: diff("a.ts", 9), + }) + expect(diffs.diffDatas()["s1"]![0]!.additions).toBe(9) + expect(diffs.diffFileLoadingFor(() => "s1").size).toBe(0) + }) + }) + + it("tracks panel loading via diffLoading", () => { + withDiffs((diffs) => { + diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: true }) + expect(diffs.diffLoading()).toBe(true) + diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: false }) + expect(diffs.diffLoading()).toBe(false) + }) + }) + + it("requestDiffFile marks a file pending, posts once, and ignores repeats", () => { + withDiffs((diffs, sent) => { + diffs.requestDiffFile("s1", "a.ts") + diffs.requestDiffFile("s1", "a.ts") + expect(sent.filter((m) => m.type === "agentManager.requestWorktreeDiffFile")).toHaveLength(1) + expect(diffs.diffFileLoadingFor(() => "s1").has("a.ts")).toBe(true) + }) + }) + + it("refreshStaleDiffs requests only files not already loading", () => { + withDiffs((diffs, sent) => { + diffs.requestDiffFile("s1", "a.ts") + diffs.refreshStaleDiffs("s1", new Set(["a.ts", "b.ts"])) + const files = sent.filter((m) => m.type === "agentManager.requestWorktreeDiffFile").map((m) => m.file) + expect(files).toEqual(["a.ts", "b.ts"]) + }) + }) + + it("clears the session key once its last pending file resolves", () => { + withDiffs((diffs) => { + diffs.requestDiffFile("s1", "a.ts") + diffs.onWorktreeDiffFile({ + type: "agentManager.worktreeDiffFile", + sessionId: "s1", + file: "a.ts", + diff: diff("a.ts"), + }) + expect(diffs.diffFileLoadingFor(() => "s1").size).toBe(0) + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/diff-scope.test.ts b/packages/kilo-vscode/tests/unit/diff-scope.test.ts new file mode 100644 index 0000000000..7714ae4fa2 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-scope.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "bun:test" +import { + composeDiffId, + parseDiffId, + isDiffScope, + normalizeScope, + scopeToSourceId, + DEFAULT_DIFF_SCOPE, +} from "../../src/agent-manager/diff-scope" + +describe("diff-scope composite ids", () => { + it("round-trips context and scope", () => { + expect(parseDiffId(composeDiffId("local", "branch"))).toEqual({ ctx: "local", scope: "branch" }) + expect(parseDiffId(composeDiffId("ses_abc", "staged"))).toEqual({ ctx: "ses_abc", scope: "staged" }) + expect(parseDiffId(composeDiffId("ses_abc", "unstaged"))).toEqual({ ctx: "ses_abc", scope: "unstaged" }) + expect(parseDiffId(composeDiffId("ses_abc", "session"))).toEqual({ ctx: "ses_abc", scope: "session" }) + }) + + it("parses session ids containing no separator as default branch scope", () => { + expect(parseDiffId("ses_abc")).toEqual({ ctx: "ses_abc", scope: DEFAULT_DIFF_SCOPE }) + }) + + it("treats an unknown trailing segment as part of the context, not a scope", () => { + // A session id that happens to contain '#' but not a valid scope keeps the + // full id as context and falls back to branch. + expect(parseDiffId("ses_a#bogus")).toEqual({ ctx: "ses_a#bogus", scope: DEFAULT_DIFF_SCOPE }) + }) + + it("isDiffScope guards the closed enum", () => { + expect(isDiffScope("branch")).toBe(true) + expect(isDiffScope("staged")).toBe(true) + expect(isDiffScope("unstaged")).toBe(true) + expect(isDiffScope("session")).toBe(true) + expect(isDiffScope("turn")).toBe(false) + expect(isDiffScope("")).toBe(false) + }) + + it("normalizeScope falls back to branch for unknown input", () => { + expect(normalizeScope("staged")).toBe("staged") + expect(normalizeScope("nope")).toBe("branch") + expect(normalizeScope(undefined)).toBe("branch") + expect(normalizeScope(42)).toBe("branch") + }) + + it("maps scopes to catalog source ids", () => { + expect(scopeToSourceId("branch", "ses_abc")).toBe("workspace") + expect(scopeToSourceId("staged", "ses_abc")).toBe("staged") + expect(scopeToSourceId("unstaged", "ses_abc")).toBe("unstaged") + expect(scopeToSourceId("session", "ses_abc")).toBe("session:ses_abc") + expect(scopeToSourceId("branch", "local")).toBe("workspace") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/extension-arch.test.ts b/packages/kilo-vscode/tests/unit/extension-arch.test.ts index 3e8e4d3067..160823e513 100644 --- a/packages/kilo-vscode/tests/unit/extension-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/extension-arch.test.ts @@ -127,7 +127,7 @@ describe("Extension — package.json command sync", () => { expect(terminal).toMatchObject({ key: "ctrl+/", mac: "cmd+/", - when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'", + when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' && !kilo-code.new.sidebarFocused", }) expect(create).toMatchObject({ key: "ctrl+shift+t", diff --git a/packages/kilo-vscode/tests/unit/i18n-keys.test.ts b/packages/kilo-vscode/tests/unit/i18n-keys.test.ts index 11c8c277e3..1aa3db8111 100644 --- a/packages/kilo-vscode/tests/unit/i18n-keys.test.ts +++ b/packages/kilo-vscode/tests/unit/i18n-keys.test.ts @@ -41,6 +41,7 @@ import { dict as appTr } from "../../webview-ui/src/i18n/tr" import { dict as appNl } from "../../webview-ui/src/i18n/nl" import { dict as appUk } from "../../webview-ui/src/i18n/uk" import { dict as appIt } from "../../webview-ui/src/i18n/it" +import { dict as appFa } from "../../webview-ui/src/i18n/fa" // Layer 2: upstream UI (@opencode-ai/ui re-exported via @kilocode/kilo-ui) import { dict as uiEn } from "../../../ui/src/i18n/en" @@ -114,6 +115,7 @@ import { dict as cliTr } from "../../src/services/cli-backend/i18n/tr" import { dict as cliNl } from "../../src/services/cli-backend/i18n/nl" import { dict as cliUk } from "../../src/services/cli-backend/i18n/uk" import { dict as cliIt } from "../../src/services/cli-backend/i18n/it" +import { dict as cliFa } from "../../src/services/cli-backend/i18n/fa" import { dict as hostEn } from "../../src/services/i18n/en" import { dict as hostZh } from "../../src/services/i18n/zh" @@ -135,6 +137,7 @@ import { dict as hostTr } from "../../src/services/i18n/tr" import { dict as hostNl } from "../../src/services/i18n/nl" import { dict as hostUk } from "../../src/services/i18n/uk" import { dict as hostIt } from "../../src/services/i18n/it" +import { dict as hostFa } from "../../src/services/i18n/fa" // ── Locale maps ───────────────────────────────────────────────────────────── @@ -161,6 +164,7 @@ const appLocales: Record> = { nl: appNl, uk: appUk, it: appIt, + fa: appFa, } const kiloLocales: Record> = { @@ -230,6 +234,7 @@ const cliLocales: Record> = { nl: cliNl, uk: cliUk, it: cliIt, + fa: cliFa, } const hostLocales: Record> = { @@ -253,6 +258,7 @@ const hostLocales: Record> = { nl: hostNl, uk: hostUk, it: hostIt, + fa: hostFa, } // Merge webview dictionaries in the same priority order as language.tsx diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts index 31ed93fd4f..cc698f3e0f 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test" import type { Config } from "@kilocode/sdk/v2/client" +import { fetchSnapshot } from "../../src/kilo-provider/config-snapshot" // vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts) const { KiloProvider } = await import("../../src/KiloProvider") @@ -17,6 +18,7 @@ type Internals = { projectUnset?: string[][], ) => Promise fetchAndSendConfig: () => Promise + fetchAndSendConfigUpdated: () => Promise fetchAndSendProviders: () => Promise fetchAndSendAgents: () => Promise fetchAndSendSkills: () => Promise @@ -28,17 +30,18 @@ type Internals = { function createConnection() { let drains = 0 const patches: unknown[] = [] + const config = { snapshot: true } + const global = { snapshot: false } + const project = { default_agent: "code" } const client = { global: { config: { - get: async () => ({ data: {} }), - update: async () => ({ data: {} }), + get: async () => ({ data: global }), }, }, config: { - get: async () => ({ data: {} }), - update: async () => ({ data: {} }), - overlay: async () => ({ data: { project: {} } }), + get: async () => ({ data: config }), + overlay: async () => ({ data: { project } }), overlayUpdate: async (patch: unknown) => { patches.push(patch) return { data: {} } @@ -47,6 +50,7 @@ function createConnection() { } return { + client, drains: () => drains, patches: () => patches, service: { @@ -59,6 +63,29 @@ function createConnection() { } describe("KiloProvider indexing refresh", () => { + it("shares snapshot payloads across load, SSE refresh, and post-save refresh", async () => { + const conn = createConnection() + const settings = () => ({ maxCost: 0, languageCommitMessage: "sync" }) + const snapshot = await fetchSnapshot(conn.client as never, "/repo", settings) + const provider = new KiloProvider({} as never, conn.service as never) + const internal = provider as unknown as Internals + const sent: unknown[] = [] + provider.postMessage = (message) => void sent.push(message) + Object.assign(internal, { connectionState: "connected", commitMessageLanguageSetting: () => "sync" }) + await internal.fetchAndSendConfig() + await internal.fetchAndSendConfigUpdated() + await internal.handleUpdateConfig({}) + + expect(sent).toEqual([ + { type: "configLoaded", ...snapshot }, + { type: "configUpdated", ...snapshot }, + { type: "configUpdated", ...snapshot }, + ]) + sent.length = 0 + internal.connectionState = "disconnected" + await internal.fetchAndSendConfig() + expect(sent).toEqual([{ type: "configLoaded", ...snapshot }]) + }) it("reloadAfterAuthChange fetches config first, then indexing status", async () => { const provider = new KiloProvider({} as never, {} as never) const internal = provider as unknown as Internals diff --git a/packages/kilo-vscode/tests/unit/language-utils.test.ts b/packages/kilo-vscode/tests/unit/language-utils.test.ts index cc1a8c286b..b4dc8e0aee 100644 --- a/packages/kilo-vscode/tests/unit/language-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/language-utils.test.ts @@ -33,6 +33,11 @@ describe("normalizeLocale", () => { expect(normalizeLocale("ko-KR")).toBe("ko") }) + it("returns 'fa' for Persian", () => { + expect(normalizeLocale("fa")).toBe("fa") + expect(normalizeLocale("fa-IR")).toBe("fa") + }) + it("returns 'no' for Norwegian Bokmål", () => { expect(normalizeLocale("nb")).toBe("no") expect(normalizeLocale("nb-NO")).toBe("no") diff --git a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts index df7a44d25d..406076ec60 100644 --- a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts +++ b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts @@ -141,7 +141,9 @@ describe("handlePermissionResponse", () => { await handlePermissionResponse(fake, "p1", "s1", "once", [], []) - expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }]) + expect(replies).toEqual([ + { requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature", interactive: true }, + ]) }) it("saves selected rules and replies in the recorded SSE directory", async () => { @@ -158,7 +160,9 @@ describe("handlePermissionResponse", () => { deniedAlways: ["rm *"], }, ]) - expect(replies).toEqual([{ requestID: "p1", reply: "reject", directory: "/workspace/.kilo/worktrees/feature" }]) + expect(replies).toEqual([ + { requestID: "p1", reply: "reject", directory: "/workspace/.kilo/worktrees/feature", interactive: true }, + ]) }) it("treats an SDK-wrapped 404 while saving rules as stale", async () => { @@ -192,7 +196,9 @@ describe("handlePermissionResponse", () => { await handlePermissionResponse(fake, "p1", "s1", "once", [], []) - expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }]) + expect(replies).toEqual([ + { requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature", interactive: true }, + ]) expect(permDirs.has("p1")).toBe(false) expect(messages).toEqual([{ type: "permissionError", permissionID: "p1", stale: true }]) }) diff --git a/packages/kilo-vscode/tests/unit/prompt-rail.test.ts b/packages/kilo-vscode/tests/unit/prompt-rail.test.ts new file mode 100644 index 0000000000..18c800e1bb --- /dev/null +++ b/packages/kilo-vscode/tests/unit/prompt-rail.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from "bun:test" +import { messageTurns } from "../../webview-ui/src/context/session-queue" +import { transcriptRows } from "../../webview-ui/src/context/transcript-rows" +import type { Message, Part, TextPart } from "../../webview-ui/src/types/messages" +import { + capacity, + historyAction, + previewText, + promptItems, + railEntries, +} from "../../webview-ui/src/components/chat/prompt-rail" + +const base = { + sessionID: "session", + createdAt: "2026-01-01T00:00:00.000Z", + time: { created: 1 }, +} + +const user = (id: string, opts: Partial = {}): Message => ({ ...base, id, role: "user", ...opts }) +const assistant = (id: string, parentID: string, opts: Partial = {}): Message => ({ + ...base, + id, + parentID, + role: "assistant", + ...opts, +}) +const text = (id: string, messageID: string, value: string, opts: Partial = {}): Part => ({ + id, + messageID, + type: "text", + text: value, + ...opts, +}) +const tool = (id: string, messageID: string, title: string): Part => ({ + id, + messageID, + type: "tool", + tool: "bash", + state: { status: "completed", input: {}, output: "", title }, +}) +const lookup = (values: Record) => (id: string) => values[id] ?? [] + +describe("previewText", () => { + it("collapses whitespace and keeps plain text", () => { + expect(previewText(" hello\n\nworld ")).toBe("hello world") + }) + + it("drops fenced code blocks and inline code", () => { + expect(previewText("before `const x = 1` after\n```\nconst y = 2\n```\nend")).toBe("before after end") + }) + + it("keeps link labels but drops URLs and images", () => { + expect(previewText("see [the docs](https://example.com) and ![shot](img.png) now")).toBe("see the docs and now") + }) + + it("keeps bracket text inside inline code literal", () => { + expect(previewText("echo `[label](url)` verbatim")).toBe("echo verbatim") + }) + + it("strips heading, list, and quote markers", () => { + expect(previewText("# Title\n- one\n> two\nthree")).toBe("Title one two three") + }) +}) + +describe("promptItems", () => { + it("emits one item per non-partial user turn with prompt and answer", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const u2 = user("u2") + const a2 = assistant("a2", "u2") + const parts = { + u1: [text("up1", "u1", "add authentication")], + a1: [text("ap1", "a1", "Done, auth is wired up.")], + u2: [text("up2", "u2", "now fix the bug")], + a2: [text("ap2", "a2", "Fixed it.")], + } + const rows = transcriptRows(messageTurns([u1, a1, u2, a2]), lookup(parts)) + + const items = promptItems(rows) + + expect(items).toEqual([ + { key: "u1:user", turn: "u1", queued: false, prompt: "add authentication", answer: "Done, auth is wired up." }, + { key: "u2:user", turn: "u2", queued: false, prompt: "now fix the bug", answer: "Fixed it." }, + ]) + }) + + it("keeps assistant chunks of one turn joined", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = { + u1: [text("up1", "u1", "go")], + a1: [text("ap1", "a1", "First."), text("ap2", "a1", "Second.")], + } + const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts)) + + expect(promptItems(rows)[0]?.answer).toBe("First. Second.") + }) + + it("skips synthetic parts and leaves an empty answer for tool-only turns", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = { + u1: [text("up1", "u1", "run it"), text("up2", "u1", "internal", { synthetic: true })], + a1: [tool("at1", "a1", "Run tests"), text("at2", "a1", "hidden", { synthetic: true })], + } + const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts)) + + expect(promptItems(rows)[0]).toMatchObject({ prompt: "run it", answer: "" }) + }) + + it("marks queued rows", () => { + const u1 = user("u1") + const u2 = user("u2") + const rows = transcriptRows(messageTurns([u1, u2]), lookup({}), { queued: new Set(["u2"]) }) + + expect(promptItems(rows).map((item) => item.queued)).toEqual([false, true]) + }) + + it("truncates long prompts and answers", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = { + u1: [text("up1", "u1", "x".repeat(400))], + a1: [text("ap1", "a1", "y".repeat(400))], + } + const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts)) + + const [item] = promptItems(rows) + expect(item!.prompt.length).toBe(160) + expect(item!.prompt.endsWith("…")).toBe(true) + expect(item!.answer.length).toBe(220) + expect(item!.answer.endsWith("…")).toBe(true) + }) + + it("omits partial turns (assistant-only leads)", () => { + const a1 = assistant("a1", "u1") + const rows = transcriptRows(messageTurns([a1]), lookup({ a1: [text("p1", "a1", "hello")] })) + + expect(promptItems(rows)).toEqual([]) + }) + + it("promotes the answer when the prompt carries no text (image-only message)", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = { + u1: [{ id: "up1", messageID: "u1", type: "file", mime: "image/png", url: "data:," } as Part], + a1: [text("ap1", "a1", "That screenshot shows the rail overlapping the gutter.")], + } + const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts)) + + expect(promptItems(rows)[0]).toMatchObject({ + prompt: "That screenshot shows the rail overlapping the gutter.", + answer: "", + }) + }) + + it("leaves both empty when neither prompt nor answer has text", () => { + const u1 = user("u1") + const rows = transcriptRows(messageTurns([u1]), lookup({})) + + expect(promptItems(rows)[0]).toMatchObject({ prompt: "", answer: "" }) + }) +}) + +describe("capacity", () => { + it("counts how many ticks fit the transcript height", () => { + expect(capacity(24 + 7 * 5)).toBe(5) + expect(capacity(31)).toBe(1) + }) + + it("fits far more ticks than the navigator lists rows", () => { + // A tick is a hairline, so a sidebar-height transcript holds a whole + // session's prompts rather than the handful of card rows that fit. + expect(capacity(724)).toBe(100) + }) + + it("returns nothing usable for unmeasured or tiny transcripts", () => { + expect(capacity(0)).toBeLessThan(1) + expect(capacity(30)).toBeLessThan(1) + }) +}) + +describe("historyAction", () => { + it("loads the next page only after the previous page made progress", () => { + expect(historyAction(80, 160, true)).toBe("load") + }) + + it("jumps after the final page", () => { + expect(historyAction(160, 200, false)).toBe("jump") + }) + + it("stops instead of retrying a page that made no progress", () => { + expect(historyAction(160, 160, true)).toBe("stop") + }) +}) + +describe("railEntries", () => { + const items = Array.from({ length: 5 }, (_, i) => ({ + key: `k${i}`, + turn: `t${i}`, + queued: false, + prompt: `p${i}`, + answer: `a${i}`, + })) + + it("passes through when everything fits", () => { + expect(railEntries(items, 5)).toEqual(items.map((item, index) => ({ type: "prompt", item, index }))) + expect(railEntries(items, 10)).toEqual(items.map((item, index) => ({ type: "prompt", item, index }))) + }) + + it("keeps the first and latest prompts at minimal capacity", () => { + expect(railEntries(items, 2)).toEqual([ + { type: "prompt", item: items[0], index: 0 }, + { type: "prompt", item: items[4], index: 4 }, + ]) + }) + + it("summarizes hidden loaded prompts between the first and recent prompts", () => { + expect(railEntries(items, 4)).toEqual([ + { type: "prompt", item: items[0], index: 0 }, + { type: "overflow", count: 2, index: 1 }, + { type: "prompt", item: items[3], index: 3 }, + { type: "prompt", item: items[4], index: 4 }, + ]) + }) + + it("reserves the first entry for unloaded history", () => { + expect(railEntries(items, 4, true)).toEqual([ + { type: "history" }, + { type: "overflow", count: 3, index: 0 }, + { type: "prompt", item: items[3], index: 3 }, + { type: "prompt", item: items[4], index: 4 }, + ]) + }) + + it("returns nothing at zero capacity", () => { + expect(railEntries(items, 0)).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/run-message.test.ts b/packages/kilo-vscode/tests/unit/run-message.test.ts new file mode 100644 index 0000000000..2f122026da --- /dev/null +++ b/packages/kilo-vscode/tests/unit/run-message.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, mock } from "bun:test" +import type { RunController } from "../../src/agent-manager/run/controller" +import { handleRunMessage } from "../../src/agent-manager/run/message" +import type { AgentManagerInMessage } from "../../src/agent-manager/types" + +function controller() { + const run = mock(() => Promise.resolve()) + const stop = mock(() => undefined) + const configure = mock(() => Promise.resolve()) + return { + value: { run, stop, configure } as unknown as RunController, + run, + stop, + configure, + } +} + +describe("Agent Manager Run messages", () => { + it.each(["agentManager", "vscode"] as const)("forwards the %s dropdown destination", (destination) => { + const item = controller() + const msg = { + type: "agentManager.runScript", + worktreeId: "wt-1", + destination, + } satisfies AgentManagerInMessage + + expect(handleRunMessage(item.value, msg)).toBe(true) + expect(item.run).toHaveBeenCalledWith("wt-1", destination) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/run-script-manager.test.ts b/packages/kilo-vscode/tests/unit/run-script-manager.test.ts index baaab57e3f..59bd3c2df8 100644 --- a/packages/kilo-vscode/tests/unit/run-script-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/run-script-manager.test.ts @@ -86,7 +86,7 @@ describe("RunScriptManager", () => { let stopped = 0 await ctx.manager.start("wt-1", async () => ({ stop: () => stopped++ })) - ctx.manager.remove("wt-1") + await ctx.manager.remove("wt-1") expect(stopped).toBe(1) expect(ctx.manager.all()).toEqual([]) @@ -123,12 +123,28 @@ describe("RunScriptManager", () => { it("finish after remove does not resurrect stale state", async () => { const ctx = createManager() await ctx.manager.start("wt-1", async () => ({ stop: () => {} })) - ctx.manager.remove("wt-1") + await ctx.manager.remove("wt-1") ctx.manager.finish("wt-1", { exitCode: 0 }) expect(ctx.manager.all()).toEqual([]) }) + it("stops and disposes once when removal races startup", async () => { + const ctx = createManager() + const gate = deferred() + let stopped = 0 + let disposed = 0 + const started = ctx.manager.start("wt-1", () => gate.promise) + const removed = ctx.manager.remove("wt-1") + + gate.resolve({ stop: () => stopped++, dispose: () => disposed++ }) + await Promise.all([started, removed]) + + expect(stopped).toBe(1) + expect(disposed).toBe(1) + expect(ctx.manager.all()).toEqual([]) + }) + it("dispose tolerates handles that throw on stop", async () => { const ctx = createManager() await ctx.manager.start("wt-1", async () => ({ diff --git a/packages/kilo-vscode/tests/unit/run-terminal-destination.test.ts b/packages/kilo-vscode/tests/unit/run-terminal-destination.test.ts new file mode 100644 index 0000000000..d2a16dee95 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/run-terminal-destination.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "bun:test" +import type { StartTask } from "../../src/agent-manager/run/controller" +import { pickRunStart } from "../../src/agent-manager/run/destination" + +describe("Run terminal destination", () => { + it("picks the adapter matching the panel dropdown destination", () => { + const handle = { stop: () => undefined, dispose: () => undefined } + const embedded: StartTask = async () => handle + const integrated: StartTask = async () => handle + + expect(pickRunStart("agentManager", embedded, integrated)).toBe(embedded) + expect(pickRunStart("vscode", embedded, integrated)).toBe(integrated) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/script-terminal-manager.test.ts b/packages/kilo-vscode/tests/unit/script-terminal-manager.test.ts new file mode 100644 index 0000000000..01bea86435 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/script-terminal-manager.test.ts @@ -0,0 +1,357 @@ +import { describe, expect, it } from "bun:test" +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { ScriptTerminalManager, type ScriptTerminalView } from "../../src/agent-manager/ScriptTerminalManager" +import { buildScriptTerminalWsUrl } from "../../src/agent-manager/script-terminal-url" +import { RunScriptManager, type RunStatus } from "../../src/agent-manager/run/manager" + +interface PtyInput { + location?: { directory?: string } + command?: string + args?: string[] + cwd?: string + env?: Record + title?: string +} + +interface PtyUpdate { + ptyID: string + location?: { directory?: string } + size?: { cols: number; rows: number } +} + +interface PtyInfo { + id: string + title: string + command: string + args: string[] + cwd: string + status: "running" | "exited" + pid: number + exitCode?: number +} + +interface PtyResponse { + data?: { location: { directory: string }; data: PtyInfo } + error?: unknown +} + +function wait(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +function deferred() { + let resolve: (value: T) => void = () => undefined + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function info(status: PtyInfo["status"] = "running", exitCode?: number): PtyInfo { + return { + id: "pty-1", + title: "Run", + command: "bun", + args: ["run", "check"], + cwd: "/repo/worktree", + status, + pid: 42, + ...(exitCode === undefined ? {} : { exitCode }), + } +} + +function harness(opts?: { + create?: (input: PtyInput) => Promise + get?: () => Promise + remove?: () => Promise<{ data?: unknown; error?: unknown }> +}) { + const calls: { create: PtyInput[]; get: unknown[]; update: PtyUpdate[]; remove: unknown[] } = { + create: [], + get: [], + update: [], + remove: [], + } + const snapshots: ScriptTerminalView[][] = [] + const closed: string[] = [] + const logs: string[] = [] + const client = { + v2: { + pty: { + create: async (input: PtyInput) => { + calls.create.push(input) + return opts?.create ? opts.create(input) : { data: { location: { directory: config.cwd }, data: info() } } + }, + get: async (input: unknown) => { + calls.get.push(input) + return opts?.get ? opts.get() : { data: { location: { directory: config.cwd }, data: info() } } + }, + update: async (input: PtyUpdate) => { + calls.update.push(input) + return { data: info() } + }, + remove: async (input: unknown) => { + calls.remove.push(input) + return opts?.remove ? opts.remove() : { data: undefined } + }, + }, + }, + } as unknown as KiloClient + const manager = new ScriptTerminalManager({ + getClient: () => client, + getClientAsync: async () => client, + buildWsUrl: (ptyID, cwd) => `ws://127.0.0.1:4096/api/pty/${ptyID}/connect?location=${cwd}`, + getTerminalFont: () => ({ fontFamily: "Menlo", fontSize: 12 }), + emit: (terminals) => snapshots.push(terminals), + closed: (terminalId) => closed.push(terminalId), + log: (msg) => logs.push(msg), + }) + return { manager, calls, snapshots, closed, logs } +} + +const config = { + worktreeId: "wt-1", + command: "bun", + args: ["run", "check"], + cwd: "/repo/worktree", + env: { PATH: "/bin", WORKTREE_PATH: "/repo/worktree" }, +} + +describe("ScriptTerminalManager", () => { + it("creates a Run PTY with explicit command settings and a safe snapshot", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + + expect(ctx.calls.create).toEqual([ + { + location: { directory: "/repo/worktree" }, + command: "bun", + args: ["run", "check"], + cwd: "/repo/worktree", + env: { PATH: "/bin", WORKTREE_PATH: "/repo/worktree" }, + title: "Run", + }, + ]) + expect(ctx.calls.get).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(ctx.snapshots.at(-1)).toEqual([ + expect.objectContaining({ + worktreeId: "wt-1", + kind: "run", + title: "Run", + state: "running", + font: { fontFamily: "Menlo", fontSize: 12 }, + }), + ]) + expect(JSON.stringify(ctx.snapshots.at(-1))).not.toContain('"command"') + expect(JSON.stringify(ctx.snapshots.at(-1))).not.toContain('"env"') + expect(done).toEqual([]) + }) + + it("normalizes the internal local Run key to a null external worktree id", async () => { + const ctx = harness() + + await ctx.manager.start("run", { ...config, worktreeId: "local", cwd: "/repo" }, () => undefined) + + expect(ctx.snapshots.at(-1)?.[0]?.worktreeId).toBeNull() + }) + + it("builds canonical authenticated replay URLs", () => { + const value = buildScriptTerminalWsUrl( + { baseUrl: "http://127.0.0.1:4096", password: "secret" }, + "pty / 1", + "/repo/worktree", + ) + const url = new URL(value) + + expect(url.protocol).toBe("ws:") + expect(url.pathname).toBe("/api/pty/pty%20%2F%201/connect") + expect(url.searchParams.get("location[directory]")).toBe("/repo/worktree") + expect(url.searchParams.get("cursor")).toBe("0") + expect(url.searchParams.get("replayExited")).toBe("1") + expect(url.searchParams.get("auth_token")).toBe(Buffer.from("kilo:secret").toString("base64")) + }) + + it("finishes once on a natural exit and retains the replayable terminal", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Run terminal") + ctx.manager.exited("pty-1", 17) + ctx.manager.exited("pty-1", 17) + + expect(done).toEqual([{ exitCode: 17 }]) + expect(ctx.calls.remove).toEqual([]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "exited", exitCode: 17 })]) + + expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true) + await wait() + expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(ctx.closed).toEqual([terminalId]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("reconciles a PTY that exited before registration", async () => { + const ctx = harness({ + get: async () => ({ data: { location: { directory: config.cwd }, data: info("exited", 7) } }), + }) + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + + expect(done).toEqual([{ exitCode: 7 }]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 7 })]) + }) + + it("reconciles an exit event that arrives before create registration", async () => { + const gate = deferred() + let state = info() + const ctx = harness({ + create: async () => gate.promise, + get: async () => ({ data: { location: { directory: config.cwd }, data: state } }), + }) + const done: unknown[] = [] + const started = ctx.manager.start("run", config, (exit) => done.push(exit)) + + await wait() + state = info("exited", 9) + ctx.manager.exited("pty-1", 9) + gate.resolve({ data: { location: { directory: config.cwd }, data: info() } }) + await started + + expect(done).toEqual([{ exitCode: 9 }]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 9 })]) + }) + + it("treats an already removed backend PTY as a successful close", async () => { + const ctx = harness({ remove: async () => ({ error: { _tag: "PtyNotFoundError", status: 404 } }) }) + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Run terminal") + + expect(await ctx.manager.close(terminalId)).toBe(true) + expect(done).toEqual([{ stopped: true }]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("stops a PTY when stop races startup", async () => { + const gate = deferred() + const ctx = harness({ create: async () => gate.promise }) + const statuses: RunStatus[] = [] + const run = new RunScriptManager( + () => undefined, + (status) => statuses.push({ ...status }), + () => new Date("2026-01-02T03:04:05.000Z"), + ) + const started = run.start("wt-1", () => ctx.manager.start("run", config, (exit) => run.finish("wt-1", exit))) + + await wait() + await run.stop("wt-1") + gate.resolve({ data: { location: { directory: config.cwd }, data: info() } }) + await started + await wait() + + expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(statuses.map((status) => status.state)).toEqual(["running", "stopping", "idle"]) + expect(run.status("wt-1")).toMatchObject({ state: "idle", stopped: true }) + }) + + it("intercepts resize and stops a running terminal when it closes", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Run terminal") + expect(ctx.manager.intercept({ type: "agentManager.terminal.resize", terminalId, cols: 120, rows: 40 })).toBe(true) + await wait() + expect(ctx.calls.update).toEqual([ + { ptyID: "pty-1", location: { directory: "/repo/worktree" }, size: { cols: 120, rows: 40 } }, + ]) + + expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true) + await wait() + expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(done).toEqual([{ stopped: true }]) + expect(ctx.closed).toEqual([terminalId]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("retries closure after a Run terminal removal fails", async () => { + let attempt = 0 + const ctx = harness({ + remove: async () => { + attempt++ + if (attempt === 1) return { error: new Error("still running") } + return { data: undefined } + }, + }) + + await ctx.manager.start("run", config, () => undefined) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Run terminal") + expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true) + await wait() + + expect(ctx.closed).toEqual([]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "failed" })]) + + expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true) + await wait() + + expect(ctx.calls.remove).toHaveLength(2) + expect(ctx.closed).toEqual([terminalId]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("drops a retained Run terminal when the backend evicts it", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + ctx.manager.exited("pty-1", 0) + ctx.manager.deleted("pty-1") + + expect(done).toEqual([{ exitCode: 0 }]) + expect(ctx.snapshots.at(-1)).toEqual([]) + expect(ctx.calls.remove).toEqual([]) + }) + + it("reconciles a natural exit missed during an event-stream reconnect", async () => { + let state: PtyInfo = info() + const ctx = harness({ get: async () => ({ data: { location: { directory: config.cwd }, data: state } }) }) + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + state = info("exited", 23) + await ctx.manager.sync() + + expect(done).toEqual([{ exitCode: 23 }]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 23 })]) + }) + + it("clears retained exited terminals by worktree context", async () => { + const ctx = harness() + + await ctx.manager.start("run", config, () => undefined) + ctx.manager.exited("pty-1", 0) + + expect(await ctx.manager.clear("run", "wt-1")).toBe(true) + expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("replays the full retained snapshot after a webview reload", async () => { + const ctx = harness() + + await ctx.manager.start("run", config, () => undefined) + const first = ctx.snapshots.at(-1) + ctx.manager.snapshot() + + expect(ctx.snapshots.at(-1)).toEqual(first) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-outcome.test.ts b/packages/kilo-vscode/tests/unit/session-outcome.test.ts index 8dd06b6dd6..93b8475e5f 100644 --- a/packages/kilo-vscode/tests/unit/session-outcome.test.ts +++ b/packages/kilo-vscode/tests/unit/session-outcome.test.ts @@ -138,6 +138,13 @@ describe("terminal", () => { ).toBe("error") }) + it("hides superseded turns that handed off to a queued follow-up", () => { + expect( + terminal({ reason: "superseded", messages: [message("tool-calls")], todos: [todo("pending")] }), + ).toBeUndefined() + expect(terminal({ reason: "superseded", messages: [message("unknown")], todos: [] })).toBeUndefined() + }) + it("reports only the latest assistant finish reason", () => { const user: Message = { id: "u1", sessionID: "s1", role: "user", createdAt: new Date(1).toISOString() } expect(terminal({ reason: "completed", messages: [message("length"), user], todos: [] })?.finish).toBeUndefined() diff --git a/packages/kilo-vscode/tests/unit/session-search.test.ts b/packages/kilo-vscode/tests/unit/session-search.test.ts new file mode 100644 index 0000000000..0bae8bb682 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-search.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "bun:test" +import { handleSessionSearch } from "../../src/kilo-provider/session-search" + +type Query = Record + +function stub(data: Array> | Error) { + const calls: Query[] = [] + const client = { + experimental: { + session: { + list: async (query: Query) => { + calls.push(query) + if (data instanceof Error) throw data + return { data } + }, + }, + }, + } + return { calls, client } +} + +function session(id: string, title: string, updated: number, worktreeName?: string) { + return { id, title, time: { updated }, worktreeName } +} + +describe("handleSessionSearch", () => { + it("lists root sessions across the worktree family for the resolved directory", async () => { + const { calls, client } = stub([session("ses_a", "Alpha", 2, "neon-author")]) + const posted: unknown[] = [] + + await handleSessionSearch({ + client: client as never, + message: { requestId: "r1", sessionID: "ses_current" }, + dir: (id) => (id === "ses_current" ? "/repo/.kilo/worktrees/wt-1" : "/repo"), + post: (msg) => posted.push(msg), + }) + + expect(calls).toEqual([{ worktrees: true, roots: true, directory: "/repo/.kilo/worktrees/wt-1", limit: 50 }]) + expect(posted).toEqual([ + { + type: "sessionSearchResult", + sessions: [{ id: "ses_a", title: "Alpha", updated: 2, worktreeName: "neon-author" }], + requestId: "r1", + }, + ]) + }) + + it("falls back to the current and context sessions for directory resolution", async () => { + const { calls, client } = stub([]) + + await handleSessionSearch({ + client: client as never, + message: { requestId: "r2" }, + current: "ses_current", + context: "ses_context", + dir: (id) => `/dir/${id}`, + post: () => {}, + }) + + expect(calls[0]?.directory).toBe("/dir/ses_current") + + await handleSessionSearch({ + client: client as never, + message: { requestId: "r3" }, + context: "ses_context", + dir: (id) => `/dir/${id}`, + post: () => {}, + }) + + expect(calls[1]?.directory).toBe("/dir/ses_context") + }) + + it("excludes the given session and sessions without titles", async () => { + const { client } = stub([ + session("ses_keep", "Keep", 3), + session("ses_exclude", "Excluded", 2), + session("ses_untitled", "", 1), + ]) + const posted: Array<{ sessions: Array<{ id: string }> }> = [] + + await handleSessionSearch({ + client: client as never, + message: { requestId: "r4" }, + dir: () => "/repo", + exclude: "ses_exclude", + post: (msg) => posted.push(msg as never), + }) + + expect(posted[0]?.sessions.map((s) => s.id)).toEqual(["ses_keep"]) + }) + + it("posts an empty result when the client is missing or the list fails", async () => { + const posted: unknown[] = [] + + await handleSessionSearch({ + client: null, + message: { requestId: "r5" }, + dir: () => "/repo", + post: (msg) => posted.push(msg), + }) + + const failing = stub(new Error("boom")) + await handleSessionSearch({ + client: failing.client as never, + message: { requestId: "r6" }, + dir: () => "/repo", + post: (msg) => posted.push(msg), + }) + + expect(posted).toEqual([ + { type: "sessionSearchResult", sessions: [], requestId: "r5" }, + { type: "sessionSearchResult", sessions: [], requestId: "r6" }, + ]) + }) +}) 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 4e763196bf..5669acaeac 100644 --- a/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect } from "bun:test" import path from "node:path" import { Project, SyntaxKind } from "ts-morph" import { SessionTerminalManager, type TerminalHost } from "../../src/agent-manager/SessionTerminalManager" +import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" const ROOT = path.resolve(import.meta.dir, "../..") const FILE = path.join(ROOT, "src/agent-manager/SessionTerminalManager.ts") @@ -130,8 +131,8 @@ describe("SessionTerminalManager structure", () => { expect(text).toContain("panel command registration skipped") }) - it("resolves the session that owns the active managed terminal", () => { - const text = body("activeSession") + it("resolves the key that owns the active managed terminal", () => { + const text = body("activeKey") expect(text).toContain("this.host.activeTerminal()") expect(text).toContain("entry.terminal === active") }) @@ -139,7 +140,8 @@ describe("SessionTerminalManager structure", () => { it("rejects context capture from another managed session", () => { const text = body("prepareContext") expect(text).toContain("this.showExisting(sessionId)") - expect(text).toContain("this.activeSession()") + expect(text).toContain("this.activeKey()") + expect(text).toContain("SessionTerminalManager.sessionKey(sessionId)") }) }) @@ -162,3 +164,74 @@ describe("SessionTerminalManager command restoration", () => { state.manager.dispose() }) }) + +describe("SessionTerminalManager worktree terminals", () => { + function scene(opts: { worktreePath?: string; repoPath?: string } = {}) { + const created: Array<{ cwd: string; name: string }> = [] + const warnings: string[] = [] + let shown = 0 + const host: TerminalHost = { + createTerminal(o) { + created.push(o) + return { + show: () => shown++, + dispose() {}, + exitStatus: undefined, + } + }, + activeTerminal: () => undefined, + repoPath: () => opts.repoPath, + showWarning: (msg) => warnings.push(msg), + setContext() {}, + onTerminalClosed: () => ({ dispose() {} }), + onActiveTerminalChanged: () => ({ dispose() {} }), + registerCommand: () => ({ dispose() {} }), + executeCommand: () => Promise.resolve(), + } + const state = { + getWorktree: (id: string) => + opts.worktreePath ? { id, path: opts.worktreePath, branch: "feature/x" } : undefined, + } as unknown as WorktreeStateManager + const manager = new SessionTerminalManager(() => {}, host) + return { manager, state, created, warnings, shown: () => shown } + } + + it("creates a terminal rooted at the worktree path", () => { + const s = scene({ worktreePath: "/repo/.kilo/worktrees/wt-1", repoPath: "/repo" }) + s.manager.showWorktreeTerminal("wt-1", s.state) + expect(s.created).toEqual([{ cwd: "/repo/.kilo/worktrees/wt-1", name: "Agent: feature/x" }]) + expect(s.shown()).toBe(1) + }) + + it("reuses the live terminal on repeat calls", () => { + const s = scene({ worktreePath: "/repo/.kilo/worktrees/wt-1" }) + s.manager.showWorktreeTerminal("wt-1", s.state) + s.manager.showWorktreeTerminal("wt-1", s.state) + expect(s.created).toHaveLength(1) + expect(s.shown()).toBe(2) + }) + + it("keeps session and worktree terminal keys in separate namespaces", () => { + const s = scene({ worktreePath: "/repo/.kilo/worktrees/wt-1", repoPath: "/repo" }) + s.manager.showTerminal("worktree:wt-1", undefined) + s.manager.showWorktreeTerminal("wt-1", s.state) + expect(s.created).toEqual([ + { cwd: "/repo", name: "Agent: local" }, + { cwd: "/repo/.kilo/worktrees/wt-1", name: "Agent: feature/x" }, + ]) + expect(s.shown()).toBe(2) + }) + + it("falls back to the repo root when the worktree is unknown", () => { + const s = scene({ repoPath: "/repo" }) + s.manager.showWorktreeTerminal("gone", s.state) + expect(s.created).toEqual([{ cwd: "/repo", name: "Agent: worktree" }]) + }) + + it("warns and creates nothing when no cwd resolves", () => { + const s = scene({}) + s.manager.showWorktreeTerminal("gone", s.state) + expect(s.created).toHaveLength(0) + expect(s.warnings).toHaveLength(1) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/worktree-diff-controller.test.ts b/packages/kilo-vscode/tests/unit/worktree-diff-controller.test.ts new file mode 100644 index 0000000000..7c3078f7a0 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/worktree-diff-controller.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "bun:test" +import { WorktreeDiffController } from "../../src/agent-manager/worktree-diff-controller" +import type { DiffSourceCatalog } from "../../src/diff/sources/catalog" +import type { DiffSource } from "../../src/diff/sources/types" +import type { PanelContext } from "../../src/diff/types" +import type { GitOps } from "../../src/agent-manager/GitOps" +import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" + +// Records every PanelContext handed to catalog.build so tests can assert which +// base branch the active source was (re)built with. The controller, scope +// resolution, and SourceController lifecycle under test are all real. +function make(onFetch?: (n: number) => Promise) { + const builds: { id: string; ctx: PanelContext }[] = [] + let fetches = 0 + const catalog = { + build: (id: string, ctx: PanelContext): DiffSource => { + builds.push({ id, ctx }) + return { + descriptor: { id, type: "workspace", group: "Git", capabilities: { revert: true, comments: true } }, + async fetch() { + await onFetch?.(++fetches) + return { diffs: [] } + }, + } + }, + } as unknown as DiffSourceCatalog + + const state = { + getSession: (id: string) => (id === "s1" ? { id: "s1", worktreeId: "w1", createdAt: "" } : undefined), + getWorktree: (id: string) => + id === "w1" ? { id: "w1", path: "/wt", parentBranch: "main", remote: "origin" } : undefined, + } as unknown as WorktreeStateManager + + const controller = new WorktreeDiffController({ + getState: () => state, + getRoot: () => "/repo", + getStateReady: () => undefined, + catalog, + git: {} as GitOps, + localDiffFile: async () => null, + post: () => {}, + log: () => {}, + }) + return { controller, builds } +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) + +async function waitFor(cond: () => boolean): Promise { + for (let i = 0; i < 50; i++) { + if (cond()) return + await tick() + } + throw new Error("waitFor timed out") +} + +describe("WorktreeDiffController.setBase", () => { + it("rebuilds the active source against the overridden base branch", async () => { + const { controller, builds } = make() + controller.start("s1#branch") + await waitFor(() => builds.length === 1) + expect(builds[0]!.ctx.dir).toBe("/wt") + expect(builds[0]!.ctx.baseBranch).toBe("origin/main") + + await controller.setBase("s1#branch", "feature-x") + expect(builds.length).toBe(2) + expect(builds[1]!.ctx.dir).toBe("/wt") + expect(builds[1]!.ctx.baseBranch).toBe("feature-x") + + // Clearing the override falls back to the recorded parent ref. + await controller.setBase("s1#branch", undefined) + expect(builds.length).toBe(3) + expect(builds[2]!.ctx.baseBranch).toBe("origin/main") + + controller.stop() + }) + + it("stores the override without rebuilding when the context isn't active", async () => { + const { controller, builds } = make() + + await controller.setBase("s1#branch", "feature-x") + expect(builds.length).toBe(0) + + // The next activation of that context resolves the stored override. + controller.start("s1#branch") + await waitFor(() => builds.length === 1) + expect(builds[0]!.ctx.baseBranch).toBe("feature-x") + + controller.stop() + }) + + it("keeps watching when the base changes during the initial fetch", async () => { + // Hold the first activation's fetch in flight, simulating a slow worktree + // diff. isPolling is still false in this window, but the watch intent must + // survive the base change rather than downgrading the panel to one-shot. + let release: () => void = () => {} + const gate = new Promise((resolve) => (release = resolve)) + const { controller, builds } = make(async (n) => { + if (n === 1) await gate + }) + + controller.start("s1#branch") + await waitFor(() => builds.length === 1) + + const change = controller.setBase("s1#branch", "feature-x") + release() + await change + expect(builds.length).toBe(2) + expect(builds[1]!.ctx.baseBranch).toBe("feature-x") + + // Polling survives: start() early-returns for an id that is already + // watched. A downgraded one-shot panel would re-activate and rebuild here. + controller.start("s1#branch") + await tick() + expect(builds.length).toBe(2) + + controller.stop() + }) +}) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.mts b/packages/kilo-vscode/tests/visual-regression.spec.mts index 3f210bb7cb..437ac4bd85 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.mts +++ b/packages/kilo-vscode/tests/visual-regression.spec.mts @@ -75,9 +75,12 @@ async function settle(page: Page) { // Spinner animation captures at an indeterminate frame, causing flaky diffs. // Permission dock config-preloaded has non-deterministic toggle rendering. // Sandboxing rows can settle at different scroll heights after settings context updates. +// Side terminal tabs mount live xterm instances whose websocket error text +// lands at indeterminate times. const SKIP = new Set([ "agentmanager--worktree-item-busy", "agentmanager--full-screen-diff-agent-edit-scroll", + "agentmanager--side-terminal-panel-tabs", "composite-webview--permission-dock-config-preloaded", "settings--sandboxing-allowlist", "settings--sandboxing-panel", diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts b/packages/kilo-vscode/tests/visual-regression.spec.ts index 3f210bb7cb..437ac4bd85 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts @@ -75,9 +75,12 @@ async function settle(page: Page) { // Spinner animation captures at an indeterminate frame, causing flaky diffs. // Permission dock config-preloaded has non-deterministic toggle rendering. // Sandboxing rows can settle at different scroll heights after settings context updates. +// Side terminal tabs mount live xterm instances whose websocket error text +// lands at indeterminate times. const SKIP = new Set([ "agentmanager--worktree-item-busy", "agentmanager--full-screen-diff-agent-edit-scroll", + "agentmanager--side-terminal-panel-tabs", "composite-webview--permission-dock-config-preloaded", "settings--sandboxing-allowlist", "settings--sandboxing-panel", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index ce6f795941..dac2895596 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -23,9 +23,8 @@ import type { AgentManagerWorktreeDiffMessage, AgentManagerWorktreeDiffFileMessage, AgentManagerWorktreeDiffLoadingMessage, + AgentManagerDiffBranchesMessage, AgentManagerApplyWorktreeDiffResultMessage, - AgentManagerApplyWorktreeDiffStatus, - AgentManagerApplyWorktreeDiffConflict, AgentManagerWorktreeStatsMessage, AgentManagerLocalStatsMessage, WorktreeFileDiff, @@ -40,8 +39,8 @@ import type { SessionInfo, SessionCreatedMessage, BranchInfo, + TerminalDestination, } from "../src/types/messages" -import { IndexingProvider } from "../src/context/indexing" import { DragDropProvider, DragDropSensors, @@ -51,43 +50,24 @@ import { createSortable, } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" -import { ThemeProvider } from "@kilocode/kilo-ui/theme" -import { DialogProvider, useDialog } from "@kilocode/kilo-ui/context/dialog" +import { useDialog } from "@kilocode/kilo-ui/context/dialog" import { Dialog } from "@kilocode/kilo-ui/dialog" import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu" -import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" -import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" -import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff" -import { FileComponentProvider } from "@kilocode/kilo-ui/context/file" -import { Code } from "@kilocode/kilo-ui/code" -import { Diff } from "@kilocode/kilo-ui/diff" -import { File } from "@kilocode/kilo-ui/file" -import { Toast, showToast } from "@kilocode/kilo-ui/toast" +import { showToast } from "@kilocode/kilo-ui/toast" import { ResizeHandle } from "@kilocode/kilo-ui/resize-handle" import { Icon } from "@kilocode/kilo-ui/icon" 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 { VSCodeProvider, useVSCode } from "../src/context/vscode" -import { ServerProvider } from "../src/context/server" -import { ProviderProvider } from "../src/context/provider" -import { ConfigProvider } from "../src/context/config" -import { DisplayProvider } from "../src/context/display" -import { KiloEmbeddingModelsProvider } from "../src/context/kilo-embedding-models" -import { ImageModelsProvider } from "../src/context/image-models" -import { NotificationsProvider } from "../src/context/notifications" -import { FeedbackProvider } from "../src/context/feedback" -import { MemoryProvider } from "../src/context/memory" -import { SessionProvider, useSession } from "../src/context/session" -import { AgentRequirementsProvider } from "../src/context/agent-requirements" +import { useVSCode } from "../src/context/vscode" +import { useSession } from "../src/context/session" import { WorktreeModeProvider } from "../src/context/worktree-mode" +import { ProviderShell } from "../src/context/provider-shell" import { ChatView } from "../src/components/chat" -import { SpeechToTextPrewarm } from "../src/components/speech-to-text/SpeechToTextPrewarm" import HistoryView from "../src/components/history/HistoryView" import { NewWorktreeDialog } from "./NewWorktreeDialog" -import { DataBridge, MermaidDownloadBridge } from "../src/App" -import { LanguageBridge } from "../src/context/language-bridge" +import { DataBridge } from "../src/App" import { useLanguage } from "../src/context/language" import { createTabFocus } from "../src/utils/tab-navigation" import { @@ -126,14 +106,17 @@ import { createTerminalHandlers, createTerminalMessageHandler, createSideTerminal, + readSavedDestination, + resolveRunScriptRequest, + resolveVscodeTerminalRequest, } from "./terminal" import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering" import { useTabScroll } from "./tab-scroll" import { DiffPanel } from "./DiffPanel" import { createRevertFile } from "./revert-file" import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView" -import { ApplyDialog } from "./ApplyDialog" -import { groupApplyConflicts } from "./apply-conflicts" +import { createApplyToLocal } from "./apply-to-local" +import { createWorktreeDiffs } from "./worktree-diffs" import type { ReviewComment } from "../diff-viewer/review-comments" import { clearReviewComposer, createReviewComposer } from "../diff-viewer/review-annotations" import type { SidebarSearchMenuRef } from "./SidebarSearchMenu" @@ -156,7 +139,9 @@ import { } from "./section-helpers" import { sectionAwareDetector } from "./section-dnd" import { ConstrainDragXAxis } from "./constrain-drag-x" -import { mergeWorktreeDiffs } from "../diff-viewer/diff-state" +import { DiffScopeControls } from "../diff-viewer/DiffScopeControls" +import { scopeCapabilities } from "./diff-scope-state" +import { createDiffReviewScope } from "./diff-review-scope" import { initialMessage, seedInitialVariant } from "./initial-message" import { createMarkdownRender } from "./review-preferences" import { createSidebarCollapse } from "./sidebar-collapse" @@ -183,12 +168,6 @@ interface WorktreeBusyState { message?: string branch?: string } - -interface ApplyState { - status: AgentManagerApplyWorktreeDiffStatus - message: string - conflicts: AgentManagerApplyWorktreeDiffConflict[] -} /** 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" | "terminal" | null @@ -241,6 +220,8 @@ const AgentManagerContent: Component = () => { const [repoBranch, setRepoBranch] = createSignal() const [busyWorktrees, setBusyWorktrees] = createSignal>(new Map()) const [staleWorktreeIds, setStaleWorktreeIds] = createSignal>(new Set()) + /** True while the ⌘/Ctrl jump modifier is held — reveals the ⌘1-9 badges on all sidebar items. */ + const [held, setHeld] = createSignal(false) const [worktreesLoaded, setWorktreesLoaded] = createSignal(false) const [sessionsLoaded, setSessionsLoaded] = createSignal(false) const [isGitRepo, setIsGitRepo] = createSignal(true) @@ -282,9 +263,10 @@ const AgentManagerContent: Component = () => { const [history, setHistory] = createSignal(false) const [sidePanel, setSidePanel] = createSignal(null) const diffOpen = () => sidePanel() === "diff" - const [diffDatas, setDiffDatas] = createSignal>({}) - const [diffLoading, setDiffLoading] = createSignal(false) - const [diffFileLoading, setDiffFileLoading] = createSignal>>({}) + const diffs = createWorktreeDiffs(vscode) + const diffDatas = diffs.diffDatas + const diffLoading = diffs.diffLoading + const setDiffLoading = diffs.setDiffLoading // 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 @@ -331,12 +313,6 @@ const AgentManagerContent: Component = () => { // Local repo git stats (branch name, diff additions/deletions, commits) const [localStats, setLocalStats] = createSignal() - // Per-worktree apply-to-local status - const [applyStates, setApplyStates] = createSignal>({}) - const [applyTarget, setApplyTarget] = createSignal() - const [applySelectedFiles, setApplySelectedFiles] = createSignal([]) - const [applySelectionTouched, setApplySelectionTouched] = createSignal(false) - const PENDING_PREFIX = "pending:" const closedDrafts = new Set() const [activePendingId, setActivePendingId] = createSignal() @@ -391,12 +367,6 @@ const AgentManagerContent: Component = () => { setReviewCommentsByContext((prev) => ({ ...prev, [sel]: comments })) } - const applyStateForSelection = createMemo(() => { - const sel = selection() - if (!sel || sel === LOCAL) return undefined - return applyStates()[sel] - }) - const resolveWorktreeSessionId = (worktreeId: string) => { const id = session.currentSessionID() if (id) { @@ -406,157 +376,19 @@ const AgentManagerContent: Component = () => { return managedSessions().find((entry) => entry.worktreeId === worktreeId)?.id } - const applyTargetSessionId = createMemo(() => { - const target = applyTarget() - if (!target) return undefined - return resolveWorktreeSessionId(target) + const apply = createApplyToLocal({ + vscode, + dialog, + t, + selection, + local: LOCAL, + worktrees, + diffDatas, + diffLoading, + resolveWorktreeSessionId, + track: metrics.track, }) - - const applyDiffs = createMemo(() => { - const target = applyTarget() - if (!target) return [] as WorktreeFileDiff[] - const data = diffDatas() - const current = applyTargetSessionId() - if (current && data[current]) return data[current]! - const ids = managedSessions() - .filter((entry) => entry.worktreeId === target) - .map((entry) => entry.id) - for (const id of ids) { - if (data[id]) return data[id]! - } - return [] as WorktreeFileDiff[] - }) - - const applyStateForTarget = createMemo(() => { - const target = applyTarget() - if (!target) return undefined - return applyStates()[target] - }) - - const applyBusyForTarget = createMemo(() => { - const state = applyStateForTarget() - if (!state) return false - return state.status === "checking" || state.status === "applying" - }) - - const applySelectedSet = createMemo(() => new Set(applySelectedFiles())) - - const applySelectionStats = createMemo(() => { - const set = applySelectedSet() - const selected = applyDiffs().filter((diff) => set.has(diff.file)) - const additions = selected.reduce((sum, diff) => sum + diff.additions, 0) - const deletions = selected.reduce((sum, diff) => sum + diff.deletions, 0) - return { - total: applyDiffs().length, - selected: selected.length, - additions, - deletions, - } - }) - - const applyHasSelection = createMemo(() => applySelectionStats().selected > 0) - - const applyConflictRows = createMemo(() => groupApplyConflicts(applyStateForTarget()?.conflicts ?? [])) - - const applyToLocal = (worktreeId: string, selectedFiles: string[]) => { - setApplyStates((prev) => ({ - ...prev, - [worktreeId]: { - status: "checking", - message: t("agentManager.apply.checking"), - conflicts: [], - }, - })) - vscode.postMessage({ type: "agentManager.applyWorktreeDiff", worktreeId, selectedFiles }) - } - - const resetApplyDialog = () => { - setApplyTarget(undefined) - setApplySelectedFiles([]) - setApplySelectionTouched(false) - } - - const closeApplyDialog = () => { - resetApplyDialog() - dialog.close() - } - - const applySelectAll = () => { - setApplySelectionTouched(true) - setApplySelectedFiles(applyDiffs().map((diff) => diff.file)) - } - - const applySelectNone = () => { - setApplySelectionTouched(true) - setApplySelectedFiles([]) - } - - const applyToggleFile = (file: string, checked: boolean) => { - setApplySelectionTouched(true) - setApplySelectedFiles((prev) => { - if (checked) { - if (prev.includes(file)) return prev - const set = new Set(prev) - set.add(file) - return applyDiffs() - .map((diff) => diff.file) - .filter((path) => set.has(path)) - } - if (!prev.includes(file)) return prev - return prev.filter((path) => path !== file) - }) - } - - const triggerApply = () => { - const target = applyTarget() - if (!target) return - if (!applyHasSelection()) return - if (applyBusyForTarget()) return - metrics.track("apply_to_local", "apply_dialog", { fileCount: applySelectedFiles().length }) - applyToLocal(target, applySelectedFiles()) - } - - const openApplyDialog = () => { - const sel = selection() - if (!sel || sel === LOCAL) return - setApplyStates((prev) => { - if (!prev[sel]) return prev - const next = { ...prev } - delete next[sel] - return next - }) - setApplyTarget(sel) - setApplySelectionTouched(false) - setApplySelectedFiles([]) - const sid = resolveWorktreeSessionId(sel) - if (sid) vscode.postMessage({ type: "agentManager.requestWorktreeDiff", sessionId: sid }) - - setApplySelectedFiles(applyDiffs().map((diff) => diff.file)) - - dialog.show( - () => ( - - ), - resetApplyDialog, - ) - } + const openApplyDialog = apply.openApplyDialog const openWorktreeDirectory = () => { const sel = selection() @@ -572,47 +404,22 @@ const AgentManagerContent: Component = () => { vscode.postMessage({ type: "agentManager.openPR", worktreeId: sel }) } - const runWorktree = (id: string) => { + const runWorktree = (id: string, destination: TerminalDestination) => { const state = runStatuses()[id]?.state ?? "idle" if (state === "running" || state === "stopping") { vscode.postMessage({ type: "agentManager.stopRunScript", worktreeId: id }) return } - vscode.postMessage({ type: "agentManager.runScript", worktreeId: id }) + vscode.postMessage(resolveRunScriptRequest(id, destination)) } const configureRunScript = () => vscode.postMessage({ type: "agentManager.configureRunScript" }) const runSelected = () => { const sel = selection() - if (sel) runWorktree(sel) + if (sel) runWorktree(sel, sideCtl.destination()) } - createEffect( - on( - () => [applyTarget(), applyDiffs(), applySelectionTouched()] as const, - ([target, diffs, touched]) => { - if (!target) return - const files = diffs.map((diff) => diff.file) - if (files.length === 0) { - if (!touched) setApplySelectedFiles([]) - return - } - - if (!touched) { - setApplySelectedFiles(files) - return - } - - const current = applySelectedFiles() - const set = new Set(current) - const next = files.filter((file) => set.has(file)) - const same = next.length === current.length && next.every((file, index) => file === current[index]) - if (!same) setApplySelectedFiles(next) - }, - ), - ) - const isPending = (id: string) => id.startsWith(PENDING_PREFIX) reportRemoteSessions(vscode, localSessionIDs, managedSessions, isPending) @@ -728,14 +535,6 @@ const AgentManagerContent: Component = () => { if (Object.keys(next).length === Object.keys(prev).length) return prev return next }) - setApplyStates((prev) => { - const next = Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id))) - if (Object.keys(next).length === Object.keys(prev).length) return prev - return next - }) - - const target = applyTarget() - if (target && !ids.has(target)) closeApplyDialog() }) const worktreeSessionIds = createMemo( @@ -1136,7 +935,7 @@ const AgentManagerContent: Component = () => { requestAnimationFrame(() => sidebarSearchMenu?.open()) } } else if (msg.action === "showTerminal") { - sideCtl.openPreferred("keyboard_shortcut") + if (!sideCtl.echo()) sideCtl.openPreferred("keyboard_shortcut") } else if (msg.action === "toggleDiff") { if (reviewActive()) { closeReviewTab() @@ -1194,6 +993,13 @@ const AgentManagerContent: Component = () => { } window.addEventListener("keydown", preventDefaults, true) + // Cmd/Ctrl+/ toggles the terminal even when VS Code's webview keybinding + // forwarding drops the key before it reaches the workbench (reported with + // the prompt input focused). When forwarding does work, the extension + // echoes the shortcut back as an action message and sideCtl dedupes it. + const shortcut = (e: KeyboardEvent) => sideCtl.press(e) + window.addEventListener("keydown", shortcut, true) + // Delete/Backspace on a selected worktree triggers inline delete confirmation. // Pressing the key twice in a row (within the 2500ms window) confirms the delete. const deleteKeyHandler = (e: KeyboardEvent) => { @@ -1207,6 +1013,18 @@ const AgentManagerContent: Component = () => { } window.addEventListener("keydown", deleteKeyHandler) + // Reveal the ⌘/Ctrl+1-9 jump badges on all sidebar items while the modifier is held. + // Capture phase so the terminal's key handlers can't swallow them; blur resets state + // when the keyup is lost (e.g. Cmd+Tab away). + const modifier = isMac ? "Meta" : "Control" + const modTrack = (e: KeyboardEvent) => { + if (e.key === modifier) setHeld(e.type === "keydown") + } + const modReset = () => setHeld(false) + window.addEventListener("keydown", modTrack, true) + window.addEventListener("keyup", modTrack, true) + window.addEventListener("blur", modReset) + // When the panel regains focus (e.g. returning from terminal), focus the prompt // and clear any stale body styles left by Kobalte modal overlays (dropdowns/dialogs // set pointer-events:none and overflow:hidden on body, but cleanup never runs if @@ -1281,9 +1099,17 @@ const AgentManagerContent: Component = () => { 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) + if (sidePanel() === "terminal" && !history() && !reviewActive() && terms.sideKey() === contextKey) { + terms.requestFocus(terminalId) + } }, - onDestinationChanged: (destination) => sideCtl.setDestination(destination), + onScriptRunning: (contextKey, terminalId) => { + if (terms.sideKey() !== contextKey) return + showSideTerminal() + terms.setSideActive(contextKey, terminalId) + terms.requestFocus(terminalId) + }, + onDestinationChanged: (destination) => sideCtl.syncDefault(destination), }) const unsubTerminals = vscode.onMessage((msg) => { terminalDispatch(msg) @@ -1492,65 +1318,23 @@ const AgentManagerContent: Component = () => { } if (msg.type === "agentManager.worktreeDiff") { - const ev = msg as AgentManagerWorktreeDiffMessage - let staleFiles: Set | undefined - setDiffDatas((prev) => { - const existing = prev[ev.sessionId] - const merged = existing - ? mergeWorktreeDiffs(existing, ev.diffs) - : { diffs: ev.diffs, stale: new Set() } - staleFiles = merged.stale - const next = merged.diffs - if (existing && existing.length === next.length && existing.every((old, i) => old === next[i])) return prev - return { ...prev, [ev.sessionId]: next } - }) - if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles) + diffs.onWorktreeDiff(msg as AgentManagerWorktreeDiffMessage) } if (msg.type === "agentManager.worktreeDiffFile") { - const ev = msg as AgentManagerWorktreeDiffFileMessage - if (ev.diff) { - setDiffDatas((prev) => { - const existing = prev[ev.sessionId] ?? [] - const next = existing.map((item) => (item.file === ev.diff!.file ? ev.diff! : item)) - return { ...prev, [ev.sessionId]: next } - }) - setDiffFilePending(ev.sessionId, ev.diff.file, false) - return - } - setDiffFilePending(ev.sessionId, ev.file, false) + diffs.onWorktreeDiffFile(msg as AgentManagerWorktreeDiffFileMessage) } if (msg.type === "agentManager.worktreeDiffLoading") { - const ev = msg as AgentManagerWorktreeDiffLoadingMessage - setDiffLoading(ev.loading) + diffs.onWorktreeDiffLoading(msg as AgentManagerWorktreeDiffLoadingMessage) + } + + if (msg.type === "agentManager.diffBranches") { + review.onBranches(msg as AgentManagerDiffBranchesMessage) } if (msg.type === "agentManager.applyWorktreeDiffResult") { - const ev = msg as AgentManagerApplyWorktreeDiffResultMessage - const files = new Set((ev.conflicts ?? []).map((entry) => entry.file).filter(Boolean)).size - const count = ev.conflicts?.length ?? 0 - setApplyStates((prev) => ({ - ...prev, - [ev.worktreeId]: { - status: ev.status, - message: ev.message, - conflicts: ev.conflicts ?? [], - }, - })) - - if (ev.status === "success") { - showToast({ variant: "success", title: t("agentManager.apply.success"), description: ev.message }) - if (applyTarget() === ev.worktreeId) closeApplyDialog() - } - if (ev.status === "conflict") { - const summary = - count > 0 ? t("agentManager.apply.conflictToast", { count, files: Math.max(files, 1) }) : ev.message - showToast({ variant: "error", title: t("agentManager.apply.conflict"), description: summary }) - } - if (ev.status === "error") { - showToast({ variant: "error", title: t("agentManager.apply.error"), description: ev.message }) - } + apply.onApplyResult(msg as AgentManagerApplyWorktreeDiffResultMessage) } if (msg.type === "agentManager.revertWorktreeFileResult") revertCtl.onResult(msg as never) @@ -1577,7 +1361,11 @@ const AgentManagerContent: Component = () => { onCleanup(() => { window.removeEventListener("message", handler) window.removeEventListener("keydown", preventDefaults, true) + window.removeEventListener("keydown", shortcut, true) window.removeEventListener("keydown", deleteKeyHandler) + window.removeEventListener("keydown", modTrack, true) + window.removeEventListener("keyup", modTrack, true) + window.removeEventListener("blur", modReset) window.removeEventListener("focus", onWindowFocus) window.removeEventListener("newTaskRequest", newTaskHandler, true) drafts.cleanup() @@ -1617,15 +1405,47 @@ const AgentManagerContent: Component = () => { const currentDiffSessionId = createMemo(selectedDiffSessionId) - // Start/stop diff watch when panel opens/closes, review tab opens, or session changes + // Diff scope + base branch state, shared by the side panel and review tab. + const review = createDiffReviewScope({ + ctx: currentDiffSessionId, + panelOpen: diffOpen, + reviewActive, + local: LOCAL, + vscode, + }) + // The composite id (ctx#scope) the extension keys diff data by. + const diffScopeId = review.id + + // Shared scope + base-picker controls for the side panel and review tab. + const diffScopeControls = (compact: boolean) => ( + + ) + + // Start/stop diff watch when panel opens/closes, review tab opens, scope + // changes, or session changes. createEffect(() => { const panel = diffOpen() - const review = reviewActive() + const active = reviewActive() + const scope = review.scope() - if (panel || review) { + if (panel || active) { const id = currentDiffSessionId() if (id) { - vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id }) + vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id, scope }) return } vscode.postMessage({ type: "agentManager.stopDiffWatch" }) @@ -1670,33 +1490,17 @@ const AgentManagerContent: Component = () => { tabFocus.restore() } - // Data for the review tab: use local diff data for local context, - // current session for selected worktree context, or first available in that worktree. + // Data for the review tab / side panel: keyed by the composite diff id + // (ctx#scope) the extension pushes, so each scope keeps its own file set and + // switching back to a fetched scope is instant. const reviewDiffs = createMemo(() => { const data = diffDatas() - const sel = selection() - const id = session.currentSessionID() - if (sel === LOCAL) return data[LOCAL] ?? [] - if (id && data[id]) { - const current = managedSessions().find((s) => s.id === id) - if (sel && current?.worktreeId === sel) return data[id]! - } - if (!sel) return [] - const ids = managedSessions() - .filter((s) => s.worktreeId === sel) - .map((s) => s.id) - for (const sid of ids) { - if (data[sid]) return data[sid]! - } - return [] + const key = diffScopeId() + if (!key) return [] + return data[key] ?? [] }) - const diffSessionKey = createMemo(() => { - const sel = selection() - if (sel === LOCAL) return `local:${LOCAL}` - if (sel === null) return `session:${session.currentSessionID() ?? ""}` - return `worktree:${sel}` - }) + const diffSessionKey = createMemo(() => diffScopeId() ?? "") const setSharedDiffStyle = (style: "unified" | "split") => { if (reviewDiffStyle() === style) return @@ -1704,56 +1508,15 @@ const AgentManagerContent: Component = () => { vscode.postMessage({ type: "agentManager.setReviewDiffStyle", style }) } - const setDiffFilePending = (sessionId: string, file: string, value: boolean) => { - setDiffFileLoading((prev) => { - const session = prev[sessionId] ?? {} - if (value) { - if (session[file]) return prev - return { - ...prev, - [sessionId]: { ...session, [file]: true }, - } - } - - if (!session[file]) return prev - const next = { ...session } - delete next[file] - if (Object.keys(next).length === 0) { - const result = { ...prev } - delete result[sessionId] - return result - } - return { - ...prev, - [sessionId]: next, - } - }) - } - const requestDiffFile = (file: string) => { - const sessionId = currentDiffSessionId() - if (!sessionId) return - if (diffFileLoading()[sessionId]?.[file]) return - setDiffFilePending(sessionId, file, true) - vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", sessionId, file }) + const id = diffScopeId() + if (!id) return + diffs.requestDiffFile(id, file) } - const refreshStaleDiffs = (sessionId: string, files: Set) => { - const loading = diffFileLoading()[sessionId] ?? {} - for (const file of files) { - if (loading[file]) continue - setDiffFilePending(sessionId, file, true) - vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", sessionId, file }) - } - } + const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(diffScopeId)) - const diffFileLoadingForCurrent = createMemo(() => { - const sessionId = currentDiffSessionId() - if (!sessionId) return new Set() - return new Set(Object.keys(diffFileLoading()[sessionId] ?? {})) - }) - - const revertCtl = createRevertFile(currentDiffSessionId, vscode, showToast, t) + const revertCtl = createRevertFile(diffScopeId, currentDiffSessionId, () => review.scope(), vscode, showToast, t) const handleConfigureSetupScript = () => { vscode.postMessage({ type: "agentManager.configureSetupScript" }) @@ -2092,9 +1855,6 @@ const AgentManagerContent: Component = () => { postMessage: (msg) => vscode.postMessage(msg as never), onRemove: freezeTabs, onShowSide: showSideTerminal, - onHideSide: () => { - if (sidePanel() === "terminal") setSidePanel(null) - }, getSelection: selection, LOCAL, REVIEW_TAB_ID, @@ -2102,18 +1862,25 @@ const AgentManagerContent: Component = () => { const sideCtl = createSideTerminal({ handlers: termHandlers, - visible: () => sidePanel() === "terminal", - focused: () => terms.focusedId() !== undefined && terms.focusedId() === terms.side()?.id, + visible: () => sidePanel() === "terminal" && !history() && !reviewActive(), + focusedId: () => terms.sideFocusedId(), 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" }) - }, + // Panel-local pick, immune to cross-window setting echoes (see side.ts). + saved: readSavedDestination(vscode.getState>()), + save: (d) => vscode.setState({ ...vscode.getState>(), terminalDestination: d }), + openVscode: () => + vscode.postMessage( + resolveVscodeTerminalRequest( + selection(), + session.currentSessionID(), + (wt) => managedSessions().find((ms) => ms.worktreeId === wt)?.id, + ) as never, + ), }) + createEffect(on(terms.sideKey, (key, previous) => sideCtl.syncContext(key, previous), { defer: true })) const handleReviewTabMouseDown = (e: MouseEvent) => { if (e.button !== 1) return @@ -2186,8 +1953,8 @@ const AgentManagerContent: Component = () => { if (!id) return undefined if (id === REVIEW_TAB_ID) return { id, title: t("session.tab.review") } if (isTerminalTabId(id)) { - const term = terms.lookup().get(id) - return term ? { id, title: term.title } : undefined + const title = terms.title(id) + return title ? { id, title } : undefined } return activeTabs().find((s) => s.id === id) }) @@ -2215,8 +1982,8 @@ const AgentManagerContent: Component = () => { 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) { + // surprising. Only that terminal dies; the panel keeps the rest. + if (sidePanel() === "terminal" && terms.sideFocusedId()) { if (sideCtl.close()) return } if (termHandlers.closeActive()) { @@ -2269,7 +2036,7 @@ const AgentManagerContent: Component = () => { >
@@ -2741,11 +2508,7 @@ const AgentManagerContent: Component = () => { const s = stats() return s && (s.files > 0 || s.additions > 0 || s.deletions > 0) } - const applyBusy = () => { - const state = applyStateForSelection() - if (!state) return false - return state.status === "checking" || state.status === "applying" - } + const applyBusy = apply.applyBusyForSelection return ( <> @@ -2755,12 +2518,19 @@ const AgentManagerContent: Component = () => { {t("agentManager.open.button")} - +
@@ -3108,6 +2882,9 @@ const AgentManagerContent: Component = () => { loadingFiles={diffFileLoadingForCurrent()} sessionId={currentDiffSessionId()} sessionKey={diffSessionKey()} + lead={diffScopeControls(false)} + canRevert={scopeCapabilities(review.scope()).revert} + canComment={scopeCapabilities(review.scope()).comments} comments={reviewComments()} onCommentsChange={setReviewCommentsForSelection} composer={reviewComposer} @@ -3139,53 +2916,16 @@ const AgentManagerContent: Component = () => { export const AgentManagerApp: Component = () => { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + ) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 1d5750caa4..b3787f8ff8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -1,4 +1,4 @@ -import { type Component, createSignal, createMemo, Show, createEffect, on } from "solid-js" +import { type Component, createSignal, createMemo, Show, createEffect, on, type JSXElement } from "solid-js" import type { VirtualizerHandle } from "virtua/solid" import { Diff } from "@kilocode/kilo-ui/diff" import { Accordion } from "@kilocode/kilo-ui/accordion" @@ -7,7 +7,6 @@ import { FileIcon } from "@kilocode/kilo-ui/file-icon" import { DiffChanges } from "@kilocode/kilo-ui/diff-changes" import { Icon } from "@kilocode/kilo-ui/icon" import { Button } from "@kilocode/kilo-ui/button" -import { RadioGroup } from "@kilocode/kilo-ui/radio-group" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Spinner } from "@kilocode/kilo-ui/spinner" import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" @@ -15,6 +14,7 @@ import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pie import type { WorktreeFileDiff } from "../src/types/messages" import { KILO_FILE_PATH_MIME } from "../src/utils/path-mentions" import { useLanguage } from "../src/context/language" +import { DiffStyleSelect } from "../diff-viewer/InlineSelect" import { useVSCode } from "../src/context/vscode" import { useServer } from "../src/context/server" import { useProvider } from "../src/context/provider" @@ -86,6 +86,10 @@ interface DiffPanelProps { onRevertFile?: (file: string) => void revertingFiles?: Set activeTerminalId?: string + /** Optional leading row rendered under the header (e.g. the scope selector). */ + lead?: JSXElement + /** Defaults to true. Hides the per-file Revert action when false. */ + canRevert?: boolean } export const DiffPanel: Component = (props) => { @@ -475,21 +479,18 @@ export const DiffPanel: Component = (props) => {
- {t("session.review.change.other")} + {/* Scope + base picker replace the static "Changes" title: it names + what you're looking at and is the primary control. Always shown, + so an empty scope can still be switched away from. */} + {props.lead} 0}> <> - style} - label={(style) => - style === "unified" ? t("ui.sessionReview.diffStyle.unified") : t("ui.sessionReview.diffStyle.split") - } - onSelect={(style) => { - if (!style) return - props.onDiffStyleChange?.(style) - }} + props.onDiffStyleChange?.(style)} + unifiedLabel={t("ui.sessionReview.diffStyle.unified")} + splitLabel={t("ui.sessionReview.diffStyle.split")} + title={t("ui.sessionReview.diffStyle.unified")} /> {t("session.review.filesChanged", { count: totals().files })} @@ -636,7 +637,7 @@ export const DiffPanel: Component = (props) => { /> - + = (props) => { props.onOpenPR?.() } + /** Worktree directory basename shown in the hover card (e.g. "decorous-taker"). */ + const name = () => { + const p = props.worktree.path.replace(/[\\/]+$/, "") + const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")) + return i >= 0 ? p.slice(i + 1) : p + } + return ( <> @@ -387,6 +394,11 @@ export const WorktreeItem: Component = (props) => { {props.navHint}
+
+
+ {t("agentManager.hoverCard.worktree")} + {name()} +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager-review.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager-review.css index 948dbaebc6..f9334bdfa6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager-review.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager-review.css @@ -43,12 +43,20 @@ .am-review-toolbar-left { display: flex; align-items: center; - gap: 12px; + gap: 10px; flex: 1; min-width: 0; overflow: hidden; } +/* Keep the radio group from being the tallest thing in the row so it matches + the 22px selector chips and the small ghost buttons. The inline scope/base + controls are styled in banners.css (non-am- prefixed, shared with the + standalone diff viewer). */ +.am-review-toolbar [data-component="radio-group"] { + font-size: var(--font-size-small); +} + .am-review-toolbar-right { display: flex; align-items: center; @@ -77,7 +85,7 @@ .am-review-toolbar-stats { display: flex; align-items: center; - flex: 1 1 auto; + flex: 0 100 auto; gap: 8px; font-size: var(--font-size-small); color: var(--text-weak); 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 6cfde9cf3b..51674e5f2c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -426,6 +426,25 @@ button.am-section-toggle:hover .am-section-label { color: var(--text-weak); } +/* Jump modifier held (⌘/Ctrl): reveal shortcut badges on every item, not just the + hovered one. Scoped via :has so items without a badge (beyond ⌘9) keep their stats. + The close button stays hidden so holding the modifier never exposes the delete action. */ +.am-show-shortcuts .am-wt-hover-actions:has(.am-shortcut-badge) { + opacity: 1; + visibility: visible; +} +.am-show-shortcuts .am-wt-hover-actions .am-worktree-close { + display: none; +} +.am-show-shortcuts .am-wt-actions-cell:has(.am-shortcut-badge) > .am-worktree-stats, +.am-show-shortcuts .am-wt-actions-cell:has(.am-shortcut-badge) > .am-worktree-stats-skeleton { + opacity: 0; + visibility: hidden; +} +.am-show-shortcuts .am-local-item .am-shortcut-badge { + opacity: 1; +} + .am-worktree-item:has(.am-worktree-rename-input) .am-wt-row2 { display: none; } @@ -1277,6 +1296,19 @@ button.am-section-toggle:hover .am-section-label { color: currentColor; } +.am-tab-icon[data-run-status="success"] { + color: var(--vscode-testing-iconPassed, #34d399); +} + +.am-tab-icon[data-run-status="failure"] { + color: var(--vscode-testing-iconFailed, #f87171); +} + +.am-terminal-tab-spinner { + width: 12px; + height: 12px; +} + .am-tab-label { flex: 1; min-width: 0; @@ -1698,35 +1730,58 @@ body.am-wt-dragging-active * { display: flex; align-items: center; justify-content: space-between; - padding: 4px 4px 4px 12px; + gap: 6px; + padding: 4px 4px 4px 8px; flex-shrink: 0; border-bottom: 1px solid var(--border-weak-base); position: relative; z-index: 20; background: var(--surface-base); -} - -.am-diff-header-title { - font-size: var(--font-size-small); - font-weight: 500; - color: var(--text-weak); + /* Query container for the narrow-panel rules below and in banners.css. The + panel width is user-draggable, so the header adapts to its own width + rather than the viewport. */ + container-type: inline-size; + container-name: am-diff-header; } .am-diff-header-main { display: flex; align-items: center; - gap: 10px; + gap: 6px; flex: 1; min-width: 0; + overflow: hidden; } .am-diff-header-stats { display: flex; align-items: center; gap: 8px; + flex: 0 1 auto; + min-width: 0; font-size: var(--font-size-small); color: var(--text-weak); white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Progressive disclosure, least important first. Thresholds are derived from the + measured control widths: scope 76px, base <=191px, diff style 76px, 6px gaps, + plus ~90px of action buttons and 12px padding. Each breakpoint fires while the + remaining set still fits, so nothing is ever clipped under the buttons. + The collapsed-files hint goes before the totals; both are also shown on the + worktree row and the diff toggle button, so no information is lost. */ +@container am-diff-header (max-width: 560px) { + .am-diff-header-collapsed { + display: none; + } +} + +@container am-diff-header (max-width: 520px) { + .am-diff-header-stats { + display: none; + } } .am-diff-header-adds { @@ -4599,6 +4654,70 @@ body.vscode-high-contrast-light { color: var(--text-weak); } +/* Side terminal tab strip — one row of tabs reusing the top bar's + .am-tab chrome, plus the "+" action. Height matches .am-diff-header + (32px) so switching inspector modes does not shift the panel chrome. + No vertical padding: tabs fill the strip like they fill .am-tab-bar, + which also keeps the "+" optically centered. The strip itself never + scrolls; the tab list does, so a narrow panel never pushes the "+" + action out of view (same split as .am-tab-list-wrap / + .am-tab-add-wrap). */ +.am-side-terminal-tabs { + display: flex; + align-items: stretch; + height: 32px; + padding: 0 4px; + gap: 2px; + flex-shrink: 0; + border-bottom: 1px solid var(--border-weak-base); + background: var(--surface-base); + position: relative; + z-index: 20; +} + +/* Same width model as .am-tab-list: tabs claim an equal share of the + strip up to a maximum, and the list itself only grows as wide as its + tabs, so the "+" action stays glued to the last tab instead of + drifting to the far edge of a wide panel. The cap is smaller than the + top bar's 240px because the panel is narrow. */ +.am-side-terminal-tablist { + --am-tab-max-width: 180px; + --am-tab-width: clamp(72px, calc(100% / var(--tab-count, 1)), var(--am-tab-max-width)); + display: flex; + align-items: stretch; + /* No gap, like .am-tab-list: equal-share tab widths already consume + the full width, so any gap would leave the list a few pixels + scrollable and keep the overflow fade lit for nothing. */ + flex: 0 1 calc(var(--tab-count, 1) * var(--am-tab-max-width)); + min-width: 0; + height: 100%; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; +} + +.am-side-terminal-tablist::-webkit-scrollbar { + display: none; +} + +.am-side-terminal-tablist[data-tab-widths-frozen] .am-tab-sortable { + transition: none; +} + +/* The left divider marks a terminal among session tabs in the top bar. + Every tab here is a terminal, so it would just be noise. */ +.am-side-terminal-tablist .am-tab-terminal { + border-left-color: transparent; +} + +.am-side-terminal-add { + display: flex; + align-items: center; + align-self: center; + flex-shrink: 0; + padding: 0 2px; +} + /* 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). */ @@ -4615,14 +4734,15 @@ body.vscode-high-contrast-light { flex: 1; min-height: 0; min-width: 0; - padding: 8px; background: var(--vscode-terminal-background, #1e1e1e); } /* Third-party xterm classes — addressed by attribute selector so the - agent-manager "am-* prefix" architecture test does not flag them. */ -.am-terminal-host [class="xterm"] { + agent-manager "am-* prefix" architecture test does not flag them. + FitAddon subtracts padding from xterm itself, not its parent host. */ +.am-terminal-host [class~="xterm"] { height: 100%; + padding: 8px; } .am-terminal-host [class~="xterm-viewport"] { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx b/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx new file mode 100644 index 0000000000..202427e8e7 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx @@ -0,0 +1,285 @@ +/** @jsxImportSource solid-js */ + +/** + * Apply-to-local workflow for Agent Manager. + * + * Owns everything about applying a selected worktree's diff into the local + * repo: the per-worktree apply status, the dialog's file selection state, the + * derived memos that drive `ApplyDialog`, and the result/toast handling for + * the backend's `applyWorktreeDiffResult` message. Extracted from + * `AgentManagerApp.tsx` so the app component only wires the workflow in. + */ + +import { createEffect, createMemo, createSignal, on, type Accessor } from "solid-js" +import { showToast } from "@kilocode/kilo-ui/toast" +import { groupApplyConflicts } from "./apply-conflicts" +import { ApplyDialog } from "./ApplyDialog" +import type { tracker } from "./telemetry" +import type { useDialog } from "@kilocode/kilo-ui/context/dialog" +import type { useLanguage } from "../src/context/language" +import type { useVSCode } from "../src/context/vscode" +import type { AgentManagerApplyWorktreeDiffResultMessage, WorktreeFileDiff } from "../src/types/messages" + +interface ApplyState { + status: AgentManagerApplyWorktreeDiffResultMessage["status"] + message: string + conflicts: NonNullable +} + +interface ApplyToLocalOptions { + vscode: ReturnType + dialog: ReturnType + t: ReturnType["t"] + /** Current sidebar selection (LOCAL, a worktree id, or null). */ + selection: Accessor + /** Sentinel id for the local repo selection. */ + local: string + worktrees: Accessor<{ id: string }[]> + diffDatas: Accessor> + diffLoading: Accessor + resolveWorktreeSessionId: (worktreeId: string) => string | undefined + /** Telemetry: metrics.track(name, surface, data). */ + track: ReturnType["track"] +} + +export function createApplyToLocal(opts: ApplyToLocalOptions) { + const { vscode, dialog, t, selection, local, worktrees, diffDatas, diffLoading } = opts + + const [applyStates, setApplyStates] = createSignal>({}) + const [applyTarget, setApplyTarget] = createSignal() + const [applySelectedFiles, setApplySelectedFiles] = createSignal([]) + const [applySelectionTouched, setApplySelectionTouched] = createSignal(false) + + const applyStateForSelection = createMemo(() => { + const sel = selection() + if (!sel || sel === local) return undefined + return applyStates()[sel] + }) + + const applyBusyForSelection = createMemo(() => { + const state = applyStateForSelection() + if (!state) return false + return state.status === "checking" || state.status === "applying" + }) + + const applyTargetSessionId = createMemo(() => { + const target = applyTarget() + if (!target) return undefined + return opts.resolveWorktreeSessionId(target) + }) + + const applyDiffs = createMemo(() => { + const target = applyTarget() + if (!target) return [] as WorktreeFileDiff[] + const data = diffDatas() + const current = applyTargetSessionId() + if (current && data[current]) return data[current]! + return [] as WorktreeFileDiff[] + }) + + const applyStateForTarget = createMemo(() => { + const target = applyTarget() + if (!target) return undefined + return applyStates()[target] + }) + + const applyBusyForTarget = createMemo(() => { + const state = applyStateForTarget() + if (!state) return false + return state.status === "checking" || state.status === "applying" + }) + + const applySelectedSet = createMemo(() => new Set(applySelectedFiles())) + + const applySelectionStats = createMemo(() => { + const set = applySelectedSet() + const selected = applyDiffs().filter((diff) => set.has(diff.file)) + const additions = selected.reduce((sum, diff) => sum + diff.additions, 0) + const deletions = selected.reduce((sum, diff) => sum + diff.deletions, 0) + return { + total: applyDiffs().length, + selected: selected.length, + additions, + deletions, + } + }) + + const applyHasSelection = createMemo(() => applySelectionStats().selected > 0) + + const applyConflictRows = createMemo(() => groupApplyConflicts(applyStateForTarget()?.conflicts ?? [])) + + const applyToLocal = (worktreeId: string, selectedFiles: string[]) => { + setApplyStates((prev) => ({ + ...prev, + [worktreeId]: { + status: "checking", + message: t("agentManager.apply.checking"), + conflicts: [], + }, + })) + vscode.postMessage({ type: "agentManager.applyWorktreeDiff", worktreeId, selectedFiles }) + } + + const resetApplyDialog = () => { + setApplyTarget(undefined) + setApplySelectedFiles([]) + setApplySelectionTouched(false) + } + + const closeApplyDialog = () => { + resetApplyDialog() + dialog.close() + } + + const applySelectAll = () => { + setApplySelectionTouched(true) + setApplySelectedFiles(applyDiffs().map((diff) => diff.file)) + } + + const applySelectNone = () => { + setApplySelectionTouched(true) + setApplySelectedFiles([]) + } + + const applyToggleFile = (file: string, checked: boolean) => { + setApplySelectionTouched(true) + setApplySelectedFiles((prev) => { + if (checked) { + if (prev.includes(file)) return prev + const set = new Set(prev) + set.add(file) + return applyDiffs() + .map((diff) => diff.file) + .filter((path) => set.has(path)) + } + if (!prev.includes(file)) return prev + return prev.filter((path) => path !== file) + }) + } + + const triggerApply = () => { + const target = applyTarget() + if (!target) return + if (!applyHasSelection()) return + if (applyBusyForTarget()) return + opts.track("apply_to_local", "apply_dialog", { fileCount: applySelectedFiles().length }) + applyToLocal(target, applySelectedFiles()) + } + + const openApplyDialog = () => { + const sel = selection() + if (!sel || sel === local) return + setApplyStates((prev) => { + if (!prev[sel]) return prev + const next = { ...prev } + delete next[sel] + return next + }) + setApplyTarget(sel) + setApplySelectionTouched(false) + setApplySelectedFiles([]) + const sid = opts.resolveWorktreeSessionId(sel) + if (sid) vscode.postMessage({ type: "agentManager.requestWorktreeDiff", sessionId: sid }) + + setApplySelectedFiles(applyDiffs().map((diff) => diff.file)) + + dialog.show( + () => ( + + ), + resetApplyDialog, + ) + } + + // Keep the dialog selection in step with the diff set: select everything on + // first load, then drop files that disappear from the diff without clobbering + // a selection the user has already made. + createEffect( + on( + () => [applyTarget(), applyDiffs(), applySelectionTouched()] as const, + ([target, diffs, touched]) => { + if (!target) return + const files = diffs.map((diff) => diff.file) + if (files.length === 0) { + if (!touched) setApplySelectedFiles([]) + return + } + + if (!touched) { + setApplySelectedFiles(files) + return + } + + const current = applySelectedFiles() + const set = new Set(current) + const next = files.filter((file) => set.has(file)) + const same = next.length === current.length && next.every((file, index) => file === current[index]) + if (!same) setApplySelectedFiles(next) + }, + ), + ) + + // Drop apply state for worktrees that no longer exist, and close a dialog + // whose target disappeared. + createEffect(() => { + const ids = new Set(worktrees().map((wt) => wt.id)) + setApplyStates((prev) => { + const next = Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id))) + if (Object.keys(next).length === Object.keys(prev).length) return prev + return next + }) + + const target = applyTarget() + if (target && !ids.has(target)) closeApplyDialog() + }) + + // Backend `applyWorktreeDiffResult` message: record the new status and toast. + const onApplyResult = (ev: AgentManagerApplyWorktreeDiffResultMessage) => { + const files = new Set((ev.conflicts ?? []).map((entry) => entry.file).filter(Boolean)).size + const count = ev.conflicts?.length ?? 0 + setApplyStates((prev) => ({ + ...prev, + [ev.worktreeId]: { + status: ev.status, + message: ev.message, + conflicts: ev.conflicts ?? [], + }, + })) + + if (ev.status === "success") { + showToast({ variant: "success", title: t("agentManager.apply.success"), description: ev.message }) + if (applyTarget() === ev.worktreeId) closeApplyDialog() + } + if (ev.status === "conflict") { + const summary = + count > 0 ? t("agentManager.apply.conflictToast", { count, files: Math.max(files, 1) }) : ev.message + showToast({ variant: "error", title: t("agentManager.apply.conflict"), description: summary }) + } + if (ev.status === "error") { + showToast({ variant: "error", title: t("agentManager.apply.error"), description: ev.message }) + } + } + + return { + applyBusyForSelection, + openApplyDialog, + onApplyResult, + } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/diff-review-scope.ts b/packages/kilo-vscode/webview-ui/agent-manager/diff-review-scope.ts new file mode 100644 index 0000000000..6af47d9712 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/diff-review-scope.ts @@ -0,0 +1,118 @@ +/** + * Diff scope + base branch state for the Agent Manager review surfaces. + * + * Owns the per-context scope selection, the branch picker data for the active + * context, and the message senders that drive both. Extracted from + * AgentManagerApp to keep that file under its line cap; both the side panel + * and the full-screen review tab consume the single instance returned here. + */ + +import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" +import type { BranchInfo } from "../src/types/messages" +import { createDiffScope, isDiffScope, scopeDescriptors, type DiffScope } from "./diff-scope-state" + +interface VsCode { + postMessage(msg: unknown): void +} + +export interface DiffReviewScopeOptions { + /** Current diff context (worktree session id or the LOCAL pseudo-id). */ + ctx: Accessor + /** Whether the diff side panel is open. */ + panelOpen: Accessor + /** Whether the full-screen review tab is active. */ + reviewActive: Accessor + /** The id that marks the local pseudo-context (omits the Session scope). */ + local: string + vscode: VsCode +} + +export function createDiffReviewScope(opts: DiffReviewScopeOptions) { + const scope = createDiffScope(opts.ctx) + // The composite id (ctx#scope) the extension keys diff data by. + const id = createMemo(() => scope.id()) + + // Branch picker state for the active context (Branch scope only). + const [branches, setBranches] = createSignal([]) + const [loading, setLoading] = createSignal(false) + const [defaultBranch, setDefaultBranch] = createSignal("") + const [autoBase, setAutoBase] = createSignal(undefined) + const [currentBase, setCurrentBase] = createSignal(undefined) + const [isAuto, setIsAuto] = createSignal(true) + const [currentBranch, setCurrentBranch] = createSignal(undefined) + + // Scope descriptors for the current context. The `local` pseudo-context and + // contexts without a real session omit the Session scope. + const descriptors = createMemo(() => { + const ctx = opts.ctx() + if (!ctx) return [] + return scopeDescriptors(ctx, ctx !== opts.local) + }) + + const isBranch = () => scope.scope() === "branch" + + const select = (next: string) => { + const ctx = opts.ctx() + if (!ctx) return + const value = next.slice(ctx.length + 1) + scope.setScope(isDiffScope(value) ? value : "branch") + } + + const selectBase = (branch: string | undefined) => { + const ctx = opts.ctx() + if (!ctx) return + // Optimistic update; the extension echoes authoritative state back. + setCurrentBase(branch ?? autoBase()) + setIsAuto(branch === undefined) + opts.vscode.postMessage({ type: "agentManager.setDiffBaseBranch", sessionId: ctx, scope: scope.scope(), branch }) + } + + // Fetch branch picker data whenever the Branch scope becomes active for the + // current context. The extension owns override state, so ask each time. + createEffect(() => { + if (scope.scope() !== "branch") return + const ctx = opts.ctx() + if (!ctx) return + if (!opts.panelOpen() && !opts.reviewActive()) return + setLoading(true) + opts.vscode.postMessage({ type: "agentManager.requestDiffBranches", sessionId: ctx, scope: scope.scope() }) + }) + + /** Handle the extension's diffBranches push, ignoring stale contexts. */ + const onBranches = (ev: { + sessionId: string + branches: BranchInfo[] + defaultBranch: string + autoBase?: string + currentBase?: string + isAuto: boolean + currentBranch?: string + }) => { + if (ev.sessionId === id()) { + setBranches(ev.branches) + setDefaultBranch(ev.defaultBranch) + setAutoBase(ev.autoBase) + setCurrentBase(ev.currentBase) + setIsAuto(ev.isAuto) + setCurrentBranch(ev.currentBranch) + } + setLoading(false) + } + + return { + scope: scope.scope, + id, + descriptors, + isBranch, + select, + selectBase, + onBranches, + branches, + loading, + defaultBranch, + autoBase, + currentBase, + isAuto, + currentBranch, + } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/diff-scope-state.ts b/packages/kilo-vscode/webview-ui/agent-manager/diff-scope-state.ts new file mode 100644 index 0000000000..fe4424b2f5 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/diff-scope-state.ts @@ -0,0 +1,103 @@ +/** + * Webview-side diff scope state for Agent Manager. + * + * Mirrors the extension's composite diff id (`ctx#scope`, see + * `src/agent-manager/diff-scope.ts`) and builds the fixed scope descriptor + * list shown in the scope selector. Agent Manager always offers the same four + * scopes per context, so the descriptors are computed client-side rather than + * pushed from the extension. + */ + +import { createMemo, createSignal, type Accessor } from "solid-js" +import type { DiffSourceDescriptor } from "../../src/diff/sources/types" + +export type DiffScope = "branch" | "staged" | "unstaged" | "session" + +export const DEFAULT_DIFF_SCOPE: DiffScope = "branch" + +const SEP = "#" + +export function composeDiffId(ctx: string, scope: DiffScope): string { + return `${ctx}${SEP}${scope}` +} + +export function parseDiffId(id: string): { ctx: string; scope: DiffScope } { + const idx = id.lastIndexOf(SEP) + const scope = id.slice(idx + SEP.length) + if (idx !== -1 && isDiffScope(scope)) return { ctx: id.slice(0, idx), scope } + return { ctx: id, scope: DEFAULT_DIFF_SCOPE } +} + +export function isDiffScope(value: string): value is DiffScope { + return value === "branch" || value === "staged" || value === "unstaged" || value === "session" +} + +/** + * The fixed scope descriptors for a context. `workspace` maps to the Branch + * scope to reuse the existing i18n keys (`diffViewer.source.workspace.*`). + * Session scope is only meaningful for a real session context, so it is + * omitted for the `local` pseudo-context and for contexts without a session. + */ +export function scopeDescriptors(ctx: string, hasSession: boolean): DiffSourceDescriptor[] { + const out: DiffSourceDescriptor[] = [ + { + id: composeDiffId(ctx, "branch"), + type: "workspace", + group: "Git", + capabilities: { revert: true, comments: true }, + }, + { id: composeDiffId(ctx, "staged"), type: "staged", group: "Git", capabilities: { revert: false, comments: true } }, + { + id: composeDiffId(ctx, "unstaged"), + type: "unstaged", + group: "Git", + capabilities: { revert: false, comments: true }, + }, + ] + if (hasSession) { + out.push({ + id: composeDiffId(ctx, "session"), + type: "session", + group: "Session", + capabilities: { revert: false, comments: true }, + }) + } + return out +} + +/** + * Whether the Branch scope supports revert. Staged/unstaged/session are + * read-only; only the Branch scope can revert files back to the merge base. + */ +export function scopeCapabilities(scope: DiffScope): { revert: boolean; comments: boolean } { + return { revert: scope === "branch", comments: true } +} + +/** + * Per-context scope selection. Keeps the last-picked scope per context id so + * switching between worktrees restores each worktree's scope, while a brand + * new context defaults to Branch. + */ +export function createDiffScope(currentCtx: Accessor) { + const [scopes, setScopes] = createSignal>({}) + + const scope = createMemo((): DiffScope => { + const ctx = currentCtx() + if (!ctx) return DEFAULT_DIFF_SCOPE + return scopes()[ctx] ?? DEFAULT_DIFF_SCOPE + }) + + const id = createMemo(() => { + const ctx = currentCtx() + if (!ctx) return undefined + return composeDiffId(ctx, scope()) + }) + + const setScope = (next: DiffScope) => { + const ctx = currentCtx() + if (!ctx) return + setScopes((prev) => ({ ...prev, [ctx]: next })) + } + + return { scope, id, setScope } +} 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 79d4b74c51..81a5e71397 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "تحريك لأعلى", "agentManager.section.moveDown": "تحريك لأسفل", "agentManager.hoverCard.branch": "الفرع", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "الأساس", "agentManager.hoverCard.sessions": "الجلسات", "agentManager.hoverCard.files": "الملفات", @@ -58,7 +59,7 @@ export const dict = { "agentManager.terminal.new": "علامة تبويب جديدة للمحطة الطرفية", "agentManager.terminal.ended": "انتهت المحطة الطرفية — أغلق علامة التبويب للإخفاء", "agentManager.terminal.connectionError": "خطأ في اتصال المحطة الطرفية", - "agentManager.terminal.kill": "إنهاء المحطة الطرفية", + "agentManager.terminal.add": "محطة طرفية جديدة", "agentManager.terminal.empty": "لا توجد محطة طرفية هنا بعد", "agentManager.terminal.start": "بدء المحطة الطرفية", "agentManager.terminal.destination": "اختر ما الذي يفتحه زر المحطة الطرفية", @@ -135,6 +136,8 @@ export const dict = { "agentManager.diff.revertFile": "استعادة الملف", "agentManager.diff.revertSuccess": "تم استعادة الملف", "agentManager.diff.revertError": "فشل الاستعادة", + "agentManager.diff.applyBranchOnly": + "لا يعمل تطبيق التغييرات إلا على فرق الفرع الكامل. انتقل إلى نطاق Branch لتطبيقها.", "agentManager.open.button": "فتح", "agentManager.open.tooltip": "فتح Worktree هذا في VS Code", "agentManager.apply.globalButton": "تطبيق", 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 67cdb9bcf3..8add925c7a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Mover para Cima", "agentManager.section.moveDown": "Mover para Baixo", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessões", "agentManager.hoverCard.files": "Arquivos", @@ -59,7 +60,7 @@ 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.add": "Novo 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", @@ -138,6 +139,8 @@ export const dict = { "agentManager.diff.revertFile": "Reverter arquivo", "agentManager.diff.revertSuccess": "Arquivo revertido", "agentManager.diff.revertError": "Falha ao reverter", + "agentManager.diff.applyBranchOnly": + "Aplicar funciona apenas no diff completo da branch. Mude para o escopo Branch para aplicar.", "agentManager.open.button": "Abrir", "agentManager.open.tooltip": "Abrir este Worktree no VS Code", "agentManager.apply.globalButton": "Aplicar", 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 f6fddfd612..3b6f209cde 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Pomjeri gore", "agentManager.section.moveDown": "Pomjeri dolje", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Baza", "agentManager.hoverCard.sessions": "Sesije", "agentManager.hoverCard.files": "Datoteke", @@ -58,7 +59,7 @@ 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.add": "Novi terminal", "agentManager.terminal.empty": "Ovdje još nema terminala", "agentManager.terminal.start": "Pokreni terminal", "agentManager.terminal.destination": "Odaberite šta otvara dugme terminala", @@ -138,6 +139,8 @@ export const dict = { "agentManager.diff.revertFile": "Vrati datoteku", "agentManager.diff.revertSuccess": "Datoteka vraćena", "agentManager.diff.revertError": "Vraćanje neuspješno", + "agentManager.diff.applyBranchOnly": + "Primijeni radi samo s kompletnim diffom grane. Prebacite se na opseg Branch da biste primijenili.", "agentManager.open.button": "Otvori", "agentManager.open.tooltip": "Otvori ovaj worktree u VS Code-u", "agentManager.apply.globalButton": "Primijeni", 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 beb827b1c9..fa11306080 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Flyt op", "agentManager.section.moveDown": "Flyt ned", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessioner", "agentManager.hoverCard.files": "Filer", @@ -60,7 +61,7 @@ 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.add": "Ny terminal", "agentManager.terminal.empty": "Ingen terminal her endnu", "agentManager.terminal.start": "Start terminal", "agentManager.terminal.destination": "Vælg, hvad terminalknappen åbner", @@ -139,6 +140,8 @@ export const dict = { "agentManager.diff.revertFile": "Gendan fil", "agentManager.diff.revertSuccess": "Fil gendannet", "agentManager.diff.revertError": "Gendannelse fejlede", + "agentManager.diff.applyBranchOnly": + "Anvend virker kun på hele Branch-diffen. Skift til Branch-området for at anvende.", "agentManager.open.button": "Åbn", "agentManager.open.tooltip": "Åbn dette Worktree i VS Code", "agentManager.apply.globalButton": "Anvend", 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 b9691ecbaf..1cc5a690eb 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Nach oben verschieben", "agentManager.section.moveDown": "Nach unten verschieben", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Basis", "agentManager.hoverCard.sessions": "Sitzungen", "agentManager.hoverCard.files": "Dateien", @@ -59,7 +60,7 @@ 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.add": "Neues Terminal", "agentManager.terminal.empty": "Hier ist noch kein Terminal", "agentManager.terminal.start": "Terminal starten", "agentManager.terminal.destination": "Auswählen, was die Terminal-Schaltfläche öffnet", @@ -139,6 +140,8 @@ export const dict = { "agentManager.diff.revertFile": "Datei zurücksetzen", "agentManager.diff.revertSuccess": "Datei zurückgesetzt", "agentManager.diff.revertError": "Zurücksetzen fehlgeschlagen", + "agentManager.diff.applyBranchOnly": + "Anwenden funktioniert nur für den vollständigen Branch-Diff. Wechsle zum Bereich Branch, um anzuwenden.", "agentManager.open.button": "Öffnen", "agentManager.open.tooltip": "Dieses Worktree in VS Code öffnen", "agentManager.apply.globalButton": "Anwenden", 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 236f18423a..50bb020f7b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -32,6 +32,7 @@ export const dict = { "agentManager.section.moveDown": "Move Down", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessions", "agentManager.hoverCard.files": "Files", @@ -60,9 +61,9 @@ export const dict = { "agentManager.sidebarSearch.contexts": "LOCAL & WORKTREES", "agentManager.terminal.new": "New Terminal Tab", + "agentManager.terminal.add": "New terminal", "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", @@ -142,6 +143,7 @@ export const dict = { "agentManager.diff.revertFile": "Revert file", "agentManager.diff.revertSuccess": "File reverted", "agentManager.diff.revertError": "Revert failed", + "agentManager.diff.applyBranchOnly": "Apply works on the full branch diff. Switch to the Branch scope to apply.", "agentManager.open.button": "Open", "agentManager.open.tooltip": "Open this worktree in VS Code", "agentManager.apply.globalButton": "Apply", 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 a75de2ce0c..613c10e640 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Mover hacia arriba", "agentManager.section.moveDown": "Mover hacia abajo", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sesiones", "agentManager.hoverCard.files": "Archivos", @@ -59,7 +60,7 @@ 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.add": "Nuevo 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", @@ -138,6 +139,8 @@ export const dict = { "agentManager.diff.revertFile": "Revertir archivo", "agentManager.diff.revertSuccess": "Archivo revertido", "agentManager.diff.revertError": "Error al revertir", + "agentManager.diff.applyBranchOnly": + "Aplicar solo funciona con el diff completo de la rama. Cambia al ámbito Branch para aplicar.", "agentManager.open.button": "Abrir", "agentManager.open.tooltip": "Abrir este Worktree en VS Code", "agentManager.apply.globalButton": "Aplicar", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts new file mode 100644 index 0000000000..a4ee752376 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts @@ -0,0 +1,209 @@ +export const dict = { + "agentManager.local": "محلی", + "agentManager.sidebar.collapse": "بستن نوار کناری", + "agentManager.sidebar.expand": "نمایش نوار کناری", + "agentManager.section.worktrees": "WORKTREES", + "agentManager.section.sessions": "SESSIONS", + "agentManager.notGitRepo": "این یک مخزن git نیست", + + "agentManager.worktree.settings": "تنظیمات Worktree", + "agentManager.worktree.new": "Worktree جدید", + "agentManager.worktree.setupScript": "اسکریپت راه‌اندازی Worktree", + "agentManager.worktree.delete": "حذف Worktree", + "agentManager.worktree.confirmDelete": "حذف شود؟", + "agentManager.worktree.stale": "منسوخ", + "agentManager.worktree.staleTooltip": "روی دیسک وجود ندارد یا دیگر توسط git worktree ردیابی نمی‌شود", + "agentManager.worktree.removeStale": "حذف worktree منسوخ", + "agentManager.worktree.doubleClickRename": "برای تغییر نام دوبار کلیک کنید", + "agentManager.worktree.versions": "{{count}} نسخه", + "agentManager.worktree.advancedOptions": "گزینه‌های پیشرفته worktree", + "agentManager.worktree.defaultBaseBranch": "شاخه پایه پیش‌فرض", + "agentManager.worktree.defaultBaseBranchAuto": "تشخیص خودکار", + "agentManager.worktree.copyPath": "کپی مسیر", + "agentManager.worktree.openInVscode": "باز کردن در VS Code", + "agentManager.worktree.rename": "تغییر نام", + "agentManager.worktree.newSection": "بخش جدید", + "agentManager.worktree.ungrouped": "بدون گروه", + "agentManager.section.rename": "تغییر نام بخش", + "agentManager.section.setColor": "تنظیم رنگ", + "agentManager.section.delete": "حذف بخش", + "agentManager.section.defaultName": "بخش جدید", + "agentManager.section.moveUp": "انتقال به بالا", + "agentManager.section.moveDown": "انتقال به پایین", + + "agentManager.hoverCard.branch": "شاخه", + "agentManager.hoverCard.worktree": "Worktree", + "agentManager.hoverCard.base": "پایه", + "agentManager.hoverCard.sessions": "جلسات", + "agentManager.hoverCard.files": "فایل‌ها", + "agentManager.hoverCard.changes": "تغییرات", + "agentManager.hoverCard.commits": "کامیت‌ها", + + "agentManager.session.new": "جلسه جدید", + "agentManager.session.untitled": "بدون عنوان", + "agentManager.session.newSession": "جلسه جدید", + "agentManager.session.openInWorktree": "باز کردن در worktree", + "agentManager.session.openLocally": "باز کردن به صورت محلی", + "agentManager.session.readonly": "جلسه فقط‌خواندنی", + "agentManager.session.noSessions": "هیچ جلسه‌ای باز نیست", + + "agentManager.tab.close": "بستن", + "agentManager.tab.closeOthers": "بستن بقیه", + "agentManager.tab.closeTab": "بستن برگه", + "agentManager.tab.forkSession": "انشعاب جلسه", + "agentManager.tab.terminal": "ترمینال", + "agentManager.tab.openTerminal": "باز کردن ترمینال", + "agentManager.tab.newOptions": "گزینه‌های بیشتر برای تب جدید", + "agentManager.tabsMenu.status.waiting": "انتظار", + "agentManager.tabsMenu.status.retry": "تلاش مجدد", + "agentManager.sidebarSearch.label": "جستجوی worktree‌ها و جلسات", + "agentManager.sidebarSearch.scope": "جستجو در فضای کاری محلی، جلسات محلی، worktree‌ها و جلسات آن‌ها", + "agentManager.sidebarSearch.contexts": "محلی و WORKTREE‌ها", + + "agentManager.terminal.new": "تب ترمینال جدید", + "agentManager.terminal.add": "ترمینال جدید", + "agentManager.terminal.ended": "ترمینال پایان یافت — برای بستن، تب را ببندید", + "agentManager.terminal.connectionError": "خطای اتصال ترمینال", + "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", + "agentManager.setup.error.git_not_found": + "Git نصب نشده یا در PATH یافت نشد. لطفاً Git را نصب کرده و VS Code را مجدداً راه‌اندازی کنید.", + "agentManager.setup.error.not_git_repo": "برای استفاده از Worktree، پوشه‌ای که حاوی یک مخزن git است را باز کنید.", + "agentManager.setup.error.lfs_missing": + "این مخزن از Git LFS استفاده می‌کند، اما git-lfs یافت نشد. لطفاً Git LFS را نصب کنید.", + "agentManager.setup.error.no_commits": + "این مخزن هنوز هیچ کامیتی ندارد. قبل از استفاده از Worktree، یک کامیت اولیه ایجاد کنید.", + "agentManager.shortcuts.title": "میانبرهای صفحه‌کلید", + "agentManager.shortcuts.category.sidebar": "نوار کناری", + "agentManager.shortcuts.category.tabs": "تب‌ها", + "agentManager.shortcuts.category.terminal": "ترمینال", + "agentManager.shortcuts.category.global": "سراسری", + "agentManager.shortcuts.previousItem": "مورد قبلی", + "agentManager.shortcuts.nextItem": "مورد بعدی", + "agentManager.shortcuts.newWorktree": "Worktree جدید", + "agentManager.shortcuts.advancedWorktree": "پیکربندی Worktree جدید", + "agentManager.shortcuts.openWorktree": "باز کردن Worktree", + "agentManager.shortcuts.openPR": "باز کردن pull request", + "agentManager.shortcuts.deleteWorktree": "حذف Worktree", + "agentManager.shortcuts.previousTab": "تب قبلی", + "agentManager.shortcuts.nextTab": "تب بعدی", + "agentManager.shortcuts.newTab": "تب جدید", + "agentManager.shortcuts.closeTab": "بستن تب", + "agentManager.shortcuts.toggleTerminal": "نمایش/پنهان کردن ترمینال", + "agentManager.shortcuts.runScript": "اجرای اسکریپت", + "agentManager.run.options": "گزینه‌های اجرا", + "agentManager.run.configure": "پیکربندی اسکریپت اجرا", + "agentManager.shortcuts.openAgentManager": "باز کردن Agent Manager", + "agentManager.shortcuts.cycleAgentMode": "حالت عامل بعدی", + "agentManager.shortcuts.cyclePreviousAgentMode": "حالت عامل قبلی", + "agentManager.shortcuts.showShortcuts": "نمایش میانبرهای صفحه‌کلید", + + "agentManager.dialog.removeStaleWorktree.title": "حذف Worktree قدیمی", + "agentManager.dialog.removeStaleWorktree.messagePre": "حذف Worktree قدیمی ", + "agentManager.dialog.removeStaleWorktree.messagePost": + "؟ این عملیات فقط نگاشت Agent Manager را حذف می‌کند و فایل‌های روی دیسک دست‌نخورده باقی می‌مانند.", + "agentManager.dialog.removeStaleWorktree.cancel": "لغو", + "agentManager.dialog.removeStaleWorktree.confirm": "حذف Worktree قدیمی", + + "agentManager.dialog.openWorktree": "Worktree جدید", + "agentManager.dialog.tab.new": "جدید", + "agentManager.dialog.tab.import": "وارد کردن", + "agentManager.dialog.namePlaceholder": "نام Worktree (اختیاری)", + "agentManager.dialog.promptPlaceholder.mac": "پیامی بنویسید (⌘Enter برای ارسال)", + "agentManager.dialog.promptPlaceholder.other": "پیامی بنویسید (Ctrl+Enter برای ارسال)", + "agentManager.dialog.advancedOptions": "گزینه‌های پیشرفته", + "agentManager.dialog.branchName": "نام شاخه", + "agentManager.dialog.branchNamePlaceholder": "تولید خودکار", + "agentManager.dialog.baseBranch": "شاخه پایه", + "agentManager.dialog.searchBranches": "جستجوی شاخه‌ها...", + "agentManager.dialog.branchBadge.default": "پیش‌فرض", + "agentManager.dialog.branchBadge.remote": "راه دور", + "agentManager.dialog.versions": "نسخه‌ها", + "agentManager.dialog.versionHint": "{{count}} worktree به صورت موازی اجرا خواهند شد", + "agentManager.dialog.compareModels": "مقایسه مدل‌ها", + "agentManager.dialog.compareModels.tooltip": "اجرای عامل‌ها روی مدل‌های مختلف به صورت موازی برای مقایسه نتایج", + "agentManager.dialog.compareModels.searchModels": "جستجوی مدل‌ها...", + "agentManager.dialog.compareModels.selectModels": "انتخاب مدل‌ها...", + "agentManager.dialog.compareModels.effort": "میزان استدلال", + "agentManager.dialog.compareModels.effortDefault": "پیش‌فرض", + "agentManager.dialog.creating": "در حال ایجاد...", + "agentManager.dialog.createWorktree": "ایجاد Worktree", + "agentManager.dialog.removeImage": "حذف تصویر", + "agentManager.dialog.configureWorktree": "پیکربندی Worktree جدید...", + + "agentManager.diff.toggle": "تغییر وضعیت diff", + "agentManager.diff.openFile": "باز کردن فایل", + "agentManager.diff.revertFile": "بازگردانی فایل", + "agentManager.diff.revertSuccess": "فایل بازگردانی شد", + "agentManager.diff.revertError": "بازگردانی ناموفق بود", + "agentManager.diff.applyBranchOnly": + "اعمال تغییرات روی اختلاف کامل شاخه انجام می‌شود. برای اعمال، به محدوده Branch بروید.", + "agentManager.open.button": "باز کردن", + "agentManager.open.tooltip": "باز کردن این Worktree در VS Code", + "agentManager.apply.globalButton": "اعمال", + "agentManager.apply.tooltip": "اعمال تغییرات worktree انتخاب‌شده به شاخه محلی", + "agentManager.apply.dialogTitle": "اعمال تغییرات به شاخه محلی", + "agentManager.apply.confirm": "همین الان اعمال کن", + "agentManager.apply.selectionSummary": "{{selected}} از {{total}} فایل انتخاب‌شده", + "agentManager.apply.selectAll": "انتخاب همه", + "agentManager.apply.selectNone": "هیچ‌کدام را انتخاب نکن", + "agentManager.apply.checking": "در حال بررسی...", + "agentManager.apply.applying": "در حال اعمال...", + "agentManager.apply.success": "تغییرات اعمال شد", + "agentManager.apply.conflict": "اعمال دارای تعارض است", + "agentManager.apply.conflictToast": "{{count}} تعارض در {{files}} فایل. جزئیات را در Apply بررسی کنید.", + "agentManager.apply.error": "اعمال ناموفق بود", + "agentManager.apply.conflictsTitle": "تعارض‌ها", + "agentManager.apply.unknownFile": "فایل نامشخص", + "agentManager.apply.reason.index": "فایل محلی با نسخه مورد انتظار تفاوت دارد", + "agentManager.apply.reason.patch": "پچ به‌درستی قابل اعمال نیست", + "agentManager.apply.reason.contents": "خواندن محتوای فایل محلی فعلی ممکن نیست", + "agentManager.apply.reason.unknown": "تعارض شناسایی شد", + "agentManager.shortcuts.toggleDiff": "نمایش/پنهان کردن پنل diff", + "agentManager.shortcuts.category.quickSwitch": "تغییر سریع", + "agentManager.shortcuts.jumpToItem": "پرش به مورد ۱–۹", + "agentManager.review.sendAllToChat": "ارسال همه به چت", + "agentManager.review.sendAllToChatWithCount": "ارسال همه به چت ({{count}})", + "agentManager.review.sendAllShortcut.mac": "⌘Enter", + "agentManager.review.sendAllShortcut.other": "Ctrl+Enter", + "agentManager.review.modalTitle": "بررسی نظر", + "agentManager.review.inlineCount": "نظرات بررسی ({{count}})", + "agentManager.review.clearAll": "پاک کردن همه", + "agentManager.review.commentOnLine": "نظر در خط {{line}}", + "agentManager.review.editCommentOnLine": "ویرایش نظر در خط {{line}}", + "agentManager.review.commentPlaceholder": "یک نظر بگذارید...", + "agentManager.review.commentAction": "نظر", + "agentManager.review.sendToChat": "ارسال به چت", + "agentManager.review.metaFile": "فایل", + "agentManager.review.metaLine": "خط", + "agentManager.review.metaComment": "نظر کاربر", + "agentManager.review.collapsedOnly": "{{count}} جمع‌شده", + "agentManager.review.collapsedWithLarge": "{{collapsed}} جمع‌شده، {{large}} بزرگ", + "agentManager.review.largeFileCollapsed": "فایل بزرگ (جمع‌شده)", + "agentManager.review.image": "تصویر", + "agentManager.review.imageBefore": "قبل", + "agentManager.review.imageAfter": "بعد", + "agentManager.review.imageTooLarge": "تصویر برای پیش‌نمایش خیلی بزرگ است ({{size}}).", + "agentManager.review.imageUnreadable": "این تصویر قابل نمایش نیست.", + "agentManager.review.imageUnavailable": "پیش‌نمایش تصویر برای این نمونه جلسه در دسترس نیست.", + "agentManager.review.endOfLongDiff": "به انتها رسیدید!", + + "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pastePrUrl": "URL درخواست PR را وارد کنید...", + "agentManager.import.open": "باز کردن", + "agentManager.import.branches": "شاخه‌ها", + "agentManager.import.selectBranch": "انتخاب شاخه...", + "agentManager.import.loading": "در حال بارگذاری...", + "agentManager.import.loadingBranches": "در حال بارگذاری شاخه‌ها...", + "agentManager.import.noMatchingBranches": "شاخه‌ای مطابق یافت نشد", + "agentManager.import.noBranchesFound": "هیچ شاخه‌ای یافت نشد.", + "agentManager.import.noBranchesHint": "یک URL درخواست ادغام را در بالا جای‌گذاری کنید یا یک worktree جدید بسازید.", + "agentManager.import.failed": "وارد کردن ناموفق بود", +} 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 d8579c9cfd..ebcdc1c454 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Déplacer vers le haut", "agentManager.section.moveDown": "Déplacer vers le bas", "agentManager.hoverCard.branch": "BRANCHE", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessions", "agentManager.hoverCard.files": "Fichiers", @@ -59,7 +60,7 @@ 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.add": "Nouveau 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", @@ -138,6 +139,8 @@ export const dict = { "agentManager.diff.revertFile": "Rétablir le fichier", "agentManager.diff.revertSuccess": "Fichier rétabli", "agentManager.diff.revertError": "Échec du rétablissement", + "agentManager.diff.applyBranchOnly": + "Appliquer ne fonctionne que sur le diff complet de la branche. Passez à la portée Branch pour appliquer.", "agentManager.open.button": "Ouvrir", "agentManager.open.tooltip": "Ouvrir ce worktree dans VS Code", "agentManager.apply.globalButton": "Appliquer", 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 1192807424..6556232120 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -32,6 +32,7 @@ export const dict = { "agentManager.section.moveDown": "Sposta giù", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessioni", "agentManager.hoverCard.files": "File", @@ -63,7 +64,7 @@ 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.add": "Nuovo 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", @@ -144,6 +145,8 @@ export const dict = { "agentManager.diff.revertFile": "Ripristina file", "agentManager.diff.revertSuccess": "File ripristinato", "agentManager.diff.revertError": "Ripristino non riuscito", + "agentManager.diff.applyBranchOnly": + "Applica funziona solo sul diff completo del branch. Passa all'ambito Branch per applicare.", "agentManager.open.button": "Apri", "agentManager.open.tooltip": "Apri questo worktree in VS Code", "agentManager.apply.globalButton": "Applica", 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 d855af8e15..dad1675bdf 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "上に移動", "agentManager.section.moveDown": "下に移動", "agentManager.hoverCard.branch": "ブランチ", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "ベース", "agentManager.hoverCard.sessions": "セッション", "agentManager.hoverCard.files": "ファイル", @@ -59,7 +60,7 @@ export const dict = { "agentManager.terminal.new": "新しいターミナルタブ", "agentManager.terminal.ended": "ターミナルが終了しました — タブを閉じて破棄", "agentManager.terminal.connectionError": "ターミナル接続エラー", - "agentManager.terminal.kill": "ターミナルを終了", + "agentManager.terminal.add": "新しいターミナル", "agentManager.terminal.empty": "ここにはまだターミナルがありません", "agentManager.terminal.start": "ターミナルを開始", "agentManager.terminal.destination": "ターミナルボタンで開く場所を選択", @@ -137,6 +138,8 @@ export const dict = { "agentManager.diff.revertFile": "ファイルを元に戻す", "agentManager.diff.revertSuccess": "ファイルを元に戻しました", "agentManager.diff.revertError": "元に戻せませんでした", + "agentManager.diff.applyBranchOnly": + "適用はブランチ全体の差分に対してのみ利用できます。適用するにはスコープを Branch に切り替えてください。", "agentManager.open.button": "開く", "agentManager.open.tooltip": "このWorktreeをVS Codeで開く", "agentManager.apply.globalButton": "適用", 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 ae87aaf993..84c879bda3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "위로 이동", "agentManager.section.moveDown": "아래로 이동", "agentManager.hoverCard.branch": "브랜치", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "베이스", "agentManager.hoverCard.sessions": "세션", "agentManager.hoverCard.files": "파일", @@ -58,7 +59,7 @@ export const dict = { "agentManager.terminal.new": "새 터미널 탭", "agentManager.terminal.ended": "터미널 종료됨 — 탭을 닫아 해제", "agentManager.terminal.connectionError": "터미널 연결 오류", - "agentManager.terminal.kill": "터미널 종료", + "agentManager.terminal.add": "새 터미널", "agentManager.terminal.empty": "아직 여기에 터미널이 없습니다", "agentManager.terminal.start": "터미널 시작", "agentManager.terminal.destination": "터미널 버튼으로 열 위치 선택", @@ -136,6 +137,8 @@ export const dict = { "agentManager.diff.revertFile": "파일 되돌리기", "agentManager.diff.revertSuccess": "파일이 되돌려졌습니다", "agentManager.diff.revertError": "되돌리기 실패", + "agentManager.diff.applyBranchOnly": + "적용은 전체 브랜치 diff에서만 작동합니다. 적용하려면 범위를 Branch로 전환하세요.", "agentManager.open.button": "열기", "agentManager.open.tooltip": "이 Worktree를 VS Code에서 열기", "agentManager.apply.globalButton": "적용", 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 1965204a47..aaab5859cd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -32,6 +32,7 @@ export const dict = { "agentManager.section.moveDown": "Omlaag verplaatsen", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Basis", "agentManager.hoverCard.sessions": "Sessies", "agentManager.hoverCard.files": "Bestanden", @@ -62,7 +63,7 @@ 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.add": "Nieuwe terminal", "agentManager.terminal.empty": "Hier is nog geen terminal", "agentManager.terminal.start": "Terminal starten", "agentManager.terminal.destination": "Kies wat de terminalknop opent", @@ -143,6 +144,8 @@ export const dict = { "agentManager.diff.revertFile": "Bestand terugzetten", "agentManager.diff.revertSuccess": "Bestand teruggezet", "agentManager.diff.revertError": "Terugzetten mislukt", + "agentManager.diff.applyBranchOnly": + "Toepassen werkt alleen op de volledige branch-diff. Schakel naar het bereik Branch om toe te passen.", "agentManager.open.button": "Openen", "agentManager.open.tooltip": "Open deze worktree in VS Code", "agentManager.apply.globalButton": "Toepassen", 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 44efed03a5..e34fd55111 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Flytt opp", "agentManager.section.moveDown": "Flytt ned", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Økter", "agentManager.hoverCard.files": "Filer", @@ -58,7 +59,7 @@ 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.add": "Ny terminal", "agentManager.terminal.empty": "Ingen terminal her ennå", "agentManager.terminal.start": "Start terminal", "agentManager.terminal.destination": "Velg hva terminalknappen åpner", @@ -137,6 +138,7 @@ export const dict = { "agentManager.diff.revertFile": "Tilbakestill fil", "agentManager.diff.revertSuccess": "Fil tilbakestilt", "agentManager.diff.revertError": "Tilbakestilling feilet", + "agentManager.diff.applyBranchOnly": "Bruk fungerer kun på hele Branch-diffen. Bytt til Branch-omfanget for å bruke.", "agentManager.open.button": "Åpne", "agentManager.open.tooltip": "Åpne dette Worktree-et i VS Code", "agentManager.apply.globalButton": "Bruk", 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 137bf34132..07f5bb3f13 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Przenieś w górę", "agentManager.section.moveDown": "Przenieś w dół", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Baza", "agentManager.hoverCard.sessions": "Sesje", "agentManager.hoverCard.files": "Pliki", @@ -59,7 +60,7 @@ 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.add": "Nowy terminal", "agentManager.terminal.empty": "Nie ma tu jeszcze terminala", "agentManager.terminal.start": "Uruchom terminal", "agentManager.terminal.destination": "Wybierz, co otwiera przycisk terminala", @@ -137,6 +138,8 @@ export const dict = { "agentManager.diff.revertFile": "Cofnij plik", "agentManager.diff.revertSuccess": "Plik cofnięty", "agentManager.diff.revertError": "Cofanie nie powiodło się", + "agentManager.diff.applyBranchOnly": + "Funkcja Zastosuj działa tylko z pełnym diffem brancha. Przełącz się na zakres Branch, aby zastosować.", "agentManager.open.button": "Otwórz", "agentManager.open.tooltip": "Otwórz ten Worktree w VS Code", "agentManager.apply.globalButton": "Zastosuj", 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 c0df2ab3c9..32c59448b5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "Переместить вверх", "agentManager.section.moveDown": "Переместить вниз", "agentManager.hoverCard.branch": "ВЕТКА", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Основа", "agentManager.hoverCard.sessions": "Сессии", "agentManager.hoverCard.files": "Файлы", @@ -59,7 +60,7 @@ export const dict = { "agentManager.terminal.new": "Новая вкладка терминала", "agentManager.terminal.ended": "терминал завершен — закройте вкладку, чтобы скрыть", "agentManager.terminal.connectionError": "ошибка подключения к терминалу", - "agentManager.terminal.kill": "Завершить терминал", + "agentManager.terminal.add": "Новый терминал", "agentManager.terminal.empty": "Здесь пока нет терминала", "agentManager.terminal.start": "Запустить терминал", "agentManager.terminal.destination": "Выберите, где будет открываться терминал", @@ -138,6 +139,8 @@ export const dict = { "agentManager.diff.revertFile": "Откатить файл", "agentManager.diff.revertSuccess": "Файл откатан", "agentManager.diff.revertError": "Ошибка отката", + "agentManager.diff.applyBranchOnly": + "Применение работает только с полным diff ветки. Чтобы применить изменения, переключитесь на область Branch.", "agentManager.open.button": "Открыть", "agentManager.open.tooltip": "Открыть этот Worktree в VS Code", "agentManager.apply.globalButton": "Применить", 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 012b3776ba..26b871e4e5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "เลื่อนขึ้น", "agentManager.section.moveDown": "เลื่อนลง", "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "ฐาน", "agentManager.hoverCard.sessions": "เซสชัน", "agentManager.hoverCard.files": "ไฟล์", @@ -58,7 +59,7 @@ export const dict = { "agentManager.terminal.new": "แท็บเทอร์มินัลใหม่", "agentManager.terminal.ended": "เทอร์มินัลสิ้นสุด — ปิดแท็บเพื่อยกเลิก", "agentManager.terminal.connectionError": "ข้อผิดพลาดการเชื่อมต่อเทอร์มินัล", - "agentManager.terminal.kill": "หยุดเทอร์มินัล", + "agentManager.terminal.add": "เทอร์มินัลใหม่", "agentManager.terminal.empty": "ยังไม่มีเทอร์มินัลที่นี่", "agentManager.terminal.start": "เริ่มเทอร์มินัล", "agentManager.terminal.destination": "เลือกว่าปุ่มเทอร์มินัลจะเปิดอะไร", @@ -133,6 +134,8 @@ export const dict = { "agentManager.diff.revertFile": "ย้อนกลับไฟล์", "agentManager.diff.revertSuccess": "ย้อนกลับไฟล์แล้ว", "agentManager.diff.revertError": "ย้อนกลับล้มเหลว", + "agentManager.diff.applyBranchOnly": + "นำไปใช้ได้เฉพาะกับ diff ของ Branch ทั้งหมดเท่านั้น สลับไปที่ขอบเขต Branch เพื่อใช้งาน", "agentManager.open.button": "เปิด", "agentManager.open.tooltip": "เปิด Worktree นี้ใน VS Code", "agentManager.apply.globalButton": "นำไปใช้", 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 e18f88694b..09d3f799fd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -32,6 +32,7 @@ export const dict = { "agentManager.section.moveDown": "Aşağı Taşı", "agentManager.hoverCard.branch": "DAL", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Temel", "agentManager.hoverCard.sessions": "Oturumlar", "agentManager.hoverCard.files": "Dosyalar", @@ -63,7 +64,7 @@ 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.add": "Yeni terminal", "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", @@ -144,6 +145,8 @@ export const dict = { "agentManager.diff.revertFile": "Dosyayı geri al", "agentManager.diff.revertSuccess": "Dosya geri alındı", "agentManager.diff.revertError": "Geri alma başarısız", + "agentManager.diff.applyBranchOnly": + "Uygula yalnızca tam Branch diff'inde çalışır. Uygulamak için Branch kapsamına geçin.", "agentManager.open.button": "Aç", "agentManager.open.tooltip": "Bu worktree'yi VS Code'da aç", "agentManager.apply.globalButton": "Uygula", 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 1c1c1cbf22..8016863650 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -32,6 +32,7 @@ export const dict = { "agentManager.section.moveDown": "Перемістити вниз", "agentManager.hoverCard.branch": "ГІЛКА", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "База", "agentManager.hoverCard.sessions": "Сесії", "agentManager.hoverCard.files": "Файли", @@ -63,7 +64,7 @@ export const dict = { "agentManager.terminal.new": "Нова вкладка термінала", "agentManager.terminal.ended": "термінал завершено — закрийте вкладку, щоб відхилити", "agentManager.terminal.connectionError": "помилка з'єднання термінала", - "agentManager.terminal.kill": "Завершити термінал", + "agentManager.terminal.add": "Новий термінал", "agentManager.terminal.empty": "Тут ще немає термінала", "agentManager.terminal.start": "Запустити термінал", "agentManager.terminal.destination": "Виберіть, що відкриватиме кнопка термінала", @@ -145,6 +146,8 @@ export const dict = { "agentManager.diff.revertFile": "Скасувати зміни файлу", "agentManager.diff.revertSuccess": "Файл відновлено", "agentManager.diff.revertError": "Не вдалося відновити", + "agentManager.diff.applyBranchOnly": + "Застосування працює лише з повним diff гілки. Щоб застосувати зміни, перемкніться на область Branch.", "agentManager.open.button": "Відкрити", "agentManager.open.tooltip": "Відкрити це робоче дерево у VS Code", "agentManager.apply.globalButton": "Застосувати", 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 2e0c7f5568..5527ed84cd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "上移", "agentManager.section.moveDown": "下移", "agentManager.hoverCard.branch": "分支", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "基础", "agentManager.hoverCard.sessions": "会话", "agentManager.hoverCard.files": "文件", @@ -58,7 +59,7 @@ export const dict = { "agentManager.terminal.new": "新建终端标签页", "agentManager.terminal.ended": "终端已结束 — 关闭标签页以消除", "agentManager.terminal.connectionError": "终端连接错误", - "agentManager.terminal.kill": "终止终端", + "agentManager.terminal.add": "新建终端", "agentManager.terminal.empty": "此处尚无终端", "agentManager.terminal.start": "启动终端", "agentManager.terminal.destination": "选择终端按钮的打开目标", @@ -132,6 +133,7 @@ export const dict = { "agentManager.diff.revertFile": "还原文件", "agentManager.diff.revertSuccess": "文件已还原", "agentManager.diff.revertError": "还原失败", + "agentManager.diff.applyBranchOnly": "应用仅适用于完整的分支差异。请切换到 Branch 范围后再应用。", "agentManager.open.button": "打开", "agentManager.open.tooltip": "在 VS Code 中打开此 Worktree", "agentManager.apply.globalButton": "应用", 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 e4403c5aaf..5f3e6a1e65 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -30,6 +30,7 @@ export const dict = { "agentManager.section.moveUp": "上移", "agentManager.section.moveDown": "下移", "agentManager.hoverCard.branch": "分支", + "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "基底", "agentManager.hoverCard.sessions": "工作階段", "agentManager.hoverCard.files": "檔案", @@ -58,7 +59,7 @@ export const dict = { "agentManager.terminal.new": "新增終端分頁", "agentManager.terminal.ended": "終端已結束 — 關閉分頁以消除", "agentManager.terminal.connectionError": "終端連線錯誤", - "agentManager.terminal.kill": "終止終端機", + "agentManager.terminal.add": "新增終端機", "agentManager.terminal.empty": "此處尚無終端機", "agentManager.terminal.start": "啟動終端機", "agentManager.terminal.destination": "選擇終端機按鈕的開啟目標", @@ -132,6 +133,7 @@ export const dict = { "agentManager.diff.revertFile": "還原檔案", "agentManager.diff.revertSuccess": "檔案已還原", "agentManager.diff.revertError": "還原失敗", + "agentManager.diff.applyBranchOnly": "套用僅適用於完整的分支差異。請切換至 Branch 範圍後再套用。", "agentManager.open.button": "開啟", "agentManager.open.tooltip": "在 VS Code 中開啟此 Worktree", "agentManager.apply.globalButton": "套用", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts b/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts index 199a9b96f2..03a3c96e44 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts @@ -12,7 +12,9 @@ interface Toast { } export function createRevertFile( + diffScopeId: Accessor, currentDiffSessionId: Accessor, + scope: Accessor, vscode: VsCode, showToast: (t: Toast) => void, t: (key: string) => string, @@ -20,20 +22,21 @@ export function createRevertFile( const [files, setFiles] = createSignal>>({}) const reverting = createMemo(() => { - const sessionId = currentDiffSessionId() - if (!sessionId) return new Set() - return files()[sessionId] ?? new Set() + const id = diffScopeId() + if (!id) return new Set() + return files()[id] ?? new Set() }) function revert(file: string) { + const id = diffScopeId() const sessionId = currentDiffSessionId() - if (!sessionId) return + if (!id || !sessionId) return setFiles((prev) => { - const set = new Set(prev[sessionId] ?? []) + const set = new Set(prev[id] ?? []) set.add(file) - return { ...prev, [sessionId]: set } + return { ...prev, [id]: set } }) - vscode.postMessage({ type: "agentManager.revertWorktreeFile", sessionId, file }) + vscode.postMessage({ type: "agentManager.revertWorktreeFile", sessionId, file, scope: scope() }) } function onResult(ev: AgentManagerRevertWorktreeFileResultMessage) { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx index bf42185b83..8185296085 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx @@ -3,44 +3,124 @@ * * 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. + * and one width. + * + * A context can own several side terminals. The header is a tab strip + * that reuses the top tab bar's whole chrome: `SortableTerminalTab` + * (icon, title, X close, right-click Close / Close Others), the same + * `@thisbeyond/solid-dnd` reorder stack, the same overflow scrolling + * with edge fades, the same width freeze while tabs close, and the same + * arrow-key tab navigation, so a terminal behaves identically in + * either surface. Reorder state lives in the terminal state, so it is + * preserved across sidebar context switches for the webview's lifetime. + * + * The `+` action sits directly after the last tab (outside the + * scrolling region, like the tab bar's `am-tab-add-wrap`), so it never + * scrolls away and never drifts to the far edge of a wide panel. The + * strip stays visible even when empty so `+` is always reachable. * * 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 type { Accessor, Component, JSX } from "solid-js" +import { For, Show, createEffect, createSignal } from "solid-js" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" 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 { ConstrainDragYAxis } from "../../src/components/chat/TabDnd" +import { useTabScroll } from "../../src/utils/tab-scroll" +import { setTabWidths } from "../../src/utils/tab-widths" +import { createTabFocus } from "../../src/utils/tab-navigation" import { renderSideTerminalLayer } from "./render" +import { SortableTerminalTab } from "./SortableTerminalTab" import type { TerminalStateControls } from "./state" +/** Only this strip's tabs freeze; the top tab bar keeps its own widths. */ +const TABLIST = ".am-side-terminal-tablist" + 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. */ + /** Make a terminal the visible one in the strip. */ + onSelect: (terminalId: string) => void + /** Kill one terminal. */ + onClose: (terminalId: string) => void + /** Kill every terminal of this context except the given one. */ + onCloseOthers: (terminalId: string) => void + /** Create a new side terminal for this context. */ onStart: () => void } export const SideTerminalPanel: Component = (props) => { const { t } = useLanguage() let panel!: HTMLElement + let strip!: HTMLDivElement createEffect(() => { panel.inert = !props.visible() }) - const side = () => props.state.side() - const pending = () => props.state.pendingSide(props.contextKey()) !== undefined + const [dragging, setDragging] = createSignal<{ id: string; width: number } | undefined>() + const sides = () => props.state.sidesForContext(props.contextKey()) + const ids = () => sides().map((term) => term.id) + const active = () => props.state.sideActiveFor(props.contextKey()) + const pending = () => props.state.pendingSide(props.contextKey()) + const scroll = useTabScroll(ids, active) + // Scoped to `strip` so arrow keys and focus restore never jump to a + // tab in the top bar, which uses the same role="tab" markup. + const focus = createTabFocus({ ids, select: props.onSelect, root: () => strip }) + // Only freeze while the pointer is over the strip: the widths must + // survive until the pointer leaves, so the remaining X buttons stay + // put across repeated closes. Releasing on the next frame would undo + // the freeze before it is ever painted (rAF runs before paint). + // "Close others" needs none of this: its context menu is portaled, so + // the pointer is off the strip, and the survivor spans the strip anyway. + const freeze = () => { + if (strip.closest(".am-side-terminal-tabs")?.matches(":hover")) setTabWidths(true, document, TABLIST) + } + const release = () => setTabWidths(false, document, TABLIST) + const close = (id: string) => { + freeze() + props.onClose(id) + // Restore focus inside the strip only while it still owns a tab. + // Falling through to `focusPrompt` would pull focus into the chat + // composer while the panel is still open on its empty state. + if (ids().length > 0) focus.restore() + } + // Adding a tab shrinks every tab's equal share, so any freeze left + // over from a close in the same hover has to go first. `+` lives + // inside the strip, so no pointerleave happens between the two + // clicks and the surviving tabs would keep their wider pixel widths. + const start = () => { + release() + props.onStart() + } + const onDragStart = (event: DragEvent) => { + const id = event.draggable?.id + if (typeof id !== "string") return + // Pin the overlay to the tab's width: the overlay container uses + // min-width, so a long OSC title would otherwise overflow it and + // shift the visual center off the cursor (the "drag offset" bug). + const width = event.draggable?.layout.width ?? event.draggable?.node.getBoundingClientRect().width + setTabWidths(true, document, TABLIST) + setDragging({ id, width }) + } + const onDragEnd = () => { + setDragging(undefined) + release() + } + const onDragOver = (event: DragEvent) => { + const from = event.draggable?.id + const to = event.droppable?.id + if (typeof from !== "string" || typeof to !== "string") return + props.state.reorderSideDrag(props.contextKey(), from, to) + } return (
= (props) => { aria-label={t("agentManager.tab.terminal")} aria-hidden={!props.visible()} > -
-
- - {side()?.title ?? t("agentManager.tab.terminal")} -
-
- +
{ + if (!dragging()) release() + }} + > + + + + {/* Overflow chrome copied from the top tab bar: the list is the + only scrolling element, wrapped by a fade host, so the "+" + action stays pinned next to the last tab. */} +
+
+
+ {/* role="tablist" only when tabs exist: axe + aria-required-children rejects an empty tablist. */} +
{ + strip = el + scroll.setRef(el) + }} + role={sides().length > 0 ? "tablist" : undefined} + aria-label={sides().length > 0 ? t("agentManager.tab.terminal") : undefined} + style={{ "--tab-count": `${sides().length}` } as JSX.CSSProperties} + > + + + {(term) => ( + focus.key(term.id, event)} + onSelect={() => props.onSelect(term.id)} + onMiddleClick={(e: MouseEvent) => { + if (e.button !== 1) return + e.preventDefault() + e.stopPropagation() + close(term.id) + }} + onClose={(e: MouseEvent) => { + e.stopPropagation() + close(term.id) + }} + onCloseOthers={() => props.onCloseOthers(term.id)} + /> + )} + + +
+
+
+
+ {/* Cursor-following clone of the dragged tab (same pattern as + the top tab bar). The overlay is what makes the in-list + original use solid-dnd's slot-compensated transform, so the + dragged tab tracks the cursor without a jump/offset. The + original stays dimmed in its slot via .am-tab-dragging. */} + + + {(tab) => ( +
+ {props.state.title(tab().id) ?? t("agentManager.tab.terminal")} +
+ )} +
+
+ +
+
{renderSideTerminalLayer({ state: props.state, contextKey: props.contextKey, visible: props.visible })} - +
{t("common.loading")}
- +
{t("agentManager.terminal.empty")}
) @@ -126,7 +130,8 @@ export function renderTerminalLayer(props: { state: TerminalStateControls }): JS * 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. + * the background and never refit — and within a context only the + * active strip tab's terminal is shown. */ export function renderSideTerminalLayer(props: { state: TerminalStateControls @@ -137,7 +142,10 @@ export function renderSideTerminalLayer(props: {
{(term) => { - const active = () => props.visible() && term.contextKey === props.contextKey() + const active = () => + props.visible() && + term.contextKey === props.contextKey() && + props.state.sideActiveFor(term.contextKey) === term.id return (
props.state.setFocusedId(focused ? term.id : undefined)} + onTitleChange={(title) => props.state.setTitle(term.id, title)} />
) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts index 3c7fe39145..63895bdf3f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts @@ -7,23 +7,70 @@ * 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. + * + * ## Destination state ownership + * + * The VS Code setting is application-scoped, so one value is shared by + * every window and echoed back via `terminal.destinationChanged` + * whenever ANY window rewrites it. Two windows can therefore fight: + * picking "VS Code terminal" in worktree window B would silently flip + * the routing of the panel in window A. To keep each panel consistent, + * an explicit dropdown pick is stored per panel (webview state) and + * wins over remote echoes; the setting only drives panels that never + * picked a destination themselves (it stays the default for new ones). */ import { createSignal } from "solid-js" import type { Accessor } from "solid-js" import type { TerminalDestination } from "../../src/types/messages/agent-manager" +import { LOCAL } from "../navigate" + +/** Read the panel-local destination choice from raw webview state. */ +export function readSavedDestination(state: Record | undefined): TerminalDestination | undefined { + const value = state?.terminalDestination + return value === "agentManager" || value === "vscode" ? value : undefined +} + +export type VscodeTerminalRequest = + | { type: "agentManager.showTerminal"; sessionId: string } + | { type: "agentManager.showWorktreeTerminal"; worktreeId: string } + | { type: "agentManager.showLocalTerminal" } + +/** Carry the panel-local dropdown choice with every Run click. */ +export function resolveRunScriptRequest(worktreeId: string, destination: TerminalDestination) { + return { type: "agentManager.runScript" as const, worktreeId, destination } +} + +/** + * Pick the message the terminal button / Focus Terminal shortcut sends + * when the destination is the VS Code integrated terminal. The fallback + * chain exists so the shortcut never dead-ends: activating a terminal + * tab clears the current session, and a worktree may have no sessions + * at all. Extracted from AgentManagerApp.tsx (max-lines cap). + */ +export function resolveVscodeTerminalRequest( + selection: string | null, + currentSessionID: string | undefined, + sessionForWorktree: (worktreeId: string) => string | undefined, +): VscodeTerminalRequest { + const id = currentSessionID ?? (selection && selection !== LOCAL ? sessionForWorktree(selection) : undefined) + if (id) return { type: "agentManager.showTerminal", sessionId: id } + if (selection && selection !== LOCAL) return { type: "agentManager.showWorktreeTerminal", worktreeId: selection } + return { type: "agentManager.showLocalTerminal" } +} interface Handlers { requestSide(): void - closeSide(): boolean + ensureSide(): void + closeSide(terminalId: string): 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 + /** Id of the side terminal holding DOM focus, if any. */ + focusedId: Accessor /** Leave terminal mode; the terminal stays alive in the background. */ hide: () => void /** Move focus back to the chat composer. */ @@ -32,10 +79,19 @@ export interface SideTerminalDeps { track: (button: string, surface: string, properties: Record) => void /** Open or focus the VS Code integrated terminal for the active context. */ openVscode: () => void + /** Panel-local choice restored from webview state, if the user ever + * picked one in this panel. */ + saved: TerminalDestination | undefined + /** Persist the panel-local choice so it survives webview reloads. */ + save: (destination: TerminalDestination) => void + /** Platform override for tests; the workbench keybinding is Cmd on + * macOS and Ctrl elsewhere, and the local fallback must match it. */ + mac?: boolean } export function createSideTerminal(deps: SideTerminalDeps) { - const [destination, setDestination] = createSignal("vscode") + const [local, setLocal] = createSignal(deps.saved) + const [destination, setDestination] = createSignal(deps.saved ?? "vscode") /** * Hiding while the terminal holds focus would strand the cursor on @@ -49,7 +105,7 @@ export function createSideTerminal(deps: SideTerminalDeps) { const toggle = () => { if (deps.visible()) { - const was = deps.focused() + const was = deps.focusedId() !== undefined deps.hide() handoff(was) return @@ -57,12 +113,22 @@ export function createSideTerminal(deps: SideTerminalDeps) { deps.handlers.requestSide() } - /** Kill the current context's side terminal (or cancel its in-flight - * create) and hide the panel. */ + /** Keep an open terminal panel useful when its worktree context changes. */ + const syncContext = (key: string, previous: string | undefined) => { + if (key === previous || !deps.visible()) return + queueMicrotask(() => { + if (deps.visible()) deps.handlers.ensureSide() + }) + } + + /** Kill the focused side terminal (Cmd/Ctrl+W). The panel stays open + * on the remaining terminals, or on the empty state when this was + * the last one. */ const close = (): boolean => { - const was = deps.focused() - const done = deps.handlers.closeSide() - if (done) handoff(was) + const id = deps.focusedId() + if (!id) return false + const done = deps.handlers.closeSide(id) + if (done) handoff(true) return done } @@ -78,17 +144,74 @@ export function createSideTerminal(deps: SideTerminalDeps) { } /** - * 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`. + * Dropdown pick. The choice is panel-local and sticky: it is kept in + * webview state and beats later `terminal.destinationChanged` echoes + * caused by other windows rewriting the shared application-scoped + * setting. The setting is still written so it stays the default for + * panels that never picked a destination (and new panels). * 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 }) + setLocal(target) setDestination(target) + deps.save(target) deps.postMessage({ type: "updateSetting", key: "agentManager.terminalButtonDestination", value: target }) } - return { destination, setDestination, toggle, close, openPreferred, choose } + /** + * Apply a remote default (initial `agentManager.state` payload or a + * live `terminal.destinationChanged` echo). Ignored once the user + * picked a destination in this panel — their choice wins over every + * echo, including ones triggered by this panel's own `choose` write. + */ + const syncDefault = (target: TerminalDestination) => { + if (local()) return + setDestination(target) + } + + /** + * Cmd/Ctrl+/ pressed while the webview holds DOM focus. VS Code normally + * forwards the keybinding to the workbench too, and the extension echoes + * it back as a showTerminal action message; `echo()` consumes one pending + * echo per press so one keypress never toggles twice while an unrelated + * invocation (command palette, terminal-focused press) still runs. + * Handling the key locally keeps the shortcut working when the + * forwarding path drops it (e.g. the chat prompt input is focused). + * The modifier matches the declared keybinding: Cmd on macOS, Ctrl + * elsewhere — accepting both would hijack the other platform's combo + * (and any user keybinding on it) without a matching workbench binding. + */ + const mac = deps.mac ?? (typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)) + let pending = 0 + let lastPress = 0 + const ECHO_MS = 500 + + const press = (e: KeyboardEvent): boolean => { + const mod = mac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey + if (e.key !== "/" || e.shiftKey || e.altKey || !mod) return false + // An echo either arrives promptly or never; drop the backlog of a + // dropped echo so it cannot swallow a later unrelated invocation. + if (Date.now() - lastPress > ECHO_MS) pending = 0 + pending++ + lastPress = Date.now() + openPreferred("keyboard_shortcut") + return true + } + + /** Consumes one pending extension echo of a local `press`. The timeout is + * only a safety valve for an echo that never arrives; an echo that + * outlasts it is indistinguishable from a real invocation and must run. */ + const echo = () => { + if (pending === 0) return false + if (Date.now() - lastPress > ECHO_MS) { + pending = 0 + return false + } + pending-- + return true + } + + return { destination, syncDefault, syncContext, toggle, close, openPreferred, choose, press, echo } } 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 3e9fba7af1..38b1915ba5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts @@ -15,15 +15,20 @@ 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 { ExtensionMessage, ScriptTerminalView } from "../../src/types/messages/extension-messages" import type { TerminalDestination, TerminalFont, TerminalPlacement } from "../../src/types/messages/agent-manager" export type { TerminalFont } /** Prefix used for terminal tab IDs in the webview (mirrors terminal-manager.ts). */ export const TERMINAL_PREFIX = "terminal:" +export const SCRIPT_TERMINAL_PREFIX = "script:" -export const isTerminalTabId = (id: string): boolean => id.startsWith(TERMINAL_PREFIX) +export const isTerminalTabId = (id: string): boolean => + id.startsWith(TERMINAL_PREFIX) || id.startsWith(SCRIPT_TERMINAL_PREFIX) + +/** Status is separate from mounted xterm records so snapshot updates never remount them. */ +export type ScriptTerminalStatus = Pick /** One row in `terminalsByContext`. `wsUrl` is short-lived and never persisted. */ export interface TerminalTabState { @@ -32,6 +37,8 @@ export interface TerminalTabState { wsUrl: string font: TerminalFont placement: TerminalPlacement + /** Provider-owned Run terminal, never created through the webview create flow. */ + kind?: "run" } /** Terminal row enriched with the sidebar context it belongs to. Used by @@ -50,11 +57,9 @@ export interface TerminalFocusRequest { } /** 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. */ + * Multiple creates can be in flight for the same context at once. */ interface SideRequest { contextKey: string - cancelled: boolean } export interface TerminalStateControls { @@ -65,6 +70,12 @@ export interface TerminalStateControls { remove(terminalId: string): TerminalTabStateWithContext | undefined /** Resolve the context key a terminal lives in, if any. */ contextFor(terminalId: string): string | undefined + /** Whether a terminal belongs to a provider-owned Run script. */ + isScript(terminalId: string): boolean + /** Reactive Run state, kept apart from stable xterm terminal records. */ + scriptStatus(terminalId: string): ScriptTerminalStatus | undefined + /** Reconcile a complete provider-owned Run terminal snapshot. Returns newly hydrated records. */ + syncScripts(views: ScriptTerminalView[]): TerminalTabStateWithContext[] /** All tab terminals for the given sidebar selection. */ forSelection(selection: string | null): TerminalTabStateWithContext[] /** Map of { id -> tab state } for O(1) lookup. */ @@ -75,10 +86,14 @@ export interface TerminalStateControls { 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 + /** Every side terminal of the given context, in creation order. */ + sidesForContext(contextKey: string): TerminalTabStateWithContext[] + /** Id of the active side terminal for a context. */ + sideActiveFor(contextKey: string): string | undefined + /** Mark a side terminal as the visible one for its context. */ + setSideActive(contextKey: string, terminalId: string): void + /** Id of the side terminal holding DOM focus in the current context, if any. */ + sideFocusedId(): string | 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 @@ -96,6 +111,13 @@ export interface TerminalStateControls { 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 + /** Live display title for a terminal: the OSC-provided title when the + * shell/program set one, otherwise the create-time title. */ + title(terminalId: string): string | undefined + /** Record an OSC title change for a terminal. Kept outside the + * terminal records so `` reference stability (and therefore the + * mounted xterm instances) is preserved. */ + setTitle(terminalId: string, title: string): void /** * Persist a new order for a context's terminals (webview-memory only — * terminals are ephemeral and never round-trip through the extension @@ -109,12 +131,16 @@ 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 + /** + * Apply a drag-over reorder within a context's side terminals (the + * side-panel strip). Returns true when both ends are side terminals + * of that context. + */ + reorderSideDrag(contextKey: string, from: string, to: string): boolean + /** Request ids of the in-flight side-terminal creates for a context. */ + pendingSide(contextKey: string): boolean /** 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 } @@ -149,10 +175,18 @@ export function createTerminalState(selection: Accessor): Termina const [activeId, setActiveId] = createSignal() const [focusedId, setFocusedId] = createSignal() const [focusRequest, setFocusRequest] = createSignal() + // OSC-provided titles, keyed by terminal id. Separate from the terminal + // records on purpose: replacing a record would remount its xterm via + // reference inequality (see the module comment above). + const [titles, setTitles] = createSignal>({}) + const [scripts, setScripts] = createSignal>({}) + // Active side terminal per context. + const [actives, setActives] = 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>({}) + // the panel shows) and per request id (what the answer carries). A + // context can have several creates in flight at once. + const [pending, setPending] = createSignal>({}) const requests = new Map() const currentKey = (): string | undefined => { @@ -195,8 +229,35 @@ export function createTerminalState(selection: Accessor): Termina return out } - const sideForContext = (key: string) => terminalsByContext()[key]?.find((t) => t.placement === "side") - const side = () => sideForContext(sideKey()) + const sidesForContext = (key: string) => (terminalsByContext()[key] ?? []).filter((t) => t.placement === "side") + const sideActiveFor = (key: string) => actives()[key] + const sideFocusedId = () => { + const id = focusedId() + if (!id) return undefined + return sidesForContext(sideKey()).some((t) => t.id === id) ? id : undefined + } + + const setSideActive = (key: string, terminalId: string) => { + setActives((prev) => (prev[key] === terminalId ? prev : { ...prev, [key]: terminalId })) + } + + const title = (terminalId: string): string | undefined => { + const key = contextFor(terminalId) + if (!key) return undefined + const term = terminalsByContext()[key]?.find((t) => t.id === terminalId) + if (!term) return undefined + // Run terminals always retain their semantic title, even when their + // command emits OSC title sequences. + if (term.kind === "run") return term.title + return titles()[terminalId] ?? term.title + } + + const setTitle = (terminalId: string, next: string) => { + if (isScript(terminalId)) return + const trimmed = next.trim() + if (!trimmed) return + setTitles((prev) => (prev[terminalId] === trimmed ? prev : { ...prev, [terminalId]: trimmed })) + } const lookup = () => new Map(current().map((t) => [t.id, t])) @@ -207,6 +268,16 @@ export function createTerminalState(selection: Accessor): Termina return undefined } + const isScript = (terminalId: string): boolean => { + const key = contextFor(terminalId) + return terminalsByContext()[key ?? ""]?.some((term) => term.id === terminalId && term.kind === "run") ?? false + } + + const scriptStatus = (terminalId: string): ScriptTerminalStatus | undefined => { + if (!isScript(terminalId)) return undefined + return scripts()[terminalId] + } + const forSelection = (sel: string | null): TerminalTabStateWithContext[] => { if (sel === null) return [] const key = sel === LOCAL ? LOCAL : sel @@ -218,9 +289,6 @@ 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] } }) @@ -238,9 +306,113 @@ export function createTerminalState(selection: Accessor): Termina return next }) if (focusedId() === terminalId) setFocusedId(undefined) + // A removed active side terminal hands activation to the last + // remaining one of its context, so the panel never shows a dead slot. + if (removed?.placement === "side" && actives()[key] === terminalId) { + const rest = sidesForContext(key) + setActives((prev) => { + const next = { ...prev } + if (rest.length === 0) delete next[key] + else next[key] = rest[rest.length - 1]!.id + return next + }) + } + if (titles()[terminalId] !== undefined) { + setTitles((prev) => { + const next = { ...prev } + delete next[terminalId] + return next + }) + } + if (removed?.kind === "run" && scripts()[terminalId] !== undefined) { + setScripts((prev) => { + const next = { ...prev } + delete next[terminalId] + return next + }) + } return removed } + const syncScripts = (views: ScriptTerminalView[]): TerminalTabStateWithContext[] => { + const ids = new Set(views.map((view) => view.terminalId)) + const added: TerminalTabStateWithContext[] = [] + const removed: TerminalTabStateWithContext[] = [] + + setTerminalsByContext((prev) => { + let changed = false + const next: Record = {} + for (const [key, list] of Object.entries(prev)) { + const kept = list.filter((term) => { + if (term.kind !== "run" || ids.has(term.id)) return true + removed.push(term) + changed = true + return false + }) + if (kept.length > 0) next[key] = kept + } + for (const view of views) { + const key = view.worktreeId ?? LOCAL + const list = next[key] ?? [] + if (list.some((term) => term.id === view.terminalId)) continue + const term: TerminalTabStateWithContext = { + id: view.terminalId, + title: "Run", + wsUrl: view.wsUrl, + font: view.font, + placement: "side", + kind: "run", + contextKey: key, + } + next[key] = [...list, term] + added.push(term) + changed = true + } + return changed ? next : prev + }) + + const states: Record = {} + for (const view of views) { + const status: ScriptTerminalStatus = { state: view.state } + if (view.exitCode !== undefined) status.exitCode = view.exitCode + states[view.terminalId] = status + } + setScripts((prev) => { + const keys = Object.keys(states) + if (keys.length !== Object.keys(prev).length) return states + for (const id of keys) { + const before = prev[id] + const after = states[id] + if (before?.state !== after?.state || before?.exitCode !== after?.exitCode) return states + } + return prev + }) + + if (removed.length > 0) { + const removedIds = new Set(removed.map((term) => term.id)) + if (focusedId() && removedIds.has(focusedId()!)) setFocusedId(undefined) + if (activeId() && removedIds.has(activeId()!)) setActiveId(undefined) + setTitles((prev) => { + const next = { ...prev } + for (const id of removedIds) delete next[id] + return next + }) + setActives((prev) => { + let changed = false + const next = { ...prev } + for (const key of new Set(removed.map((term) => term.contextKey))) { + if (!prev[key] || !removedIds.has(prev[key]!)) continue + const rest = sidesForContext(key) + if (rest.length === 0) delete next[key] + else next[key] = rest[rest.length - 1]!.id + changed = true + } + return changed ? next : prev + }) + } + return added + } + const requestFocus = (id: string) => { focusSerial++ setFocusRequest({ id, serial: focusSerial }) @@ -299,34 +471,58 @@ 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 + /** + * Reorder the side terminals of a context by moving `from` to `to`'s + * position (side-panel strip drag-and-drop). Tab terminals keep their + * leading positions; only the side subset is reshuffled. The order + * lives in the same `terminalsByContext` list, so it survives sidebar + * context switches for the lifetime of the webview. + */ + const reorderSideDrag = (key: string, from: string, to: string): boolean => { + const order = sidesForContext(key).map((t) => t.id) + const fi = order.indexOf(from) + const ti = order.indexOf(to) + if (fi === -1 || ti === -1 || fi === ti) return false + const next = [...order] + next.splice(fi, 1) + next.splice(ti, 0, from) + setTerminalsByContext((prev) => { + const list = prev[key] + if (!list || list.length === 0) return prev + const tabs = list.filter((t) => t.placement === "tab") + const sides = list.filter((t) => t.placement === "side") + const byId = new Map(sides.map((t) => [t.id, t])) + const moved: TerminalTabStateWithContext[] = [] + for (const id of next) { + const t = byId.get(id) + if (t) moved.push(t) + } + // Fresh terminals that appeared mid-drag keep their tail position. + for (const t of sides) if (!moved.includes(t)) moved.push(t) + const ordered = [...tabs, ...moved] + if (ordered.length === list.length && ordered.every((t, i) => t.id === list[i]!.id)) return prev + return { ...prev, [key]: ordered } }) return true } + const pendingSide = (key: string) => (pending()[key]?.length ?? 0) > 0 + + const beginSide = (key: string, createId: string) => { + requests.set(createId, { contextKey: key }) + setPending((prev) => ({ ...prev, [key]: [...(prev[key] ?? []), createId] })) + } + 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 list = (prev[request.contextKey] ?? []).filter((id) => id !== createId) + if (list.length === (prev[request.contextKey]?.length ?? 0)) return prev const next = { ...prev } - delete next[request.contextKey] + if (list.length === 0) delete next[request.contextKey] + else next[request.contextKey] = list return next }) return request @@ -336,13 +532,18 @@ export function createTerminalState(selection: Accessor): Termina add, remove, contextFor, + isScript, + scriptStatus, + syncScripts, forSelection, lookup, current, all, sides, - side, - sideForContext, + sidesForContext, + sideActiveFor, + setSideActive, + sideFocusedId, currentKey, sideKey, activeId, @@ -352,11 +553,13 @@ export function createTerminalState(selection: Accessor): Termina focusRequest, requestFocus, hasRemembered, + title, + setTitle, reorder, reorderDrag, + reorderSideDrag, pendingSide, beginSide, - cancelSide, completeSide, } } @@ -376,8 +579,6 @@ export interface TerminalHandlerDeps { 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. */ @@ -417,33 +618,61 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { }) } - /** - * 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 createSide = () => { 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, + worktreeId: key === deps.LOCAL ? null : key, }) } + /** + * Always create a fresh side terminal for the current context (the + * panel's `+` action and empty state). Multiple creates may be in + * flight at once; each lands as its own tab in the panel strip. + */ + const addSide = () => { + deps.onShowSide(deps.state.sideKey()) + createSide() + } + + /** Ensure the current context has a terminal without changing panel mode. */ + const ensureSide = () => { + const key = deps.state.sideKey() + if (deps.state.sidesForContext(key).length > 0 || deps.state.pendingSide(key)) return + createSide() + } + + /** + * Reveal the side panel and focus the context's active side terminal, + * creating one when the context has none. Never touches the tab strip + * or the chat session. + */ + const requestSide = () => { + const key = deps.state.sideKey() + deps.onShowSide(key) + const existing = deps.state.sidesForContext(key) + if (existing.length > 0) { + const active = deps.state.sideActiveFor(key) ?? existing[existing.length - 1]!.id + deps.state.setSideActive(key, active) + deps.state.requestFocus(active) + return + } + ensureSide() + } + const closeTerminal = (terminalId: string) => { + // Run terminals transition through a provider-owned stopping snapshot. + // Keep their xterm mounted until closure is confirmed by a snapshot or + // terminal.closed message so live output is never discarded early. + if (deps.state.isScript(terminalId)) { + deps.postMessage({ type: "agentManager.terminal.close", terminalId }) + return + } deps.onRemove?.() const ids = deps.tabIds() const idx = ids.indexOf(terminalId) @@ -477,19 +706,49 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { } /** - * 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. + * Kill one side terminal. The panel stays open on the remaining + * terminals (or the empty state when this was the last one) — hiding + * is the toggle's job, not the close button's. Active-tab fallback + * is handled by the state layer. */ - 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 }) + const closeSide = (terminalId: string): boolean => { + // Validate before mutating: dropping a non-side record here would + // unmount its xterm while the backend PTY leaks (no close sent). + const term = deps.state.sides().find((t) => t.id === terminalId) + if (!term) return false + if (term.kind === "run") { + deps.postMessage({ type: "agentManager.terminal.close", terminalId }) + return true + } + deps.state.remove(terminalId) + deps.postMessage({ type: "agentManager.terminal.close", terminalId }) return true } + /** + * Kill every side terminal of a context except one, and make the + * survivor the visible tab: the side-strip counterpart of the tab + * bar's "Close Others". + */ + const closeSideOthers = (terminalId: string) => { + const key = deps.state.contextFor(terminalId) + if (!key) return + for (const term of deps.state.sidesForContext(key)) { + if (term.id === terminalId) continue + closeSide(term.id) + } + deps.state.setSideActive(key, terminalId) + deps.state.requestFocus(terminalId) + } + + /** Make a side terminal the visible one in its panel and focus it. */ + const selectSide = (terminalId: string) => { + const key = deps.state.contextFor(terminalId) + if (!key) return + deps.state.setSideActive(key, terminalId) + deps.state.requestFocus(terminalId) + } + const middleClick = (terminalId: string, e: MouseEvent) => { if (e.button !== 1) return e.preventDefault() @@ -504,7 +763,20 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { return true } - return { closeTerminal, closeSide, middleClick, activate, deactivate, requestNew, requestSide, closeActive } + return { + closeTerminal, + closeSide, + closeSideOthers, + selectSide, + middleClick, + activate, + deactivate, + requestNew, + requestSide, + ensureSide, + addSide, + closeActive, + } } export interface TerminalMessageHandlerDeps { @@ -528,11 +800,14 @@ export interface TerminalMessageHandlerDeps { onSideError?: (contextKey: string) => void /** Side terminal was closed (locally or by the extension). */ onSideClosed?: (contextKey: string) => void + /** A newly hydrated running Run terminal belongs to the selected context. */ + onScriptRunning?: (contextKey: string, terminalId: string) => void /** The destination setting changed (live settings sync). */ onDestinationChanged?: (destination: TerminalDestination) => void } type CreatedMessage = Extract +type ScriptTerminalsMessage = Extract function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { const contextKey = msg.worktreeId === null ? LOCAL : msg.worktreeId @@ -545,14 +820,17 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { } 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. + // missing or context-mismatched request means the webview was + // reloaded (or the context is gone) — close the PTY again instead + // of leaking it. const request = deps.state.completeSide(msg.createId) - if (!request || request.cancelled || request.contextKey !== contextKey) { + if (!request || request.contextKey !== contextKey) { deps.postMessage({ type: "agentManager.terminal.close", terminalId: msg.terminalId }) return } deps.state.add(msg.worktreeId, term) + // The newest terminal becomes the visible one in its panel. + deps.state.setSideActive(contextKey, msg.terminalId) deps.onSideCreated?.(contextKey, msg.terminalId) return } @@ -563,6 +841,13 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { deps.activate(msg.terminalId) } +function handleScriptTerminals(deps: TerminalMessageHandlerDeps, msg: ScriptTerminalsMessage) { + const added = deps.state.syncScripts(msg.terminals) + for (const term of added) { + if (deps.state.scriptStatus(term.id)?.state === "running") deps.onScriptRunning?.(term.contextKey, term.id) + } +} + /** * Wire handlers for the inbound terminal messages. Returns a dispatcher * that accepts each message type and returns true if it handled the @@ -575,6 +860,10 @@ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) { handleCreated(deps, msg) return true } + if (msg.type === "agentManager.scriptTerminals") { + handleScriptTerminals(deps, msg) + return true + } if (msg.type === "agentManager.terminal.closed") { const removed = deps.state.remove(msg.terminalId) if (deps.state.activeId() === msg.terminalId) deps.state.setActiveId(undefined) @@ -583,8 +872,6 @@ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) { } 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 diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts new file mode 100644 index 0000000000..7f1d4947ea --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts @@ -0,0 +1,129 @@ +/** + * Worktree diff data for Agent Manager. + * + * Owns the per-session diff map, the panel loading flag, and the per-file + * pending set, plus the backend message handlers that fill them and the + * helpers that request individual files. Extracted from `AgentManagerApp.tsx` + * so the app component only routes the diff messages and reads the signals. + */ + +import { createSignal, type Accessor } from "solid-js" +import { mergeWorktreeDiffs } from "../diff-viewer/diff-state" +import { parseDiffId } from "./diff-scope-state" +import type { useVSCode } from "../src/context/vscode" +import type { + AgentManagerWorktreeDiffFileMessage, + AgentManagerWorktreeDiffLoadingMessage, + AgentManagerWorktreeDiffMessage, + WorktreeFileDiff, +} from "../src/types/messages" + +/** + * Decompose a composite diff id (`ctx#scope`) into the wire fields the + * extension expects. Bare ids (no scope separator) parse to the default + * branch scope. + */ +function wire(id: string) { + const { ctx, scope } = parseDiffId(id) + return { sessionId: ctx, scope } +} + +export function createWorktreeDiffs(vscode: ReturnType) { + const [diffDatas, setDiffDatas] = createSignal>({}) + const [diffLoading, setDiffLoading] = createSignal(false) + const [diffFileLoading, setDiffFileLoading] = createSignal>>({}) + + const setDiffFilePending = (sessionId: string, file: string, value: boolean) => { + setDiffFileLoading((prev) => { + const session = prev[sessionId] ?? {} + if (value) { + if (session[file]) return prev + return { + ...prev, + [sessionId]: { ...session, [file]: true }, + } + } + + if (!session[file]) return prev + const next = { ...session } + delete next[file] + if (Object.keys(next).length === 0) { + const result = { ...prev } + delete result[sessionId] + return result + } + return { + ...prev, + [sessionId]: next, + } + }) + } + + /** Lazily load a single file's full diff for the given composite diff id. */ + const requestDiffFile = (id: string, file: string) => { + if (diffFileLoading()[id]?.[file]) return + setDiffFilePending(id, file, true) + vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", file, ...wire(id) }) + } + + /** Files the backend flagged as stale in a merged update need a fresh fetch. */ + const refreshStaleDiffs = (id: string, files: Set) => { + const loading = diffFileLoading()[id] ?? {} + for (const file of files) { + if (loading[file]) continue + setDiffFilePending(id, file, true) + vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", file, ...wire(id) }) + } + } + + /** Files currently being fetched for a session, for per-file spinners. */ + const diffFileLoadingFor = (sessionId: Accessor) => { + const id = sessionId() + if (!id) return new Set() + return new Set(Object.keys(diffFileLoading()[id] ?? {})) + } + + // Backend messages. + + const onWorktreeDiff = (ev: AgentManagerWorktreeDiffMessage) => { + let staleFiles: Set | undefined + setDiffDatas((prev) => { + const existing = prev[ev.sessionId] + const merged = existing ? mergeWorktreeDiffs(existing, ev.diffs) : { diffs: ev.diffs, stale: new Set() } + staleFiles = merged.stale + const next = merged.diffs + if (existing && existing.length === next.length && existing.every((old, i) => old === next[i])) return prev + return { ...prev, [ev.sessionId]: next } + }) + if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles) + } + + const onWorktreeDiffFile = (ev: AgentManagerWorktreeDiffFileMessage) => { + if (ev.diff) { + setDiffDatas((prev) => { + const existing = prev[ev.sessionId] ?? [] + const next = existing.map((item) => (item.file === ev.diff!.file ? ev.diff! : item)) + return { ...prev, [ev.sessionId]: next } + }) + setDiffFilePending(ev.sessionId, ev.diff.file, false) + return + } + setDiffFilePending(ev.sessionId, ev.file, false) + } + + const onWorktreeDiffLoading = (ev: AgentManagerWorktreeDiffLoadingMessage) => { + setDiffLoading(ev.loading) + } + + return { + diffDatas, + diffLoading, + setDiffLoading, + requestDiffFile, + refreshStaleDiffs, + diffFileLoadingFor, + onWorktreeDiff, + onWorktreeDiffFile, + onWorktreeDiffLoading, + } +} diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/BaseBranchPicker.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/BaseBranchPicker.tsx index 953c9c26b9..44217e02e7 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/BaseBranchPicker.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/BaseBranchPicker.tsx @@ -96,7 +96,7 @@ export const BaseBranchPicker: Component = (props) => { placement="bottom-start" flip trigger={ - + } + > +
+ + {(opt, index) => ( + <> + {(text) =>
{text()}
}
+ + + )} +
+
+ + ) +} + +/** Compact unified/split picker. Replaces the wide radio group in tight rows. */ +export const DiffStyleSelect: Component<{ + value: "unified" | "split" + onSelect: (value: "unified" | "split") => void + unifiedLabel: string + splitLabel: string + title: string +}> = (props) => ( + +) diff --git a/packages/kilo-vscode/webview-ui/kiloclaw/context/language.tsx b/packages/kilo-vscode/webview-ui/kiloclaw/context/language.tsx index e4e079638f..4a64ce3c7e 100644 --- a/packages/kilo-vscode/webview-ui/kiloclaw/context/language.tsx +++ b/packages/kilo-vscode/webview-ui/kiloclaw/context/language.tsx @@ -27,6 +27,7 @@ import { dict as tr } from "../i18n/tr" import { dict as zh } from "../i18n/zh" import { dict as uk } from "../i18n/uk" import { dict as zht } from "../i18n/zht" +import { dict as fa } from "../i18n/fa" const dicts: Record> = { en, @@ -49,6 +50,7 @@ const dicts: Record> = { uk: { ...en, ...uk }, zh: { ...en, ...zh }, zht: { ...en, ...zht }, + fa: { ...en, ...fa }, } type LanguageCtx = { diff --git a/packages/kilo-vscode/webview-ui/kiloclaw/i18n/fa.ts b/packages/kilo-vscode/webview-ui/kiloclaw/i18n/fa.ts new file mode 100644 index 0000000000..1c63633715 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/kiloclaw/i18n/fa.ts @@ -0,0 +1,95 @@ +export const dict = { + // App + "kiloClaw.loading": "در حال بارگذاری KiloClaw...", + + // Chat + "kiloClaw.chat.connecting": "در حال اتصال...", + "kiloClaw.chat.notRunning": "نمونه در حال اجرا نیست", + "kiloClaw.chat.placeholder": "پیامی بنویسید... (Enter برای ارسال)", + "kiloClaw.chat.online": "آنلاین", + "kiloClaw.chat.offline": "آفلاین", + "kiloClaw.chat.idle": "بیکار", + "kiloClaw.chat.unknown": "نامشخص", + "kiloClaw.chat.empty": "هنوز پیامی وجود ندارد. سلام کنید!", + "kiloClaw.chat.emptyWithBot": "هنوز پیامی وجود ندارد. به {bot} سلام کنید!", + "kiloClaw.chat.send": "ارسال", + "kiloClaw.chat.waitingBotStatus": "در انتظار وضعیت ربات...", + "kiloClaw.chat.botOffline": "ربات آفلاین است", + + // Conversations + "kiloClaw.conversations.title": "مکالمات", + "kiloClaw.conversations.new": "مکالمه جدید", + "kiloClaw.conversations.empty": "هنوز مکالمه‌ای وجود ندارد", + "kiloClaw.conversations.untitled": "بدون عنوان", + "kiloClaw.conversations.rename": "تغییر نام", + "kiloClaw.conversations.leave": "خروج", + "kiloClaw.conversations.selectOne": "یک مکالمه را برای شروع گفتگو انتخاب کنید", + "kiloClaw.conversations.groupToday": "امروز", + "kiloClaw.conversations.groupYesterday": "دیروز", + "kiloClaw.conversations.groupThisWeek": "این هفته", + "kiloClaw.conversations.groupOlder": "قدیمی‌تر", + + // Typing + "kiloClaw.typing.one": "{name} در حال تایپ است...", + "kiloClaw.typing.many": "{count} نفر در حال تایپ هستند...", + + // Messages + "kiloClaw.message.bot": "KiloClaw", + "kiloClaw.message.you": "شما", + "kiloClaw.message.thinking": "در حال فکر کردن...", + "kiloClaw.message.deleted": "[پیام حذف شد]", + "kiloClaw.message.replyDeleted": "پیام اصلی حذف شد", + "kiloClaw.message.notDelivered": "ارسال نشد", + "kiloClaw.message.edited": "(ویرایش شده)", + "kiloClaw.message.reply": "پاسخ", + "kiloClaw.message.replyTo": "در حال پاسخ به", + "kiloClaw.message.cancelReply": "لغو پاسخ", + "kiloClaw.message.edit": "ویرایش", + "kiloClaw.message.delete": "حذف", + "kiloClaw.message.confirmDelete": "این پیام حذف شود؟", + "kiloClaw.message.cancel": "انصراف", + "kiloClaw.message.save": "ذخیره", + "kiloClaw.message.react": "واکنش", + "kiloClaw.message.removeReaction": "حذف واکنش", + "kiloClaw.message.copy": "کپی", + "kiloClaw.message.copied": "در کلیپ‌بورد کپی شد", + "kiloClaw.message.copyFailed": "کپی انجام نشد", + + // Error + "kiloClaw.error.retry": "تلاش مجدد", + + // Setup + "kiloClaw.setup.title": "KiloClaw", + "kiloClaw.setup.subtitle": "هوش مصنوعی شخصی برای زندگی روزمره", + "kiloClaw.setup.description1": + "KiloClaw یک هوش مصنوعی شخصی در اختیار شما قرار می‌دهد که ایمیل‌ها را می‌خواند، تقویم شما را مدیریت می‌کند، پروژه‌هایتان را رصد می‌کند و در Telegram، Slack — هر چیزی که از قبل استفاده می‌کنید — در دسترس است.", + "kiloClaw.setup.description2": + "نیازی به نصب برنامه نیست. رابط جدیدی برای یادگیری وجود ندارد. فقط مثل یک دوست برایش پیام بفرستید.", + "kiloClaw.setup.learnMore": "بیشتر بدانید", + "kiloClaw.setup.tryKiloClaw": "امتحان KiloClaw", + + // Upgrade + "kiloClaw.upgrade.title": "KiloClaw Chat نیاز به ارتقا دارد", + "kiloClaw.upgrade.description1": "این نمونه قبل از فعال‌سازی چت راه‌اندازی شده است.", + "kiloClaw.upgrade.description2.before": "از دکمه ", + "kiloClaw.upgrade.description2.bold": "ارتقا به آخرین نسخه", + "kiloClaw.upgrade.description2.after": " در داشبورد KiloClaw برای فعال‌سازی چت زنده با ربات خود استفاده کنید.", + "kiloClaw.upgrade.openDashboard": "باز کردن داشبورد", + + // Sidebar + "kiloClaw.sidebar.title": "KiloClaw", + "kiloClaw.sidebar.instance": "نمونه", + "kiloClaw.sidebar.unknown": "ناشناخته", + "kiloClaw.sidebar.bot": "ربات", + "kiloClaw.sidebar.botStatus": "وضعیت ربات", + "kiloClaw.sidebar.context": "زمینه", + "kiloClaw.sidebar.used": "استفاده‌شده", + "kiloClaw.sidebar.tokens": "توکن‌ها", + "kiloClaw.sidebar.model": "مدل", + "kiloClaw.sidebar.provider": "ارائه‌دهنده", + "kiloClaw.sidebar.details": "جزئیات", + "kiloClaw.sidebar.region": "منطقه", + "kiloClaw.sidebar.version": "نسخه", + "kiloClaw.sidebar.channels": "کانال‌ها", + "kiloClaw.sidebar.noData": "داده‌ای برای نمونه وجود ندارد", +} diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 1aa6768823..7fa356e848 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -1,34 +1,18 @@ import { Component, createSignal, createMemo, Switch, Match, Show, onMount, onCleanup } from "solid-js" -import { ThemeProvider } from "@kilocode/kilo-ui/theme" -import { DialogProvider } from "@kilocode/kilo-ui/context/dialog" -import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" -import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" -import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff" -import { FileComponentProvider } from "@kilocode/kilo-ui/context/file" -import { Code } from "@kilocode/kilo-ui/code" -import { Diff } from "@kilocode/kilo-ui/diff" -import { File } from "@kilocode/kilo-ui/file" import { DataProvider } from "@kilocode/kilo-ui/context/data" -import { Toast } from "@kilocode/kilo-ui/toast" import Settings from "./components/settings/Settings" import ProfileView from "./components/profile/ProfileView" -import { VSCodeProvider, useVSCode } from "./context/vscode" -import { ServerProvider, useServer } from "./context/server" -import { ProviderProvider, useProvider } from "./context/provider" -import { ConfigProvider } from "./context/config" -import { DisplayProvider } from "./context/display" +import { useVSCode } from "./context/vscode" +import { useServer } from "./context/server" +import { useProvider } from "./context/provider" import { WorkStyleProvider } from "./context/work-style" -import { IndexingProvider } from "./context/indexing" -import { AgentRequirementsProvider } from "./context/agent-requirements" -import { MemoryProvider } from "./context/memory" -import { SessionProvider, useSession } from "./context/session" +import { useSession } from "./context/session" import { LocalTabsProvider, useLocalTabs } from "./context/local-tabs" -import { LanguageBridge } from "./context/language-bridge" +import { ProviderShell } from "./context/provider-shell" import { ChatView } from "./components/chat" import { SidebarEmptyState } from "./components/chat/SidebarEmptyState" import { registerExpandedTaskTool } from "./components/chat/TaskToolExpanded" import { registerVscodeToolOverrides } from "./components/chat/VscodeToolOverrides" -import { SpeechToTextPrewarm } from "./components/speech-to-text/SpeechToTextPrewarm" // Override the upstream "task" tool renderer with the fully-expanded version // that shows child session parts inline in the VS Code sidebar. @@ -37,10 +21,6 @@ registerExpandedTaskTool() registerVscodeToolOverrides() import HistoryView from "./components/history/HistoryView" import { MigrationWizard } from "./components/migration" // legacy-migration -import { NotificationsProvider } from "./context/notifications" -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" @@ -210,27 +190,6 @@ export const DataBridge: Component<{ children: any }> = (props) => { ) } -type MermaidImageEvent = CustomEvent<{ dataUrl: string; filename: string }> - -export const MermaidDownloadBridge: Component = () => { - const vscode = useVSCode() - - onMount(() => { - const save = (event: Event) => { - const detail = (event as MermaidImageEvent).detail - if (!detail?.dataUrl || !detail.filename) return - event.preventDefault() - vscode.postMessage({ type: "saveImage", dataUrl: detail.dataUrl, filename: detail.filename }) - } - window.addEventListener("kilo:save-image", save) - onCleanup(() => { - window.removeEventListener("kilo:save-image", save) - }) - }) - - return null -} - // Inner app component that uses the contexts const AppContent: Component = () => { const [currentView, setCurrentView] = createSignal("newTask") @@ -412,59 +371,21 @@ const AppContent: Component = () => { ) } -// Main App component with context providers const App: Component = () => { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + ) } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index 40e0b72876..e5aa67b906 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -70,6 +70,8 @@ import { type TranscriptHold, type TranscriptRow, } from "../../context/transcript-rows" +import { PromptRail } from "./PromptRail" +import { capacity, historyAction, promptItems, railEntries, type PromptRailItem } from "./prompt-rail" import { onTimelineHighlight, type TimelineHighlight } from "../../utils/timeline/highlight" import { useTranscriptSearch, type SearchMatch } from "../../context/transcript-search" import { applyTranscriptHighlights, clearTranscriptHighlights } from "./transcript-search-highlight" @@ -164,6 +166,8 @@ export const MessageList: Component = (props) => { const [scrollEl, setScrollEl] = createSignal() const [virtualizer, setVirtualizer] = createSignal() const [layout, setLayout] = createSignal("") + // Transcript height, kept reactive so the prompt rail re-caps on resize. + const [height, setHeight] = createSignal(0) const revert = () => session.revert() ?? undefined const turns = createMemo((prev: MessageTurn[] | undefined) => @@ -905,8 +909,8 @@ export const MessageList: Component = (props) => { // entirely to the precise per-occurrence check in paintHighlights, // which only scrolls when the exact match actually needs it. if (!mounted) { - const index = keys().indexOf(match.key) - if (index >= 0) { + const index = indexes().get(match.key) + if (index !== undefined) { virtualizer()?.scrollToIndex(index, { align: "center" }) } } @@ -942,8 +946,65 @@ export const MessageList: Component = (props) => { const tail = createMemo(() => partition().direct.map((row) => row.key)) const lookup = createMemo(() => new Map(partition().direct.map((row) => [row.key, row]))) const keys = createMemo(() => partition().virtual.map((row) => row.key)) + const indexes = createMemo(() => new Map(keys().map((key, index) => [key, index]))) const fingerprint = createMemo(() => rowFingerprint(keys())) + const [pending, setPending] = createSignal<{ sid: string; key: string }>() + + // Scrolls the transcript to a row by key. Virtualized rows jump through + // the virtualizer; direct/live/queued rows are mounted, so they use + // scrollIntoView. Pauses auto-follow first so the jump isn't snapped back. + const jump = (key: string) => { + autoScroll.pause() + const index = indexes().get(key) + if (index !== undefined) { + const handle = virtualizer() + if (handle) { + setPending(undefined) + handle.scrollToIndex(index, { align: "start" }) + return + } + const sid = session.currentSessionID() + if (sid) setPending({ sid, key }) + return + } + const el = scrollEl() + const target = el?.querySelector(`[data-row-key="${CSS.escape(key)}"]`) + if (target) { + setPending(undefined) + target.scrollIntoView({ block: "start" }) + return + } + const sid = session.currentSessionID() + if (sid) setPending({ sid, key }) + } + + // Keep unresolved targets by stable row key. Virtual rows resolve once + // Virtua installs its handle; direct/live rows resolve once Solid mounts + // their DOM node. + createEffect(() => { + const target = pending() + if (!target) return + if (target.sid !== session.currentSessionID()) { + setPending(undefined) + return + } + const index = indexes().get(target.key) + const handle = virtualizer() + if (index !== undefined && handle) { + setPending(undefined) + autoScroll.pause() + handle.scrollToIndex(index, { align: "start" }) + return + } + const el = scrollEl() + const row = el?.querySelector(`[data-row-key="${CSS.escape(target.key)}"]`) + if (!row) return + setPending(undefined) + autoScroll.pause() + row.scrollIntoView({ block: "start" }) + }) + // Clicking a bar in the task timeline scrolls the transcript to that message. // Jumps land instantly (no smooth animation): while pinned at the bottom, a // smooth scroll's initial frames sit within createAutoScroll's near-bottom @@ -956,19 +1017,110 @@ export const MessageList: Component = (props) => { // actually contains the clicked part, not just the message's first chunk. const row = matches.find((r) => r.type === "assistant" && r.parts.some((p) => p.id === detail.partId)) ?? matches[0] if (!row) return - autoScroll.pause() - const index = keys().indexOf(row.key) - if (index >= 0) { - virtualizer()?.scrollToIndex(index, { align: "start" }) - return - } - const el = scrollEl() - const target = el?.querySelector(`[data-row-key="${CSS.escape(row.key)}"]`) - target?.scrollIntoView({ block: "start" }) + jump(row.key) } window.addEventListener("scrollToMessage", onScrollToMessage) onCleanup(() => window.removeEventListener("scrollToMessage", onScrollToMessage)) + // Prompt rail: one tick per user prompt, positioned to the left of the + // readable lane, opening a card of prompt/answer previews on hover. + const items = createMemo(() => promptItems(rows())) + // Until the transcript is measured there is no height to cap against, and + // rendering every prompt would spill ticks past the rail on long sessions. + const entries = createMemo(() => railEntries(items(), capacity(height()), session.hasOlderMessages())) + const [activeTurn, setActiveTurn] = createSignal() + const railActiveKey = createMemo(() => items().find((item) => item.turn === activeTurn())?.key) + + const [seek, setSeek] = createSignal<{ sid: string; count: number }>() + let paging = false + + const first = () => { + const item = items()[0] + if (!session.hasOlderMessages()) { + if (item) jump(item.key) + return + } + const sid = session.currentSessionID() + if (!sid || session.loadingOlderMessages()) return + setSeek({ sid, count: session.messages().length }) + if (!session.loadOlderMessages()) setSeek(undefined) + } + + // Loading the first prompt is deliberate and progressive: each completed + // prepend advances the existing page cursor, while hover/open remains free + // of network and full-history work. Stop if a request makes no progress so + // backend failures cannot turn into a retry loop. + createEffect(() => { + const loading = session.loadingOlderMessages() + const target = seek() + if (!target) { + paging = loading + return + } + if (target.sid !== session.currentSessionID()) { + paging = false + setSeek(undefined) + return + } + if (loading) { + paging = true + return + } + if (!paging) return + paging = false + const count = session.messages().length + const action = historyAction(target.count, count, session.hasOlderMessages()) + if (action === "stop") { + const item = items()[0] + setSeek(undefined) + if (item) jump(item.key) + return + } + if (action === "load") { + setSeek({ sid: target.sid, count }) + if (!session.loadOlderMessages()) setSeek(undefined) + return + } + const item = items()[0] + setSeek(undefined) + if (item) jump(item.key) + }) + + const trackActive = () => { + const list = items() + if (list.length === 0) return setActiveTurn(undefined) + const handle = virtualizer() + const offset = handle?.scrollOffset + if (handle && offset !== undefined && offset > 1) { + const row = partition().virtual[handle.findItemIndex(offset)] + if (row) return setActiveTurn(row.turn) + } + const el = scrollEl() + if (handle && el && el.scrollHeight > el.clientHeight + 1) { + const row = partition().virtual[0] + if (row) return setActiveTurn(row.turn) + } + setActiveTurn(list.at(-1)?.turn) + } + let activeFrame: number | undefined + const scheduleActive = () => { + if (activeFrame !== undefined) return + activeFrame = requestAnimationFrame(() => { + activeFrame = undefined + trackActive() + }) + } + onCleanup(() => { + if (activeFrame !== undefined) cancelAnimationFrame(activeFrame) + }) + // Re-derive the active turn whenever the transcript changes so the rail + // reflects a newly started turn even before any scrolling happens. + createEffect(() => { + items() + partition() + scheduleActive() + }) + // Highlights the part behind the currently hovered/focused timeline bar // (dispatched by TaskTimeline) so the two stay visually correlated. const [highlight, setHighlight] = createSignal() @@ -1018,6 +1170,7 @@ export const MessageList: Component = (props) => { const handleScroll = () => { autoScroll.handleScroll() maybeLoadOlder() + scheduleActive() if (search.active()) scheduleHighlight() } @@ -1026,6 +1179,7 @@ export const MessageList: Component = (props) => { const el = scrollEl() if (!el) return const style = getComputedStyle(el) + setHeight(el.clientHeight) setLayout( layoutFingerprint({ width: Math.round(el.clientWidth), @@ -1210,6 +1364,28 @@ export const MessageList: Component = (props) => {
+ railActiveKey()} + onSelect={(item: PromptRailItem) => jump(item.key)} + onFirst={first} + onLatest={() => { + const item = items().at(-1) + if (item) jump(item.key) + }} + onLoadOlder={() => session.loadOlderMessages()} + onWheel={(deltaY: number) => { + const el = scrollEl() + if (el) el.scrollTop += deltaY + }} + height={height} + hasOlder={session.hasOlderMessages} + loadingOlder={session.loadingOlderMessages} + prepending={() => session.messageMutation() === "prepend"} + seeking={() => Boolean(seek())} + /> + + ) + + return ( + = 2}> + + + + {(position) => ( + + + } + > + { + list = handle + }} + class="prompt-rail-list" + data={items()} + itemSize={ROW_HEIGHT} + bufferSize={ROW_HEIGHT * 3} + shift={props.prepending()} + onScroll={page} + onScrollEnd={() => { + revealing = false + }} + > + {row} + + +
+ + )} + + + ) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx index 736c6355a0..74fa8f1ba7 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx @@ -348,7 +348,9 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
{summary()}
-
{questionText()}
+
+ {questionText()} +
e.stopPropagation()}> @@ -391,7 +393,9 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
-
{questionText()}
+
+ {questionText()} +
{language.t("ui.question.singleHint")}
}>
{language.t("ui.question.multiHint")}
@@ -419,9 +423,13 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => - {localized.label()} + + {localized.label()} + - {localized.description()} + + {localized.description()} + @@ -498,8 +506,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => const answered = () => Boolean(value()) return (
- {tr(language.t, q.questionKey, q.question)} - + + {tr(language.t, q.questionKey, q.question)} + + {answered() ? value() : language.t("ui.question.review.notAnswered")}
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionMentionPicker.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionMentionPicker.tsx index 24abafa27b..7471b99610 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionMentionPicker.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionMentionPicker.tsx @@ -42,7 +42,7 @@ export function SessionMentionPicker(props: Props) { items={props.sessions} key={(item) => item.id} - filterKeys={["title"]} + filterKeys={["title", "worktreeName"]} search={{ placeholder: "Search sessions", autofocus: true }} onSelect={(item) => { if (item) props.onSelect(item) @@ -52,6 +52,7 @@ export function SessionMentionPicker(props: Props) { {item.title} + {item.worktreeName && {item.worktreeName}} {formatRelativeDate(new Date(item.updated).toISOString())} )} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SuggestBar.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SuggestBar.tsx index 2eac797698..63cc677ad6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SuggestBar.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SuggestBar.tsx @@ -30,7 +30,9 @@ export const SuggestBar: Component<{ request: SuggestionRequest }> = (props) => - {props.request.text} + + {props.request.text} +
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TabDnd.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TabDnd.tsx index 37c650a228..bd86ada3d5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TabDnd.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TabDnd.tsx @@ -27,13 +27,13 @@ export const ConstrainDragYAxis: Component = () => { return null } -export const SortableTabContainer: ParentComponent<{ id: string }> = (props) => { +export const SortableTabContainer: ParentComponent<{ id: string; class?: string }> = (props) => { const sortable = createSortable(props.id) void sortable return (
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-rail.ts b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-rail.ts new file mode 100644 index 0000000000..b18b76da44 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-rail.ts @@ -0,0 +1,144 @@ +import type { Part } from "../../types/messages" +import type { TranscriptRow } from "../../context/transcript-rows" + +export interface PromptRailItem { + key: string + turn: string + queued: boolean + prompt: string + answer: string +} + +export type PromptRailEntry = + | { type: "prompt"; item: PromptRailItem; index: number } + | { type: "overflow"; count: number; index: number } + | { type: "history" } + +const PROMPT_LIMIT = 160 +const ANSWER_LIMIT = 220 + +/** + * Height of the tallest card row (padding + a one-line prompt + a two-line + * answer). Sizes the navigator's virtualized rows; the rail's own fit cap is + * measured in tick spacing instead, since a tick is only a hairline. + */ +export const ROW_HEIGHT = 76 +/** Vertical padding reserved at the top and bottom of the rail. */ +export const RAIL_INSET = 24 +/** Natural spacing between ticks, and the tightest they are allowed to pack. */ +export const TICK_STEP = 14 +export const TICK_MIN = 7 + +/** + * How many ticks fit the available transcript height. Measured in tick + * spacing, not card row height: a tick is a 1.5px line, so the rail holds + * several times more prompts than the navigator can list at once, and + * summarizing at the card's row count would hide prompts that have room to + * show. The complete prompt list lives in the bounded navigator. + */ +export function capacity(height: number): number { + return Math.floor((height - RAIL_INSET) / TICK_MIN) +} + +export function historyAction(before: number, after: number, more: boolean): "stop" | "load" | "jump" { + if (after <= before) return "stop" + return more ? "load" : "jump" +} + +// The card never renders markdown — user message text shows literally, and +// assistant text should too. Code spans (inline and fenced) are dropped, and +// link URLs / images are stripped rather than parsed (mirrors MessageList's +// stripMarkdownLinkUrls split so bracket text inside inline code stays out, +// not half-stripped). +export function previewText(raw: string): string { + const segments = raw.split(/(```[\s\S]*?```|`[^`\n]*`)/g) + const text = segments.map((segment, i) => (i % 2 === 1 ? "" : stripLinks(segment))).join(" ") + return text + .replace(/^#{1,6}\s+/gm, "") + .replace(/^\s*[-*>+]\s+/gm, "") + .replace(/\s+/g, " ") + .trim() +} + +function stripLinks(text: string) { + return text.replace(/!\[[^\]]*\]\([^)]*\)/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") +} + +function text(parts: Part[], limit: number): string { + const joined = parts + .filter((part) => part.type === "text" && !part.synthetic && part.text.trim()) + .map((part) => (part.type === "text" ? part.text : "")) + .join("\n") + return truncate(previewText(joined), limit) +} + +function truncate(value: string, limit: number) { + return value.length <= limit ? value : `${value.slice(0, limit - 1)}…` +} + +export function promptItems(rows: TranscriptRow[]): PromptRailItem[] { + const items: PromptRailItem[] = [] + for (const row of rows) { + if (row.type !== "user") continue + items.push({ key: row.key, turn: row.turn, queued: row.queued, prompt: text(row.parts, PROMPT_LIMIT), answer: "" }) + } + // Answer text is grouped by turn so one pass fills every item; assistant + // rows follow their user row and carry the same `turn` id. + let index = 0 + for (const row of rows) { + if (row.type === "user") { + index += 1 + continue + } + if (row.type !== "assistant" || index === 0) continue + const item = items[index - 1]! + if (item.turn !== row.turn || item.answer) continue + const value = text(row.parts, ANSWER_LIMIT) + if (value) item.answer = value + } + // A prompt can carry no text at all (image-only or file-only message). Rather + // than render a blank row, promote the answer into the label so the row still + // says something; if neither has text the card falls back to its placeholder. + for (const item of items) { + if (item.prompt || !item.answer) continue + item.prompt = truncate(item.answer, PROMPT_LIMIT) + item.answer = "" + } + return items +} + +export function railEntries(items: PromptRailItem[], capacity: number, history = false): PromptRailEntry[] { + if (capacity < 1) return [] + if (!history && items.length <= capacity) { + return items.map((item, index) => ({ type: "prompt", item, index })) + } + if (capacity === 1) { + if (history) return [{ type: "history" }] + const index = items.length - 1 + const item = items[index] + return item ? [{ type: "prompt", item, index }] : [] + } + if (capacity === 2) { + const item = items.at(-1) + const latest = item ? [{ type: "prompt" as const, item, index: items.length - 1 }] : [] + if (history) return [{ type: "history" }, ...latest] + const first = items[0] + return first ? [{ type: "prompt", item: first, index: 0 }, ...latest] : latest + } + + const count = Math.min(items.length, capacity - 2) + const start = items.length - count + const recent = items.slice(start).map((item, offset) => ({ + type: "prompt" as const, + item, + index: start + offset, + })) + const prefix: PromptRailEntry[] = history + ? [{ type: "history" }] + : items[0] + ? [{ type: "prompt", item: items[0], index: 0 }] + : [] + const hidden = start - (history ? 0 : 1) + if (hidden < 1) return [...prefix, ...recent] + return [...prefix, { type: "overflow", count: hidden, index: history ? 0 : 1 }, ...recent] +} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx index 380f33efd9..508e835fe6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx @@ -17,7 +17,7 @@ import ModeEditView from "./ModeEditView" import ModeCreateView from "./ModeCreateView" import McpEditView from "./McpEditView" import WorkflowsTab from "./agent-behaviour/WorkflowsTab" -import { selectedDefaultAgentValue } from "./agent-behaviour-patches" +import { mcpConfigScope, mcpEnabledPatch, selectedDefaultAgentValue } from "./agent-behaviour-patches" import { parseImport, MAX_IMPORT_SIZE } from "./mode-io" import type { ImportError } from "./mode-io" @@ -50,7 +50,7 @@ type AgentView = "list" | "create" | "edit" const AgentBehaviourTab: Component = () => { const language = useLanguage() - const { config, updateConfig } = useConfig() + const { config, collections, updateConfig, updateGlobalConfig, updateProjectConfig } = useConfig() const session = useSession() const dialog = useDialog() const vscode = useVSCode() @@ -675,12 +675,17 @@ const AgentBehaviourTab: Component = () => { { - if (isConnected(name)) { - session.disconnectMcp(name) - } else { - session.connectMcp(name) + onChange={(enabled: boolean) => { + const scope = mcpConfigScope(name, collections()) + if (scope) { + const update = scope === "project" ? updateProjectConfig : updateGlobalConfig + update(mcpEnabledPatch(name, enabled)) } + if (!enabled) { + session.disconnectMcp(name) + return + } + session.connectMcp(name) }} hideLabel > diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx index 876a230f65..eee016f3f5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx @@ -1,14 +1,32 @@ -import { Component, createSignal, onCleanup, onMount } from "solid-js" +import { Component, Show, createSignal, onCleanup, onMount } from "solid-js" import { Switch } from "@kilocode/kilo-ui/switch" import { Card } from "@kilocode/kilo-ui/card" import { useVSCode } from "../../context/vscode" import { useLanguage } from "../../context/language" +import { useConfig } from "../../context/config" import type { BrowserSettings } from "../../types/messages" import SettingsRow from "./SettingsRow" +const Header: Component<{ title: string }> = (props) => ( +

+ {props.title} +

+) + const BrowserTab: Component = () => { const { postMessage, onMessage } = useVSCode() const { t } = useLanguage() + const { globalConfig, projectConfig, updateGlobalConfig } = useConfig() const [settings, setSettings] = createSignal({ enabled: false, @@ -33,6 +51,12 @@ const BrowserTab: Component = () => { postMessage({ type: "updateSetting", key: `browserAutomation.${key}`, value }) } + const updateWebsearch = (checked: boolean) => { + updateGlobalConfig({ web_search: checked }) + } + + const overridden = () => projectConfig().web_search !== undefined + return (
{/* Info text */} @@ -52,43 +76,90 @@ const BrowserTab: Component = () => { "line-height": "1.5", }} > - {t("settings.browser.description")} + {t("settings.webTools.description")}

- - {/* Enable toggle */} - - update("enabled", checked)} hideLabel> - {t("settings.browser.enable.title")} - - - - {/* Use System Chrome */} - - update("useSystemChrome", checked)} - hideLabel +
+
+ + t("settings.config.scope.global")} + last={!overridden()} > - {t("settings.browser.systemChrome.title")} - - + + {t("settings.webTools.webSearch.title")} + + + + t("settings.config.scope.local")} + last + > + + {`${t("settings.webTools.webSearch.title")} (${t("settings.config.scope.local")})`} + + + + +
- {/* Headless mode */} - +
+

- update("headless", checked)} hideLabel> - {t("settings.browser.headless.title")} - - - + {t("settings.browser.description")} +

+ + {/* Enable toggle */} + + update("enabled", checked)} hideLabel> + {t("settings.browser.enable.title")} + + + + {/* Use System Chrome */} + + update("useSystemChrome", checked)} + hideLabel + > + {t("settings.browser.systemChrome.title")} + + + + {/* Headless mode */} + + update("headless", checked)} + hideLabel + > + {t("settings.browser.headless.title")} + + + +
) } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx index 7e67d9df60..8bd39f14a4 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx @@ -177,9 +177,9 @@ const Settings: Component = (props) => { {language.t("settings.autoApprove.title")} - + - {language.t("settings.browser.title")} + {language.t("settings.webTools.title")} @@ -249,7 +249,7 @@ const Settings: Component = (props) => { -

{language.t("settings.browser.title")}

+

{language.t("settings.webTools.title")}

diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour-patches.ts b/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour-patches.ts index 7d031e2a22..eabc6550c5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour-patches.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour-patches.ts @@ -1,3 +1,20 @@ +import type { Config, ConfigCollections } from "../../types/messages" + +export function mcpEnabledPatch(name: string, enabled: boolean): Partial { + return { + mcp: { + [name]: { + enabled, + }, + }, + } +} + +export function mcpConfigScope(name: string, collections: ConfigCollections): "global" | "project" | undefined { + const source = collections.mcp?.find((entry) => entry.key === name)?.source + return source === "project" || source === "global" ? source : undefined +} + export function selectedDefaultAgentValue(value: string): string | null { return value || null } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts b/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts index d4b1949d2a..be43b46284 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts @@ -34,6 +34,7 @@ export const KNOWN_KEYS: ReadonlyArray = [ "compaction", "commit_message", "tools", + "web_search", "auto_collapse_reasoning", "terminal_command_display", "code_edit_display", diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index 81fd046cc6..63de42f868 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -11,7 +11,7 @@ import { createContext, useContext, createSignal, createMemo, onCleanup } from "solid-js" import type { ParentComponent, Accessor } from "solid-js" import { useVSCode } from "./vscode" -import type { Config, ExtensionMessage, FeatureFlags } from "../types/messages" +import type { Config, ConfigCollections, ExtensionMessage, FeatureFlags } from "../types/messages" import { configUnsetPaths, deepMerge, @@ -35,6 +35,7 @@ interface ConfigContextValue { config: Accessor globalConfig: Accessor projectConfig: Accessor + collections: Accessor settings: Accessor> features: Accessor loading: Accessor @@ -57,6 +58,7 @@ export const ConfigProvider: ParentComponent = (props) => { const [config, setConfig] = createSignal({}) const [globalConfig, setGlobalConfig] = createSignal({}) const [projectConfig, setProjectConfig] = createSignal({}) + const [collections, setCollections] = createSignal({}) const [settings, setSettings] = createSignal>({}) const [features, setFeatures] = createSignal({ indexing: false, sandboxControls: false }) const [loading, setLoading] = createSignal(true) @@ -82,6 +84,9 @@ export const ConfigProvider: ParentComponent = (props) => { // Error from the most recent saveConfig() attempt, or null if no error. // Cleared when the user edits the draft again or starts a new save. const [saveError, setSaveError] = createSignal(null) + const updateCollections = (next: ConfigCollections | undefined) => { + if (next !== undefined) setCollections(next) + } // Register handler immediately (not in onMount) so we never miss // a configLoaded message that arrives before the DOM mount. @@ -136,6 +141,7 @@ export const ConfigProvider: ParentComponent = (props) => { setProjectConfig(mergeScopedConfig(message.projectConfig, projectDraft())) setSavedProject(message.projectConfig) } + updateCollections(message.collections) setLoading(false) return } @@ -163,6 +169,7 @@ export const ConfigProvider: ParentComponent = (props) => { setProjectConfig(message.projectConfig) setSavedProject(message.projectConfig) } + updateCollections(message.collections) setFeatures(message.features) } else { // configUpdated from a different source (e.g. PermissionDock save). @@ -176,6 +183,7 @@ export const ConfigProvider: ParentComponent = (props) => { setProjectConfig(mergeScopedConfig(message.projectConfig, projectDraft())) setSavedProject(message.projectConfig) } + updateCollections(message.collections) setFeatures(message.features) } if (message.settings) mergeSettings(message.settings) @@ -313,6 +321,7 @@ export const ConfigProvider: ParentComponent = (props) => { config, globalConfig, projectConfig, + collections, settings, features, loading, diff --git a/packages/kilo-vscode/webview-ui/src/context/language-utils.ts b/packages/kilo-vscode/webview-ui/src/context/language-utils.ts index af837c7e92..247a5da01e 100644 --- a/packages/kilo-vscode/webview-ui/src/context/language-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/context/language-utils.ts @@ -19,9 +19,10 @@ export type Locale = | "nl" | "uk" | "it" + | "fa" /** Locales that use right-to-left script. */ -export const RTL_LOCALES = new Set(["ar"]) +export const RTL_LOCALES = new Set(["ar", "fa"]) /** Map internal locale IDs to valid BCP 47 language tags for the HTML lang attribute. */ export const LOCALE_BCP47: Partial> = { @@ -55,6 +56,7 @@ export const LOCALES: readonly Locale[] = [ "nl", "uk", "it", + "fa", ] /** diff --git a/packages/kilo-vscode/webview-ui/src/context/language.tsx b/packages/kilo-vscode/webview-ui/src/context/language.tsx index 7dad79270d..709e0a05cf 100644 --- a/packages/kilo-vscode/webview-ui/src/context/language.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/language.tsx @@ -49,6 +49,7 @@ import { dict as appTr } from "../i18n/tr" import { dict as appNl } from "../i18n/nl" import { dict as appUk } from "../i18n/uk" import { dict as appIt } from "../i18n/it" +import { dict as appFa } from "../i18n/fa" import { dict as amEn } from "../../agent-manager/i18n/en" import { dict as amZh } from "../../agent-manager/i18n/zh" import { dict as amZht } from "../../agent-manager/i18n/zht" @@ -69,6 +70,7 @@ import { dict as amTr } from "../../agent-manager/i18n/tr" import { dict as amNl } from "../../agent-manager/i18n/nl" import { dict as amUk } from "../../agent-manager/i18n/uk" import { dict as amIt } from "../../agent-manager/i18n/it" +import { dict as amFa } from "../../agent-manager/i18n/fa" import { dict as kiloEn } from "@kilocode/kilo-i18n/en" import { dict as kiloZh } from "@kilocode/kilo-i18n/zh" import { dict as kiloZht } from "@kilocode/kilo-i18n/zht" @@ -118,6 +120,7 @@ export const LOCALE_LABELS: Record = { nl: "Nederlands", uk: "Українська", it: "Italiano", + fa: "فارسی", } // Merge 4 dict layers: app + ui + kilo + agent manager (kilo and agent manager override last) @@ -143,6 +146,9 @@ const dicts: Record> = { nl: { ...base, ...appNl, ...uiNl, ...kiloNl, ...amEn, ...amNl }, uk: { ...base, ...appUk, ...uiUk, ...kiloUk, ...amEn, ...amUk }, it: { ...base, ...appIt, ...uiIt, ...kiloIt, ...amEn, ...amIt }, + // Persian (Kilo fork addition). Only app + agent-manager layers are localized; + // the upstream ui/kilo layers fall back to English via `base`. + fa: { ...base, ...appFa, ...amEn, ...amFa }, } function normalizeLocale(lang: string): Locale { diff --git a/packages/kilo-vscode/webview-ui/src/context/provider-shell.tsx b/packages/kilo-vscode/webview-ui/src/context/provider-shell.tsx new file mode 100644 index 0000000000..7a89205249 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/provider-shell.tsx @@ -0,0 +1,96 @@ +import { onCleanup, onMount, type Component, type ParentComponent } from "solid-js" +import { ThemeProvider } from "@kilocode/kilo-ui/theme" +import { DialogProvider } from "@kilocode/kilo-ui/context/dialog" +import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" +import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" +import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff" +import { FileComponentProvider } from "@kilocode/kilo-ui/context/file" +import { Code } from "@kilocode/kilo-ui/code" +import { Diff } from "@kilocode/kilo-ui/diff" +import { File } from "@kilocode/kilo-ui/file" +import { Toast } from "@kilocode/kilo-ui/toast" +import { VSCodeProvider, useVSCode } from "./vscode" +import { ServerProvider } from "./server" +import { ProviderProvider } from "./provider" +import { ConfigProvider } from "./config" +import { DisplayProvider } from "./display" +import { IndexingProvider } from "./indexing" +import { AgentRequirementsProvider } from "./agent-requirements" +import { MemoryProvider } from "./memory" +import { SessionProvider } from "./session" +import { LanguageBridge } from "./language-bridge" +import { NotificationsProvider } from "./notifications" +import { FeedbackProvider } from "./feedback" +import { KiloEmbeddingModelsProvider } from "./kilo-embedding-models" +import { ImageModelsProvider } from "./image-models" +import { SpeechToTextPrewarm } from "../components/speech-to-text/SpeechToTextPrewarm" + +type MermaidImageEvent = CustomEvent<{ dataUrl: string; filename: string }> + +const MermaidDownloadBridge: Component = () => { + const vscode = useVSCode() + + onMount(() => { + const save = (event: Event) => { + const detail = (event as MermaidImageEvent).detail + if (!detail?.dataUrl || !detail.filename) return + event.preventDefault() + vscode.postMessage({ type: "saveImage", dataUrl: detail.dataUrl, filename: detail.filename }) + } + window.addEventListener("kilo:save-image", save) + onCleanup(() => window.removeEventListener("kilo:save-image", save)) + }) + + return null +} + +const Root: ParentComponent = (props) => ( + + + + + + + + + + + + + + {props.children} + + + + + + + + + + + + +) + +const Session: ParentComponent = (props) => ( + + + + + {props.children} + + + + +) + +const Chat: ParentComponent = (props) => ( + + + {props.children} + + +) + +export const ProviderShell = { Root, Session, Chat } diff --git a/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts b/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts index e17f548ef8..eaf9b6bf76 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts @@ -41,6 +41,9 @@ function identifiers( export function terminal(input: Input): TerminalState | undefined { if (!input.reason) return undefined + // A superseded turn handed off to a queued follow-up; it is not a premature + // stop, and the follow-up turn closes with its own reason afterwards. + if (input.reason === "superseded") return undefined const last = input.messages[input.messages.length - 1] const finish = last?.role === "assistant" ? last.finish : undefined const ids = identifiers(last, input.parts) diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index b279c79651..c7bad43c0a 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -291,7 +291,7 @@ interface SessionContextValue { createSession: () => void clearCurrentSession: () => void loadSessions: () => void - loadOlderMessages: () => void + loadOlderMessages: () => boolean selectSession: (id: string) => void deleteSession: (id: string) => void renameSession: (id: string, title: string) => void @@ -2560,9 +2560,9 @@ export const SessionProvider: ParentComponent = (props) => { function loadOlderMessages() { const id = currentSessionID() - if (!id || !server.isConnected()) return + if (!id || !server.isConnected()) return false const page = pages[id] ?? emptyPageState - if (!page.hasMore || page.loadingOlder || page.loadingInitial || !page.before) return + if (!page.hasMore || page.loadingOlder || page.loadingInitial || !page.before) return false patchPage(id, { loadingOlder: true }) vscode.postMessage({ type: "loadMessages", @@ -2571,6 +2571,7 @@ export const SessionProvider: ParentComponent = (props) => { before: page.before, limit: MESSAGE_PAGE_LIMIT, }) + return true } // Session whose message fetch was deferred because the backend was offline at diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 592ae22dcf..f4b6ed9a8e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -296,7 +296,6 @@ export const dict = { "session.tab.review": "مراجعة", "session.review.filesChanged": "تم تغيير {{count}} ملفات", - "session.review.change.other": "تغييرات", "session.review.loadingChanges": "جارٍ تحميل التغييرات...", "session.review.noChanges": "لا توجد تغييرات", "session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...", @@ -649,7 +648,12 @@ export const dict = { "settings.agentBehaviour.title": "سلوك الوكيل", "settings.autoApprove.title": "الموافقة التلقائية", - "settings.browser.title": "المتصفح", + "settings.webTools.title": "أدوات الويب", + "settings.webTools.description": "اضبط البحث على الويب وأتمتة المتصفح.", + "settings.webTools.webSearch.enable": "تمكين لجميع المزوّدين", + "settings.webTools.browserAutomation": "أتمتة المتصفح", + "settings.webTools.webSearch.title": "البحث على الويب", + "settings.webTools.webSearch.description": "اجعل البحث على الويب متاحًا لنماذج جميع المزوّدين.", "settings.checkpoints.title": "نقاط التحقق", "settings.display.title": "العرض", "settings.autocomplete.title": "الإكمال التلقائي", @@ -705,6 +709,13 @@ export const dict = { "session.messages.scrollToBottom": "التمرير إلى الأسفل", "session.messages.initializing": "جاري التهيئة...", "session.messages.taskStarting": "جاري البدء...", + "session.prompts.navLabel": "مستعرض المطالبات", + "session.prompts.tick": "المطالبة {{index}} من {{total}}: {{prompt}}", + "session.prompts.noAnswer": "لا توجد استجابة بعد", + "session.prompts.queued": "في قائمة الانتظار", + "session.prompts.first": "أول مطالبة", + "session.prompts.latest": "أحدث مطالبة", + "session.prompts.overflow": "{{count}} مطالبات سابقة", "session.status.writingResponse": "...جارٍ كتابة الرد", "session.status.retry": "جارٍ إعادة المحاولة…", "session.status.working": "...جارٍ العمل", @@ -732,7 +743,7 @@ export const dict = { "prompt.placeholder.connecting": "جارٍ الاتصال بالخادم...", "prompt.placeholder.error": "فشل الاتصال. تحقق من لوحة الإخراج أو أعد تشغيل الإضافة.", - "prompt.placeholder.default": "اكتب رسالة... (Enter للإرسال، Shift+Enter لسطر جديد)", + "prompt.placeholder.default": "اكتب رسالة، @ للإشارة إلى الملفات... (Enter للإرسال، Shift+Enter لسطر جديد)", "context.usage.sessionCost": "تكلفة الجلسة", "context.usage.olderSessions": "{{count}} جلسات أقدم", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 03055f3543..c7123ea56e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -306,7 +306,6 @@ export const dict = { "session.tab.review": "Revisão", "session.review.filesChanged": "{{count}} Arquivos Alterados", - "session.review.change.other": "Alterações", "session.review.loadingChanges": "Carregando alterações...", "session.review.noChanges": "Sem alterações", "session.messages.loadingEarlier": "Carregando mensagens anteriores...", @@ -665,7 +664,12 @@ export const dict = { "settings.agentBehaviour.title": "Comportamento do Agente", "settings.autoApprove.title": "Aprovação Automática", - "settings.browser.title": "Navegador", + "settings.webTools.title": "Ferramentas da Web", + "settings.webTools.description": "Configure a pesquisa na web e a automação do navegador.", + "settings.webTools.webSearch.enable": "Ativar para todos os provedores", + "settings.webTools.browserAutomation": "Automação do navegador", + "settings.webTools.webSearch.title": "Pesquisa na Web", + "settings.webTools.webSearch.description": "Disponibilize a pesquisa na web para modelos de todos os provedores.", "settings.checkpoints.title": "Pontos de Verificação", "settings.display.title": "Exibição", "settings.autocomplete.title": "Autocompletar", @@ -723,6 +727,13 @@ export const dict = { "session.messages.scrollToBottom": "Rolar para o final", "session.messages.initializing": "O teraouiñ...", "session.messages.taskStarting": "O kregiñ...", + "session.prompts.navLabel": "Navegador de prompts", + "session.prompts.tick": "Prompt {{index}} de {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Sem resposta ainda", + "session.prompts.queued": "Na fila", + "session.prompts.first": "Primeiro prompt", + "session.prompts.latest": "Prompt mais recente", + "session.prompts.overflow": "{{count}} prompts anteriores", "session.status.writingResponse": "Escrevendo resposta…", "session.status.retry": "Tentando novamente…", "session.status.working": "Trabalhando…", @@ -750,7 +761,8 @@ export const dict = { "prompt.placeholder.connecting": "Conectando ao servidor...", "prompt.placeholder.error": "Conexão falhou. Verifique o painel de saída ou reinicie a extensão.", - "prompt.placeholder.default": "Digite uma mensagem... (Enter para enviar, Shift+Enter para nova linha)", + "prompt.placeholder.default": + "Digite uma mensagem, @ para mencionar arquivos... (Enter para enviar, Shift+Enter para nova linha)", "context.usage.sessionCost": "Custo da sessão", "context.usage.olderSessions": "{{count}} sessões anteriores", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 5575a758b8..06258c8196 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -304,7 +304,6 @@ export const dict = { "session.tab.review": "Pregled", "session.review.filesChanged": "Izmijenjeno {{count}} datoteka", - "session.review.change.other": "Izmjene", "session.review.loadingChanges": "Učitavanje izmjena...", "session.review.noChanges": "Nema izmjena", @@ -705,7 +704,12 @@ export const dict = { "settings.agentBehaviour.title": "Ponašanje agenta", "settings.autoApprove.title": "Automatsko odobravanje", - "settings.browser.title": "Preglednik", + "settings.webTools.title": "Web alati", + "settings.webTools.description": "Konfigurišite web pretragu i automatizaciju preglednika.", + "settings.webTools.webSearch.enable": "Omogući za sve pružaoce", + "settings.webTools.browserAutomation": "Automatizacija preglednika", + "settings.webTools.webSearch.title": "Web pretraga", + "settings.webTools.webSearch.description": "Omogućite web pretragu modelima svih pružalaca.", "settings.checkpoints.title": "Kontrolne tačke", "settings.display.title": "Prikaz", "settings.autocomplete.title": "Automatsko dovršavanje", @@ -723,6 +727,13 @@ export const dict = { "session.messages.scrollToBottom": "Pomakni se na dno", "session.messages.initializing": "Inicijalizacija...", "session.messages.taskStarting": "Pokretanje...", + "session.prompts.navLabel": "Navigator upita", + "session.prompts.tick": "Upit {{index}} od {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Još nema odgovora", + "session.prompts.queued": "Na čekanju", + "session.prompts.first": "Prvi upit", + "session.prompts.latest": "Najnoviji upit", + "session.prompts.overflow": "{{count}} ranijih upita", "session.status.writingResponse": "Pisanje odgovora…", "session.status.retry": "Ponovni pokušaj…", "session.status.working": "Radim…", @@ -750,7 +761,8 @@ export const dict = { "prompt.placeholder.connecting": "Povezivanje na server...", "prompt.placeholder.error": "Povezivanje nije uspjelo. Provjerite panel za izlaz ili ponovo pokrenite ekstenziju.", - "prompt.placeholder.default": "Unesite poruku... (Enter za slanje, Shift+Enter za novi red)", + "prompt.placeholder.default": + "Unesite poruku, @ za spominjanje datoteka... (Enter za slanje, Shift+Enter za novi red)", "context.usage.sessionCost": "Cijena sesije", "context.usage.olderSessions": "{{count}} starijih sesija", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 90ff025087..4c7e1bb215 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -303,7 +303,6 @@ export const dict = { "session.tab.review": "Gennemgang", "session.review.filesChanged": "{{count}} Filer ændret", - "session.review.change.other": "Ændringer", "session.review.loadingChanges": "Indlæser ændringer...", "session.review.noChanges": "Ingen ændringer", "session.messages.loadingEarlier": "Indlæser tidligere beskeder...", @@ -703,7 +702,12 @@ export const dict = { "settings.agentBehaviour.title": "Agentadfærd", "settings.autoApprove.title": "Automatisk godkendelse", - "settings.browser.title": "Browser", + "settings.webTools.title": "Webværktøjer", + "settings.webTools.description": "Konfigurer websøgning og browserautomatisering.", + "settings.webTools.webSearch.enable": "Aktivér for alle udbydere", + "settings.webTools.browserAutomation": "Browserautomatisering", + "settings.webTools.webSearch.title": "Websøgning", + "settings.webTools.webSearch.description": "Gør websøgning tilgængelig for modeller fra alle udbydere.", "settings.checkpoints.title": "Kontrolpunkter", "settings.display.title": "Visning", "settings.autocomplete.title": "Autofuldførelse", @@ -721,6 +725,13 @@ export const dict = { "session.messages.scrollToBottom": "Rul til bunden", "session.messages.initializing": "Initialiserer...", "session.messages.taskStarting": "Starter...", + "session.prompts.navLabel": "Promptnavigator", + "session.prompts.tick": "Prompt {{index}} af {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Intet svar endnu", + "session.prompts.queued": "I kø", + "session.prompts.first": "Første prompt", + "session.prompts.latest": "Seneste prompt", + "session.prompts.overflow": "{{count}} tidligere prompter", "session.status.writingResponse": "Skriver svar…", "session.status.retry": "Prøver igen…", "session.status.working": "Arbejder…", @@ -748,7 +759,8 @@ export const dict = { "prompt.placeholder.connecting": "Opretter forbindelse til server...", "prompt.placeholder.error": "Forbindelse mislykkedes. Tjek outputpanelet eller genstart udvidelsen.", - "prompt.placeholder.default": "Skriv en besked... (Enter for at sende, Shift+Enter for ny linje)", + "prompt.placeholder.default": + "Skriv en besked, @ for at nævne filer... (Enter for at sende, Shift+Enter for ny linje)", "context.usage.sessionCost": "Sessionsomkostning", "context.usage.olderSessions": "{{count}} ældre sessioner", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 811b9ee0fd..9c67b59fd9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -312,7 +312,6 @@ export const dict = { "session.tab.review": "Überprüfung", "session.review.filesChanged": "{{count}} Dateien geändert", - "session.review.change.other": "Änderungen", "session.review.loadingChanges": "Lade Änderungen...", "session.review.noChanges": "Keine Änderungen", "session.messages.loadingEarlier": "Lade frühere Nachrichten...", @@ -716,7 +715,12 @@ export const dict = { "settings.agentBehaviour.title": "Agentenverhalten", "settings.autoApprove.title": "Automatisch genehmigen", - "settings.browser.title": "Browser", + "settings.webTools.title": "Web-Tools", + "settings.webTools.description": "Konfigurieren Sie Websuche und Browserautomatisierung.", + "settings.webTools.webSearch.enable": "Für alle Anbieter aktivieren", + "settings.webTools.browserAutomation": "Browserautomatisierung", + "settings.webTools.webSearch.title": "Websuche", + "settings.webTools.webSearch.description": "Machen Sie die Websuche für Modelle aller Anbieter verfügbar.", "settings.checkpoints.title": "Prüfpunkte", "settings.display.title": "Anzeige", "settings.autocomplete.title": "Autovervollständigung", @@ -734,6 +738,13 @@ export const dict = { "session.messages.scrollToBottom": "Nach unten scrollen", "session.messages.initializing": "Initialisierung...", "session.messages.taskStarting": "Wird gestartet...", + "session.prompts.navLabel": "Prompt-Navigation", + "session.prompts.tick": "Prompt {{index}} von {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Noch keine Antwort", + "session.prompts.queued": "In Warteschlange", + "session.prompts.first": "Erster Prompt", + "session.prompts.latest": "Neuester Prompt", + "session.prompts.overflow": "{{count}} frühere Prompts", "session.status.writingResponse": "Antwort wird geschrieben…", "session.status.retry": "Erneuter Versuch…", "session.status.working": "Wird bearbeitet…", @@ -762,7 +773,8 @@ export const dict = { "prompt.placeholder.connecting": "Verbindung zum Server wird hergestellt...", "prompt.placeholder.error": "Verbindung fehlgeschlagen. Überprüfen Sie das Ausgabepanel oder starten Sie die Erweiterung neu.", - "prompt.placeholder.default": "Nachricht eingeben... (Enter zum Senden, Shift+Enter für neue Zeile)", + "prompt.placeholder.default": + "Nachricht eingeben, @ um Dateien zu erwähnen... (Enter zum Senden, Shift+Enter für neue Zeile)", "context.usage.sessionCost": "Sitzungskosten", "context.usage.olderSessions": "{{count}} ältere Sitzungen", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 7cc55121fe..a78f9cd18c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -301,7 +301,6 @@ export const dict = { "session.tab.review": "Review", "session.review.filesChanged": "{{count}} Files Changed", - "session.review.change.other": "Changes", "session.review.loadingChanges": "Loading changes...", "session.review.noChanges": "No changes", @@ -622,7 +621,12 @@ export const dict = { "settings.agentBehaviour.title": "Agent Behaviour", "settings.autoApprove.title": "Auto-Approve", - "settings.browser.title": "Browser", + "settings.webTools.title": "Web Tools", + "settings.webTools.description": "Configure web search and browser automation.", + "settings.webTools.webSearch.enable": "Enable for All Providers", + "settings.webTools.browserAutomation": "Browser Automation", + "settings.webTools.webSearch.title": "Web Search", + "settings.webTools.webSearch.description": "Make web search available to models from all providers.", "settings.checkpoints.title": "Checkpoints", "settings.display.title": "Display", "settings.autocomplete.title": "Autocomplete", @@ -675,6 +679,13 @@ export const dict = { "session.messages.scrollToBottom": "Scroll to bottom", "session.messages.initializing": "Initializing...", "session.messages.taskStarting": "Starting...", + "session.prompts.navLabel": "Prompt navigator", + "session.prompts.tick": "Prompt {{index}} of {{total}}: {{prompt}}", + "session.prompts.noAnswer": "No response yet", + "session.prompts.queued": "Queued", + "session.prompts.first": "First prompt", + "session.prompts.latest": "Latest prompt", + "session.prompts.overflow": "{{count}} earlier prompts", "session.status.writingResponse": "Writing response...", "session.status.retry": "Retrying…", "session.status.working": "Working...", @@ -724,7 +735,7 @@ export const dict = { "dialog.model.noProviders": "No providers", "prompt.placeholder.connecting": "Connecting to server...", - "prompt.placeholder.default": "Type a message... (Enter to send, Shift+Enter for new line)", + "prompt.placeholder.default": "Type a message, @ to mention files... (Enter to send, Shift+Enter for new line)", "prompt.placeholder.error": "Connection failed. Check the output panel or restart the extension.", "context.usage.sessionCost": "Session cost", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 7981f2a551..0a25240d2f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -307,7 +307,6 @@ export const dict = { "session.tab.review": "Revisión", "session.review.filesChanged": "{{count}} Archivos Cambiados", - "session.review.change.other": "Cambios", "session.review.loadingChanges": "Cargando cambios...", "session.review.noChanges": "Sin cambios", "session.messages.loadingEarlier": "Cargando mensajes anteriores...", @@ -710,7 +709,12 @@ export const dict = { "settings.agentBehaviour.title": "Comportamiento del agente", "settings.autoApprove.title": "Aprobación automática", - "settings.browser.title": "Navegador", + "settings.webTools.title": "Herramientas web", + "settings.webTools.description": "Configura la búsqueda web y la automatización del navegador.", + "settings.webTools.webSearch.enable": "Habilitar para todos los proveedores", + "settings.webTools.browserAutomation": "Automatización del navegador", + "settings.webTools.webSearch.title": "Búsqueda web", + "settings.webTools.webSearch.description": "Permite que los modelos de todos los proveedores usen la búsqueda web.", "settings.checkpoints.title": "Puntos de control", "settings.display.title": "Pantalla", "settings.autocomplete.title": "Autocompletado", @@ -728,6 +732,13 @@ export const dict = { "session.messages.scrollToBottom": "Desplazar al final", "session.messages.initializing": "Inicializando...", "session.messages.taskStarting": "Iniciando...", + "session.prompts.navLabel": "Navegador de prompts", + "session.prompts.tick": "Prompt {{index}} de {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Aún no hay respuesta", + "session.prompts.queued": "En cola", + "session.prompts.first": "Primera solicitud", + "session.prompts.latest": "Última solicitud", + "session.prompts.overflow": "{{count}} solicitudes anteriores", "session.status.writingResponse": "Escribiendo respuesta…", "session.status.retry": "Reintentando…", "session.status.working": "Trabajando…", @@ -755,7 +766,8 @@ export const dict = { "prompt.placeholder.connecting": "Conectando al servidor...", "prompt.placeholder.error": "Conexión fallida. Revisa el panel de salida o reinicia la extensión.", - "prompt.placeholder.default": "Escribe un mensaje... (Enter para enviar, Shift+Enter para nueva línea)", + "prompt.placeholder.default": + "Escribe un mensaje, @ para mencionar archivos... (Enter para enviar, Shift+Enter para nueva línea)", "context.usage.sessionCost": "Coste de la sesión", "context.usage.olderSessions": "{{count}} sesiones anteriores", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts new file mode 100644 index 0000000000..c887ca444d --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -0,0 +1,1296 @@ +export const anacondaDesktopDict = { + "provider.anaconda.title.connect": "اتصال به Anaconda Desktop", + "provider.anaconda.title.manage": "مدیریت Anaconda Desktop", + "provider.anaconda.status.checking": "در حال بررسی Anaconda Desktop...", + "provider.anaconda.status.opening": "در حال باز کردن Anaconda Desktop...", + "provider.anaconda.status.syncing": "در حال به‌روزرسانی مدل‌های ارائه‌دهنده...", + "provider.anaconda.status.ready": "آماده اتصال", + "provider.anaconda.status.waiting": "در انتظار Desktop", + "provider.anaconda.status.attention": "نیاز به توجه", + "provider.anaconda.status.unavailable": "در دسترس نیست", + "provider.anaconda.state.unsupported": "Anaconda Desktop در {{platform}} پشتیبانی نمی‌شود.", + "provider.anaconda.state.notInstalled": + "Anaconda Desktop را روی این دستگاه نصب کنید، سپس به اینجا بازگردید. Kilo نصب‌کننده را برای شما اجرا نمی‌کند.", + "provider.anaconda.state.notRunning": + "Anaconda Desktop را باز کنید، راه‌اندازی را تکمیل کرده و وارد شوید، سپس «بررسی مجدد» را انتخاب کنید.", + "provider.anaconda.state.invalidConfig": + "راه‌اندازی Anaconda Desktop ناقص است. Desktop را باز کنید، راه‌اندازی را تکمیل کنید و در صورت نیاز آن را مجدداً راه‌اندازی کنید.", + "provider.anaconda.state.signedOut": "قبل از اتصال Kilo، Anaconda Desktop را باز کرده و وارد شوید.", + "provider.anaconda.state.unauthorized": + "Kilo نتوانست به Anaconda Desktop دسترسی پیدا کند. Desktop را باز کنید، دوباره وارد شوید و در صورت نیاز آن را مجدداً راه‌اندازی کنید.", + "provider.anaconda.state.unavailable": + "Anaconda Desktop هنوز پاسخ نمی‌دهد. آن را باز کنید و منتظر بمانید تا برنامه کاملاً راه‌اندازی شود.", + "provider.anaconda.state.noModel": + "در Anaconda Desktop، یک مدل تولید متن دانلود کنید. در صورت امکان مدلی با قابلیت فراخوانی ابزار انتخاب کنید، سپس سرور آن را راه‌اندازی کنید.", + "provider.anaconda.state.noServer_one": + "۱ مدل تولید متن دانلودشده در دسترس است. در Anaconda Desktop، یک سرور مدل راه‌اندازی کنید. مدل‌های دارای پشتیبانی از فراخوانی ابزار به شدت توصیه می‌شوند.", + "provider.anaconda.state.noServer_other": + "{{count}} مدل تولید متن دانلودشده در دسترس است. در Anaconda Desktop، یک سرور مدل راه‌اندازی کنید. مدل‌های دارای پشتیبانی از فراخوانی ابزار به شدت توصیه می‌شوند.", + "provider.anaconda.state.unhealthy": + "سرور استنتاج فعال هنوز سالم نیست. آن را در Anaconda Desktop بررسی کنید و در صورت نیاز سرور را مجدداً راه‌اندازی کنید.", + "provider.anaconda.state.ready": + "Kilo یک سرور تولید متن محلی سالم پیدا کرد و می‌تواند تنظیمات اتصال فعلی آن را وارد کند.", + "provider.anaconda.server": "سرور استنتاج فعال", + "provider.anaconda.context": "پنجره زمینه", + "provider.anaconda.contextValue": "{{count}} توکن", + "provider.anaconda.tools": "فراخوانی ابزار", + "provider.anaconda.tools.supported": "پشتیبانی می‌شود", + "provider.anaconda.tools.unsupported": "فعال نیست", + "provider.anaconda.tools.unknown": "نامشخص", + "provider.anaconda.warning.title": "پشتیبانی از ابزار محدود است", + "provider.anaconda.warning.description": + "این سرور فراخوانی ابزار را تأیید نمی‌کند. اقدامات عامل کدنویسی ممکن است با شکست مواجه شوند یا در دسترس نباشند. تنها در صورت پذیرش این محدودیت‌ها ادامه دهید.", + "provider.anaconda.action.download": "دانلود Anaconda Desktop", + "provider.anaconda.action.open": "باز کردن Anaconda Desktop", + "provider.anaconda.action.checkAgain": "بررسی مجدد", + "provider.anaconda.action.continue": "ادامه به هر حال", + "provider.anaconda.action.manage": "مدیریت / بازنشانی", + "provider.anaconda.toast.refreshed.title": "Anaconda Desktop بازنشانی شد", + "provider.anaconda.toast.refreshed.description": "سرور محلی فعال و مدل‌ها در Kilo به‌روز هستند.", + "settings.providers.note.anacondaDesktop": "یک مدل ارائه‌شده به‌صورت محلی توسط Anaconda Desktop را اجرا کنید.", + "settings.providers.tag.local": "محلی", +} as const + +export const dict = { + ...anacondaDesktopDict, + + "command.provider.connect": "اتصال به ارائه‌دهنده", + + "command.session.new": "جلسه جدید", + "command.session.show.changes": "نمایش تغییرات", + "command.review.toggle": "نمایش/پنهان کردن بررسی", + "revert.banner.count_one": "{{count}} پیام بازگردانده شد", + "revert.banner.count_other": "{{count}} پیام بازگردانده شد", + "revert.banner.redo": "بازانجام", + "revert.banner.redo.all": "بازانجام همه", + "revert.banner.hint": "می‌توانید این تغییرات را تا زمانی که پیام جدیدی ارسال نکرده‌اید بازانجام دهید", + "revert.banner.workspace.snapshotsDisabled": + "مکالمه بازگردانده شد. تغییرات فایل بازیابی نشدند زیرا عکس‌برداری غیرفعال است.", + "revert.banner.workspace.unavailable": + "مکالمه بازگردانده شد. هیچ نقطه بازیابی فایلی موجود نبود، بنابراین تغییرات فضای کاری بازیابی نشدند.", + "revert.banner.workspace.legacy": + "مکالمه بازگردانده شد. وضعیت بازیابی فضای کاری برای این بازگردانی قدیمی‌تر در دسترس نیست.", + "revert.banner.workspace.enableSnapshots": "فعال‌سازی اسنپ‌شات‌ها", + "revert.disabled.agentBusy": "منتظر بمانید تا عامل کارش تمام شود", + "command.session.compact": "فشرده‌سازی جلسه", + "command.session.export": "صدور رونوشت جلسه", + + "agentRequirements.skill.installed": "نصب شده", + "agentRequirements.skill.checkFailed": "بررسی مهارت ناموفق بود", + "agentRequirements.skill.missing": "نصب نشده", + "agentRequirements.mcp.connected": "متصل", + "agentRequirements.mcp.checkFailed": "بررسی MCP ناموفق بود", + "agentRequirements.mcp.missing": "متصل نیست", + "agentRequirements.extension.installed": "نصب شده", + "agentRequirements.extension.checkFailed": "بررسی افزونه VS Code ناموفق بود", + "agentRequirements.extension.missing": "نصب نشده", + "agentRequirements.extension.description": "افزونه‌های مفقود را در VS Code نصب کنید.", + "agentRequirements.group.skills": "مهارت‌ها", + "agentRequirements.group.mcps": "MCPs", + "agentRequirements.group.extensions": "افزونه‌های VS Code", + "agentRequirements.blocked.title": "پیش‌نیازهای عامل {{agent}}", + "agentRequirements.blocked.description": "این عامل پیش از اجرا به ابزارهای زیر نیاز دارد.", + "agentRequirements.prompt.blocked": "ابتدا بررسی‌های لازم را تکمیل کنید تا بتوانید از این عامل استفاده کنید", + "agentRequirements.action.openMarketplace": "باز کردن Marketplace", + "agentRequirements.error.unknownAgent": "عامل انتخاب‌شده یافت نشد.", + "agentRequirements.error.malformedDeclaration": "این عامل دارای اعلان پیش‌نیاز نامعتبر است.", + "agentRequirements.error.discoveryFailed": "Kilo نتوانست مهارت‌های موجود را بررسی کند.", + "agentRequirements.error.mcpStatusFailed": "Kilo نتوانست وضعیت سرور MCP را بررسی کند.", + "agentRequirements.error.scopeMismatch": "این بررسی نیازمندی‌های عامل دیگر فعال نیست.", + "agentRequirements.error.requestFailed": "Kilo نتوانست نیازمندی‌های عامل را بررسی کند.", + + "dialog.provider.search.placeholder": "جستجوی ارائه‌دهندگان", + "dialog.provider.empty": "ارائه‌دهنده‌ای یافت نشد", + "dialog.provider.group.other": "سایر", + "dialog.provider.tag.recommended": "پیشنهادی", + + "dialog.model.select.title": "انتخاب مدل", + "dialog.model.search.placeholder": "جستجوی مدل‌ها", + "dialog.model.empty": "نتیجه‌ای یافت نشد", + "dialog.model.select": "انتخاب", + "dialog.model.expand": "گسترش", + "dialog.model.collapse": "جمع‌کردن", + + "dialog.provider.viewAll": "نمایش ارائه‌دهندگان بیشتر", + + "provider.connect.title": "اتصال به {{provider}}", + "provider.connect.selectMethod": "روش ورود را برای {{provider}} انتخاب کنید.", + "provider.connect.method.apiKey": "کلید API", + "provider.connect.status.inProgress": "در حال احراز هویت...", + "provider.connect.status.waiting": "در انتظار احراز هویت...", + "provider.connect.status.failed": "احراز هویت ناموفق بود: {{error}}", + "provider.connect.apiKey.description": + "کلید API {{provider}} خود را وارد کنید تا حساب‌تان متصل شود و از مدل‌های {{provider}} در Kilo استفاده کنید.", + "provider.connect.apiKey.description.local": + "به سرور محلی {{provider}} خود متصل شوید. اگر سرور نیازی به کلید API ندارد، آن را خالی بگذارید (پیش‌فرض برای localhost).", + "provider.connect.atomicChat.description": + "به Atomic Chat روی دستگاه خود متصل شوید (پیش‌فرض http://127.0.0.1:1337). برای سرور محلی نیازی به کلید API نیست — Atomic Chat را راه‌اندازی کنید، یک مدل بارگذاری کنید، سپس متصل شوید.", + "provider.connect.apiKey.label": "کلید API {{provider}}", + "provider.connect.apiKey.label.optional": "کلید API {{provider}} (اختیاری)", + "provider.connect.apiKey.placeholder": "کلید API", + "provider.connect.apiKey.placeholder.optional": "برای سرور محلی خالی بگذارید", + "provider.connect.apiKey.required": "کلید API الزامی است", + "provider.connect.prompt.required": "{{field}} الزامی است", + "provider.connect.azure.endpointType.label": "پیکربندی endpoint Azure را انتخاب کنید", + "provider.connect.azure.endpointType.resourceName.label": "نام منبع", + "provider.connect.azure.endpointType.resourceName.hint": "نقطه پایانی را از نام منبع Azure خود بسازید", + "provider.connect.azure.endpointType.baseURL.label": "URL کامل نقطه پایانی", + "provider.connect.azure.endpointType.baseURL.hint": "از یک نقطه پایانی سفارشی Azure OpenAI استفاده کنید", + "provider.connect.azure.resourceName.label": "نام منبع Azure", + "provider.connect.azure.resourceName.placeholder": "مثلاً my-models", + "provider.connect.azure.baseURL.label": "URL نقطه پایانی Azure OpenAI", + "provider.connect.azure.baseURL.placeholder": "مثلاً https://my-models.openai.azure.com/openai", + "provider.connect.oauth.code.visit.prefix": "به ", + "provider.connect.oauth.code.visit.link": "این لینک", + "provider.connect.oauth.code.visit.suffix": + " مراجعه کنید تا کد مجوز خود را دریافت کرده، حساب خود را متصل کنید و از مدل‌های {{provider}} در Kilo استفاده کنید.", + "provider.connect.oauth.code.label": "کد مجوز {{method}}", + "provider.connect.oauth.code.placeholder": "کد مجوز", + "provider.connect.oauth.code.required": "کد مجوز الزامی است", + "provider.connect.oauth.auto.visit.prefix": "به ", + "provider.connect.oauth.auto.visit.link": "این لینک", + "provider.connect.oauth.auto.visit.suffix": + " مراجعه کنید و کد زیر را وارد کنید تا حساب خود را متصل کرده و از مدل‌های {{provider}} در Kilo استفاده کنید.", + "provider.connect.oauth.auto.confirmationCode": "کد تأیید", + "provider.connect.toast.connected.title": "{{provider}} متصل شد", + "provider.connect.toast.connected.description": "مدل‌های {{provider}} اکنون در دسترس هستند.", + + "provider.disconnect.toast.disconnected.title": "{{provider}} قطع شد", + "provider.disconnect.toast.disconnected.description": "مدل‌های {{provider}} دیگر در دسترس نیستند.", + + "model.tag.free": "رایگان", + "model.tag.dataCollected": "ممکن است داده‌ها برای آموزش استفاده شوند", + "model.group.auto": "مدل‌های خودکار", + "model.group.recommended": "پیشنهادی", + "model.group.favorites": "موردعلاقه‌ها", + "model.favorite.add": "افزودن به موردعلاقه‌ها", + "model.favorite.remove": "حذف از موردعلاقه‌ها", + "model.preview.label.released": "منتشر شده", + "model.preview.label.input": "ورودی", + "model.preview.label.output": "خروجی", + "model.preview.label.cached": "کش شده", + "model.preview.label.average": "هزینه تخمینی میانگین", + "model.preview.label.context": "زمینه", + "model.preview.group.terminalBench": "Terminal Bench 2.0", + "model.preview.group.autoEfficientChoices": "انتخاب‌های مدل", + "model.preview.label.completion": "تکمیل", + "model.preview.label.costAttempt": "هزینه / تلاش", + "model.preview.value.notSupported": "پشتیبانی نمی‌شود", + "model.preview.tooltip.average": + "میانگین هزینه تخمینی بر اساس نسبت معمول توکن‌های ورودی، خروجی و خواندن کش محاسبه می‌شود.", + "model.preview.badge.reasoning": "استدلال", + "model.preview.modality.text": "متن", + "model.preview.modality.image": "تصاویر", + "model.preview.modality.audio": "صدا", + "model.preview.modality.video": "ویدیو", + "model.preview.modality.pdf": "PDF", + + "common.goBack": "بازگشت", + "common.loading": "در حال بارگذاری", + "common.cancel": "لغو", + "common.connect": "اتصال", + "common.disconnect": "قطع اتصال", + "common.submit": "ارسال", + "common.save": "ذخیره", + "common.saving": "در حال ذخیره...", + "common.default": "پیش‌فرض", + + "prompt.thinking.tooltip": "میزان استدلال", + "prompt.action.send": "ارسال", + "prompt.action.send.blocked": "ابتدا به سؤال در انتظار پاسخ دهید یا آن را رد کنید", + "prompt.action.send.recording": "رونویسی و ارسال", + "prompt.action.stop": "توقف", + "prompt.action.enhance": "بهبود پرامپت", + "prompt.action.indexing": "تنظیمات ایندکس‌گذاری", + "prompt.action.autoApprove.enable": "فعال‌سازی تأیید خودکار", + "prompt.action.autoApprove.disable": "غیرفعال‌سازی تأیید خودکار", + "prompt.action.autoApprove.enabled": "تأیید خودکار فعال است. درخواست‌های مجوز به‌صورت خودکار تأیید می‌شوند.", + "prompt.action.autoApprove.disabled": "تأیید خودکار غیرفعال است. برای تأیید خودکار درخواست‌های مجوز کلیک کنید.", + "prompt.action.sandbox.enable": "فعال‌سازی سندباکس", + "prompt.action.sandbox.disable": "غیرفعال‌سازی سندباکس", + "prompt.action.sandbox.enabled": "سندباکس فعال است. دستورات شل عامل به پوشه‌های پروژه و Kilo محدود شده‌اند.", + "prompt.action.sandbox.disabled": + "سندباکس غیرفعال است. برای محدود کردن نوشتن دستورات شل عامل به پوشه‌های پروژه و Kilo کلیک کنید.", + "prompt.action.sandbox.status.enabled": "Sandbox فعال", + "prompt.action.sandbox.status.disabled": "Sandbox غیرفعال", + "prompt.action.sandbox.filesystem": "سیستم فایل", + "prompt.action.sandbox.network": "شبکه", + "prompt.action.sandbox.filesystem.restricted": "محدود", + "prompt.action.sandbox.network.blocked": "مسدود", + "prompt.action.sandbox.network.allowed": "مجاز", + "prompt.action.sandbox.unrestricted": "بدون محدودیت", + "prompt.action.sandbox.description.enabled": "نوشتن‌ها به پوشه‌های پروژه و Kilo محدود شده‌اند.", + "prompt.action.sandbox.description.disabled": "برای محدود کردن نوشتن در سیستم فایل و دسترسی به شبکه کلیک کنید.", + "prompt.action.sandbox.description.disabledNetworkAllowed": + "برای محدود کردن نوشتن در سیستم فایل کلیک کنید. دسترسی به شبکه طبق تنظیمات sandbox شما مجاز است.", + "prompt.action.resetModel": "بازنشانی مدل به حالت پیش‌فرض", + "prompt.action.enhanceDescription": + "دکمه «بهبود پرامپت» با ارائه زمینه بیشتر، توضیح یا بازنویسی، به بهتر کردن پرامپت شما کمک می‌کند. یک پرامپت تایپ کنید و دوباره روی دکمه کلیک کنید تا نحوه عملکرد آن را ببینید.", + "speechToText.tooltip.start": "شروع ورودی صوتی با Kilo Gateway", + "speechToText.tooltip.starting": "در حال راه‌اندازی میکروفون... منتظر بمانید.", + "speechToText.tooltip.stop": "در حال ضبط. برای توقف کلیک کنید.", + "speechToText.tooltip.transcribing": "در حال رونویسی... برای لغو کلیک کنید.", + "speechToText.tooltip.error": "ورودی صوتی ناموفق بود. برای پاک کردن کلیک کنید.", + "speechToText.error.title": "ورودی صوتی ناموفق بود", + "speechToText.error.loginRequired": "برای استفاده از ورودی صوتی وارد Kilo شوید.", + "speechToText.error.emptyTranscript": "هیچ گفتاری شناسایی نشد.", + + "prompt.toast.promptSendFailed.title": "ارسال پرامپت ناموفق بود", + + "mcp.status.connected": "متصل", + "mcp.status.failed": "ناموفق", + "mcp.status.needs_auth": "نیاز به احراز هویت", + "mcp.status.needs_registration": "نیاز به ثبت کلاینت", + "mcp.status.disabled": "غیرفعال", + + "toast.session.rename.invalid.title": "عنوان جلسه نامعتبر است", + + "error.startup.title": "اتصال به سرور ناموفق بود", + + "error.paidModel.title": "برای استفاده از این مدل باید وارد شوید", + "error.paidModel.description": + "برای دسترسی به بیش از ۵۰۰ مدل، استفاده از اعتبار با هزینه واقعی یا استفاده از کلید خودتان، وارد شوید یا حساب کاربری بسازید.", + "error.paidModel.action": "ورود", + "error.promotionLimit.title": "برای ادامه باید ثبت‌نام کنید", + "error.promotionLimit.description": + "برای ادامه و دسترسی به ۵۰۰ مدل دیگر، رایگان ثبت‌نام کنید. تنها ۲ دقیقه طول می‌کشد و نیازی به کارت اعتباری نیست. یا بعداً برگردید.", + "error.promotionLimit.action": "ثبت‌نام", + "error.providerAuth.title": "{{provider}} شما را خارج کرد", + "error.providerAuth.description": "{{provider}} را دوباره متصل کنید، سپس پیام خود را مجدداً ارسال کنید.", + "error.providerAuth.chatgpt.title": "OpenAI شما را خارج کرد", + "error.providerAuth.chatgpt.description": + "دوباره با ChatGPT وارد شوید، سپس پیام خود را مجدداً ارسال کنید تا از مدل‌های Codex استفاده کنید.", + + "notification.permission.title": "مجوز لازم است", + "notification.permission.titleSubagent": "مجوز مورد نیاز است (زیرعامل)", + "ui.permission.manageAutoApprove": "مدیریت قوانین تأیید خودکار", + "ui.permission.doomLoop.prompt": "حلقه احتمالی برای ابزار {{tool}} شناسایی شد. ادامه می‌دهید؟", + "ui.permission.doomLoop.rule": "ادامه فراخوانی‌های {{tool}}", + "ui.permission.rule.addToAllowed": "افزودن به لیست مجاز", + "ui.permission.rule.removeFromAllowed": "حذف از لیست مجاز", + "ui.permission.rule.addToDenied": "افزودن به لیست مسدود", + "ui.permission.rule.removeFromDenied": "حذف از لیست مسدود", + "ui.permission.toolLabel.read": "خواندن", + "ui.permission.toolLabel.edit": "ویرایش", + "ui.permission.toolLabel.write": "نوشتن", + "ui.permission.toolLabel.patch": "وصله", + "ui.permission.toolLabel.globSearch": "جستجوی Glob", + "ui.permission.toolLabel.grepSearch": "جستجوی Grep", + "ui.permission.toolLabel.webSearch": "جستجوی وب", + "ui.permission.toolLabel.list": "فهرست", + "ui.permission.toolLabel.externalDirectory": "دسترسی به پوشه خارجی", + "ui.permission.toolLabel.webFetch": "دریافت از وب", + "ui.permission.toolLabel.task": "وظیفه", + "ui.permission.toolLabel.skill": "مهارت", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "خواندن Todo", + "ui.permission.toolLabel.todoWrite": "نوشتن Todo", + "ui.permission.toolLabel.codeSearch": "جستجوی کد", + "ui.permission.copyCommand": "کپی", + "ui.approval.auto": "تأیید خودکار", + "ui.approval.manual": "تأیید شده توسط شما", + "ui.approval.rule": "با قانون `{{pattern}}` از `{{permission}}` مطابقت داشت", + "ui.approval.source.agent": "توسط عامل {{agent}}", + "ui.approval.source.agent.default": "توسط عامل", + "ui.approval.source.global": "توسط تنظیمات سراسری شما", + "ui.approval.source.project": "توسط تنظیمات پروژه", + "ui.approval.source.yolo": "توسط حالت تأیید خودکار (YOLO)", + "ui.approval.source.session": "توسط قانون تأیید خودکار جلسه", + "ui.approval.source.default": "به‌طور پیش‌فرض", + + "session.tab.review": "بررسی", + "session.review.filesChanged": "{{count}} فایل تغییر یافته", + "session.review.change.other": "تغییرات", + "session.review.loadingChanges": "در حال بارگذاری تغییرات...", + "session.review.noChanges": "بدون تغییر", + + "session.messages.loadingEarlier": "در حال بارگذاری پیام‌های قبلی...", + "session.messages.loadEarlier": "بارگذاری پیام‌های قبلی", + "session.messages.loading": "در حال بارگذاری پیام‌ها...", + + "common.closeTab": "بستن برگه", + "common.signIn": "ورود", + "common.dismiss": "رد کردن", + "common.requestFailed": "درخواست ناموفق بود", + "common.rename": "تغییر نام", + "common.delete": "حذف", + "common.close": "بستن", + "common.edit": "ویرایش", + "common.loadMore": "بارگذاری بیشتر", + + "sidebar.settings": "تنظیمات", + + "sound.option.alert01": "هشدار ۰۱", + "sound.option.alert02": "هشدار ۰۲", + "sound.option.alert03": "هشدار ۰۳", + "sound.option.alert04": "هشدار ۰۴", + "sound.option.alert05": "هشدار ۰۵", + "sound.option.alert06": "هشدار ۰۶", + "sound.option.alert07": "هشدار ۰۷", + "sound.option.alert08": "هشدار ۰۸", + "sound.option.alert09": "هشدار ۰۹", + "sound.option.alert10": "هشدار ۱۰", + "sound.option.bipbop01": "بیپ-باپ ۰۱", + "sound.option.bipbop02": "Bip-bop 02", + "sound.option.bipbop03": "Bip-bop 03", + "sound.option.bipbop04": "Bip-bop 04", + "sound.option.bipbop05": "Bip-bop 05", + "sound.option.bipbop06": "Bip-bop 06", + "sound.option.bipbop07": "Bip-bop 07", + "sound.option.bipbop08": "Bip-bop 08", + "sound.option.bipbop09": "Bip-bop 09", + "sound.option.bipbop10": "Bip-bop 10", + "sound.option.staplebops01": "Staplebops 01", + "sound.option.staplebops02": "Staplebops 02", + "sound.option.staplebops03": "Staplebops 03", + "sound.option.staplebops04": "Staplebops 04", + "sound.option.staplebops05": "Staplebops 05", + "sound.option.staplebops06": "Staplebops 06", + "sound.option.staplebops07": "Staplebops 07", + "sound.option.nope01": "Nope 01", + "sound.option.nope02": "Nope 02", + "sound.option.nope03": "Nope 03", + "sound.option.nope04": "Nope 04", + "sound.option.nope05": "نه ۰۵", + "sound.option.nope06": "نه ۰۶", + "sound.option.nope07": "نه ۰۷", + "sound.option.nope08": "نه ۰۸", + "sound.option.nope09": "نه ۰۹", + "sound.option.nope10": "نه ۱۰", + "sound.option.nope11": "نه ۱۱", + "sound.option.nope12": "نه ۱۲", + "sound.option.yup01": "بله ۰۱", + "sound.option.yup02": "بله ۰۲", + "sound.option.yup03": "Yup 03", + "sound.option.yup04": "Yup 04", + "sound.option.yup05": "Yup 05", + "sound.option.yup06": "Yup 06", + + "settings.providers.title": "ارائه‌دهندگان", + "settings.providers.section.connected": "ارائه‌دهندگان متصل", + "settings.providers.connected.empty": "هیچ ارائه‌دهنده متصلی وجود ندارد", + "settings.providers.section.popular": "ارائه‌دهندگان محبوب", + "settings.providers.tag.gateway": "Gateway", + "settings.providers.tag.environment": "محیط", + "settings.providers.tag.config": "پیکربندی", + "settings.providers.tag.chatgpt": "ChatGPT", + "settings.providers.tag.custom": "سفارشی", + "settings.providers.tag.customProvider": "ارائه‌دهنده سفارشی", + "settings.providers.tag.other": "سایر", + "settings.providers.connected.environmentDescription": "از متغیرهای محیطی شما متصل شده است", + "settings.providers.action.signInChatGPT": "ورود با ChatGPT", + "settings.providers.custom.description": "یک ارائه‌دهنده سفارشی از طریق URL پایه اضافه کنید.", + + "provider.custom.title": "ارائه‌دهنده سفارشی", + "provider.custom.description.prefix": "یک ارائه‌دهنده سفارشی پیکربندی کنید. به ", + "provider.custom.description.link": "مستندات پیکربندی ارائه‌دهنده", + "provider.custom.description.suffix": ".", + "provider.custom.field.providerID.label": "شناسه ارائه‌دهنده", + "provider.custom.field.providerID.placeholder": "myprovider", + "provider.custom.field.providerID.description": "حروف کوچک، اعداد، خط تیره یا زیرخط", + "provider.custom.field.name.label": "نام نمایشی", + "provider.custom.field.name.placeholder": "ارائه‌دهنده هوش مصنوعی من", + "provider.custom.field.package.label": "API ارائه‌دهنده", + "provider.custom.field.baseURL.label": "URL پایه", + "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1", + "provider.custom.field.apiKey.label": "کلید API", + "provider.custom.field.apiKey.placeholder": "کلید API", + "provider.custom.field.apiKey.description": "اختیاری. اگر احراز هویت را از طریق هدرها مدیریت می‌کنید، خالی بگذارید.", + "provider.custom.models.label": "مدل‌ها", + "provider.custom.models.id.label": "شناسه", + "provider.custom.models.id.placeholder": "model-id", + "provider.custom.models.name.label": "نام", + "provider.custom.models.name.placeholder": "نام نمایشی", + "provider.custom.models.reasoning.label": "استدلال", + "provider.custom.models.modalities.image": "تصویر", + "provider.custom.models.variants.label": "نسخه‌های متغیر", + "provider.custom.models.variants.add": "افزودن نسخه متغیر", + "provider.custom.models.variants.remove": "حذف نسخه متغیر", + "provider.custom.models.variants.name.label": "نام", + "provider.custom.models.variants.name.placeholder": "مثلاً thinking", + "provider.custom.models.variants.option.unset": "(تنظیم نشده)", + "provider.custom.models.variants.enableThinking.label": "فعال‌سازی تفکر (مثلاً Alibaba)", + "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", + "provider.custom.models.variants.enableThinking.true": "true", + "provider.custom.models.variants.enableThinking.false": "false", + "provider.custom.models.variants.thinking.label": "نوع تفکر (مثلاً Z.ai)", + "provider.custom.models.variants.thinking.placeholder": "thinking", + "provider.custom.models.variants.thinking.enabled": "فعال", + "provider.custom.models.variants.thinking.disabled": "غیرفعال", + "provider.custom.models.variants.thinking.adaptive": "تطبیقی", + "provider.custom.models.variants.splitReasoning.label": "تقسیم استدلال (لازم برای مثلاً MiniMax)", + "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", + "provider.custom.models.variants.splitReasoning.true": "true", + "provider.custom.models.variants.splitReasoning.false": "false", + "provider.custom.models.variants.chatTemplateArgs.label": + "فعال‌سازی تفکر از طریق آرگومان‌های قالب چت (مثلاً Hugging Face)", + "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", + "provider.custom.models.variants.chatTemplateArgs.true": "true", + "provider.custom.models.variants.chatTemplateArgs.false": "false", + "provider.custom.models.variants.reasoningEffort.label": "سطح استدلال", + "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", + "provider.custom.models.variants.reasoningEffort.none": "هیچ", + "provider.custom.models.variants.reasoningEffort.minimal": "حداقل", + "provider.custom.models.variants.reasoningEffort.low": "کم", + "provider.custom.models.variants.reasoningEffort.medium": "متوسط", + "provider.custom.models.variants.reasoningEffort.high": "زیاد", + "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", + "provider.custom.models.variants.outputEffort.label": "تلاش خروجی (مثلاً Anthropic)", + "provider.custom.models.variants.outputEffort.placeholder": "تلاش", + "provider.custom.models.variants.outputEffort.low": "کم", + "provider.custom.models.variants.outputEffort.medium": "متوسط", + "provider.custom.models.variants.outputEffort.high": "زیاد", + "provider.custom.models.variants.outputEffort.xhigh": "xhigh", + "provider.custom.models.variants.outputEffort.max": "حداکثر", + "provider.custom.models.remove": "حذف مدل", + "provider.custom.models.add": "افزودن مدل", + "provider.custom.models.fetch.authError": "احراز هویت ناموفق بود. کلید API بالا را بررسی کرده و دوباره امتحان کنید.", + "provider.custom.models.fetch.empty": "هیچ مدلی در این سرور یافت نشد.", + "provider.custom.models.fetch.added": "{{count}} مدل اضافه شد.", + "provider.custom.models.fetch.allExist": "تمام مدل‌های دریافت‌شده قبلاً اضافه شده‌اند.", + "provider.custom.models.fetch.selectAll": "انتخاب همه", + "provider.custom.models.fetch.deselectAll": "لغو انتخاب همه", + "provider.custom.models.fetch.found": "{{count}} مدل یافت شد", + "provider.custom.models.fetch.showing": "نمایش {{shown}} از {{total}}", + "provider.custom.models.fetch.search": "جستجوی مدل‌ها…", + "provider.custom.models.fetch.add": "افزودن {{count}} مدل", + "provider.custom.edit.title": "ویرایش ارائه‌دهنده", + "provider.custom.headers.label": "هدرها (اختیاری)", + "provider.custom.headers.key.label": "هدر", + "provider.custom.headers.key.placeholder": "Header-Name", + "provider.custom.headers.value.label": "مقدار", + "provider.custom.headers.value.placeholder": "value", + "provider.custom.headers.remove": "حذف هدر", + "provider.custom.headers.add": "افزودن هدر", + "provider.custom.error.providerID.required": "شناسه ارائه‌دهنده الزامی است", + "provider.custom.error.providerID.format": "از حروف کوچک، اعداد، خط تیره یا زیرخط استفاده کنید", + "provider.custom.error.providerID.exists": "این شناسه ارائه‌دهنده از قبل وجود دارد", + "provider.custom.error.name.required": "نام نمایشی الزامی است", + "provider.custom.error.baseURL.required": "URL پایه الزامی است", + "provider.custom.error.baseURL.format": "باید با http:// یا https:// شروع شود", + "provider.custom.error.required": "الزامی", + "provider.custom.error.duplicate": "تکراری", + "settings.openLocalConfig": "پیکربندی محلی", + "settings.openGlobalConfig": "پیکربندی سراسری", + "settings.config.scope.local": "محلی", + "settings.config.scope.global": "سراسری", + "settings.config.status.loaded": "بارگذاری شد", + "settings.config.status.loadedLegacy": "پیکربندی قدیمی بارگذاری شد", + "settings.config.status.notLoaded": "بارگذاری نشد", + "settings.config.status.create": "یافت نشد - این فایل را ایجاد کنید", + "settings.config.title": "باز کردن فایل پیکربندی {{scope}} Kilo", + "settings.config.placeholder": + "فایل‌های پیکربندی به ترتیب ادغام می‌شوند؛ فایل‌های علامت‌گذاری‌شده به‌عنوان بارگذاری‌شده در حال حاضر روی تنظیمات تأثیر می‌گذارند.", + "settings.config.noWorkspace": "یک پوشه کاری باز کنید تا فایل پیکربندی محلی Kilo را ویرایش کنید.", + "settings.config.openFailed": "باز کردن فایل پیکربندی {{scope}} Kilo با شکست مواجه شد: {{message}}", + "settings.config.source.xdg": "پیکربندی سراسری XDG", + "settings.config.source.homeKilo": "پیکربندی .kilo در پوشه خانگی", + "settings.config.source.homeKilocode": "پیکربندی .kilocode در پوشه خانگی", + "settings.config.source.homeOpencode": "پیکربندی .opencode در پوشه خانگی", + "settings.config.source.envFile": "فایل محیطی KILO_CONFIG", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "پیکربندی محیطی درون‌خطی", + "settings.config.source.projectKilo": "پیکربندی .kilo پروژه", + "settings.config.source.projectRoot": "پیکربندی ریشه پروژه", + "settings.config.source.projectKilocode": "پیکربندی قدیمی .kilocode", + "settings.config.source.projectOpencode": "پیکربندی قدیمی .opencode", + "settings.models.title": "مدل‌ها", + + "settings.permissions.toast.updateFailed.title": "به‌روزرسانی مجوزها ناموفق بود", + + "settings.permissions.tool.read.title": "خواندن", + "settings.permissions.tool.read.description": "خواندن یک فایل (با مسیر فایل تطابق دارد)", + "settings.permissions.tool.edit.title": "ویرایش", + "settings.permissions.tool.edit.description": "تغییر فایل‌ها، شامل ویرایش، نوشتن، وصله‌گذاری و ویرایش‌های چندگانه", + "settings.permissions.tool.glob.title": "Glob", + "settings.permissions.tool.glob.description": "تطبیق فایل‌ها با استفاده از الگوهای glob", + "settings.permissions.tool.grep.title": "Grep", + "settings.permissions.tool.grep.description": "جستجوی محتوای فایل‌ها با استفاده از عبارات منظم", + "settings.permissions.tool.list.title": "فهرست", + "settings.permissions.tool.list.description": "فهرست کردن فایل‌های درون یک پوشه", + "settings.permissions.tool.bash.title": "Bash", + "settings.permissions.tool.bash.description": "اجرای دستورات شل", + "settings.permissions.tool.task.title": "وظیفه", + "settings.permissions.tool.task.description": "راه‌اندازی زیر-عامل‌ها", + "settings.permissions.tool.skill.title": "مهارت", + "settings.permissions.tool.skill.description": "بارگذاری یک مهارت با نام", + "settings.permissions.tool.lsp.title": "LSP", + "settings.permissions.tool.lsp.description": "اجرای پرس‌وجوهای سرور زبان", + "settings.permissions.tool.todoread.title": "خواندن وظایف", + "settings.permissions.tool.todoread.description": "خواندن فهرست وظایف", + "settings.permissions.tool.todowrite.title": "نوشتن وظایف", + "settings.permissions.tool.todowrite.description": "به‌روزرسانی فهرست وظایف", + "settings.permissions.tool.webfetch.title": "دریافت وب", + "settings.permissions.tool.webfetch.description": "دریافت محتوا از یک URL", + "settings.permissions.tool.websearch.title": "جستجوی وب", + "settings.permissions.tool.websearch.description": "جستجو در وب", + "settings.permissions.tool.codesearch.title": "جستجوی کد", + "settings.permissions.tool.codesearch.description": "جستجوی کد در وب", + "settings.permissions.tool.external_directory.title": "پوشه خارجی", + "settings.permissions.tool.external_directory.description": "دسترسی به فایل‌های خارج از پوشه پروژه", + "settings.permissions.tool.doom_loop.title": "حلقه بی‌پایان", + "settings.permissions.tool.doom_loop.description": "تشخیص فراخوانی‌های تکراری ابزار با ورودی یکسان", + + "session.delete.title": "حذف جلسه", + "session.delete.confirm": "حذف جلسه «{{name}}»؟", + "session.delete.button": "حذف جلسه", + "session.untitled": "بدون عنوان", + "session.current": "جلسه فعلی", + "session.recent": "اخیر", + "session.showHistory": "نمایش تاریخچه", + "session.history.sources": "منبع تاریخچه", + "session.search.placeholder": "جستجوی جلسات...", + "session.empty": "هنوز جلسه‌ای وجود ندارد. برای شروع مکالمه جدید روی + کلیک کنید.", + "session.tabs.switcher.open": "نمایش تب‌های باز", + "session.tabs.switcher.search": "جستجو در تب‌های باز...", + "session.tabs.switcher.current": "فعلی", + "session.tabs.switcher.pending": "جدید", + "session.tabs.switcher.busy": "در حال کار", + "session.tab.local": "محلی", + "session.tab.cloud": "Cloud", + "session.tab.worktree": "Worktree", + "session.cloud.repoOnly": "فقط این مخزن", + "session.cloud.import": "وارد کردن جلسه", + "feedback.button": "بازخورد و پشتیبانی", + "feedback.dialog.message": "خوشحال می‌شویم نظرات شما را بشنویم یا در رفع مشکلاتتان کمک کنیم.", + "feedback.dialog.github": "گزارش مشکل در GitHub", + "feedback.dialog.discord": "پیوستن به جامعه Discord ما", + "feedback.dialog.support": "پشتیبانی مشتریان", + "workStyle.onboarding.welcome": "به Kilo خوش آمدید", + "workStyle.onboarding.title": "نحوه کار خود را انتخاب کنید", + "workStyle.onboarding.settingsNote": "می‌توانید این گزینه‌ها را هر زمان در", + "workStyle.onboarding.settings": "تنظیمات تغییر دهید.", + "workStyle.toast.saved.title": "حالت با موفقیت ذخیره شد", + "workStyle.choice.permissions": "مجوزها", + "workStyle.choice.bash": "Bash", + "workStyle.choice.visibility": "نمایان‌پذیری", + "workStyle.choice.human-in-the-loop.eyebrow": "انسان در حلقه", + "workStyle.choice.human-in-the-loop.title": "ابتدا بررسی کنید", + "workStyle.choice.human-in-the-loop.description": "Kilo در حین کار مکث می‌کند و برنامه خود را به شما نشان می‌دهد.", + "workStyle.choice.human-in-the-loop.permissions": "قبل از ویرایش فایل‌ها یا اجرای دستورات اجازه می‌گیرد.", + "workStyle.choice.human-in-the-loop.bash": "هنگام اجرای تمام دستورات ترمینال اجازه می‌گیرد.", + "workStyle.choice.human-in-the-loop.visibility": "جزئیات کامل مکالمه، از جمله استدلال، را نمایش می‌دهد.", + "workStyle.choice.autonomous.eyebrow": "وقفه‌های کمتر", + "workStyle.choice.autonomous.title": "استقلال بالا", + "workStyle.choice.autonomous.description": "وقفه‌های کمتر، رابط کاربری ساده‌تر.", + "workStyle.choice.autonomous.permissions": + "بدون درخواست اجازه، فایل‌ها را ویرایش می‌کند و دستورات را در فضای کاری اجرا می‌کند.", + "workStyle.choice.autonomous.bash": "می‌تواند بدون تأیید، دستورات ترمینال را در فضای کاری اجرا کند.", + "workStyle.choice.autonomous.visibility": "جزئیات تا زمانی که آن‌ها را باز کنید، جمع‌شده باقی می‌مانند.", + "session.cloud.import.title": "وارد کردن جلسه", + "session.cloud.import.placeholder": "شناسه جلسه، URL، یا دستور kilo import", + "session.cloud.import.button": "وارد کردن", + "session.cloud.import.invalid": "فرمت شناسه جلسه نامعتبر است", + "session.cloud.import.legacy": "به نظر می‌رسد این یک جلسه قدیمی است که دیگر پشتیبانی نمی‌شود.", + "session.cloud.import.failed": "وارد کردن جلسه ابری ناموفق بود", + + "deviceAuth.toast.urlCopied": "URL در کلیپ‌بورد کپی شد", + "deviceAuth.toast.codeCopied": "کد در کلیپ‌بورد کپی شد", + "deviceAuth.toast.errorCopied": "خطا در کلیپ‌بورد کپی شد", + "deviceAuth.status.initiating": "در حال شروع ورود...", + "deviceAuth.title": "ورود به Kilo Code", + "deviceAuth.step1": "مرحله ۱: این URL را باز کنید", + "deviceAuth.action.copyUrl": "کپی URL", + "deviceAuth.action.openBrowser": "باز کردن مرورگر", + "deviceAuth.qrCode.alt": "کد QR", + "deviceAuth.step2": "مرحله ۲: این کد را وارد کنید", + "deviceAuth.action.clickToCopy": "برای کپی کلیک کنید", + "deviceAuth.status.waiting": "در انتظار تأیید...", + "deviceAuth.status.success": "ورود موفقیت‌آمیز بود!", + "deviceAuth.status.failed": "ورود ناموفق بود", + "deviceAuth.status.cancelled": "ورود لغو شد", + "deviceAuth.action.copyError": "خطا در کپی", + "deviceAuth.action.showDetails": "مشاهده جزئیات", + "deviceAuth.action.tryAgain": "تلاش مجدد", + "deviceAuth.error.detailsTitle": "جزئیات خطای ورود", + + "common.retry": "تلاش مجدد", + "common.refresh": "بازخوانی", + "common.reload": "بارگذاری مجدد", + "common.reloadDescription": "بارگذاری مجدد تنظیمات، مهارت‌ها، عوامل و دستورات از دیسک", + + "profile.title": "پروفایل", + "profile.notLoggedIn": "وارد نشده‌اید", + "profile.action.login": "ورود با Kilo Code", + "profile.balance.title": "موجودی", + "profile.balance.refresh": "بازخوانی موجودی", + "profile.action.dashboard": "داشبورد", + "profile.action.topUp": "شارژ کردن", + "profile.pass.subscribe": "Kilo Pass را دریافت کنید تا اعتبار اضافه کنید و پاداش کسب کنید", + "profile.pass.bonus": "پاداش", + "profile.pass.renews": "تمدید می‌شود", + "profile.action.logout": "خروج", + + "settings.agentBehaviour.title": "رفتار عامل", + "settings.autoApprove.title": "تأیید خودکار", + "settings.webTools.title": "ابزارهای وب", + "settings.webTools.description": "جستجوی وب و اتوماسیون مرورگر را پیکربندی کنید.", + "settings.webTools.webSearch.enable": "فعال‌سازی برای همه ارائه‌دهندگان", + "settings.webTools.browserAutomation": "اتوماسیون مرورگر", + "settings.webTools.webSearch.title": "جستجوی وب", + "settings.webTools.webSearch.description": "جستجوی وب را برای مدل‌های همه ارائه‌دهندگان در دسترس قرار دهید.", + "settings.checkpoints.title": "نقاط بازیابی", + "settings.display.title": "نمایش", + "settings.autocomplete.title": "تکمیل خودکار", + "settings.notifications.title": "اعلان‌ها", + "settings.context.title": "زمینه", + "settings.indexing.title": "ایندکس‌گذاری", + "settings.indexing.status.title": "وضعیت", + "settings.indexing.enable.title": "فعال‌سازی ایندکس‌گذاری", + "settings.indexing.enable.description": "ایندکس‌گذاری معنایی پایگاه کد را روشن یا خاموش کنید.", + "settings.indexing.showButton.title": "نمایش دکمه هنگامی که ایندکس‌گذاری خاموش است", + "settings.indexing.showButton.description": + "دکمه ایندکس‌گذاری را در زیر پرامپت هنگامی که ایندکس‌گذاری خاموش است نشان دهید. در صورت پنهان بودن، برای فعال‌سازی ایندکس‌گذاری به تنظیمات > ایندکس‌گذاری بروید.", + "settings.indexing.globalEnable.title": "فعال‌سازی سراسری", + "settings.indexing.globalEnable.description": "فهرست‌سازی را برای همه فضاهای کاری فعال کنید.", + "settings.indexing.projectEnable.title": "فعال‌سازی برای این پروژه", + "settings.indexing.projectEnable.description": + "هنگامی که فهرست‌سازی سراسری غیرفعال است، فهرست‌سازی را برای این فضای کاری فعال کنید.", + "settings.indexing.provider.title": "ارائه‌دهنده جاسازی", + "settings.indexing.provider.description": + "ارائه‌دهنده مورد استفاده برای تولید جاسازی‌ها در جستجوی معنایی را انتخاب کنید.", + "settings.indexing.kiloModel.title": "پیش‌تنظیم مدل Kilo", + "settings.indexing.kiloModel.description": "یک مدل جاسازی میزبانی‌شده توسط Kilo را انتخاب کنید.", + "settings.indexing.kiloSignIn.title": "ورود به Kilo الزامی است", + "settings.indexing.kiloSignIn.description": "برای استفاده از جاسازی‌های میزبانی‌شده، وارد Kilo شوید.", + "settings.indexing.model.title": "مدل جاسازی", + "settings.indexing.model.description": "مدل جاسازی پیش‌فرض برای ارائه‌دهنده انتخاب‌شده را بازنویسی کنید.", + "settings.indexing.dimension.title": "بُعد برداری", + "settings.indexing.dimension.description": "برای تشخیص خودکار بُعد جاسازی از مدل، خالی بگذارید.", + "settings.indexing.dimension.placeholder": "خودکار", + "settings.indexing.providerField.description": "تنظیم اتصال مخصوص ارائه‌دهنده.", + "settings.indexing.vectorStore.title": "فروشگاه برداری", + "settings.indexing.vectorStore.description": "محل ذخیره‌سازی جاسازی‌های ایندکس‌شده را انتخاب کنید.", + "settings.indexing.lancedbDirectory.title": "پوشه LanceDB", + "settings.indexing.lancedbDirectory.description": "پوشه اختیاری برای ذخیره‌گاه محلی LanceDB.", + "settings.indexing.lancedbDirectory.placeholder": "برای پیش‌فرض خالی بگذارید", + "settings.indexing.qdrantUrl.title": "آدرس URL Qdrant", + "settings.indexing.qdrantUrl.description": "آدرس سرور برای نمونه Qdrant.", + "settings.indexing.qdrantApiKey.title": "کلید API Qdrant", + "settings.indexing.qdrantApiKey.description": "کلید API اختیاری برای نمونه Qdrant.", + "settings.indexing.qdrantApiKey.placeholder": "کلید API اختیاری", + "settings.indexing.fileExtensions.title": "پسوندهای فایل", + "settings.indexing.fileExtensions.description": + "فهرست مجاز با جداکننده کاما. برای استفاده از پیش‌فرض‌های داخلی خالی بگذارید.", + "settings.indexing.fileExtensions.invalid": "پسوند نامعتبر: {{extension}}", + "settings.indexing.tuning.description": "پارامتر پیشرفته جستجو و دسته‌بندی.", + "settings.experimental.title": "آزمایشی", + "settings.language.title": "زبان", + "settings.aboutKiloCode.title": "درباره Kilo Code", + + "session.messages.welcome": + "Kilo Code یک دستیار هوش مصنوعی برای کدنویسی است. از آن بخواهید ویژگی‌ها بسازد، باگ‌ها را رفع کند یا کدبیس شما را توضیح دهد.", + "session.messages.scrollToBottom": "رفتن به پایین", + "session.messages.initializing": "در حال راه‌اندازی...", + "session.messages.taskStarting": "در حال شروع...", + "session.prompts.navLabel": "ناوبر پرامپت", + "session.prompts.tick": "پرامپت {{index}} از {{total}}: {{prompt}}", + "session.prompts.noAnswer": "هنوز پاسخی وجود ندارد", + "session.prompts.queued": "در صف انتظار", + "session.prompts.first": "اولین پرامپت", + "session.prompts.latest": "آخرین پرامپت", + "session.prompts.overflow": "{{count}} پرامپت قبلی", + "session.status.writingResponse": "در حال نوشتن پاسخ...", + "session.status.retry": "در حال تلاش مجدد…", + "session.status.working": "در حال پردازش...", + "session.status.offline": "اتصال شبکه قطع شد — در حال اتصال مجدد...", + "session.outcome.incomplete": "نوبت با {{count}} کار باقی‌مانده پایان یافت.", + "session.outcome.limit": "پاسخ به محدودیت خروجی رسید و ممکن است ناقص باشد.", + "session.outcome.unknown": "پاسخ بدون دلیل پایان یافت و ممکن است ناقص باشد.", + "session.outcome.filtered": "ارائه‌دهنده این پاسخ را به دلیل فیلتر محتوا متوقف کرد.", + "session.outcome.unexpected": "پاسخ به‌طور غیرمنتظره‌ای پایان یافت و ممکن است ناقص باشد.", + "session.outcome.generationId": "شناسه تولید: {{id}}", + "session.outcome.interrupted": "نوبت قطع شد.", + "session.outcome.error": "نوبت با شکست مواجه شد.", + "session.outcome.finish": "دلیل فنی پایان: {{reason}}", + "session.costAlert.header": "هشدار هزینه جلسه", + "session.costAlert.continue": "ادامه", + "session.costAlert.question": "هزینه این جلسه از آستانه هشدار {{limit}} شما فراتر رفت و {{cost}} شد. ادامه می‌دهید؟", + "session.costAlert.stop": "توقف", + "sidebar.session.newSession": "جلسه جدید", + "sidebar.session.newSession.tooltip": "یک مکالمه تازه شروع کنید و جلسه فعلی را دست‌نخورده نگه دارید.", + "sidebar.session.newWorktree": "Worktree جدید", + "sidebar.session.newWorktree.tooltip": + "یک worktree گیت ایزوله ایجاد کنید تا به‌صورت امن آزمایش کنید، تغییرات را جدا نگه دارید و بدون اختلال در شاخه فعلی، جلسات موازی اجرا کنید.", + "sidebar.session.configureWorktree.tooltip": + "برای پیکربندی یک worktree جدید قبل از ایجاد آن، دیالوگ worktree در Agent Manager را باز کنید.", + "sidebar.session.newWorktree.from": "Worktree جدید از", + "sidebar.session.currentBranch": "شاخه فعلی", + "sidebar.session.moveToWorktree": "انتقال به Worktree", + "sidebar.session.moveToWorktree.tooltip.empty": + "این مکالمه و تغییرات محلی فعلی شما را به یک worktree اختصاصی برای کار پیگیری ایزوله منتقل کنید.", + "sidebar.session.moveToWorktree.tooltip.one": + "این مکالمه و ۱ فایل تغییریافته را به یک worktree اختصاصی برای کار پیگیری ایزوله منتقل کنید.", + "sidebar.session.moveToWorktree.tooltip.other": + "این مکالمه و {{files}} فایل تغییریافته را به یک worktree اختصاصی برای کار پیگیری ایزوله منتقل کنید.", + "sidebar.session.showChanges.tooltip.empty": "نمای تغییرات را برای بررسی working tree فعلی باز کنید.", + "sidebar.session.progress.capturing": "در حال ثبت تغییرات...", + "sidebar.session.progress.creating": "در حال ایجاد worktree...", + "sidebar.session.progress.setup": "در حال اجرای راه‌اندازی...", + "sidebar.session.progress.transferring": "در حال انتقال تغییرات...", + "sidebar.session.progress.forking": "در حال شروع جلسه...", + "sidebar.session.progress.failed": "ادامه در worktree با شکست مواجه شد", + + "ui.sessionTurn.cancel": "لغو", + "ui.sessionTurn.status.thinking": "در حال فکر کردن...", + "ui.sessionTurn.status.consideringNextSteps": "در حال بررسی مراحل بعدی...", + + "dialog.model.noProviders": "هیچ ارائه‌دهنده‌ای وجود ندارد", + + "prompt.placeholder.connecting": "در حال اتصال به سرور...", + "prompt.placeholder.default": "پیامی بنویسید... (Enter برای ارسال، Shift+Enter برای خط جدید)", + "prompt.placeholder.error": "اتصال ناموفق بود. پنل خروجی را بررسی کنید یا افزونه را مجدداً راه‌اندازی کنید.", + + "context.usage.sessionCost": "هزینه جلسه", + "context.usage.olderSessions": "{{count}} جلسه قدیمی‌تر", + "context.stats.thisSession": "این جلسه", + + "time.today": "امروز", + "time.yesterday": "دیروز", + "time.thisWeek": "این هفته", + "time.thisMonth": "این ماه", + "time.older": "قدیمی‌تر", + + "settings.aboutKiloCode.status.connected": "متصل", + "settings.aboutKiloCode.status.connecting": "در حال اتصال...", + "settings.aboutKiloCode.status.disconnected": "قطع شده", + "settings.aboutKiloCode.status.error": "خطا", + "settings.aboutKiloCode.cliServer": "سرور CLI", + "settings.aboutKiloCode.status.label": "وضعیت:", + "settings.aboutKiloCode.port.label": "پورت:", + "settings.aboutKiloCode.versionInfo": "اطلاعات نسخه", + "settings.aboutKiloCode.version.label": "نسخه:", + "settings.aboutKiloCode.community": "جامعه و پشتیبانی", + "settings.aboutKiloCode.feedback.prefix": "اگر سؤال یا بازخوردی دارید، می‌توانید یک issue در", + "settings.aboutKiloCode.feedback.or": "یا", + "settings.aboutKiloCode.support.prefix": "برای سؤالات مربوط به صورت‌حساب یا حساب کاربری، با پشتیبانی مشتریان در", + "settings.aboutKiloCode.resetSettings.title": "بازنشانی تنظیمات", + "settings.aboutKiloCode.resetSettings.description": + "این گزینه فقط تنظیمات مخصوص افزونه VS Code را به مقادیر پیش‌فرض بازنشانی می‌کند. تنظیمات مشترک با CLI، مانند حالت‌ها و قوانین تأیید خودکار، در پیکربندی CLI ذخیره می‌شوند و بازنشانی نخواهند شد.", + "settings.aboutKiloCode.resetSettings.button": "بازنشانی همه تنظیمات", + "settings.aboutKiloCode.resetSettings.notificationsButton": "بازنشانی اعلان‌های خوانده‌شده", + "settings.aboutKiloCode.settingsTransfer.title": "انتقال تنظیمات", + "settings.aboutKiloCode.settingsTransfer.description": + "تنظیمات خود را برای انتقال بین نمونه‌های VS Code صادر یا وارد کنید.", + "settings.aboutKiloCode.exportSettings": "صادر کردن", + "settings.aboutKiloCode.importSettings": "وارد کردن", + "settings.aboutKiloCode.importSettings.invalidJson": + "فایل JSON نامعتبر است. لطفاً یک فایل تنظیمات معتبر انتخاب کنید.", + "settings.aboutKiloCode.importSettings.invalidConfig": "فایل حاوی تنظیمات معتبر Kilo نیست.", + "settings.aboutKiloCode.importSettings.tooLarge": + "فایل بیش از حد بزرگ است. فایل‌های تنظیمات باید کمتر از ۱ مگابایت باشند.", + "settings.aboutKiloCode.importSettings.newerVersion": + "این فایل از نسخه جدیدتری از Kilo صادر شده است. برخی تنظیمات ممکن است نادیده گرفته شوند.", + "settings.aboutKiloCode.importSettings.success": + "تنظیمات وارد شد. تغییرات بالا را بررسی کنید، سپس روی ذخیره کلیک کنید.", + + "settings.aboutKiloCode.telemetry.title": "تله‌متری", + "settings.aboutKiloCode.telemetry.description": + "تله‌متری توسط تنظیمات داخلی تله‌متری VS Code کنترل می‌شود. برای غیرفعال کردن آن، به Settings > Telemetry > Telemetry Level بروید و آن را روی «off» تنظیم کنید. VS Code را مجدداً راه‌اندازی کنید تا تغییر اعمال شود.", + "settings.aboutKiloCode.telemetry.openSettings": "باز کردن تنظیمات تله‌متری", + + "settings.agentBehaviour.subtab.agents": "عوامل", + "settings.agentBehaviour.subtab.mcpServers": "MCP Servers", + "settings.agentBehaviour.subtab.rules": "قوانین", + "settings.agentBehaviour.subtab.workflows": "گردش‌های کاری", + "settings.agentBehaviour.subtab.skills": "مهارت‌ها", + + "settings.browser.description": + "وقتی فعال است، عامل هوش مصنوعی می‌تواند با صفحات وب تعامل داشته باشد — پیمایش، کلیک، تایپ و گرفتن اسکرین‌شات. یک پنجره Chrome باز می‌شود تا بتوانید عملکرد عامل را مشاهده کنید.", + "settings.browser.enable.title": "فعال‌سازی اتوماسیون مرورگر", + "settings.browser.enable.description": "سرور Playwright MCP را با بک‌اند CLI ثبت کنید.", + "settings.browser.systemChrome.title": "استفاده از Chrome سیستم", + "settings.browser.systemChrome.description": + "به جای یک نمونه Chromium جداگانه، از مرورگر Chrome نصب‌شده شما استفاده کنید.", + "settings.browser.headless.title": "حالت Headless", + "settings.browser.headless.description": "در حالت headless اجرا شود (بدون پنجره مرورگر قابل مشاهده).", + + "settings.language.description": + "زبان رابط کاربری Kilo Code را انتخاب کنید. «Auto» از زبان نمایشی VS Code شما استفاده می‌کند.", + "settings.language.auto": "خودکار (زبان VS Code)", + "settings.language.current": "فعلی:", + + "common.add": "افزودن", + + "settings.autocomplete.model.title": "مدل تکمیل خودکار", + "settings.autocomplete.model.description": "مدل مورد استفاده برای تکمیل‌های درون‌خطی کد را انتخاب کنید", + "settings.autocomplete.autoTrigger.title": "فعال‌سازی تکمیل‌های خودکار درون‌خطی", + "settings.autocomplete.autoTrigger.description": "پیشنهادهای تکمیل درون‌خطی را هنگام تایپ به‌صورت خودکار نمایش بده", + "settings.autocomplete.smartKeybinding.title": "فعال‌سازی میانبر هوشمند وظایف درون‌خطی", + "settings.autocomplete.smartKeybinding.description": "از یک میانبر هوشمند برای فعال‌سازی وظایف درون‌خطی استفاده کن", + "settings.autocomplete.chatAutocomplete.title": "فعال‌سازی تکمیل خودکار متن چت", + "settings.autocomplete.chatAutocomplete.description": "نمایش پیشنهادات تکمیل خودکار در کادر متنی چت", + "settings.autocomplete.modelsHint": "برای انتخاب مدل مورد استفاده در تکمیل خودکار، به تنظیمات مدل‌ها مراجعه کنید.", + + "settings.notifications.sounds": "صداها", + "settings.notifications.enable.title": "فعال‌سازی اعلان‌های صوتی", + "settings.notifications.enable.description": "پخش صدا هنگام تکمیل جلسات، بروز خطا یا نیاز به ورودی شما", + "settings.notifications.testSound": "آزمایش", + "settings.notifications.sound.default": "پیش‌فرض", + "settings.notifications.sound.system": "سیستم", + "settings.notifications.sound.description": + "پیش‌فرض از صداهای مختلف برای تکمیل، ورودی و خطاها استفاده می‌کند. سایر گزینه‌ها از یک صدا برای همه رویدادها استفاده می‌کنند.", + + "settings.experimental.share.title": "حالت اشتراک‌گذاری", + "settings.experimental.share.description": "نحوه رفتار اشتراک‌گذاری جلسه", + "settings.experimental.share.manual": "دستی", + "settings.experimental.share.auto": "خودکار", + "settings.experimental.share.disabled": "غیرفعال", + "settings.experimental.formatter.title": "قالب‌بند", + "settings.experimental.formatter.description": "فعال‌سازی قالب‌بند خودکار کد", + "settings.experimental.lsp.title": "LSP", + "settings.experimental.lsp.description": "فعال‌سازی یکپارچه‌سازی پروتکل سرور زبان", + "settings.experimental.batch.title": "ابزار دسته‌ای", + "settings.experimental.batch.description": "فعال‌سازی دسته‌بندی چندین فراخوانی ابزار", + "settings.experimental.codebaseSearch.title": "جستجوی پایگاه کد", + "settings.experimental.codebaseSearch.description": + "فعال‌سازی جستجوی زبان طبیعی مبتنی بر هوش مصنوعی در سراسر پایگاه کد", + "settings.experimental.imageGeneration.title": "تولید تصویر", + "settings.experimental.imageGeneration.description": "فعال‌سازی تولید تصویر با هوش مصنوعی", + "settings.experimental.imageGenerationModel.title": "مدل تصویر", + "settings.experimental.imageGenerationModel.description": "مدل تولید تصویر", + "settings.experimental.imageGenerationModel.placeholder": "پیش‌فرض (مسیریاب خودکار)", + + "settings.models.speechToText.disabledDescription": + "برای استفاده از تبدیل گفتار به متن، ارائه‌دهنده Kilo را فعال کرده و وارد شوید. تبدیل گفتار به متن در حال حاضر فقط از طریق Kilo Gateway پشتیبانی می‌شود.", + "settings.models.speechToTextModel.title": "مدل تبدیل گفتار به متن", + "settings.models.speechToTextModel.description": "مدل رونویسی Kilo Gateway را برای ورودی صوتی انتخاب کنید.", + "settings.experimental.nativeNotebookTools.title": "ابزارهای بومی Notebook", + "settings.experimental.nativeNotebookTools.description": + "ابزارهای آزمایشی برای خواندن، ویرایش و اجرای VS Code notebooks را فعال کنید", + "settings.experimental.continueOnDeny.title": "ادامه در صورت رد", + "settings.experimental.continueOnDeny.description": "حلقه عامل را هنگام رد شدن یک مجوز ادامه دهید", + "settings.sandboxing.enabled.title": "Sandbox", + "settings.sandboxing.enabled.description": + "اجرای دستورات شل عامل در یک Sandbox سطح سیستم‌عامل که نوشتن را به پوشه‌های پروژه و وضعیت Kilo محدود می‌کند", + "settings.sandboxing.title": "Sandboxing", + "settings.sandboxing.network.title": "محدود کردن دسترسی شبکه", + "settings.sandboxing.network.description": + "مسدود کردن دسترسی مستقیم خروجی از دستورات مدل و ابزارهای HTTP. ابزارهای MCP محلی و راه‌دور در حین محدودیت در دسترس نیستند. ترافیک ارائه‌دهنده و هوک‌های پلاگین مورد اعتماد خارج از این محدودیت باقی می‌مانند.", + "settings.sandboxing.allowedHosts.title": "مقصدهای شبکه مجاز", + "settings.sandboxing.allowedHosts.description": + "مقصدهای DNS هاست و پورت برای ترافیک پروکسی HTTP و HTTPS در Sandbox. GitHub CLI و Git از طریق HTTPS معمولاً به github.com:443 و api.github.com:443 نیاز دارند. تغییرات در نشست‌های جدید اعمال می‌شوند.", + "settings.sandboxing.writablePaths.title": "مسیرهای قابل نوشتن اضافی", + "settings.sandboxing.writablePaths.description": + "مسیرهای فایل‌سیستم اضافی که Sandbox اجازه نوشتن به آن‌ها را می‌دهد (مثلاً /tmp، /var/log). این مسیرها هنگام فعال بودن Sandbox با مسیرهای قابل نوشتن پیش‌فرض ادغام می‌شوند.", + "settings.experimental.swePruner.title": "SWE-Pruner", + "settings.experimental.swePruner.description": + "فعال‌سازی SWE-Pruner: هرس آگاه از وظیفه برای خروجی‌های بزرگ ابزارهای خواندن، جستجو و پوسته، هدایت‌شده توسط یک سؤال تمرکز از عامل", + "settings.experimental.swePrunerModel.title": "مدل SWE-Pruner", + "settings.experimental.swePrunerModel.description": + "مدل مورد استفاده برای مرور سریع خروجی‌های ابزار؛ به‌طور پیش‌فرض از مدل کوچک پیکربندی‌شده استفاده می‌کند", + "settings.experimental.mcpTimeout.title": "زمان‌وقفه MCP (میلی‌ثانیه)", + "settings.experimental.mcpTimeout.description": "زمان‌وقفه برای درخواست‌های سرور MCP بر حسب میلی‌ثانیه", + "settings.experimental.remote.title": "کنترل از راه دور", + "settings.experimental.remote.description": + "فعال‌سازی کنترل از راه دور جلسات از طریق Kilo Cloud. این تنظیم بر CLIهای این دستگاه نیز تأثیر می‌گذارد.", + "settings.experimental.remote.current": "وضعیت فعلی:", + "settings.experimental.remote.startup": "فعال‌سازی خودکار هنگام راه‌اندازی:", + "settings.experimental.remote.active": "فعال", + "settings.experimental.remote.inactive": "غیرفعال", + "settings.experimental.remote.hint": "برای تغییر وضعیت از /remote در چت استفاده کنید", + "settings.experimental.toolToggles": "تنظیمات ابزارها", + + "settings.agentBehaviour.defaultAgent.title": "عامل پیش‌فرض", + "settings.agentBehaviour.defaultAgent.description": "عاملی که در صورت عدم تعیین استفاده می‌شود", + "settings.agentBehaviour.availableAgents": "عامل‌های موجود", + "settings.agentBehaviour.modelOverride.title": "جایگزینی مدل", + "settings.agentBehaviour.modelOverride.description": "مدل پیش‌فرض این عامل را بازنویسی کنید", + "settings.agentBehaviour.variantOverride.title": "بازنویسی نوع", + "settings.agentBehaviour.variantOverride.description": "نوع مدل این عامل را بازنویسی کنید", + "settings.agentBehaviour.temperature.title": "دما", + "settings.agentBehaviour.temperature.description": + "کنترل می‌کند که پاسخ‌های هوش مصنوعی چقدر تصادفی باشند (۰–۲). مقادیر پایین‌تر (مثلاً ۰.۲) خروجی متمرکز و یکنواخت تولید می‌کنند. مقادیر بالاتر (مثلاً ۱.۰) پاسخ‌های متنوع‌تر و خلاقانه‌تری ایجاد می‌کنند. برای استفاده از مقدار پیش‌فرض مدل، خالی بگذارید.", + "settings.agentBehaviour.topP.title": "Top P", + "settings.agentBehaviour.topP.description": + "آستانه نمونه‌برداری هسته‌ای (۰–۱). انتخاب توکن‌ها را به کوچک‌ترین مجموعه‌ای محدود می‌کند که احتمال تجمعی آن به P برسد. مقادیر پایین‌تر خروجی را متمرکزتر می‌کنند؛ مقادیر بالاتر تنوع بیشتری را مجاز می‌دانند. برای استفاده از مقدار پیش‌فرض مدل، خالی بگذارید.", + "settings.agentBehaviour.maxSteps.title": "حداکثر مراحل", + "settings.agentBehaviour.maxSteps.description": + "حداکثر تعداد گام‌های عامل. در این محدودیت، به عامل دستور داده می‌شود که استفاده از ابزارها را متوقف کرده و پاسخ نهایی ارائه دهد. برای وظایف پیچیده چندمرحله‌ای افزایش دهید؛ برای پاسخ‌های کوتاه‌تر و قابل پیش‌بینی‌تر کاهش دهید.", + "settings.agentBehaviour.hidden.title": "پنهان", + "settings.agentBehaviour.hidden.description": "این عامل را از انتخابگر حالت در ورودی چت پنهان کنید", + "settings.agentBehaviour.disable.title": "غیرفعال", + "settings.agentBehaviour.disable.description": "این عامل را کاملاً غیرفعال کنید — در هیچ جایی نمایش داده نخواهد شد", + "settings.agentBehaviour.badge.hidden": "پنهان", + "settings.agentBehaviour.badge.disabled": "غیرفعال", + "settings.agentBehaviour.badge.deprecated": "منسوخ", + "settings.agentBehaviour.discoveredSkills": "مهارت‌های کشف‌شده", + "settings.agentBehaviour.noSkillsFound": + "هیچ مهارتی کشف نشد. مسیرهای پوشه مهارت یا URL را در زیر اضافه کنید تا مهارت‌ها در دسترس قرار گیرند.", + "settings.agentBehaviour.noAgentsFound": "هیچ عاملی یافت نشد.", + "settings.agentBehaviour.removeAgent.title": "حذف عامل", + "settings.agentBehaviour.removeAgent.confirm": + "ایجنت «{{name}}» حذف شود؟ این کار با به‌روزرسانی پیکربندی شما، ایجنت را غیرفعال می‌کند.", + "settings.agentBehaviour.removeAgent.button": "حذف", + "settings.agentBehaviour.skillPaths": "مسیرهای پوشه مهارت", + "settings.agentBehaviour.skillUrls": "URL های مهارت", + "settings.agentBehaviour.removeSkill.title": "حذف مهارت", + "settings.agentBehaviour.removeSkill.confirm": + "مهارت «{{name}}» حذف شود؟ این کار فایل‌های مهارت را از دیسک حذف می‌کند.", + "settings.agentBehaviour.removeSkill.button": "حذف", + "settings.agentBehaviour.rules.description": + "قوانین، فایل‌های دستورالعملی هستند که رفتار عامل را هدایت می‌کنند. این قوانین در پرامپت سیستم برای هر مکالمه گنجانده می‌شوند. برای افزودن قوانین بیشتر، مسیرهای فایل را در زیر وارد کنید.", + "settings.agentBehaviour.instructionFiles": "فایل‌های دستورالعمل اضافی", + "settings.agentBehaviour.instructionFiles.description": + "مسیرهای فایل‌های دستورالعمل اضافی که در پرامپت سیستم گنجانده می‌شوند", + "settings.agentBehaviour.claudeCompat.heading": "سازگاری با Claude Code", + "settings.agentBehaviour.claudeCompat.title": "بارگذاری فایل‌های Claude Code", + "settings.agentBehaviour.claudeCompat.description": + "دستورالعمل‌ها و مهارت‌های CLAUDE.md را از پوشه پیکربندی Claude Code شما در جلسات بارگذاری می‌کند. اگر می‌خواهید Kilo از دستورالعمل‌ها و مهارت‌های Claude Code شما استفاده کند، این گزینه را فعال کنید. نیاز به راه‌اندازی مجدد دارد.", + "settings.agentBehaviour.removeMcp.title": "حذف سرور MCP", + "settings.agentBehaviour.removeMcp.confirm": "سرور MCP «{{name}}» حذف شود؟ این کار آن را از پیکربندی شما حذف می‌کند.", + "settings.agentBehaviour.removeMcp.button": "حذف", + "settings.agentBehaviour.mcpDetail.command": "دستور", + "settings.agentBehaviour.mcpDetail.args": "آرگومان‌ها", + "settings.agentBehaviour.mcpDetail.env": "محیط", + "settings.agentBehaviour.editMcp": "ویرایش سرور MCP", + "settings.agentBehaviour.editMcp.transportLocal": "سرور محلی (انتقال stdio)", + "settings.agentBehaviour.editMcp.transportRemote": "سرور راه‌دور (انتقال SSE/HTTP)", + "settings.agentBehaviour.editMcp.env": "متغیرهای محیطی", + "settings.agentBehaviour.editMcp.env.help": "متغیرهایی که به فرآیند سرور MCP ارسال می‌شوند.", + "settings.agentBehaviour.addMcp.command": "دستور", + "settings.agentBehaviour.addMcp.command.placeholder": "مثلاً npx", + "settings.agentBehaviour.addMcp.args": "آرگومان‌ها", + "settings.agentBehaviour.addMcp.args.help": "یک آرگومان در هر خط. مسیرهای دارای فاصله عیناً حفظ می‌شوند.", + "settings.agentBehaviour.addMcp.args.placeholder": "مثلاً\n-y\n@modelcontextprotocol/server-filesystem\n/tmp", + "settings.agentBehaviour.addMcp.url": "URL سرور", + "settings.agentBehaviour.addMcp.url.placeholder": "مثلاً http://localhost:3000/sse", + "settings.agentBehaviour.mcpBrowseMarketplace": "مرور Marketplace", + "settings.agentBehaviour.mcpEmpty": + "هیچ سرور MCP پیکربندی نشده است. سرورهای MCP را در kilo.jsonc اضافه کنید، یا از agent بخواهید آن‌ها را برایتان اضافه کند.", + "settings.agentBehaviour.workflows.description": + "Workflow‌ها دستورات slash سفارشی هستند که در پیکربندی شما تعریف شده‌اند. برای فراخوانی آن‌ها /command-name را در چت تایپ کنید. دستورات در opencode.json زیر بخش 'command' پیکربندی می‌شوند.", + "settings.agentBehaviour.workflows.empty": + "هیچ دستور سفارشی پیکربندی نشده است. دستورات را به opencode.json خود اضافه کنید تا اینجا نمایش داده شوند.", + "settings.agentBehaviour.workflows.detail.description": "توضیحات", + "settings.agentBehaviour.workflows.detail.template": "قالب", + + "settings.agentBehaviour.createMode": "ایجاد حالت جدید", + "settings.agentBehaviour.createMode.name": "نام", + "settings.agentBehaviour.createMode.name.placeholder": "مثلاً reviewer", + "settings.agentBehaviour.createMode.name.description": + "شناسه یکتا برای حالت. فقط از حروف کوچک، اعداد و خط تیره استفاده کنید.", + "settings.agentBehaviour.createMode.description": "توضیحات", + "settings.agentBehaviour.createMode.description.placeholder": "مثلاً کد را از نظر کیفیت و بهترین روش‌ها بررسی می‌کند", + "settings.agentBehaviour.createMode.description.help": "توضیح کوتاهی از عملکرد این حالت.", + "settings.agentBehaviour.createMode.prompt": "پرامپت سیستم", + "settings.agentBehaviour.createMode.prompt.placeholder": + "مثلاً شما یک بازبین کد هستید. بر کیفیت کد، بهترین روش‌ها و باگ‌های احتمالی تمرکز کنید.", + "settings.agentBehaviour.createMode.prompt.help": "دستورالعمل‌های عامل هوش مصنوعی هنگام استفاده از این حالت.", + "settings.agentBehaviour.createMode.button": "ایجاد", + "settings.agentBehaviour.createMode.cancel": "لغو", + "settings.agentBehaviour.createMode.nameRequired": "نام الزامی است", + "settings.agentBehaviour.createMode.nameInvalid": + "نام باید با یک حرف کوچک شروع شود و فقط شامل حروف کوچک، اعداد و خط تیره باشد", + "settings.agentBehaviour.createMode.nameTaken": "یک حالت با این نام از قبل وجود دارد", + "settings.agentBehaviour.importMode": "وارد کردن", + "settings.agentBehaviour.importMode.invalidName": + "نام حالت در فایل نامعتبر است. نام باید با یک حرف کوچک شروع شود و فقط شامل حروف کوچک، اعداد و خط تیره باشد.", + "settings.agentBehaviour.importMode.nameTaken": "یک حالت با این نام از قبل وجود دارد.", + "settings.agentBehaviour.importMode.invalidJson": + "فایل JSON نامعتبر است. لطفاً یک فایل تعریف عامل معتبر انتخاب کنید.", + "settings.agentBehaviour.importMode.tooLarge": "فایل بیش از حد بزرگ است. تعریف عامل‌ها باید کمتر از ۱ مگابایت باشند.", + "settings.agentBehaviour.exportMode": "صادر کردن تعریف عامل", + "settings.agentBehaviour.editMode": "حالت ویرایش", + "settings.agentBehaviour.editMode.description": "توضیحات", + "settings.agentBehaviour.editMode.prompt": "پرامپت سیستم", + "settings.agentBehaviour.editMode.save": "انجام شد", + "settings.agentBehaviour.editMode.back": "بازگشت به فهرست", + "settings.agentBehaviour.editMode.native": + "این یک حالت داخلی است. تعریف پایه آن قابل تغییر نیست، اما می‌توانید تنظیمات سفارشی را در زیر پیکربندی کنید.", + "settings.agentBehaviour.editMode.promptOverride": "پرامپت سفارشی جایگزین برای این حالت داخلی", + "settings.agentBehaviour.badge.subagent": "زیرعامل", + "settings.agentBehaviour.permissions.title": "مجوزهای محاسبه‌شده", + "settings.agentBehaviour.permissions.count": "{{count}} قانون", + "settings.agentBehaviour.permissions.effective": "مؤثر (wildcard):", + "settings.agentBehaviour.permissions.col.tool": "ابزار", + "settings.agentBehaviour.permissions.col.pattern": "الگو", + "settings.agentBehaviour.permissions.col.action": "عملکرد", + "settings.agentBehaviour.permissions.copy": "کپی مجوزها به‌صورت JSON", + "settings.agentBehaviour.permissions.hint": + "قوانین به ترتیب ارزیابی می‌شوند — آخرین قانون منطبق اعمال می‌شود. این مجموعه قوانین حل‌شده از backend CLI است.", + + "settings.autoApprove.description": + "نحوه اجرای ابزارها را تعریف کنید. اکثر ابزارها به‌طور پیش‌فرض روی Allow هستند. doom_loop و external_directory به‌طور پیش‌فرض روی Ask هستند.", + "settings.autoApprove.maxCost.title": "هشدار هزینه جلسه", + "settings.autoApprove.maxCost.description": + "هنگامی که هزینه یک جلسه از این مقدار دلاری فراتر رفت، یک هشدار غیرمسدودکننده نمایش داده شود. از اعداد صحیح استفاده کنید؛ برای غیرفعال کردن خالی بگذارید.", + "settings.autoApprove.level.allow": "اجازه", + "settings.autoApprove.level.ask": "پرسش", + "settings.autoApprove.level.deny": "رد کردن", + "settings.autoApprove.wildcardLabel.commands": "همه دستورات (*)", + "settings.autoApprove.wildcardLabel.paths": "همه مسیرها (*)", + "settings.autoApprove.exceptions": "استثناها", + "settings.autoApprove.addCommand": "افزودن دستور", + "settings.autoApprove.addPath": "افزودن مسیر", + "settings.autoApprove.placeholder.command": "مثال: git *", + "settings.autoApprove.placeholder.path": "مثال: *.env", + "settings.autoApprove.tool.external_directory": + "دسترسی به فایل‌های خارج از فضای کاری. هنگام دسترسی به فایل‌های خارج از پوشه پروژه جاری فعال می‌شود.", + "settings.autoApprove.tool.bash": "اجرای دستورات ترمینال. اجازه اجرای دستورات شل را می‌دهد (مثلاً git status).", + "settings.autoApprove.tool.read": "خواندن فایل‌ها. به عامل اجازه می‌دهد فایل‌های منطبق با مسیر مشخص‌شده را بخواند.", + "settings.autoApprove.tool.edit": + "ویرایش فایل‌ها. به عامل اجازه می‌دهد فایل‌ها را ایجاد یا ویرایش کند، از جمله پچ‌ها و به‌روزرسانی‌های چندفایلی.", + "settings.autoApprove.tool.glob": + "تطبیق فایل‌ها با الگو. اجازه تطبیق فایل با استفاده از الگوهای glob را می‌دهد (مثلاً src/**/*.ts).", + "settings.autoApprove.tool.grep": "جستجو در محتوای فایل‌ها. اجازه جستجوی مبتنی بر regex درون فایل‌ها را می‌دهد.", + "settings.autoApprove.tool.list": "فهرست محتوای پوشه. اجازه مشاهده فایل‌ها و پوشه‌های داخل یک دایرکتوری را می‌دهد.", + "settings.autoApprove.tool.task": "راه‌اندازی زیرعامل‌ها. اجازه شروع زیرعامل‌های تخصصی برای وظایف خاص را می‌دهد.", + "settings.autoApprove.tool.skill": + "بارگذاری مهارت‌ها. اجازه بارگذاری مهارت‌های از پیش تعریف‌شده بر اساس نام را می‌دهد.", + "settings.autoApprove.tool.lsp": "پرس‌وجو از سرور زبان. اجازه اجرای پرس‌وجوهای LSP برای هوشمندی کد را می‌دهد.", + "settings.autoApprove.tool.todoreadwrite": + "مدیریت فهرست وظایف. اجازه خواندن و به‌روزرسانی فهرست وظایف داخلی را می‌دهد.", + "settings.autoApprove.tool.webfetch": "دریافت یک URL. امکان بازیابی محتوا از یک URL مشخص را فراهم می‌کند.", + "settings.autoApprove.tool.websearch": "جستجو در وب. امکان انجام جستجوهای خارجی در وب را فراهم می‌کند.", + "settings.autoApprove.tool.doom_loop": + "جلوگیری از اقدامات تکراری یکسان. زمانی فعال می‌شود که همان فراخوانی ابزار با ورودی یکسان تکرار شود.", + + "settings.checkpoints.enable.title": "فعال‌سازی اسنپ‌شات‌ها", + "settings.checkpoints.enable.description": + "قبل از ویرایش فایل‌ها نقاط بازیابی ایجاد کنید تا بتوانید به حالت‌های قبلی بازگردید", + + "settings.context.autoCompaction.title": "فشرده‌سازی خودکار", + "settings.context.autoCompaction.description": "قبل از رسیدن به محدودیت، زمینه را به‌طور خودکار فشرده کنید", + "settings.context.compaction.title": "فشرده‌سازی", + "settings.context.compactionLimit.title": "محدودیت فشرده‌سازی خودکار", + "settings.context.compactionLimit.description": + "زمانی فشرده‌سازی انجام شود که زمینه به این درصد از پنجره مدل برسد. برای استفاده تنها از بافر ایمنی، خالی بگذارید.", + "settings.context.prune.title": "حذف خروجی‌های قدیمی", + "settings.context.prune.description": "حذف خروجی‌های قدیمی ابزار در حین فشرده‌سازی", + "settings.context.watcherPatterns": "الگوهای نادیده‌گیری ناظر فایل", + "settings.context.watcherPatterns.description": "الگوهای Glob برای فایل‌هایی که ناظر باید نادیده بگیرد", + "settings.context.memory.title": "حافظه", + "settings.context.memory.project.title": "حافظه پروژه", + "settings.context.memory.autoSave.title": "ذخیره خودکار حافظه پروژه", + "settings.context.memory.autoSave.description": + "هنگامی که حافظه فعال است، اطلاعات پایدار پروژه از نوبت‌های تکمیل‌شده به‌طور خودکار ذخیره می‌شوند.", + "settings.context.memory.storage.title": "ذخیره‌سازی", + "settings.context.memory.status.notLoaded": "بارگذاری نشده", + "settings.context.memory.status.disabled": "غیرفعال", + "settings.context.memory.status.enabledTokens": "فعال - ~{{tokens}} توکن ذخیره‌شده", + "settings.context.memory.storage.path": "{{path}}", + "settings.context.memory.storage.enable": "حافظه را فعال کنید تا فایل‌های حافظه پروژه ایجاد شوند.", + "settings.context.memory.inspect": "بررسی", + "chat.memory.project.disabled": "حافظه پروژه غیرفعال است", + "chat.memory.project.empty": "این پروژه هنوز هیچ حافظه‌ای ندارد. پس از استفاده از Kilo نمایش داده خواهد شد.", + "chat.memory.command.failed": "دستور حافظه ناموفق بود", + "chat.memory.updated": "حافظه به‌روزرسانی شد", + "chat.memory.rebuild": "ایندکس حافظه بازسازی شد", + + "settings.commitMessage.title": "پیام Commit", + "settings.commitMessage.override.title": "استفاده از Prompt سفارشی", + "settings.commitMessage.override.description": + "جایگزینی Prompt پیش‌فرض پیام commit. در صورت فعال‌سازی، Prompt سفارشی شما به‌طور کامل جایگزین Prompt داخلی conventional commits می‌شود.", + "settings.commitMessage.prompt.title": "پرامپت سفارشی", + "settings.commitMessage.prompt.description": + "پرامپت سیستمی که هنگام تولید پیام‌های کامیت به هوش مصنوعی ارسال می‌شود. این گزینه پرامپت پیش‌فرض را به‌طور کامل جایگزین می‌کند.", + "settings.commitMessage.prompt.placeholder": + "مثال: پیام‌های کامیت را به زبان اسپانیایی و با فرمت conventional commits تولید کن. فقط پیام کامیت را برگردان.", + + "settings.commitMessage.language.sync": "همگام‌سازی با زبان رابط کاربری", + "settings.commitMessage.language.description": + "زبان مورد استفاده برای پیام‌های کامیت تولیدشده توسط هوش مصنوعی را انتخاب کنید:", + + "settings.display.username.title": "نام کاربری", + "settings.display.username.description": "نام کاربری سفارشی که در مکالمات نمایش داده می‌شود", + "settings.display.fontSize.title": "اندازه قلم", + "settings.display.fontSize.description": "اندازه قلم رابط کاربری وب‌ویو Kilo را مستقل از VS Code تنظیم کنید.", + "settings.display.reasoningAutoCollapse.title": "جمع‌شدن خودکار استدلال", + "settings.display.reasoningAutoCollapse.description": + "بلوک‌های استدلال را پس از اتمام نوشتن توسط عامل جمع می‌کند. برای نگه داشتن استدلال در حالت باز، این گزینه را خاموش بگذارید مگر اینکه خودتان آن را جمع کنید.", + "settings.display.shiftTabCycle.title": "چرخش سطح استدلال با Shift+Tab", + "settings.display.shiftTabCycle.description": + "در ورودی پرامپت، Shift+Tab را فشار دهید تا به سطح تلاش استدلال بعدی بروید. برای حفظ عملکرد Shift+Tab جهت ناوبری فوکوس صفحه‌کلید، این گزینه را غیرفعال کنید.", + "settings.display.terminalCommand.title": "بلوک‌های دستور ترمینال", + "settings.display.terminalCommand.description": + "انتخاب کنید که بلوک‌های دستور ترمینال در حالت باز یا بسته شروع شوند.", + "settings.display.terminalCommand.expanded": "باز", + "settings.display.terminalCommand.collapsed": "بسته", + "settings.display.codeEdit.title": "بلوک‌های ویرایش کد", + "settings.display.codeEdit.description": "انتخاب کنید که بلوک‌های ویرایش کد و تفاوت در حالت باز یا بسته شروع شوند.", + "settings.display.codeEdit.expanded": "گسترش‌یافته", + "settings.display.codeEdit.collapsed": "جمع‌شده", + "settings.display.tokenThroughput.title": "نمایش توان عملیاتی توکن", + "settings.display.tokenThroughput.description": + "نرخ تولید متن (توکن/ثانیه) را در آخرین پیام دستیار و در سربرگ وظیفه نمایش می‌دهد. به‌طور پیش‌فرض پنهان است تا چت شلوغ نشود.", + + "chat.throughput.tooltip": + "میانگین {{speed}} توکن/ثانیه برای این نوبت. شامل توکن‌های خروجی و استدلال می‌شود؛ زمان اجرای ابزار و انتظار را شامل نمی‌شود.", + "chat.throughput.tooltip.missing": "معیارهای توان عملیاتی برای این نوبت در دسترس نیست.", + + "settings.providers.defaultModel.title": "مدل پیش‌فرض", + "settings.providers.defaultModel.description": "مدل اصلی برای مکالمات", + "settings.providers.smallModel.title": "مدل سبک", + "settings.providers.smallModel.description": + "مدل سبک‌وزن برای تولید عنوان، تولید پیام کامیت، بهبود پرامپت و سایر وظایف سریع", + "settings.providers.subagentModel.title": "مدل زیرعامل", + "settings.providers.subagentModel.description": + "مدل پیش‌فرض و میزان استدلال برای زیرعامل‌های ابزار-وظیفه. برای به ارث بردن مدل عامل فراخواننده، خالی بگذارید.", + "settings.models.hidePromptTraining.title": "پنهان کردن مدل‌های آموزش پرامپت", + "settings.models.hidePromptTraining.description": + "مدل‌های Kilo Gateway را که ارائه‌دهندگانشان ممکن است از پرامپت‌های شما برای آموزش استفاده کنند پنهان کنید.", + "settings.providers.modeModels": "مدل به ازای حالت", + "settings.providers.modeModels.description": + "مدل پیش‌فرض را برای حالت‌های خاص بازنویسی کنید. در صورت عدم تنظیم، از مدل پیش‌فرض سراسری استفاده می‌شود.", + "settings.providers.disabled": "ارائه‌دهندگان غیرفعال", + "settings.providers.disabled.description": "ارائه‌دهندگانی که از فهرست پنهان می‌شوند", + "settings.providers.disabled.enable": "فعال‌سازی", + "settings.providers.notSet": "تنظیم نشده (از پیش‌فرض سرور استفاده شود)", + "settings.providers.select.placeholder": "انتخاب ارائه‌دهنده...", + + "dialog.model.notSet": "تنظیم نشده", + + "profile.personalAccount": "حساب شخصی", + "profile.switchingAccount": "در حال تغییر حساب…", + + // Agent Manager strings live in webview-ui/agent-manager/i18n/en.ts + + "question.summary": "{{n}} از {{total}} سؤال", + "common.review": "بررسی", + + // legacy-migration start + "settings.legacyMigration.link": "انتقال از نسخه قدیمی", + "settings.aboutKiloCode.legacyMigration.title": "انتقال از نسخه قدیمی", + "settings.aboutKiloCode.legacyMigration.description": + "تنظیمات را از نصب قبلی Kilo Code منتقل کنید، از جمله کلیدهای API ارائه‌دهنده و مدل پیش‌فرض.", + "settings.aboutKiloCode.rooImport.description": "تاریخچه مکالمات را از یک نصب Roo Code وارد کنید.", + "settings.aboutKiloCode.rooImport.button": "وارد کردن جلسات از Roo Code", + + // Screen 1 — What's New + "migration.whatsNew.title": "تازه‌های Kilo Code", + "migration.whatsNew.subtitle": "افزونه را بر پایه‌ای سریع‌تر و کارآمدتر بازسازی کرده‌ایم.", + "migration.whatsNew.features.performance.title": "عملکرد سریع‌تر عامل", + "migration.whatsNew.features.performance.detail": + "فراخوانی ابزارهای موازی و زیرعامل‌ها به عامل شما امکان می‌دهند کارهای بیشتری را همزمان انجام دهد — تا زمان کمتری صرف نظارت کنید و زمان بیشتری برای ارسال داشته باشید.", + "migration.whatsNew.features.interface.title": "رابط کاربری ساده‌تر", + "migration.whatsNew.features.interface.detail": "حواس‌پرتی کمتر، خواندن آسان‌تر و سریع‌تر.", + "migration.whatsNew.features.agentManager.title": "Agent Manager", + "migration.whatsNew.features.agentManager.detail": + "یک رابط یکپارچه برای اجرای چندین عامل به‌صورت موازی، هر کدام در worktree مخصوص خود — پیشرفت را رصد کنید، بین زمینه‌ها جابه‌جا شوید و تغییرات را در یک مکان بررسی کنید.", + "migration.whatsNew.features.foundation.title": "پایه مشترک", + "migration.whatsNew.features.foundation.detail": + "یک هسته کوچک و کارآمد در تمام محصولات Kilo. تجربه‌ای آشنا، هر طور که انتخاب کنید کار کنید.", + "migration.whatsNew.blogLink": "خواندن اعلامیه کامل", + "migration.whatsNew.docsLink": "چه چیزی جدید است و سؤالات متداول", + "migration.whatsNew.continue": "ادامه", + + // Screen 2 — Migrate Settings + "migration.migrate.title": "تنظیمات خود را منتقل کنید", + "migration.migrate.subtitle": "تنظیماتی از نصب قبلی شما یافتیم. این‌ها چیزهایی هستند که می‌توانیم منتقل کنیم.", + "migration.migrate.selectLabel": "انتخاب موارد برای انتقال", + "migration.migrate.chatHistory": "جلسات و تاریخچه چت", + "migration.migrate.button": "انتقال تنظیمات", + "migration.migrate.skip": "رد کردن", + "migration.migrate.keysDetected": "{{count}} کلید شناسایی شد", + "migration.migrate.serversConfigured": "{{count}} سرور پیکربندی شده", + "migration.migrate.modesFound": "{{count}} حالت یافت شد", + "migration.migrate.sessionsDetected": "{{count}} نشست شناسایی شد", + "migration.migrate.nothingToMigrate": "هیچ موردی برای انتقال در تنظیمات قدیمی یافت نشد.", + + // Migrate — item labels (reused from old select keys) + "migration.select.providers": "کلیدهای API ارائه‌دهنده", + "migration.select.mcpServers": "سرورهای MCP", + "migration.select.customModes": "حالت‌ها / عوامل سفارشی", + "migration.select.defaultModel": "مدل پیش‌فرض", + "migration.select.autoApproval": "تأیید خودکار", + "migration.select.language": "زبان رابط کاربری", + "migration.select.autocomplete": "تنظیمات تکمیل خودکار", + + // Migrate — completion + "migration.complete.summary": "{{success}} از {{total}} مورد با موفقیت منتقل شد.", + "migration.complete.cleanup": "حذف داده‌های تنظیمات قدیمی", + "migration.complete.cleanupDescription": + "این گزینه تنظیمات قدیمی را از حافظه VS Code حذف می‌کند. پس از این کار، امکان اجرای مجدد این انتقال وجود نخواهد داشت.", + "migration.complete.done": "انجام شد", + "migration.error.continue": "ادامه", + "migration.sessionSummary.title": "خلاصه:", + "migration.sessionSummary.copy": "کپی گزارش", + "migration.sessionSummary.toast.copied": "گزارش کپی شد", + "migration.sessionSummary.successful": "موفق", + "migration.sessionSummary.skipped": "رد شده", + "migration.sessionSummary.alreadyMigrated": "قبلاً منتقل شده", + "migration.sessionSummary.errored": "با خطا مواجه شد", + "migration.sessionSummary.none": "هیچ‌کدام", + "migration.forceReimport.title": "وارد کردن مجدد اجباری", + "migration.forceReimport.description": + "وارد کردن مجدد {{target}} آن‌ها را بازنویسی کرده و هر پیام جدیدی که در آن نشست‌ها ایجاد شده را حذف می‌کند.", + "migration.forceReimport.target.one": "این نشست", + "migration.forceReimport.target.many": "این {{count}} نشست", + "migration.forceReimport.button": "وارد کردن مجدد اجباری", + "migration.forceReimport.all": "وارد کردن مجدد همه", + "migration.forceReimport.proceed": "ادامه", + "migration.forceReimport.toast.started": "وارد کردن مجدد اجباری آغاز شد", + "migration.running.title": "انتقال در حال انجام است", + "migration.running.description.line1": "در حالی که هنوز نشست‌هایی در حال انتقال هستند، می‌خواهید پایان دهید.", + "migration.running.description.line2": "اگر اکنون خارج شوید، برخی جلسات ممکن است ناتمام بمانند.", + "migration.running.stay": "ماندن", + "migration.running.proceed": "ادامه", + "migration.sessionProgress.preparing": "در حال آماده‌سازی جلسه", + "migration.sessionProgress.storing": "در حال ذخیره جلسه", + "migration.sessionProgress.skipped": "جلسه رد شد", + "migration.sessionProgress.header": "در حال انتقال {{current}} از {{total}}", + "migration.sessionFormat.unknownDate": "تاریخ نامشخص", + "migration.sessionFormat.unknown": "نامشخص", + "migration.sessionFormat.unknownError": "خطای نامشخص", + // legacy-migration end + + "error.details.show": "جزئیات", + + "task.todos.progress": "{{done}}/{{total}} کار انجام شد", + "task.todos.allDone": "{{count}} کار انجام شد", + + "settings.saveBar.unsavedChanges": "تغییرات ذخیره‌نشده", + "settings.saveBar.discard": "رد کردن", + "settings.saveBar.save": "ذخیره", + "settings.saveBar.saving": "در حال ذخیره…", + "settings.saveBar.warning.one": "یک جلسه در حال اجرا است و قطع خواهد شد", + "settings.saveBar.warning.many": "چند جلسه در حال اجرا هستند و قطع خواهند شد", + "settings.saveBar.saveAnyway": "در هر صورت ذخیره کن", + "settings.saveBar.cancel": "لغو", + "settings.saveBar.saveFailed": "ذخیره تنظیمات ممکن نشد", + + "notifications.action.next": "بعدی", + "notifications.action.close": "بستن", + "notifications.action.tryModel": "امتحان {{model}}", + "notifications.action.tryModelGeneric": "امتحان مدل", + + "diffViewer.source.workspace.label": "شاخه", + "diffViewer.source.workspace.tooltip": + "تمام تغییرات این شاخه در مقایسه با شاخه پایه. شامل فایل‌های کامیت‌نشده (staged، unstaged، untracked) و کامیت‌های محلی که هنوز در شاخه پایه نیستند.", + "diffViewer.source.staged.label": "آماده‌سازی‌شده", + "diffViewer.source.staged.tooltip": + "فایل‌هایی با تغییراتی که به ناحیه staging گیت اضافه کرده‌اید (`git add`)، همان‌طور که در کامیت بعدی ظاهر خواهند شد.", + "diffViewer.source.unstaged.label": "مرحله‌بندی‌نشده", + "diffViewer.source.unstaged.tooltip": + "فایل‌های تغییر یافته در working tree شما که هنوز stage نشده‌اند، به علاوه فایل‌های ردیابی‌نشده (جدید).", + "diffViewer.source.session.label": "جلسه", + "diffViewer.source.session.tooltip": + "فایل‌هایی که توسط Kilo در جلسه جاری تغییر کرده‌اند، بر اساس عکس‌های فوری هر نوبت. با شروع جلسه جدید بازنشانی می‌شود.", + "diffViewer.group.session": "جلسه", + "diffViewer.group.git": "Git", + "diffViewer.notice.snapshotsDisabled": + "عکس‌های فوری برای این مخزن غیرفعال هستند. لطفاً فایل‌های پیکربندی خود را ویرایش کنید تا تغییرات جلسه نمایش داده شوند.", + + "diffViewer.baseBranch.auto": "پیش‌فرض", + "diffViewer.baseBranch.default": "پیش‌فرض", + "diffViewer.baseBranch.remote": "راه دور", + "diffViewer.baseBranch.search": "جستجوی شاخه‌ها", + "diffViewer.baseBranch.empty": "هیچ شاخه‌ای یافت نشد", + "diffViewer.baseBranch.loading": "در حال بارگذاری شاخه‌ها…", + "diffViewer.baseBranch.none": "—", + + "plan.exit.ready": "طرح آماده است:", + "chat.search.placeholder": "جستجو در چت…", + "chat.search.toggle": "جستجو در چت", + "chat.search.matchCase": "تطابق حروف بزرگ و کوچک", + "chat.search.matchWholeWord": "تطابق کلمه کامل", + "chat.search.useRegex": "استفاده از عبارت منظم", + "chat.search.previousMatch": "تطابق قبلی", + "chat.search.nextMatch": "تطابق بعدی", + "chat.search.close": "بستن جستجو", + "chat.search.invalidRegex": "عبارت منظم نامعتبر", + "chat.search.noResults": "نتیجه‌ای یافت نشد", + "chat.search.searchingHistory": "در حال جستجو در پیام‌های قبلی…", +} diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 451b0a1bc6..b759caf6af 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -306,7 +306,6 @@ export const dict = { "session.tab.review": "Revue", "session.review.filesChanged": "{{count}} fichiers modifiés", - "session.review.change.other": "Modifications", "session.review.loadingChanges": "Chargement des modifications...", "session.review.noChanges": "Aucune modification", "session.messages.loadingEarlier": "Chargement des messages précédents...", @@ -716,7 +715,13 @@ export const dict = { "settings.agentBehaviour.title": "Comportement de l'agent", "settings.autoApprove.title": "Approbation automatique", - "settings.browser.title": "Navigateur", + "settings.webTools.title": "Outils web", + "settings.webTools.description": "Configurez la recherche web et l’automatisation du navigateur.", + "settings.webTools.webSearch.enable": "Activer pour tous les fournisseurs", + "settings.webTools.browserAutomation": "Automatisation du navigateur", + "settings.webTools.webSearch.title": "Recherche web", + "settings.webTools.webSearch.description": + "Rendez la recherche web disponible pour les modèles de tous les fournisseurs.", "settings.checkpoints.title": "Points de contrôle", "settings.display.title": "Affichage", "settings.autocomplete.title": "Autocomplétion", @@ -734,6 +739,13 @@ export const dict = { "session.messages.scrollToBottom": "Défiler vers le bas", "session.messages.initializing": "Initialisation...", "session.messages.taskStarting": "Démarrage...", + "session.prompts.navLabel": "Navigateur de prompts", + "session.prompts.tick": "Prompt {{index}} sur {{total}} : {{prompt}}", + "session.prompts.noAnswer": "Pas encore de réponse", + "session.prompts.queued": "En attente", + "session.prompts.first": "Premier prompt", + "session.prompts.latest": "Dernier prompt", + "session.prompts.overflow": "{{count}} prompts précédents", "session.status.writingResponse": "Rédaction de la réponse…", "session.status.retry": "Nouvelle tentative…", "session.status.working": "En cours…", @@ -761,7 +773,8 @@ export const dict = { "prompt.placeholder.connecting": "Connexion au serveur...", "prompt.placeholder.error": "Échec de la connexion. Vérifiez le panneau de sortie ou redémarrez l'extension.", - "prompt.placeholder.default": "Tapez un message... (Entrée pour envoyer, Maj+Entrée pour un saut de ligne)", + "prompt.placeholder.default": + "Tapez un message, @ pour mentionner des fichiers... (Entrée pour envoyer, Maj+Entrée pour un saut de ligne)", "context.usage.sessionCost": "Coût de la session", "context.usage.olderSessions": "{{count}} sessions précédentes", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 066cd30098..a5220e13ac 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -219,7 +219,6 @@ export const dict = { "ui.approval.source.default": "per impostazione predefinita", "session.tab.review": "Revisione", "session.review.filesChanged": "{{count}} file modificati", - "session.review.change.other": "Modifiche", "session.review.loadingChanges": "Caricamento modifiche...", "session.review.noChanges": "Nessuna modifica", "session.messages.loadingEarlier": "Caricamento messaggi precedenti...", @@ -529,7 +528,12 @@ export const dict = { "profile.action.logout": "Esci", "settings.agentBehaviour.title": "Comportamento agente", "settings.autoApprove.title": "Approvazione automatica", - "settings.browser.title": "Browser", + "settings.webTools.title": "Strumenti web", + "settings.webTools.description": "Configura la ricerca web e l'automazione del browser.", + "settings.webTools.webSearch.enable": "Abilita per tutti i provider", + "settings.webTools.browserAutomation": "Automazione del browser", + "settings.webTools.webSearch.title": "Ricerca web", + "settings.webTools.webSearch.description": "Rendi disponibile la ricerca web ai modelli di tutti i provider.", "settings.checkpoints.title": "Checkpoint", "settings.display.title": "Visualizzazione", "settings.autocomplete.title": "Autocompletamento", @@ -574,6 +578,13 @@ export const dict = { "session.messages.scrollToBottom": "Scorri in fondo", "session.messages.initializing": "Inizializzazione...", "session.messages.taskStarting": "Avvio...", + "session.prompts.navLabel": "Navigatore dei prompt", + "session.prompts.tick": "Prompt {{index}} di {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Nessuna risposta ancora", + "session.prompts.queued": "In coda", + "session.prompts.first": "Primo prompt", + "session.prompts.latest": "Ultimo prompt", + "session.prompts.overflow": "{{count}} prompt precedenti", "session.status.writingResponse": "Scrittura risposta...", "session.status.retry": "Riprovo...", "session.status.working": "Al lavoro...", @@ -606,7 +617,8 @@ export const dict = { "ui.sessionTurn.status.consideringNextSteps": "Valutazione prossimi passi...", "dialog.model.noProviders": "Nessun provider", "prompt.placeholder.connecting": "Connessione al server...", - "prompt.placeholder.default": "Scrivi un messaggio... (Invio per inviare, Maiusc+Invio per nuova riga)", + "prompt.placeholder.default": + "Scrivi un messaggio, @ per menzionare i file... (Invio per inviare, Maiusc+Invio per nuova riga)", "prompt.placeholder.error": "Connessione non riuscita. Controlla il pannello output o riavvia l'estensione.", "context.usage.sessionCost": "Costo sessione", "context.usage.olderSessions": "{{count}} sessioni precedenti", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index b338abd210..06aa996cae 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -303,7 +303,6 @@ export const dict = { "session.tab.review": "レビュー", "session.review.filesChanged": "{{count}} ファイル変更", - "session.review.change.other": "変更", "session.review.loadingChanges": "変更を読み込み中...", "session.review.noChanges": "変更なし", "session.messages.loadingEarlier": "以前のメッセージを読み込み中...", @@ -697,7 +696,12 @@ export const dict = { "settings.agentBehaviour.title": "エージェントの動作", "settings.autoApprove.title": "自動承認", - "settings.browser.title": "ブラウザ", + "settings.webTools.title": "ウェブツール", + "settings.webTools.description": "ウェブ検索とブラウザ自動化を設定します。", + "settings.webTools.webSearch.enable": "すべてのプロバイダーで有効化", + "settings.webTools.browserAutomation": "ブラウザ自動化", + "settings.webTools.webSearch.title": "ウェブ検索", + "settings.webTools.webSearch.description": "すべてのプロバイダーのモデルでウェブ検索を利用できるようにします。", "settings.checkpoints.title": "チェックポイント", "settings.display.title": "表示", "settings.autocomplete.title": "オートコンプリート", @@ -715,6 +719,13 @@ export const dict = { "session.messages.scrollToBottom": "下にスクロール", "session.messages.initializing": "初期化中...", "session.messages.taskStarting": "開始中...", + "session.prompts.navLabel": "プロンプトナビゲーター", + "session.prompts.tick": "プロンプト {{index}}/{{total}}: {{prompt}}", + "session.prompts.noAnswer": "まだ応答がありません", + "session.prompts.queued": "キューに追加済み", + "session.prompts.first": "最初のプロンプト", + "session.prompts.latest": "最新のプロンプト", + "session.prompts.overflow": "{{count}} 件前のプロンプト", "session.status.writingResponse": "応答を作成中…", "session.status.retry": "再試行中…", "session.status.working": "作業中…", @@ -742,7 +753,7 @@ export const dict = { "prompt.placeholder.connecting": "サーバーに接続中...", "prompt.placeholder.error": "接続に失敗しました。出力パネルを確認するか、拡張機能を再起動してください。", - "prompt.placeholder.default": "メッセージを入力... (Enterで送信、Shift+Enterで改行)", + "prompt.placeholder.default": "メッセージを入力、@ でファイルを参照... (Enterで送信、Shift+Enterで改行)", "context.usage.sessionCost": "セッションコスト", "context.usage.olderSessions": "{{count}} 件の古いセッション", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 20fc092585..38bcf1d861 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -304,7 +304,6 @@ export const dict = { "session.tab.review": "검토", "session.review.filesChanged": "{{count}}개 파일 변경됨", - "session.review.change.other": "변경", "session.review.loadingChanges": "변경 사항 로드 중...", "session.review.noChanges": "변경 없음", "session.messages.loadingEarlier": "이전 메시지 로드 중...", @@ -657,7 +656,12 @@ export const dict = { "settings.agentBehaviour.title": "에이전트 동작", "settings.autoApprove.title": "자동 승인", - "settings.browser.title": "브라우저", + "settings.webTools.title": "웹 도구", + "settings.webTools.description": "웹 검색 및 브라우저 자동화를 구성합니다.", + "settings.webTools.webSearch.enable": "모든 제공업체에 사용", + "settings.webTools.browserAutomation": "브라우저 자동화", + "settings.webTools.webSearch.title": "웹 검색", + "settings.webTools.webSearch.description": "모든 제공업체의 모델에서 웹 검색을 사용할 수 있도록 합니다.", "settings.checkpoints.title": "체크포인트", "settings.display.title": "디스플레이", "settings.autocomplete.title": "자동 완성", @@ -716,6 +720,13 @@ export const dict = { "session.messages.scrollToBottom": "하단으로 스크롤", "session.messages.initializing": "초기화 중...", "session.messages.taskStarting": "시작 중...", + "session.prompts.navLabel": "프롬프트 탐색기", + "session.prompts.tick": "프롬프트 {{index}}/{{total}}: {{prompt}}", + "session.prompts.noAnswer": "아직 응답이 없습니다", + "session.prompts.queued": "대기 중", + "session.prompts.first": "첫 번째 프롬프트", + "session.prompts.latest": "최신 프롬프트", + "session.prompts.overflow": "{{count}}개 이전 프롬프트", "session.status.writingResponse": "응답 작성 중...", "session.status.retry": "재시도 중…", "session.status.working": "작업 중...", @@ -742,7 +753,7 @@ export const dict = { "prompt.placeholder.connecting": "서버에 연결 중...", "prompt.placeholder.error": "연결에 실패했습니다. 출력 패널을 확인하거나 확장 프로그램을 다시 시작하세요.", - "prompt.placeholder.default": "메시지를 입력하세요... (Enter로 전송, Shift+Enter로 줄 바꿈)", + "prompt.placeholder.default": "메시지를 입력하세요, @로 파일 언급... (Enter로 전송, Shift+Enter로 줄 바꿈)", "context.usage.sessionCost": "세션 비용", "context.usage.olderSessions": "{{count}}개의 이전 세션", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 5f0d90da4b..b4ac467643 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -307,7 +307,6 @@ export const dict = { "session.tab.review": "Beoordelen", "session.review.filesChanged": "{{count}} bestanden gewijzigd", - "session.review.change.other": "Wijzigingen", "session.review.loadingChanges": "Wijzigingen laden...", "session.review.noChanges": "Geen wijzigingen", @@ -658,7 +657,12 @@ export const dict = { "settings.agentBehaviour.title": "Agent Gedrag", "settings.autoApprove.title": "Automatisch Goedkeuren", - "settings.browser.title": "Browser", + "settings.webTools.title": "Webtools", + "settings.webTools.description": "Configureer zoeken op internet en browserautomatisering.", + "settings.webTools.webSearch.enable": "Inschakelen voor alle providers", + "settings.webTools.browserAutomation": "Browserautomatisering", + "settings.webTools.webSearch.title": "Zoeken op internet", + "settings.webTools.webSearch.description": "Maak zoeken op internet beschikbaar voor modellen van alle providers.", "settings.checkpoints.title": "Controlepunten", "settings.display.title": "Weergave", "settings.autocomplete.title": "Automatisch Aanvullen", @@ -713,6 +717,13 @@ export const dict = { "session.messages.scrollToBottom": "Scroll naar beneden", "session.messages.initializing": "Initialiseren...", "session.messages.taskStarting": "Starten...", + "session.prompts.navLabel": "Promptnavigator", + "session.prompts.tick": "Prompt {{index}} van {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Nog geen reactie", + "session.prompts.queued": "In wachtrij", + "session.prompts.first": "Eerste prompt", + "session.prompts.latest": "Meest recente prompt", + "session.prompts.overflow": "{{count}} eerdere prompts", "session.status.writingResponse": "Antwoord schrijven...", "session.status.retry": "Opnieuw proberen...", "session.status.working": "Bezig...", @@ -739,7 +750,8 @@ export const dict = { "dialog.model.noProviders": "Geen providers", "prompt.placeholder.connecting": "Verbinden met server...", - "prompt.placeholder.default": "Typ een bericht... (Enter om te verzenden, Shift+Enter voor nieuwe regel)", + "prompt.placeholder.default": + "Typ een bericht, @ om bestanden te vermelden... (Enter om te verzenden, Shift+Enter voor nieuwe regel)", "prompt.placeholder.error": "Verbinding mislukt. Controleer het uitvoerpaneel of herstart de extensie.", "context.usage.sessionCost": "Sessiekosten", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index f9a0d5f096..0b218a448a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -310,7 +310,6 @@ export const dict = { "session.tab.review": "Gjennomgang", "session.review.filesChanged": "{{count}} filer endret", - "session.review.change.other": "Endringer", "session.review.loadingChanges": "Laster endringer...", "session.review.noChanges": "Ingen endringer", "session.messages.loadingEarlier": "Laster inn tidligere meldinger...", @@ -665,7 +664,12 @@ export const dict = { "settings.agentBehaviour.title": "Agentoppførsel", "settings.autoApprove.title": "Automatisk godkjenning", - "settings.browser.title": "Nettleser", + "settings.webTools.title": "Nettverktøy", + "settings.webTools.description": "Konfigurer nettsøk og nettleserautomatisering.", + "settings.webTools.webSearch.enable": "Aktiver for alle leverandører", + "settings.webTools.browserAutomation": "Nettleserautomatisering", + "settings.webTools.webSearch.title": "Nettsøk", + "settings.webTools.webSearch.description": "Gjør nettsøk tilgjengelig for modeller fra alle leverandører.", "settings.checkpoints.title": "Kontrollpunkter", "settings.display.title": "Visning", "settings.autocomplete.title": "Autofullfør", @@ -683,6 +687,13 @@ export const dict = { "session.messages.scrollToBottom": "Rull til bunnen", "session.messages.initializing": "Initialiserer...", "session.messages.taskStarting": "Starter...", + "session.prompts.navLabel": "Ledetekstnavigering", + "session.prompts.tick": "Ledetekst {{index}} av {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Ingen svar ennå", + "session.prompts.queued": "I kø", + "session.prompts.first": "Første ledetekst", + "session.prompts.latest": "Siste ledetekst", + "session.prompts.overflow": "{{count}} tidligere ledetekster", "session.status.writingResponse": "Skriver svar…", "session.status.retry": "Prøver på nytt…", "session.status.working": "Arbeider…", @@ -710,7 +721,8 @@ export const dict = { "prompt.placeholder.connecting": "Kobler til server...", "prompt.placeholder.error": "Tilkobling mislyktes. Sjekk utdatapanelet eller start utvidelsen på nytt.", - "prompt.placeholder.default": "Skriv en melding... (Enter for å sende, Shift+Enter for ny linje)", + "prompt.placeholder.default": + "Skriv en melding, @ for å nevne filer... (Enter for å sende, Shift+Enter for ny linje)", "context.usage.sessionCost": "Sesjonskostnad", "context.usage.olderSessions": "{{count}} eldre sesjoner", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 99741deb92..a3a8a15774 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -304,7 +304,6 @@ export const dict = { "session.tab.review": "Przegląd", "session.review.filesChanged": "Zmieniono {{count}} plików", - "session.review.change.other": "Zmiany", "session.review.loadingChanges": "Ładowanie zmian...", "session.review.noChanges": "Brak zmian", "session.messages.loadingEarlier": "Ładowanie wcześniejszych wiadomości...", @@ -661,7 +660,12 @@ export const dict = { "settings.agentBehaviour.title": "Zachowanie agenta", "settings.autoApprove.title": "Automatyczne zatwierdzanie", - "settings.browser.title": "Przeglądarka", + "settings.webTools.title": "Narzędzia internetowe", + "settings.webTools.description": "Skonfiguruj wyszukiwanie w sieci i automatyzację przeglądarki.", + "settings.webTools.webSearch.enable": "Włącz dla wszystkich dostawców", + "settings.webTools.browserAutomation": "Automatyzacja przeglądarki", + "settings.webTools.webSearch.title": "Wyszukiwanie w sieci", + "settings.webTools.webSearch.description": "Udostępnij wyszukiwanie w sieci modelom wszystkich dostawców.", "settings.checkpoints.title": "Punkty kontrolne", "settings.display.title": "Wyświetlanie", "settings.autocomplete.title": "Autouzupełnianie", @@ -679,6 +683,13 @@ export const dict = { "session.messages.scrollToBottom": "Przewiń na dół", "session.messages.initializing": "Inicjalizacja...", "session.messages.taskStarting": "Uruchamianie...", + "session.prompts.navLabel": "Nawigator promptów", + "session.prompts.tick": "Prompt {{index}} z {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Jeszcze brak odpowiedzi", + "session.prompts.queued": "W kolejce", + "session.prompts.first": "Pierwszy prompt", + "session.prompts.latest": "Ostatni prompt", + "session.prompts.overflow": "{{count}} wcześniejszych promptów", "session.status.writingResponse": "Pisanie odpowiedzi…", "session.status.retry": "Ponawianie…", "session.status.working": "Pracuję…", @@ -706,7 +717,8 @@ export const dict = { "prompt.placeholder.connecting": "Łączenie z serwerem...", "prompt.placeholder.error": "Połączenie nie powiodło się. Sprawdź panel wyjściowy lub uruchom ponownie rozszerzenie.", - "prompt.placeholder.default": "Wpisz wiadomość... (Enter, aby wysłać, Shift+Enter dla nowej linii)", + "prompt.placeholder.default": + "Wpisz wiadomość, @ aby wspomnieć pliki... (Enter, aby wysłać, Shift+Enter dla nowej linii)", "context.usage.sessionCost": "Koszt sesji", "context.usage.olderSessions": "{{count}} starszych sesji", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 649c5b84d1..42b7cf4241 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -302,7 +302,6 @@ export const dict = { "session.tab.review": "Обзор", "session.review.filesChanged": "{{count}} файлов изменено", - "session.review.change.other": "Изменения", "session.review.loadingChanges": "Загрузка изменений...", "session.review.noChanges": "Нет изменений", "session.messages.loadingEarlier": "Загрузка предыдущих сообщений...", @@ -702,7 +701,12 @@ export const dict = { "settings.agentBehaviour.title": "Поведение агента", "settings.autoApprove.title": "Автоодобрение", - "settings.browser.title": "Браузер", + "settings.webTools.title": "Веб-инструменты", + "settings.webTools.description": "Настройте веб-поиск и автоматизацию браузера.", + "settings.webTools.webSearch.enable": "Включить для всех провайдеров", + "settings.webTools.browserAutomation": "Автоматизация браузера", + "settings.webTools.webSearch.title": "Веб-поиск", + "settings.webTools.webSearch.description": "Сделайте веб-поиск доступным для моделей всех провайдеров.", "settings.checkpoints.title": "Контрольные точки", "settings.display.title": "Отображение", "settings.autocomplete.title": "Автодополнение", @@ -720,6 +724,13 @@ export const dict = { "session.messages.scrollToBottom": "Прокрутить вниз", "session.messages.initializing": "Инициализация...", "session.messages.taskStarting": "Запуск...", + "session.prompts.navLabel": "Навигатор промптов", + "session.prompts.tick": "Промпт {{index}} из {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Ответа пока нет", + "session.prompts.queued": "В очереди", + "session.prompts.first": "Первый запрос", + "session.prompts.latest": "Последний запрос", + "session.prompts.overflow": "{{count}} предыдущих запросов", "session.status.writingResponse": "Пишу ответ…", "session.status.retry": "Повторная попытка…", "session.status.working": "Работаю…", @@ -747,7 +758,8 @@ export const dict = { "prompt.placeholder.connecting": "Подключение к серверу...", "prompt.placeholder.error": "Не удалось подключиться. Проверьте панель вывода или перезапустите расширение.", - "prompt.placeholder.default": "Введите сообщение... (Enter для отправки, Shift+Enter для новой строки)", + "prompt.placeholder.default": + "Введите сообщение, @ чтобы упомянуть файлы... (Enter для отправки, Shift+Enter для новой строки)", "context.usage.sessionCost": "Стоимость сессии", "context.usage.olderSessions": "{{count}} предыдущих сессий", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index a6f5e657ee..338f31a68f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -301,7 +301,6 @@ export const dict = { "session.tab.review": "ตรวจสอบ", "session.review.filesChanged": "{{count}} ไฟล์ที่เปลี่ยนแปลง", - "session.review.change.other": "การเปลี่ยนแปลง", "session.review.loadingChanges": "กำลังโหลดการเปลี่ยนแปลง...", "session.review.noChanges": "ไม่มีการเปลี่ยนแปลง", @@ -695,7 +694,12 @@ export const dict = { "settings.agentBehaviour.title": "พฤติกรรมของเอเจนต์", "settings.autoApprove.title": "อนุมัติอัตโนมัติ", - "settings.browser.title": "เบราว์เซอร์", + "settings.webTools.title": "เครื่องมือเว็บ", + "settings.webTools.description": "กำหนดค่าการค้นหาเว็บและระบบอัตโนมัติของเบราว์เซอร์", + "settings.webTools.webSearch.enable": "เปิดใช้สำหรับผู้ให้บริการทั้งหมด", + "settings.webTools.browserAutomation": "ระบบอัตโนมัติของเบราว์เซอร์", + "settings.webTools.webSearch.title": "ค้นหาเว็บ", + "settings.webTools.webSearch.description": "ทำให้โมเดลจากผู้ให้บริการทั้งหมดใช้การค้นหาเว็บได้", "settings.checkpoints.title": "จุดตรวจสอบ", "settings.display.title": "การแสดงผล", "settings.autocomplete.title": "เติมข้อความอัตโนมัติ", @@ -712,6 +716,13 @@ export const dict = { "session.messages.scrollToBottom": "เลื่อนไปด้านล่าง", "session.messages.initializing": "กำลังเริ่มต้น...", "session.messages.taskStarting": "กำลังเริ่มทำงาน...", + "session.prompts.navLabel": "ตัวนำทางพรอมต์", + "session.prompts.tick": "พรอมต์ {{index}} จาก {{total}}: {{prompt}}", + "session.prompts.noAnswer": "ยังไม่มีการตอบกลับ", + "session.prompts.queued": "อยู่ในคิว", + "session.prompts.first": "พรอมต์แรก", + "session.prompts.latest": "พรอมต์ล่าสุด", + "session.prompts.overflow": "พรอมต์ก่อนหน้า {{count}} รายการ", "session.status.writingResponse": "กำลังเขียนคำตอบ...", "session.status.retry": "กำลังลองใหม่…", "session.status.working": "กำลังทำงาน...", @@ -739,7 +750,7 @@ export const dict = { "prompt.placeholder.connecting": "กำลังเชื่อมต่อกับเซิร์ฟเวอร์...", "prompt.placeholder.error": "การเชื่อมต่อล้มเหลว ตรวจสอบแผงเอาต์พุตหรือรีสตาร์ทส่วนขยาย", - "prompt.placeholder.default": "พิมพ์ข้อความ... (Enter เพื่อส่ง, Shift+Enter เพื่อขึ้นบรรทัดใหม่)", + "prompt.placeholder.default": "พิมพ์ข้อความ, @ เพื่ออ้างถึงไฟล์... (Enter เพื่อส่ง, Shift+Enter เพื่อขึ้นบรรทัดใหม่)", "context.usage.sessionCost": "ค่าใช้จ่ายเซสชัน", "context.usage.olderSessions": "{{count}} เซสชันก่อนหน้า", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 9a9da245a6..d17ae9d3c5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -302,7 +302,6 @@ export const dict = { "session.tab.review": "İnceleme", "session.review.filesChanged": "{{count}} Dosya Değişti", - "session.review.change.other": "Değişiklik", "session.review.loadingChanges": "Değişiklikler yükleniyor...", "session.review.noChanges": "Değişiklik yok", @@ -653,7 +652,13 @@ export const dict = { "settings.agentBehaviour.title": "Ajan Davranışı", "settings.autoApprove.title": "Otomatik Onay", - "settings.browser.title": "Tarayıcı", + "settings.webTools.title": "Web Araçları", + "settings.webTools.description": "Web aramasını ve tarayıcı otomasyonunu yapılandırın.", + "settings.webTools.webSearch.enable": "Tüm Sağlayıcılar İçin Etkinleştir", + "settings.webTools.browserAutomation": "Tarayıcı Otomasyonu", + "settings.webTools.webSearch.title": "Web Araması", + "settings.webTools.webSearch.description": + "Web aramasını tüm sağlayıcıların modelleri için kullanılabilir hale getirin.", "settings.checkpoints.title": "Kontrol Noktaları", "settings.display.title": "Görünüm", "settings.autocomplete.title": "Otomatik Tamamlama", @@ -707,6 +712,13 @@ export const dict = { "session.messages.scrollToBottom": "En alta kaydır", "session.messages.initializing": "Başlatılıyor...", "session.messages.taskStarting": "Başlıyor...", + "session.prompts.navLabel": "Komut gezgini", + "session.prompts.tick": "Komut {{index}} / {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Henüz yanıt yok", + "session.prompts.queued": "Sırada", + "session.prompts.first": "İlk istem", + "session.prompts.latest": "En son istem", + "session.prompts.overflow": "{{count}} önceki istem", "session.status.writingResponse": "Yanıt yazılıyor...", "session.status.retry": "Yeniden deneniyor…", "session.status.working": "Çalışıyor...", @@ -733,7 +745,8 @@ export const dict = { "dialog.model.noProviders": "Sağlayıcı yok", "prompt.placeholder.connecting": "Sunucuya bağlanılıyor...", - "prompt.placeholder.default": "Bir mesaj yazın... (Göndermek için Enter, yeni satır için Shift+Enter)", + "prompt.placeholder.default": + "Bir mesaj yazın, dosyaları belirtmek için @ kullanın... (Göndermek için Enter, yeni satır için Shift+Enter)", "prompt.placeholder.error": "Bağlantı başarısız. Çıktı panelini kontrol edin veya uzantıyı yeniden başlatın.", "context.usage.sessionCost": "Oturum maliyeti", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index f80fdf0f99..9826ce4369 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -306,7 +306,6 @@ export const dict = { "session.tab.review": "Огляд", "session.review.filesChanged": "{{count}} файлів змінено", - "session.review.change.other": "Змін", "session.review.loadingChanges": "Завантаження змін...", "session.review.noChanges": "Змін немає", @@ -655,7 +654,12 @@ export const dict = { "settings.agentBehaviour.title": "Поведінка агента", "settings.autoApprove.title": "Автоматичне схвалення", - "settings.browser.title": "Браузер", + "settings.webTools.title": "Вебінструменти", + "settings.webTools.description": "Налаштуйте вебпошук і автоматизацію браузера.", + "settings.webTools.webSearch.enable": "Увімкнути для всіх постачальників", + "settings.webTools.browserAutomation": "Автоматизація браузера", + "settings.webTools.webSearch.title": "Вебпошук", + "settings.webTools.webSearch.description": "Зробіть вебпошук доступним для моделей усіх постачальників.", "settings.checkpoints.title": "Контрольні точки", "settings.display.title": "Відображення", "settings.autocomplete.title": "Автодоповнення", @@ -709,6 +713,13 @@ export const dict = { "session.messages.scrollToBottom": "Прокрутити до низу", "session.messages.initializing": "Ініціалізація...", "session.messages.taskStarting": "Запуск...", + "session.prompts.navLabel": "Навігатор запитів", + "session.prompts.tick": "Запит {{index}} з {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Відповіді ще немає", + "session.prompts.queued": "У черзі", + "session.prompts.first": "Перший запит", + "session.prompts.latest": "Останній запит", + "session.prompts.overflow": "{{count}} попередніх запитів", "session.status.writingResponse": "Пишу відповідь...", "session.status.retry": "Повторна спроба…", "session.status.working": "Працює...", @@ -735,7 +746,8 @@ export const dict = { "dialog.model.noProviders": "Немає провайдерів", "prompt.placeholder.connecting": "Підключення до сервера...", - "prompt.placeholder.default": "Напишіть повідомлення... (Enter для надсилання, Shift+Enter для нового рядка)", + "prompt.placeholder.default": + "Напишіть повідомлення, @ щоб згадати файли... (Enter для надсилання, Shift+Enter для нового рядка)", "prompt.placeholder.error": "Підключення не вдалося. Перевірте панель виводу або перезапустіть розширення.", "context.usage.sessionCost": "Вартість сесії", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index ae31c74807..e49208d29d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -292,7 +292,6 @@ export const dict = { "session.tab.review": "审查", "session.review.filesChanged": "{{count}} 个文件变更", - "session.review.change.other": "更改", "session.review.loadingChanges": "正在加载更改...", "session.review.noChanges": "无更改", "session.messages.loadingEarlier": "正在加载更早的消息...", @@ -679,7 +678,12 @@ export const dict = { "settings.agentBehaviour.title": "智能体行为", "settings.autoApprove.title": "自动审批", - "settings.browser.title": "浏览器", + "settings.webTools.title": "网络工具", + "settings.webTools.description": "配置网页搜索和浏览器自动化。", + "settings.webTools.webSearch.enable": "为所有提供商启用", + "settings.webTools.browserAutomation": "浏览器自动化", + "settings.webTools.webSearch.title": "网页搜索", + "settings.webTools.webSearch.description": "让所有提供商的模型都可使用网页搜索。", "settings.checkpoints.title": "检查点", "settings.display.title": "显示", "settings.autocomplete.title": "自动补全", @@ -696,6 +700,13 @@ export const dict = { "session.messages.scrollToBottom": "滚动到底部", "session.messages.initializing": "初始化中...", "session.messages.taskStarting": "启动中...", + "session.prompts.navLabel": "提示词导航", + "session.prompts.tick": "提示词 {{index}}/{{total}}:{{prompt}}", + "session.prompts.noAnswer": "暂无响应", + "session.prompts.queued": "已排队", + "session.prompts.first": "首个提示", + "session.prompts.latest": "最新提示", + "session.prompts.overflow": "{{count}} 个更早的提示", "session.status.writingResponse": "正在撰写回复…", "session.status.retry": "正在重试…", "session.status.working": "处理中…", @@ -722,7 +733,7 @@ export const dict = { "prompt.placeholder.connecting": "正在连接服务器...", "prompt.placeholder.error": "连接失败。请检查输出面板或重启扩展。", - "prompt.placeholder.default": "输入消息... (Enter 发送,Shift+Enter 换行)", + "prompt.placeholder.default": "输入消息,用 @ 提及文件... (Enter 发送,Shift+Enter 换行)", "context.usage.sessionCost": "会话费用", "context.usage.olderSessions": "{{count}} 个较早的会话", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 097657622d..9afedb19bc 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -290,7 +290,6 @@ export const dict = { "session.tab.review": "審查", "session.review.filesChanged": "{{count}} 個檔案變更", - "session.review.change.other": "變更", "session.review.loadingChanges": "正在載入變更...", "session.review.noChanges": "沒有變更", "session.messages.loadingEarlier": "正在載入更早的訊息...", @@ -639,7 +638,12 @@ export const dict = { "settings.agentBehaviour.title": "Agent 行為", "settings.autoApprove.title": "自動核准", - "settings.browser.title": "瀏覽器", + "settings.webTools.title": "網路工具", + "settings.webTools.description": "設定網頁搜尋和瀏覽器自動化。", + "settings.webTools.webSearch.enable": "為所有供應商啟用", + "settings.webTools.browserAutomation": "瀏覽器自動化", + "settings.webTools.webSearch.title": "網頁搜尋", + "settings.webTools.webSearch.description": "讓所有供應商的模型都可使用網頁搜尋。", "settings.checkpoints.title": "檢查點", "settings.display.title": "顯示", "settings.autocomplete.title": "自動完成", @@ -656,6 +660,13 @@ export const dict = { "session.messages.scrollToBottom": "捲動至底部", "session.messages.initializing": "初始化中...", "session.messages.taskStarting": "啟動中...", + "session.prompts.navLabel": "提示詞導覽", + "session.prompts.tick": "提示詞 {{index}}/{{total}}:{{prompt}}", + "session.prompts.noAnswer": "尚無回應", + "session.prompts.queued": "已排入佇列", + "session.prompts.first": "第一個提示", + "session.prompts.latest": "最新提示", + "session.prompts.overflow": "{{count}} 個較早的提示", "session.status.writingResponse": "正在撰寫回覆…", "session.status.retry": "正在重試…", "session.status.working": "處理中…", @@ -682,7 +693,7 @@ export const dict = { "prompt.placeholder.connecting": "正在連線至伺服器...", "prompt.placeholder.error": "連線失敗。請檢查輸出面板或重新啟動擴充功能。", - "prompt.placeholder.default": "輸入訊息... (Enter 送出,Shift+Enter 換行)", + "prompt.placeholder.default": "輸入訊息,用 @ 提及檔案... (Enter 送出,Shift+Enter 換行)", "context.usage.sessionCost": "工作階段費用", "context.usage.olderSessions": "{{count}} 個較早的工作階段", diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 18b9b4cc7f..a19ae35fc1 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -262,7 +262,7 @@ export function mockSessionValue(overrides?: { createSession: noop, clearCurrentSession: noop, loadSessions: noop, - loadOlderMessages: noop, + loadOlderMessages: () => false, selectSession: noop, deleteSession: noop, renameSession: noop, @@ -335,6 +335,7 @@ const ConfigWrapper: ParentComponent<{ config: createMemo(() => cfg()), globalConfig: createMemo(() => (scoped ? global() : cfg())), projectConfig: createMemo(() => (scoped ? project() : cfg())), + collections: () => ({}), settings, features, loading: () => false, diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index 009b72bb17..3a0eb6d208 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -933,9 +933,9 @@ export const TabBarSingleTab: Story = { } // Side terminal panel inside the real inspector host chain, empty state — -// no live PTY, so the start affordance renders. The header reuses the -// .am-diff-header metrics so the a11y/screenshot baseline also guards the -// alignment against the diff panel chrome. +// no live PTY, so the start affordance renders. The tab strip header keeps +// the .am-diff-header height so the a11y/screenshot baseline also guards +// the alignment against the diff panel chrome. export const SideTerminalPanelEmpty: Story = { name: "Side terminal panel — empty", render: () => { @@ -953,7 +953,50 @@ export const SideTerminalPanelEmpty: Story = { state={state} contextKey={() => LOCAL} visible={() => true} + onSelect={() => undefined} onClose={() => undefined} + onCloseOthers={() => undefined} + onStart={() => undefined} + /> +
+
+
+
+ + ) + }, +} + +// Tab strip with several side terminals: the active one shows the X close +// button, the others reveal it on hover. Terminals point at a dead port — +// xterm renders its connection-error notice inside the panel, which keeps +// the story self-contained without a live PTY. +export const SideTerminalPanelTabs: Story = { + name: "Side terminal panel — tabs", + render: () => { + const state = createTerminalState(() => LOCAL) + const font = { fontFamily: "monospace", fontSize: 12 } + state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://127.0.0.1:1/a", font, placement: "side" }) + state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://127.0.0.1:1/b", font, placement: "side" }) + state.add(null, { id: "terminal:three", title: "Terminal 3", wsUrl: "ws://127.0.0.1:1/c", font, placement: "side" }) + state.setSideActive(LOCAL, "terminal:two") + state.setTitle("terminal:two", "npm run dev") + return ( + +
+
+
+ Agent session stays visible beside the terminal. +
+
+
+ LOCAL} + visible={() => true} + onSelect={(id) => state.setSideActive(LOCAL, id)} + onClose={() => undefined} + onCloseOthers={() => undefined} onStart={() => undefined} />
diff --git a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx index 7d5e6705e9..94abfd215d 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx @@ -9,6 +9,7 @@ import type { Meta, StoryObj } from "storybook-solidjs-vite" import type { AssistantMessage } from "@kilocode/sdk/v2" +import { batch, createSignal } from "solid-js" import { StoryProviders, defaultMockData, mockSessionValue } from "./StoryProviders" import { ChatView } from "../components/chat/ChatView" import { ErrorDisplay } from "../components/chat/ErrorDisplay" @@ -617,6 +618,167 @@ export const ChatViewReadable420: Story = { render: () => renderReadableChat("busy"), } +// --------------------------------------------------------------------------- +// PromptRail — the left-edge tick rail and its hover card +// Several turns so the rail and card are populated: a long prompt, a short +// low-signal follow-up, a tool-only answer (empty preview), and a queued one. +// --------------------------------------------------------------------------- + +const railNow = 1_700_000_200_000 +const railTurn = (i: number, prompt: string, answer: string | undefined, queued = false) => { + const userID = `rail-user-${i}` + const assistantID = `rail-asst-${i}` + const messages: any[] = [{ id: userID, sessionID: SESSION_ID, role: "user", time: { created: railNow + i * 100 } }] + if (!queued) { + messages.push({ + id: assistantID, + sessionID: SESSION_ID, + role: "assistant", + parentID: userID, + time: { created: railNow + i * 100 + 50 }, + modelID: "claude-sonnet-4-20250514", + providerID: "anthropic", + mode: "default", + agent: "default", + path: { cwd: "/project", root: "/project" }, + }) + } + const parts: Record = { + [userID]: [{ id: `rail-part-user-${i}`, sessionID: SESSION_ID, messageID: userID, type: "text", text: prompt }], + } + if (!queued) { + parts[assistantID] = answer + ? [{ id: `rail-part-asst-${i}`, sessionID: SESSION_ID, messageID: assistantID, type: "text", text: answer }] + : [ + { + id: `rail-part-asst-${i}`, + sessionID: SESSION_ID, + messageID: assistantID, + type: "tool", + callID: `rail-call-${i}`, + tool: "bash", + state: { + status: "completed", + input: { command: "ls", description: "List files" }, + output: "a.ts b.ts", + title: "ls", + metadata: {}, + time: { start: railNow + i * 100 + 50, end: railNow + i * 100 + 80 }, + }, + }, + ] + } + return { messages, parts } +} + +const railTurns = [ + railTurn( + 1, + "Add a prompt navigator rail to the left edge of the chat that expands into a card of prompt and answer previews when I hover it, without shrinking the readable lane", + "Added PromptRail with a tick per prompt and a hover card; the lane width is untouched.", + ), + railTurn(2, "yes", "Confirmed — wiring it into MessageList next."), + railTurn(3, "run the tests", undefined), + railTurn( + 4, + "now do the same in the Agent Manager chat", + "ChatView → MessageList is shared, so the rail appears there automatically; no Agent Manager specific code needed.", + ), + railTurn(5, "looks good, ship it", "", true), +] +const railMessages = railTurns.flatMap((turn) => turn.messages) +const railParts = Object.assign({}, ...railTurns.map((turn) => turn.parts)) +const railData = { + ...defaultMockData, + message: { [SESSION_ID]: railMessages }, + part: railParts, +} + +const renderRailChat = (status: "idle" | "busy" = "idle") => { + const session = { + ...mockSessionValue({ id: SESSION_ID, status }), + messages: () => railMessages, + userMessages: () => railMessages.filter((msg) => msg.role === "user"), + getParts: (id: string) => railParts[id] ?? [], + } + return ( + + +
+ +
+
+
+ ) +} + +export const PromptRailWide: Story = { + name: "PromptRail - wide editor tab", + render: () => renderRailChat(), +} + +export const PromptRailSidebar: Story = { + name: "PromptRail - narrow sidebar", + render: () => renderRailChat("busy"), +} + +// Long session: more prompts than fit the transcript height, so the rail and +// the card both cap to the newest ones that fit. +const manyTurns = Array.from({ length: 80 }, (_, i) => + railTurn(100 + i, `Prompt number ${i + 1} in a long running session`, `Answer number ${i + 1}.`), +) +const manyMessages = manyTurns.flatMap((turn) => turn.messages) +const recentMessages = manyTurns.slice(-40).flatMap((turn) => turn.messages) +const manyData = { + ...defaultMockData, + message: { [SESSION_ID]: manyMessages }, + part: Object.assign({}, ...manyTurns.map((turn) => turn.parts)), +} + +export const PromptRailManyPrompts: Story = { + name: "PromptRail - long session caps to what fits", + render: () => { + const [messages, setMessages] = createSignal(recentMessages) + const [older, setOlder] = createSignal(true) + const [loading, setLoading] = createSignal(false) + const [mutation, setMutation] = createSignal<"prepend">() + const load = () => { + if (!older() || loading()) return false + setLoading(true) + // Paging is a backend round trip, so the story keeps a short delay: the + // navigator's loading row is part of the behavior being shown. + setTimeout(() => { + batch(() => { + setMessages(manyMessages) + setOlder(false) + setMutation("prepend") + setLoading(false) + }) + }, 300) + return true + } + const session = { + ...mockSessionValue({ id: SESSION_ID, status: "idle" }), + messages, + userMessages: () => messages().filter((msg) => msg.role === "user"), + getParts: (id: string) => manyData.part[id] ?? [], + hasOlderMessages: older, + loadingOlderMessages: loading, + messageMutation: mutation, + loadOlderMessages: load, + } + return ( + + +
+ +
+
+
+ ) + }, +} + export const MessageListToolToQueuedUserSpacing: Story = { name: "MessageList — queued users stay at bottom", render: () => { diff --git a/packages/kilo-vscode/webview-ui/src/styles/banners.css b/packages/kilo-vscode/webview-ui/src/styles/banners.css index e8e66675f1..edbda58d57 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/banners.css +++ b/packages/kilo-vscode/webview-ui/src/styles/banners.css @@ -169,6 +169,119 @@ align-items: center; gap: 6px; min-width: 0; + flex-shrink: 1; +} + +/* ============================================ + Inline diff toolbar controls (scope, base, diff style) + ============================================ + + These sit inside a toolbar row next to ghost buttons and a radio group, so + they must not carry header-band margins and must match the small control + height. `.am-selector-trigger` is a 32px full-width input-style control; + everything below shrinks it to a toolbar-sized chip. */ + +.diff-scope-controls { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + flex-shrink: 1; +} + +/* Two-class selector on purpose: `.am-selector-trigger` (agent-manager.css) is + a 32px full-width input-style control and loads after this file, so a single + `.diff-inline-trigger` class would lose the cascade tie and keep the tall + size. */ +.am-selector-trigger.diff-inline-trigger { + width: auto; + height: 22px; + gap: 4px; + padding: 0 6px; + font-size: var(--kilo-font-size-12); + /* Tight line box: the inherited 19.5px line-height leaves no room inside a + 22px chip and makes inline children look like they burst out of it. */ + line-height: 20px; + /* Never squeeze: shrinking these to unreadable slivers looks broken. The + container queries below drop whole controls instead. */ + flex-shrink: 0; + min-width: 0; +} + +/* The "Default" badge is sized for dropdown rows (19.5px line box + padding = + 21.5px), which fills a 22px chip edge to edge. Shrink it so it reads as a + badge inside the control instead of overflowing it. */ +.am-selector-trigger.diff-inline-trigger .am-branch-badge { + padding: 0 4px; + line-height: 14px; + border-radius: 2px; +} + +.am-selector-trigger.diff-inline-trigger .am-selector-right { + margin-left: 2px; +} + +/* Long branch names ellipsize rather than pushing the row wider. The compact + variant (narrow side panel) caps tighter than the full-screen toolbar. */ +.am-selector-trigger.diff-inline-trigger .am-selector-value { + max-width: 160px; +} + +.diff-scope-controls-compact .am-selector-trigger.diff-inline-trigger .am-selector-value { + max-width: 92px; +} + +/* The shared `.am-dropdown` rule pins popover width to the anchor width, which + would make these narrow triggers open an unreadably narrow menu. */ +.diff-inline-menu[data-component="popover-content"] { + width: auto; + min-width: 200px; +} + +.diff-inline-group { + padding: 6px 10px 2px; + font-size: var(--kilo-font-size-11); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-weaker); +} + +/* Inline controls inside the two Agent Manager diff toolbars. They must not add + vertical margin: each toolbar's height is its own padding plus the tallest + control, so a header-band margin here would visibly grow the row (this is + why these rows do not reuse `[data-component="diff-picker-header"]`). */ +.am-review-toolbar-left > .diff-scope-controls, +.am-diff-header-main > .diff-scope-controls { + margin: 0; + min-width: 0; + flex-shrink: 0; +} + +/* ============================================ + Narrow side panel: progressive disclosure + ============================================ + + The user can drag the diff side panel arbitrarily narrow. Flex shrinking + alone clips controls mid-glyph and slides them under the action buttons, so + whole controls are dropped instead, least important first. The scope select + always survives: it is the only way back out of an empty scope. + + The container is declared on `.am-diff-header` in agent-manager.css (that + file only permits `am-`-prefixed selectors, so the `diff-`-prefixed rules + live here). */ + +/* Diff style has a home in the full-screen review, so it goes before base. */ +@container am-diff-header (max-width: 470px) { + .am-diff-header-main .diff-style-select { + display: none; + } +} + +@container am-diff-header (max-width: 390px) { + .am-diff-header-main .diff-base-picker { + display: none; + } } .diff-base-current { @@ -193,12 +306,6 @@ line-height: 1; } -.diff-base-trigger { - height: 24px; - font-size: var(--kilo-font-size-12); - padding: 0 8px; -} - /* ============================================ Diff Viewer Notice (e.g. snapshots disabled) ============================================ */ diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat.css b/packages/kilo-vscode/webview-ui/src/styles/chat.css index 9bd9a1b166..6f75206919 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat.css @@ -10,6 +10,7 @@ @import "./search-menu.css"; @import "./session-tabs.css"; @import "./chat-layout.css"; +@import "./prompt-rail.css"; @import "./banners.css"; @import "./session-actions.css"; @import "./welcome.css"; diff --git a/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css b/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css index a87d87f9fd..70901682b7 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css +++ b/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css @@ -138,6 +138,16 @@ font-size: var(--kilo-font-size-11); } +.session-mention-worktree { + flex-shrink: 0; + opacity: 0.6; + font-size: var(--kilo-font-size-11); + max-width: 40%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* ============================================ Slash Command Dropdown ============================================ */ diff --git a/packages/kilo-vscode/webview-ui/src/styles/prompt-rail.css b/packages/kilo-vscode/webview-ui/src/styles/prompt-rail.css new file mode 100644 index 0000000000..4bbe9a022d --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/styles/prompt-rail.css @@ -0,0 +1,296 @@ +/* ============================================ + Prompt Rail + ============================================ */ + +.prompt-rail { + --prompt-rail-width: 16px; + --prompt-rail-gap: 8px; + --prompt-rail-step: 14px; + --prompt-rail-ease: cubic-bezier(0.22, 1, 0.36, 1); + + position: absolute; + inset-inline-start: max( + 0px, + calc(50% - var(--chat-readable-width) / 2 - var(--prompt-rail-width) - var(--prompt-rail-gap)) + ); + top: 0; + bottom: 0; + width: var(--prompt-rail-width); + display: flex; + flex-direction: column; + justify-content: center; + /* Guards the rail against ever painting outside its own column, whatever + the tick count or panel height. */ + overflow: hidden; + padding: 12px 0; + pointer-events: none; + z-index: 2; + opacity: 0.5; + transition: opacity 0.25s var(--prompt-rail-ease); +} + +.message-list-container:hover .prompt-rail, +.prompt-rail:focus-within { + opacity: 1; +} + +.prompt-rail-tick { + pointer-events: auto; + display: flex; + align-items: center; + justify-content: flex-start; + height: var(--prompt-rail-step); + min-height: 4px; + width: 100%; + border: none; + background: none; + padding: 0; + cursor: pointer; + /* No outline box: the focus ring is drawn on the line itself, so a focused + tick reads as part of the rail instead of a rectangle floating over it. */ + outline: none; +} + +.prompt-rail-tick-line { + display: block; + height: 1.5px; + width: 9px; + border-radius: 1px; + background: var(--icon-base); + opacity: 0.7; + transition: + width 0.24s var(--prompt-rail-ease), + opacity 0.2s var(--prompt-rail-ease), + background-color 0.2s var(--prompt-rail-ease), + box-shadow 0.2s var(--prompt-rail-ease); +} + +.prompt-rail-tick[data-queued] .prompt-rail-tick-line { + background: var(--icon-weaker); + opacity: 0.5; +} + +.prompt-rail-tick--overflow .prompt-rail-tick-line { + width: 6px; + background: repeating-linear-gradient( + to right, + var(--icon-weaker) 0, + var(--icon-weaker) 2px, + transparent 2px, + transparent 4px + ); +} + +/* Scroll position: subtle, always-on cue. */ +.prompt-rail-tick--active .prompt-rail-tick-line { + width: 13px; + opacity: 1; + background: var(--icon-strong-base); +} + +/* Pointer or keyboard target: the loud state, full width. */ +.prompt-rail-tick:hover .prompt-rail-tick-line, +.prompt-rail-tick--open .prompt-rail-tick-line { + width: 100%; + height: 2px; + opacity: 1; + background: var(--icon-strong-base); +} + +.prompt-rail-tick:focus-visible .prompt-rail-tick-line { + width: 100%; + height: 2px; + opacity: 1; + background: var(--icon-strong-base); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--vscode-focusBorder) 50%, transparent); +} + +/* ============================================ + Prompt Rail card + ============================================ */ + +.prompt-rail-card { + position: fixed; + z-index: 1000; + box-sizing: border-box; + width: min(360px, calc(100vw - var(--prompt-rail-width, 16px) - 40px)); + max-height: min(calc(100vh - 24px), var(--prompt-rail-card-height, calc(100vh - 24px))); + overflow-y: auto; + padding: 6px; + border-radius: 12px; + /* Reads as floating glass rather than a panel pasted over the transcript: + the text behind stays faintly perceptible through the blur, so the card + feels like it belongs to the same surface. Falls back to an opaque fill + where backdrop-filter is unavailable. */ + background: color-mix(in srgb, var(--surface-float-base) 78%, transparent); + backdrop-filter: blur(20px) saturate(1.6); + -webkit-backdrop-filter: blur(20px) saturate(1.6); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--text-strong) 7%, transparent), + 0 2px 6px -2px rgba(0, 0, 0, 0.22), + 0 12px 32px -8px rgba(0, 0, 0, 0.4); + animation: prompt-rail-in 0.22s var(--prompt-rail-ease, cubic-bezier(0.22, 1, 0.36, 1)); + transform-origin: left center; +} + +.prompt-rail-card[data-virtualized] { + display: flex; + flex-direction: column; + height: min(calc(100vh - 24px), var(--prompt-rail-card-height)); + overflow: hidden; + background: var(--surface-float-base); +} + +.prompt-rail-card-header { + position: sticky; + top: -6px; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 32px; + padding: 4px 5px 4px 11px; + background: color-mix(in srgb, var(--surface-float-base) 88%, transparent); + backdrop-filter: blur(20px) saturate(1.6); + -webkit-backdrop-filter: blur(20px) saturate(1.6); +} + +.prompt-rail-card-title { + min-width: 0; + overflow: hidden; + color: var(--text-weak); + font-size: var(--kilo-font-size-11); + font-weight: var(--font-weight-medium, 500); + text-overflow: ellipsis; + white-space: nowrap; +} + +.prompt-rail-card-actions { + display: flex; + align-items: center; + flex: none; + gap: 2px; +} + +.prompt-rail-loading { + display: flex; + flex: none; + align-items: center; + justify-content: center; + gap: 8px; + padding: 6px 0 8px; + color: var(--text-weaker); + font-size: var(--kilo-font-size-12); +} + +.prompt-rail-list { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.prompt-rail-list-static { + min-width: 0; +} + +/* A floating popover should not paint an OS scrollbar over its own glass; the + rail already shows where you are in the session. */ +.prompt-rail-card, +.prompt-rail-list { + scrollbar-width: none; +} + +.prompt-rail-card::-webkit-scrollbar, +.prompt-rail-list::-webkit-scrollbar { + display: none; +} + +@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { + .prompt-rail-card { + background: var(--surface-float-base); + } +} + +@keyframes prompt-rail-in { + from { + opacity: 0; + transform: translateX(-6px) scale(0.98); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +.prompt-rail-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + width: 100%; + box-sizing: border-box; + border: none; + background: none; + padding: 9px 11px; + border-radius: 8px; + text-align: start; + font: inherit; + cursor: pointer; + outline: none; + transition: background-color 0.16s var(--prompt-rail-ease, ease); +} + +.prompt-rail-row--hover, +.prompt-rail-row:hover { + background: color-mix(in srgb, var(--text-strong) 8%, transparent); +} + +.prompt-rail-row:active { + background: color-mix(in srgb, var(--text-strong) 12%, transparent); +} + +.prompt-rail-row-prompt { + font-size: var(--kilo-font-size-13); + line-height: var(--line-height-normal); + color: var(--text-strong); + font-weight: var(--font-weight-medium, 500); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + min-width: 0; + width: 100%; +} + +.prompt-rail-row-prompt[data-queued] { + color: var(--text-weak); +} + +.prompt-rail-row-status { + color: var(--text-weaker); + font-weight: 400; +} + +.prompt-rail-row-answer { + font-size: var(--kilo-font-size-12); + line-height: var(--line-height-large, 1.5); + color: var(--text-weak); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + min-width: 0; + width: 100%; +} + +@media (prefers-reduced-motion: reduce) { + .prompt-rail, + .prompt-rail-tick-line, + .prompt-rail-row { + transition: none; + } + .prompt-rail-card { + animation: none; + } +} diff --git a/packages/kilo-vscode/webview-ui/src/styles/question-dock.css b/packages/kilo-vscode/webview-ui/src/styles/question-dock.css index 2d25d339d7..d31af72f2c 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/question-dock.css +++ b/packages/kilo-vscode/webview-ui/src/styles/question-dock.css @@ -29,7 +29,8 @@ align-items: center; justify-content: space-between; gap: 6px; - padding: 6px 8px 4px 12px; + padding-block: 6px 4px; + padding-inline: 12px 8px; cursor: pointer; } @@ -176,7 +177,7 @@ background-color: transparent; border: none; border-radius: 6px; - text-align: left; + text-align: start; width: 100%; cursor: pointer; outline: none; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts index c10943cecc..b59633f748 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -15,6 +15,15 @@ export interface McpConfig { enabled?: boolean } +export type ConfigOrigin = "project" | "global" | "system" | "default" + +export interface ConfigCollectionEntry { + key: string + source: ConfigOrigin +} + +export type ConfigCollections = Record + export interface CommandConfig { template: string description?: string @@ -154,6 +163,7 @@ export interface Config { compaction?: CompactionConfig commit_message?: CommitMessageConfig tools?: Record + web_search?: boolean auto_collapse_reasoning?: boolean experimental?: ExperimentalConfig sandbox?: SandboxConfig diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 30a55b4de1..631ccb0bb0 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -18,7 +18,14 @@ import type { AnacondaDesktopExtensionMessage } from "../../../../src/shared/ana import type { QuestionRequest, SuggestionRequest, TodoItem } from "./questions" import type { ModelSelection, Provider, ProviderAuthState } from "./providers" import type { AgentInfo, AgentRequirementResult, SkillInfo, SlashCommandInfo } from "./agents" -import type { BrowserSettings, Config, FeatureFlags, IndexingStatus, KiloEmbeddingModelCatalog } from "./config" +import type { + BrowserSettings, + Config, + ConfigCollections, + FeatureFlags, + IndexingStatus, + KiloEmbeddingModelCatalog, +} from "./config" import type { WorkStyle, WorkStyleState } from "../../../../src/shared/work-style-presets" import type { KilocodeNotification, ProfileData } from "./profile" import type { @@ -465,6 +472,8 @@ export interface SessionSearchItem { id: string title: string updated: number + /** Name of the worktree the session runs in, when listed across the worktree family. */ + worktreeName?: string } export interface SessionSearchResultMessage { @@ -568,6 +577,7 @@ export interface ConfigLoadedMessage { config: Config globalConfig?: Config projectConfig?: Config + collections?: ConfigCollections settings?: ExtensionSettings features: FeatureFlags } @@ -577,6 +587,7 @@ export interface ConfigUpdatedMessage { config: Config globalConfig?: Config projectConfig?: Config + collections?: ConfigCollections settings?: ExtensionSettings features: FeatureFlags } @@ -733,6 +744,24 @@ export interface AgentManagerTerminalDestinationChangedMessage { destination: TerminalDestination } +/** Provider-owned Run script terminal. Full snapshots replace only this terminal kind. */ +export interface ScriptTerminalView { + terminalId: string + /** null for LOCAL, worktree id otherwise */ + worktreeId: string | null + kind: "run" + title: "Run" + wsUrl: string + state: "running" | "stopping" | "exited" | "failed" + exitCode?: number + font: TerminalFont +} + +export interface AgentManagerScriptTerminalsMessage { + type: "agentManager.scriptTerminals" + terminals: ScriptTerminalView[] +} + export interface AgentManagerRunStatusMessage extends RunStatus { type: "agentManager.runStatus" } @@ -868,6 +897,18 @@ export interface AgentManagerRevertWorktreeFileResultMessage { message: string } +// Agent Manager: Branch picker data for a diff context (extension → webview) +export interface AgentManagerDiffBranchesMessage { + type: "agentManager.diffBranches" + sessionId: string + branches: BranchInfo[] + defaultBranch: string + autoBase?: string + currentBase?: string + isAuto: boolean + currentBranch?: string +} + // Agent Manager: Worktree git stats push (extension → webview) export interface AgentManagerWorktreeStatsMessage { type: "agentManager.worktreeStats" @@ -1237,6 +1278,7 @@ export type ExtensionMessage = | AgentManagerWorktreeDiffLoadingMessage | AgentManagerApplyWorktreeDiffResultMessage | AgentManagerRevertWorktreeFileResultMessage + | AgentManagerDiffBranchesMessage | AgentManagerWorktreeStatsMessage | AgentManagerLocalStatsMessage | AgentManagerPRStatusMessage @@ -1245,6 +1287,7 @@ export type ExtensionMessage = | AgentManagerTerminalClosedMessage | AgentManagerTerminalErrorMessage | AgentManagerTerminalDestinationChangedMessage + | AgentManagerScriptTerminalsMessage // legacy-migration start | MigrationStateMessage | MigrationDataMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts b/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts index 90a5733027..1322349e28 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts @@ -3,7 +3,7 @@ import type { Part, TokenUsage } from "./parts" export type SessionModelUsage = KilocodeSessionModelUsageResponse -export type SessionCloseReason = "completed" | "error" | "interrupted" +export type SessionCloseReason = "completed" | "error" | "interrupted" | "superseded" // Message structure (simplified for webview) export interface Message { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 16fcfe59e5..b966e496b8 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -4,7 +4,7 @@ import type { MessageLoadMode } from "./sessions" import type { PermissionFileDiff } from "./permissions" import type { ModelSelection, ProviderConfig } from "./providers" import type { Config } from "./config" -import type { ModelAllocation, ReviewComment, TerminalPlacement } from "./agent-manager" +import type { ModelAllocation, ReviewComment, TerminalDestination, TerminalPlacement } from "./agent-manager" import type { ReviewMessageData } from "../../../../src/shared/review-comments" import type { WorkStyle, WorkStyleState } from "../../../../src/shared/work-style-presets" import type { AnacondaDesktopWebviewMessage } from "../../../../src/shared/anaconda-desktop-messages" @@ -677,6 +677,7 @@ export interface ConfigureRunScriptRequest { export interface RunScriptRequest { type: "agentManager.runScript" worktreeId: string + destination: TerminalDestination } export interface StopRunScriptRequest { @@ -695,6 +696,12 @@ export interface ShowLocalTerminalRequest { type: "agentManager.showLocalTerminal" } +// Show a terminal rooted at a worktree directory (worktree has no session) +export interface ShowWorktreeTerminalRequest { + type: "agentManager.showWorktreeTerminal" + worktreeId: string +} + // Open a worktree directory in VS Code export interface OpenWorktreeRequest { type: "agentManager.openWorktree" @@ -822,18 +829,21 @@ export interface ImportFromPRRequest { export interface RequestWorktreeDiffMessage { type: "agentManager.requestWorktreeDiff" sessionId: string + scope?: string } export interface RequestWorktreeDiffFileMessage { type: "agentManager.requestWorktreeDiffFile" sessionId: string file: string + scope?: string } // Agent Manager: Start polling for live diff updates (webview → extension) export interface StartDiffWatchMessage { type: "agentManager.startDiffWatch" sessionId: string + scope?: string } // Agent Manager: Stop polling for diff updates (webview → extension) @@ -841,6 +851,21 @@ export interface StopDiffWatchMessage { type: "agentManager.stopDiffWatch" } +// Agent Manager: Request branch picker data for a diff context (webview → extension) +export interface RequestDiffBranchesMessage { + type: "agentManager.requestDiffBranches" + sessionId: string + scope?: string +} + +// Agent Manager: Set or clear the base branch override for a diff context (webview → extension) +export interface SetDiffBaseBranchMessage { + type: "agentManager.setDiffBaseBranch" + sessionId: string + scope?: string + branch?: string +} + // Agent Manager: PR messages (webview → extension) export interface RefreshPRMessage { type: "agentManager.refreshPR" @@ -863,6 +888,7 @@ export interface RevertWorktreeFileMessage { type: "agentManager.revertWorktreeFile" sessionId: string file: string + scope?: string } // Variant persistence (webview → extension) @@ -1336,6 +1362,7 @@ export type WebviewMessage = | StopRunScriptRequest | ShowTerminalRequest | ShowLocalTerminalRequest + | ShowWorktreeTerminalRequest | OpenWorktreeRequest | CopyToClipboardRequest | ShowExistingLocalTerminalRequest @@ -1358,6 +1385,8 @@ export type WebviewMessage = | RequestWorktreeDiffFileMessage | StartDiffWatchMessage | StopDiffWatchMessage + | RequestDiffBranchesMessage + | SetDiffBaseBranchMessage | RefreshPRMessage | OpenPRMessage // legacy-migration start diff --git a/packages/kilo-vscode/webview-ui/src/utils/tab-widths.ts b/packages/kilo-vscode/webview-ui/src/utils/tab-widths.ts index 9508905558..0728b4ee34 100644 --- a/packages/kilo-vscode/webview-ui/src/utils/tab-widths.ts +++ b/packages/kilo-vscode/webview-ui/src/utils/tab-widths.ts @@ -1,5 +1,11 @@ -export function setTabWidths(frozen: boolean, root: ParentNode = document) { - const list = root.querySelector(".am-tab-list") +/** + * Pin tab widths while a strip mutates, so closing a tab does not reflow + * the remaining ones out from under the cursor. `selector` picks the + * strip: the agent-manager tab bar by default, or the side terminal + * strip, which mirrors the same chrome. + */ +export function setTabWidths(frozen: boolean, root: ParentNode = document, selector = ".am-tab-list") { + const list = root.querySelector(selector) if (!(list instanceof HTMLElement)) return list.toggleAttribute("data-tab-widths-frozen", frozen) diff --git a/packages/kilo-web-ui/package.json b/packages/kilo-web-ui/package.json index 94abbda32a..a831a4ea50 100644 --- a/packages/kilo-web-ui/package.json +++ b/packages/kilo-web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-web-ui", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "exports": { diff --git a/packages/llm/package.json b/packages/llm/package.json index c5a632ffac..64eaf70f32 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.16", + "version": "7.4.17", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 707c839036..13eef058d4 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,64 @@ # @kilocode/cli +## 7.4.17 + +### Patch Changes + +- [#12544](https://github.com/Kilo-Org/kilocode/pull/12544) [`b8d83fb`](https://github.com/Kilo-Org/kilocode/commit/b8d83fb537040afd6632a6d893acc412395832e4) - Support adaptive thinking levels for Claude Opus and Sonnet 5 and later. + +- [#12587](https://github.com/Kilo-Org/kilocode/pull/12587) [`16f8e7e`](https://github.com/Kilo-Org/kilocode/commit/16f8e7ef7fbd47755395539e7df54af3baae0c63) - Keep conversations and workspace files unchanged when a checkpoint cannot be fully restored. + +- [#12444](https://github.com/Kilo-Org/kilocode/pull/12444) [`92076e7`](https://github.com/Kilo-Org/kilocode/commit/92076e7071084b4bf2ce87d90eb6d45a502c836c) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Add a `/auto-approve` slash command in the TUI for toggling auto-approve mode, with aliases `/autoapprove`, `/approve-all`, and `/approveall`. The command dispatches the existing palette entry, so behavior matches the Ctrl+P "Enable/Disable auto-approve mode" toggle. + +- [#11986](https://github.com/Kilo-Org/kilocode/pull/11986) [`0abe474`](https://github.com/Kilo-Org/kilocode/commit/0abe474b6d5c5d482ce950abfd9033bf0c5af3b4) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Let the Context section in the TUI session sidebar collapse and expand on header click, matching the existing collapsible pattern used by Token Usage, Models, and Terminal Bench 2.0. When collapsed, the header shows a one-line summary of percent used and total cost. + +- [#12601](https://github.com/Kilo-Org/kilocode/pull/12601) [`dab2e79`](https://github.com/Kilo-Org/kilocode/commit/dab2e79d6ecc24acfd8737a10dfdf8ef02765b30) - Exclude GPT-5.6 from models available through ChatGPT subscriptions while retaining access to variants such as GPT-5.6 Sol. + +- [#12592](https://github.com/Kilo-Org/kilocode/pull/12592) [`8c88048`](https://github.com/Kilo-Org/kilocode/commit/8c880487818728f41ffc3087d27d6ce6b4591b53) Thanks [@noobezlol](https://github.com/noobezlol)! - Keep Nix builds on the Bun version required by the repository. + +- [#12545](https://github.com/Kilo-Org/kilocode/pull/12545) [`b2735bf`](https://github.com/Kilo-Org/kilocode/commit/b2735bfbc9df170274a12ec4786106dacb61090f) - 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. + +- [#12470](https://github.com/Kilo-Org/kilocode/pull/12470) [`c0ebf98`](https://github.com/Kilo-Org/kilocode/commit/c0ebf987789ab6fa070106219ebc8c46cd0105af) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Route the websearch tool's Exa requests through the Kilo proxy when signed into Kilo. The MCP-Exa transport is preserved as a fallback for users who set `EXA_API_KEY` or are not authenticated. A new `KILO_WEBSEARCH_PROVIDER=kilo-exa` env override forces the Kilo proxy path. Results are capped at 10. + +- [#12460](https://github.com/Kilo-Org/kilocode/pull/12460) [`51d8031`](https://github.com/Kilo-Org/kilocode/commit/51d8031c9997bd5478bcde715562169f732d04d4) - 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. + +- [#12585](https://github.com/Kilo-Org/kilocode/pull/12585) [`a0a760e`](https://github.com/Kilo-Org/kilocode/commit/a0a760e00e915a800125f03db7e08381ddc63e2a) - 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. + +- [#12505](https://github.com/Kilo-Org/kilocode/pull/12505) [`bcf8b8b`](https://github.com/Kilo-Org/kilocode/commit/bcf8b8b9a852969ee842783e33a7fe32f9b3c3b8) - Emit each agent event once from `kilo run --format json`. + +- [#12593](https://github.com/Kilo-Org/kilocode/pull/12593) [`160b066`](https://github.com/Kilo-Org/kilocode/commit/160b06661acc5f04b21221ab6578c468325f64c5) - Prevent the VS Code backend from eagerly starting native file watchers for every Agent Manager worktree. + +- [#12583](https://github.com/Kilo-Org/kilocode/pull/12583) [`1310c12`](https://github.com/Kilo-Org/kilocode/commit/1310c1200ab613b316f27fe4fd59e23e262df02f) Thanks [@noobezlol](https://github.com/noobezlol)! - Keep Windows snapshot diffs parseable and preserve valid files when a stored patch is malformed. + +- [#12588](https://github.com/Kilo-Org/kilocode/pull/12588) [`deddf00`](https://github.com/Kilo-Org/kilocode/commit/deddf0012fe36c5bb8072f4378abd444fbd134fe) - 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. + +- [#12514](https://github.com/Kilo-Org/kilocode/pull/12514) [`a33493e`](https://github.com/Kilo-Org/kilocode/commit/a33493e7222857a5c9f5e2c09a17312781567b3f) - Stabilize cross-platform CLI subprocess tests under constrained CI runners + +- [#12639](https://github.com/Kilo-Org/kilocode/pull/12639) [`8a47d8b`](https://github.com/Kilo-Org/kilocode/commit/8a47d8b78885fa8fd14c73b3aecdb57e1fc96c9c) - Stop flashing a "Turn interrupted" warning when a follow-up message is queued while the assistant is still working. The running turn now closes with a dedicated "superseded" reason instead of "interrupted" when it hands off to the queued prompt, so the premature-stop warning only appears for real interruptions. + ## 7.4.16 ### Minor Changes diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 7d8af56de6..05ad81b5f5 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.16", + "version": "7.4.17", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/opencode/script/kilocode/npm-publish.ts b/packages/opencode/script/kilocode/npm-publish.ts index 2744ff675e..8f07ed2e75 100644 --- a/packages/opencode/script/kilocode/npm-publish.ts +++ b/packages/opencode/script/kilocode/npm-publish.ts @@ -3,6 +3,10 @@ export namespace NpmPublish { const base = 10_000 const jitter = 5_000 + export function aliases(channel: string) { + return channel === "latest" ? ["rc"] : [] + } + export async function retry(input: { name: string version: string diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index bd94c7c0e3..cfc99cacab 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -16,18 +16,21 @@ async function publish(dir: string, name: string, version: string) { // GitHub artifact downloads can drop the executable bit, and Docker uses the // unpacked dist binaries directly rather than the published tarball. if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir) - if (await published(name, version)) { - console.log(`already published ${name}@${version}`) - return - } - await $`bun pm pack`.cwd(dir) // kilocode_change start - await NpmPublish.retry({ - name, - version, - run: () => $`npm publish *.tgz --access public --tag ${Script.channel} --provenance`.cwd(dir), - exists: () => published(name, version), - }) + const exists = await published(name, version) + if (exists) { + console.log(`already published ${name}@${version}`) + } + if (!exists) { + await $`bun pm pack`.cwd(dir) + await NpmPublish.retry({ + name, + version, + run: () => $`npm publish *.tgz --access public --tag ${Script.channel} --provenance`.cwd(dir), + exists: () => published(name, version), + }) + } + for (const tag of NpmPublish.aliases(Script.channel)) await $`npm dist-tag add ${name}@${version} ${tag}` // kilocode_change end } diff --git a/packages/opencode/src/acp/permission.ts b/packages/opencode/src/acp/permission.ts index 06b394238d..edca6c903c 100644 --- a/packages/opencode/src/acp/permission.ts +++ b/packages/opencode/src/acp/permission.ts @@ -12,6 +12,7 @@ import { exists, readText } from "@/util/filesystem" import type { ACPSession } from "./session" import { pendingToolCall, toLocations, type ToolInput } from "./tool" import { Effect } from "effect" +import { SkillShellPrompt } from "@/kilocode/acp/permission" // kilocode_change type PermissionEvent = Extract type Reply = "once" | "always" | "reject" @@ -58,6 +59,7 @@ export class Handler { return } + const skillShell = SkillShellPrompt.is(permission.metadata) // kilocode_change - skill batches list commands and never persist const result = await this.input.connection .requestPermission({ sessionId: permission.sessionID, @@ -65,8 +67,9 @@ export class Handler { toolCallId: permission.tool?.callID ?? permission.id, toolName: permission.permission, input: permission.metadata, + title: skillShell ? SkillShellPrompt.title : undefined, // kilocode_change }), - options: permissionOptions, + options: skillShell ? SkillShellPrompt.options : permissionOptions, // kilocode_change }) .catch(async () => { await this.reply(permission.id, "reject", session.cwd) @@ -85,14 +88,15 @@ export class Handler { await this.writeProposedEdit(session.id, permission.metadata).catch(() => {}) } - await this.reply(permission.id, reply, session.cwd) + await this.reply(permission.id, reply, session.cwd, true) // kilocode_change - human selected via requestPermission } - private async reply(requestID: string, reply: Reply, directory: string) { + private async reply(requestID: string, reply: Reply, directory: string, interactive = false) { // kilocode_change - interactive param await this.input.sdk.permission.reply({ requestID, reply, directory, + interactive, // kilocode_change }) } @@ -119,13 +123,14 @@ async function permissionToolCall(input: { readonly toolCallId: string readonly toolName: string readonly input: ToolInput + readonly title?: string // kilocode_change - skill-shell batches title the prompt themselves }): Promise { const toolCall = pendingToolCall({ toolCallId: input.toolCallId, toolName: input.toolName, state: { input: input.input, - title: permissionTitle(input.toolName, input.input), + title: input.title ?? permissionTitle(input.toolName, input.input), // kilocode_change }, }) const content = await permissionContent(input.toolName, input.input) diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index a5fba1146b..0fe0e687c1 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -1,10 +1,12 @@ import { cmd } from "./cmd" import { UI } from "@/cli/ui" -import { createKiloClient } from "@kilocode/sdk/v2" // kilocode_change -import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change import { errorMessage } from "@opencode-ai/tui/util/error" import { validateSession } from "../tui/validate-session" import { ServerAuth } from "@/server/auth" +// kilocode_change start - Kilo implementations (sdk client, cloud-session) are +// dynamically imported inside the handler so other CLI commands don't pay their +// module cost at startup. +// kilocode_change end export const AttachCommand = cmd({ command: "attach ", @@ -75,6 +77,7 @@ export const AttachCommand = cmd({ const noReplay = args.replay === false || args.noReplay === true // kilocode_change start + const { importCloudSession, validateCloudFork } = await import("@/kilocode/cloud-session") const cloudForkError = validateCloudFork(args) if (cloudForkError) { UI.error(cloudForkError) @@ -131,6 +134,7 @@ export const AttachCommand = cmd({ // kilocode_change start - import cloud session before TUI renders if (args.cloudFork && args.session) { UI.println("Importing session from cloud...") + const { createKiloClient } = await import("@kilocode/sdk/v2") const sdk = createKiloClient({ baseUrl: args.url, directory, diff --git a/packages/opencode/src/cli/cmd/config.ts b/packages/opencode/src/cli/cmd/config.ts index 5ff58ff2ef..3cade4a388 100644 --- a/packages/opencode/src/cli/cmd/config.ts +++ b/packages/opencode/src/cli/cmd/config.ts @@ -1,11 +1,11 @@ // kilocode_change - new file import { EOL } from "os" -import { Config } from "../../config/config" -import { AppRuntime } from "../../effect/app-runtime" -import { bootstrap } from "../bootstrap" import { cmd } from "./cmd" import { UI } from "../ui" +// Keep the top-level import graph light: this module is registered eagerly at CLI +// startup, so implementation dependencies are imported inside the handler (same +// deferral pattern as upstream opencode#30453). export const ConfigCommand = cmd({ command: "config", describe: "configuration tools", @@ -15,6 +15,9 @@ export const ConfigCommand = cmd({ command: "check", describe: "check configuration for warnings and errors", async handler() { + const { bootstrap } = await import("../bootstrap") + const { AppRuntime } = await import("../../effect/app-runtime") + const { Config } = await import("../../config/config") await bootstrap(process.cwd(), async () => { const list = await AppRuntime.runPromise(Config.Service.use((svc) => svc.warnings())) if (list.length === 0) { diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index d852c9ec92..5e085a7ef1 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -18,7 +18,7 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "path" import { Global } from "@opencode-ai/core/global" import { modify, applyEdits } from "jsonc-parser" -import { KilocodeMcpConfig } from "@/kilocode/cli/cmd/mcp" // kilocode_change +// kilocode_change - KilocodeMcpConfig is dynamically imported in addMcpToConfig to keep startup fast import { Filesystem } from "@/util/filesystem" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change @@ -436,7 +436,10 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCPV1.Info, configP const edits = modify(text, ["mcp", name], mcpConfig, { formattingOptions: { tabSize: 2, insertSpaces: true }, }) - const result = KilocodeMcpConfig.format(configPath, applyEdits(text, edits)) // kilocode_change + // kilocode_change start - lazy import keeps the CLI startup graph light + const { KilocodeMcpConfig } = await import("@/kilocode/cli/cmd/mcp") + const result = KilocodeMcpConfig.format(configPath, applyEdits(text, edits)) + // kilocode_change end await Filesystem.write(configPath, result) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 6575ac9be7..e8b2c4bae4 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -17,7 +17,7 @@ import { Process } from "@/util/process" import { errorMessage } from "@/util/error" import { text } from "node:stream/consumers" import { Effect, Option } from "effect" -import { remove as removeAuth } from "@/kilocode/auth/remove" // kilocode_change +// kilocode_change - @/kilocode/auth/remove is dynamically imported in the logout handler to keep startup fast type PluginAuth = NonNullable @@ -538,7 +538,10 @@ export const ProvidersLogoutCommand = effectCmd({ }), ) if (!provider) return yield* fail(`Unknown configured provider "${args.provider}"`) - yield* removeAuth(provider) // kilocode_change + // kilocode_change start - lazy import keeps the CLI startup graph light + const { remove: removeAuth } = yield* Effect.promise(() => import("@/kilocode/auth/remove")) + yield* removeAuth(provider) + // kilocode_change end yield* Prompt.outro("Logout successful") }), }) diff --git a/packages/opencode/src/cli/cmd/remote.ts b/packages/opencode/src/cli/cmd/remote.ts index 1cc7ed520b..5198a00e4f 100644 --- a/packages/opencode/src/cli/cmd/remote.ts +++ b/packages/opencode/src/cli/cmd/remote.ts @@ -1,20 +1,23 @@ // kilocode_change - new file import { cmd } from "./cmd" -import { bootstrap } from "../bootstrap" -import { KiloSessions } from "@/kilo-sessions/kilo-sessions" import { buildInstanceAdvertisement } from "@/kilo-sessions/instance-advertisement" -import { context } from "@/project/instance-context" -import { InstanceRuntime } from "@/project/instance-runtime" -import { Instance } from "@/kilocode/instance" // Re-export so existing unit tests that import from this module keep working. export { buildInstanceAdvertisement } +// Keep the top-level import graph light: this module is registered eagerly at CLI +// startup, so implementation dependencies are imported inside the handler (same +// deferral pattern as upstream opencode#30453). export const RemoteCommand = cmd({ command: "remote", describe: "enable remote connection for real-time session relay", builder: (yargs) => yargs, handler: async () => { + const { bootstrap } = await import("../bootstrap") + const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions") + const { context } = await import("@/project/instance-context") + const { InstanceRuntime } = await import("@/project/instance-runtime") + const { Instance } = await import("@/kilocode/instance") await bootstrap(process.cwd(), async () => { // kilocode_change - K1 W1: advertise this instance on the relay // heartbeat so the cloud side can show it as a spawn-capable instance. diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 827eff87a2..37034a38e4 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -22,18 +22,15 @@ import { open } from "node:fs/promises" import { Effect } from "effect" import { UI } from "../ui" import { effectCmd } from "../effect-cmd" -import { buildRunMessage } from "@/kilocode/cli/cmd/run-message" // kilocode_change import { EOL } from "os" import { Filesystem } from "@/util/filesystem" -import { createKiloClient, type KiloClient, type Session, type ToolPart } from "@kilocode/sdk/v2" -import { Agent } from "@/agent/agent" -import { RuntimeFlags } from "@/effect/runtime-flags" +import type { KiloClient, Session, ToolPart } from "@kilocode/sdk/v2" import { FormatError, FormatUnknownError } from "../error" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" -import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change -import { KiloRunAuto } from "@/kilocode/cli/run-auto" // kilocode_change -import { KiloHeadless } from "@/kilocode/permission/headless" // kilocode_change -import { KiloRun, KiloRunDaemon } from "@/kilocode/cli/cmd/run" // kilocode_change +// kilocode_change start - Kilo implementations (createKiloClient, run-message, +// cloud-session, run-auto, headless, KiloRun) are dynamically imported inside the +// handler so other CLI commands don't pay their module cost at startup. +// kilocode_change end type ModelInput = Parameters[0]["model"] @@ -281,6 +278,14 @@ export const RunCommand = effectCmd({ const { RuntimeFlags } = yield* Effect.promise(() => import("@/effect/runtime-flags")) const { InstanceRef } = yield* Effect.promise(() => import("@/effect/instance-ref")) const { ServerAuth } = yield* Effect.promise(() => import("@/server/auth")) + // kilocode_change start - lazy Kilo implementations (see top-of-file note) + const { createKiloClient } = yield* Effect.promise(() => import("@kilocode/sdk/v2")) + const { buildRunMessage } = yield* Effect.promise(() => import("@/kilocode/cli/cmd/run-message")) + const { importCloudSession, validateCloudFork } = yield* Effect.promise(() => import("@/kilocode/cloud-session")) + const { KiloRunAuto } = yield* Effect.promise(() => import("@/kilocode/cli/run-auto")) + const { KiloHeadless } = yield* Effect.promise(() => import("@/kilocode/permission/headless")) + const { KiloRun, KiloRunDaemon } = yield* Effect.promise(() => import("@/kilocode/cli/cmd/run")) + // kilocode_change end const agentSvc = yield* Agent.Service const flags = yield* RuntimeFlags.Service const localInstance = yield* InstanceRef @@ -784,6 +789,7 @@ export const RunCommand = effectCmd({ const MAX_RETRIES = 3 // kilocode_change let retries = 0 // kilocode_change let error: string | undefined + let autoRejected = false // kilocode_change - plain headless auto-reject must fail the run // kilocode_change start - revert to upstream: consume native events without normalizing sync copies for await (const event of events.stream) { @@ -874,8 +880,10 @@ export const RunCommand = effectCmd({ err = String(props.error.data.message) } error = error ? error + EOL + err : err - if (emit("error", { error: props.error })) continue + // kilocode_change start - stderr first so --format json still surfaces the diagnostic UI.error(err) + emit("error", { error: props.error }) + // kilocode_change end } // kilocode_change start - reset retry budget only after resumed work becomes busy @@ -898,6 +906,13 @@ export const RunCommand = effectCmd({ if (event.type === "permission.asked") { const permission = event.properties + // kilocode_change start - skill shell batches need an interactive human decision. The server ignores + // non-interactive approvals, so headless runs must reject explicitly rather than leave them pending. + if (permission.metadata?.["skillShell"] === true) { + await client.permission.reply({ requestID: permission.id, reply: "reject" }) + continue + } + // kilocode_change end // kilocode_change start - approve root and tracked Task child permissions in auto mode if (args.auto) { if (!KiloRunAuto.allowed(tracked, permission.sessionID)) continue @@ -926,6 +941,7 @@ export const RunCommand = effectCmd({ UI.Style.TEXT_NORMAL + `subagent permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, ) + autoRejected = true // kilocode_change await client.permission.reply({ requestID: permission.id, reply: "reject", @@ -947,6 +963,7 @@ export const RunCommand = effectCmd({ UI.Style.TEXT_NORMAL + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, ) + autoRejected = true // kilocode_change await client.permission.reply({ requestID: permission.id, reply: "reject", @@ -973,6 +990,14 @@ export const RunCommand = effectCmd({ } // kilocode_change end } + // kilocode_change start - idle must not clear an auto-rejected headless run + if (autoRejected) { + const msg = "run ended with an auto-rejected permission; pass --auto for autonomous use" + error = error ? error + EOL + msg : msg + UI.error(msg) + emit("error", { error: msg }) + } + // kilocode_change end return error } const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root) diff --git a/packages/opencode/src/cli/cmd/run/footer.permission.tsx b/packages/opencode/src/cli/cmd/run/footer.permission.tsx index 0eb412a03b..ab3ea3cc4f 100644 --- a/packages/opencode/src/cli/cmd/run/footer.permission.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.permission.tsx @@ -141,7 +141,8 @@ export function RunPermissionBody(props: { const info = createMemo(() => permissionInfo(props.request)) const ft = createMemo(() => toolFiletype(info().file)) const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow) - const opts = createMemo(() => permissionOptions(state().stage)) + const skillShell = createMemo(() => props.request.metadata?.["skillShell"] === true) // kilocode_change + const opts = createMemo(() => permissionOptions(state().stage, skillShell())) // kilocode_change - skillShell-aware options const busy = createMemo(() => state().submitting) const title = createMemo(() => { if (state().stage === "always") { @@ -165,7 +166,7 @@ export function RunPermissionBody(props: { }) const shift = (dir: -1 | 1) => { - setState((prev) => permissionShift(prev, dir)) + setState((prev) => permissionShift(prev, dir, skillShell())) // kilocode_change - skillShell-aware options } const submit = async (next: PermissionReply) => { diff --git a/packages/opencode/src/cli/cmd/run/permission.shared.ts b/packages/opencode/src/cli/cmd/run/permission.shared.ts index 2db4193696..8b0f33a241 100644 --- a/packages/opencode/src/cli/cmd/run/permission.shared.ts +++ b/packages/opencode/src/cli/cmd/run/permission.shared.ts @@ -77,9 +77,11 @@ export function createPermissionBodyState(requestID: string): PermissionBodyStat } } -export function permissionOptions(stage: PermissionStage): PermissionOption[] { +export function permissionOptions(stage: PermissionStage, skillShell?: boolean): PermissionOption[] { // kilocode_change - skillShell param if (stage === "permission") { - return ["once", "always", "reject"] + // kilocode_change start - skill-shell batches are never persisted, so no "Allow always" + return skillShell ? ["once", "reject"] : ["once", "always", "reject"] + // kilocode_change end } if (stage === "always") { @@ -146,12 +148,13 @@ export function permissionReply(requestID: string, reply: PermissionReply["reply return { requestID, reply, + interactive: true, // kilocode_change - footer replies are human-driven; the server refuses non-interactive skill-shell approvals ...(message && message.trim() ? { message: message.trim() } : {}), } } -export function permissionShift(state: PermissionBodyState, dir: -1 | 1): PermissionBodyState { - const list = permissionOptions(state.stage) +export function permissionShift(state: PermissionBodyState, dir: -1 | 1, skillShell?: boolean): PermissionBodyState { // kilocode_change - skillShell param + const list = permissionOptions(state.stage, skillShell) // kilocode_change - skillShell-aware options if (list.length === 0) { return state } diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index cbb65e4e97..f6200bc2bb 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -2,9 +2,6 @@ import { Effect } from "effect" import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" -import { InstanceRuntime } from "../../project/instance-runtime" // kilocode_change -import { startParentWatchdog } from "../../kilocode/parent-watchdog" // kilocode_change -import { KiloSessions } from "@/kilo-sessions/kilo-sessions" // kilocode_change export const ServeCommand = effectCmd({ command: "serve", @@ -31,6 +28,9 @@ export const ServeCommand = effectCmd({ // kilocode_change start - graceful signal shutdown // yield* Effect.never + const { InstanceRuntime } = yield* Effect.promise(() => import("../../project/instance-runtime")) + const { startParentWatchdog } = yield* Effect.promise(() => import("../../kilocode/parent-watchdog")) + const { KiloSessions } = yield* Effect.promise(() => import("@/kilo-sessions/kilo-sessions")) yield* Effect.promise( () => new Promise((resolve) => { diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 3d9853e288..f69b16116b 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -11,11 +11,8 @@ import { withNetworkOptions, resolveNetworkOptionsNoConfig, hasArg } from "@/cli import { Filesystem } from "@/util/filesystem" import type { GlobalEvent } from "@kilocode/sdk/v2" import type { EventSource } from "@opencode-ai/tui/context/sdk" -import { importCloudSession, localSessionID, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change -import { createKiloClient } from "@kilocode/sdk/v2" // kilocode_change import { writeHeapSnapshot } from "v8" -import { KiloTuiThreadDaemon, type StartInput } from "@/kilocode/cli/cmd/tui/thread" // kilocode_change -import { preload } from "@/kilocode/cli/cmd/tui" // kilocode_change +import type { StartInput } from "@/kilocode/cli/cmd/tui/thread" // kilocode_change - runtime imports deferred into handlers import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32" import { validateSession } from "../tui/validate-session" // kilocode_change start - correlate the TUI worker with its parent process @@ -26,7 +23,7 @@ import { sanitizedProcessEnv, } from "@opencode-ai/core/util/opencode-process" // kilocode_change end -import { createParentRemoteExitBridge, type RemoteExitBridgeClient } from "@/kilocode/cli/cmd/tui/remote-exit-bridge" // kilocode_change +import type { RemoteExitBridgeClient } from "@/kilocode/cli/cmd/tui/remote-exit-bridge" // kilocode_change - runtime import deferred import type { Exit } from "@opencode-ai/tui/context/exit" // kilocode_change declare global { @@ -46,6 +43,7 @@ export async function runEmbeddedRemoteExitBridge(input: { done: Promise timeoutMs?: number }) { + const { createParentRemoteExitBridge } = await import("@/kilocode/cli/cmd/tui/remote-exit-bridge") const timeoutMs = input.timeoutMs ?? 5_000 const bridge = createParentRemoteExitBridge(input.client, input.exit) let ready = false @@ -259,6 +257,12 @@ export const TuiThreadCommand = cmd({ return } + // kilocode_change start - lazy Kilo implementations so other CLI commands + // don't pay their module cost at startup + const { importCloudSession, localSessionID, validateCloudFork } = await import("@/kilocode/cloud-session") + const { KiloTuiThreadDaemon } = await import("@/kilocode/cli/cmd/tui/thread") + const { preload } = await import("@/kilocode/cli/cmd/tui") + // kilocode_change end const unguard = win32InstallCtrlCGuard() const shutdown = { pending: undefined as Promise | undefined, @@ -285,9 +289,8 @@ export const TuiThreadCommand = cmd({ const next = resolveThreadDirectory(args.project) const file = await target() // kilocode_change start - const preloads = preload( - typeof KILO_WORKER_PATH !== "undefined", - () => import.meta.resolve("@opentui/solid/preload"), + const preloads = preload(typeof KILO_WORKER_PATH !== "undefined", () => + import.meta.resolve("@opentui/solid/preload"), ) // kilocode_change end try { @@ -441,6 +444,7 @@ export const TuiThreadCommand = cmd({ // kilocode_change start - import cloud session before TUI renders if (args.cloudFork && args.session) { UI.println("Importing session from cloud...") + const { createKiloClient } = await import("@kilocode/sdk/v2") const sdk = createKiloClient({ baseUrl: transport.url, fetch: transport.fetch, diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 9b37ddf09c..36df40c8c9 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -3,7 +3,6 @@ import { UI } from "../ui" import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" -import { InstanceRuntime } from "../../project/instance-runtime" // kilocode_change import open from "open" export const WebCommand = effectCmd({ @@ -33,17 +32,14 @@ export const WebCommand = effectCmd({ } if (opts.mdns) { - UI.println( - UI.Style.TEXT_INFO_BOLD + " mDNS: ", - UI.Style.TEXT_NORMAL, - `${opts.mdnsDomain}:${server.port}`, - ) + UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, `${opts.mdnsDomain}:${server.port}`) } open(urls.local).catch(() => {}) // kilocode_change end // kilocode_change start - graceful signal shutdown + const { InstanceRuntime } = yield* Effect.promise(() => import("../../project/instance-runtime")) yield* Effect.promise( () => new Promise((resolve) => { diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index f395135b80..1ae51367f4 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -25,6 +25,7 @@ export const Info = Schema.Struct({ agent: Schema.optional(Schema.String), model: Schema.optional(Schema.String), source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])), + trusted: Schema.optional(Schema.Boolean), // kilocode_change - skill-sourced templates only run `!`cmd`` shell when trusted // Some command templates are lazy promises from MCP prompt resolution. template: Schema.Unknown, subtask: Schema.optional(Schema.Boolean), @@ -59,6 +60,7 @@ function fromSkill(item: Skill.Info, dir?: string): Info { name: item.name, description: item.description, source: "skill", + trusted: item.trusted === true, get template() { if (!dir) return item.content return [ diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ddd3cabc4f..a470b7160a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -207,6 +207,13 @@ function globalConfigFile() { function patchJsonc(input: string, patch: unknown, path: string[] = []): string { if (!isRecord(patch)) { + // kilocode_change start - jsonc-parser throws when deleting a path whose + // parent does not exist in the document; absent keys are already "unset" + if (patch === null) { + const tree = parseTree(input) + if (!tree || !findNodeAtLocation(tree, path)) return input + } + // kilocode_change end const edits = modify(input, path, patch === null ? undefined : patch, { // kilocode_change formattingOptions: { @@ -1015,20 +1022,28 @@ const layer = Layer.effect( Effect.gen(function* () { const before = (yield* readConfigFile(file)) ?? "{}" const patch = writableGlobal(config) + // Reads merge every global config file, so delete sentinels must be + // removed from all of them, not just the primary write target. + const propagated = yield* KilocodeConfig.propagateUnset({ + fs, + files: KilocodeConfig.GLOBAL_CONFIG_FILES.map((name) => path.join(Global.Path.config, name)), + exclude: file, + patch, + }) if (!file.endsWith(".jsonc")) { const existing = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(before, file), file) const next = KilocodeConfig.mergeConfig(writable(existing), patch) const serialized = JSON.stringify(next, null, 2) - const changed = serialized !== before - if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie) + const changed = serialized !== before || propagated + if (serialized !== before) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie) return { next, changed } } const updated = patchJsonc(before, patch) const next = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(updated, file), file) - const changed = updated !== before - if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) + const changed = updated !== before || propagated + if (updated !== before) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) return { next, changed } }), `config:global:${path.resolve(Global.Path.config)}`, diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index ae80f16d1a..33d3be008c 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -20,6 +20,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime disableChannelDb: bool("KILO_DISABLE_CHANNEL_DB"), // kilocode_change disableEmbeddedWebUi: bool("KILO_DISABLE_EMBEDDED_WEB_UI"), disableExternalSkills: bool("KILO_DISABLE_EXTERNAL_SKILLS"), + disableSkillShell: bool("KILO_DISABLE_SKILL_SHELL"), // kilocode_change - disable shell injection in skill bodies disableLspDownload: bool("KILO_DISABLE_LSP_DOWNLOAD"), skipMigrations: bool("KILO_SKIP_MIGRATIONS"), // kilocode_change disableClaudeCodePrompt: Config.all({ diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 8af252e6dd..42a575cd80 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -38,6 +38,9 @@ const PermissionData = z.object({ requestID: z.string(), reply: z.enum(["once", "always", "reject"]), message: z.string().optional(), + // Set by a remote human client; threads through to permission.reply so the server + // accepts a human approval of a skill-shell batch (non-interactive ones are refused). + interactive: z.boolean().optional(), }) const SuggestionData = z.object({ diff --git a/packages/opencode/src/kilocode/acp/permission.ts b/packages/opencode/src/kilocode/acp/permission.ts new file mode 100644 index 0000000000..7271c72ddb --- /dev/null +++ b/packages/opencode/src/kilocode/acp/permission.ts @@ -0,0 +1,16 @@ +import type { PermissionOption } from "@agentclientprotocol/sdk" + +// Skill-shell batches list their commands in the prompt and are never persisted, so the ACP +// prompt offers only Allow / Reject (no "Always allow") and a fixed title. +const options: PermissionOption[] = [ + { optionId: "once", kind: "allow_once", name: "Allow" }, + { optionId: "reject", kind: "reject_once", name: "Reject" }, +] + +export const SkillShellPrompt = { + is(metadata: unknown) { + return (metadata as { skillShell?: unknown })?.skillShell === true + }, + options, + title: "Run skill shell commands", +} diff --git a/packages/opencode/src/kilocode/background-process/windows-job.ts b/packages/opencode/src/kilocode/background-process/windows-job.ts deleted file mode 100644 index 85d05b38e6..0000000000 --- a/packages/opencode/src/kilocode/background-process/windows-job.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { dlopen, ptr } from "bun:ffi" - -export namespace WindowsJob { - const LIMITS = 9 - const MEMBERS = 3 - const KILL_ON_CLOSE = 0x00002000 - const PROCESS_TERMINATE = 0x0001 - const PROCESS_SET_QUOTA = 0x0100 - const MORE_DATA = 234 - - function kernel() { - return dlopen("kernel32.dll", { - CreateJobObjectW: { args: ["ptr", "ptr"], returns: "u64" }, - SetInformationJobObject: { args: ["u64", "u32", "ptr", "u32"], returns: "i32" }, - OpenProcess: { args: ["u32", "i32", "u32"], returns: "u64" }, - AssignProcessToJobObject: { args: ["u64", "u64"], returns: "i32" }, - QueryInformationJobObject: { args: ["u64", "u32", "ptr", "u32", "ptr"], returns: "i32" }, - TerminateJobObject: { args: ["u64", "u32"], returns: "i32" }, - CloseHandle: { args: ["u64"], returns: "i32" }, - GetLastError: { args: [], returns: "u32" }, - }) - } - - export function create() { - const lib = (() => { - try { - return kernel() - } catch { - return undefined - } - })() - if (!lib) return - const handle = lib.symbols.CreateJobObjectW(null, null) - if (handle === 0n) { - const code = lib.symbols.GetLastError() - lib.close() - throw new Error(`CreateJobObjectW failed with Windows error ${code}`) - } - const limits = new Uint8Array(144) - new DataView(limits.buffer).setUint32(16, KILL_ON_CLOSE, true) - if (lib.symbols.SetInformationJobObject(handle, LIMITS, ptr(limits), limits.byteLength) === 0) { - const code = lib.symbols.GetLastError() - lib.symbols.CloseHandle(handle) - lib.close() - throw new Error(`SetInformationJobObject failed with Windows error ${code}`) - } - let closed = false - return { - assign(pid: number) { - const proc = lib.symbols.OpenProcess(PROCESS_TERMINATE | PROCESS_SET_QUOTA, 0, pid) - if (proc === 0n) throw new Error(`OpenProcess failed with Windows error ${lib.symbols.GetLastError()}`) - const assigned = lib.symbols.AssignProcessToJobObject(handle, proc) - const code = assigned === 0 ? lib.symbols.GetLastError() : 0 - lib.symbols.CloseHandle(proc) - if (assigned === 0) throw new Error(`AssignProcessToJobObject failed with Windows error ${code}`) - }, - members() { - let size = 4 * 1024 - while (true) { - const info = new Uint8Array(size) - const ok = lib.symbols.QueryInformationJobObject(handle, MEMBERS, ptr(info), info.byteLength, null) - const code = ok === 0 ? lib.symbols.GetLastError() : 0 - const view = new DataView(info.buffer) - const assigned = view.getUint32(0, true) - const count = view.getUint32(4, true) - if (ok !== 0 && count === assigned) { - return Array.from({ length: count }, (_, index) => Number(view.getBigUint64(8 + index * 8, true))) - } - if (ok === 0 && code !== MORE_DATA) { - throw new Error(`QueryInformationJobObject failed with Windows error ${code}`) - } - size = Math.max(size * 2, 8 + assigned * 8) - } - }, - terminate() { - if (lib.symbols.TerminateJobObject(handle, 1) === 0) { - throw new Error(`TerminateJobObject failed with Windows error ${lib.symbols.GetLastError()}`) - } - }, - close() { - if (closed) return - closed = true - lib.symbols.CloseHandle(handle) - lib.close() - }, - } - } -} diff --git a/packages/opencode/src/kilocode/cli/cmd/cloud.ts b/packages/opencode/src/kilocode/cli/cmd/cloud.ts index 61b4e75bae..a9b1ad957b 100644 --- a/packages/opencode/src/kilocode/cli/cmd/cloud.ts +++ b/packages/opencode/src/kilocode/cli/cmd/cloud.ts @@ -2,7 +2,11 @@ import type { Argv } from "yargs" import { Effect } from "effect" import { cmd } from "@/cli/cmd/cmd" import { effectCmd } from "@/cli/effect-cmd" -import { CloudCommands } from "@/kilocode/cloud/commands" + +// Keep the top-level import graph light: this module is registered eagerly at CLI +// startup, so the cloud implementation is imported inside handlers (same deferral +// pattern as upstream opencode#30453). +const cloud = Effect.promise(() => import("@/kilocode/cloud/commands").then((m) => m.CloudCommands)) export const CloudStartCommand = effectCmd({ command: "start", @@ -44,6 +48,7 @@ export const CloudStartCommand = effectCmd({ describe: "connect to the WebSocket stream and print events as JSONL", }), handler: Effect.fn("Cli.cloud.start")(function* (args) { + const CloudCommands = yield* cloud yield* CloudCommands.start({ prompt: args.prompt, ...(args.repo === undefined ? {} : { repo: args.repo }), @@ -74,6 +79,7 @@ export const CloudSendCommand = effectCmd({ describe: "follow-up prompt for the Cloud Agent", }), handler: Effect.fn("Cli.cloud.send")(function* (args) { + const CloudCommands = yield* cloud yield* CloudCommands.send({ sessionID: args.sessionId, prompt: args.prompt }) }), }) @@ -95,6 +101,7 @@ export const CloudStatusCommand = effectCmd({ describe: "Cloud Agent message ID", }), handler: Effect.fn("Cli.cloud.status")(function* (args) { + const CloudCommands = yield* cloud yield* CloudCommands.status({ sessionID: args.sessionId, messageID: args.messageId }) }), }) @@ -116,6 +123,7 @@ export const CloudResultCommand = effectCmd({ describe: "Cloud Agent message ID", }), handler: Effect.fn("Cli.cloud.result")(function* (args) { + const CloudCommands = yield* cloud yield* CloudCommands.result({ sessionID: args.sessionId, messageID: args.messageId }) }), }) diff --git a/packages/opencode/src/kilocode/cli/cmd/console.ts b/packages/opencode/src/kilocode/cli/cmd/console.ts index ae0b9cf1c5..019ea29050 100644 --- a/packages/opencode/src/kilocode/cli/cmd/console.ts +++ b/packages/opencode/src/kilocode/cli/cmd/console.ts @@ -1,14 +1,14 @@ -import open from "open" import type { Argv } from "yargs" +import type { Daemon } from "@/kilocode/daemon/daemon" import { cmd } from "@/cli/cmd/cmd" -import { explicitNetworkOptions, withNetworkOptions, resolveNetworkOptions } from "@/cli/network" +import { explicitNetworkOptions, withNetworkOptions } from "@/cli/network" import { serverUrls } from "@/kilocode/cli/server-urls" -import { AppRuntime } from "@/effect/app-runtime" -import { Daemon } from "@/kilocode/daemon/daemon" -import { warnPort } from "@/kilocode/cli/port-warning" import { hasDisplay } from "@/kilocode/cli/cmd/tui/util/display" import { StopCommand } from "@/kilocode/cli/cmd/daemon" +// Keep the top-level import graph light: this module is registered eagerly at CLI +// startup, so implementation dependencies are imported inside handlers (same +// deferral pattern as upstream opencode#30453). function withCredentials(base: string, state: Daemon.State) { const url = new URL("/console", base) url.username = state.username @@ -17,6 +17,7 @@ function withCredentials(base: string, state: Daemon.State) { } async function launch(url: string) { + const { default: open } = await import("open") const child = await open(url) await new Promise((resolve, reject) => { const timer = setTimeout(resolve, 500) @@ -46,9 +47,10 @@ const OpenCommand = cmd({ type: "boolean", }), handler: async (args) => { + const { Daemon } = await import("@/kilocode/daemon/daemon") + const { warnedNetworkOptions } = await import("@/kilocode/cli/port-warning") const run = async (signal?: AbortSignal) => { - const opts = await AppRuntime.runPromise(resolveNetworkOptions(args)) - warnPort(opts.port) + const opts = await warnedNetworkOptions(args) const daemon = await Daemon.ensure(opts, explicitNetworkOptions()) const state = daemon.result.state if (!state) throw new Error("Kilo daemon did not provide connection state") diff --git a/packages/opencode/src/kilocode/cli/cmd/daemon.ts b/packages/opencode/src/kilocode/cli/cmd/daemon.ts index 42f1165f50..e3a0deb4ec 100644 --- a/packages/opencode/src/kilocode/cli/cmd/daemon.ts +++ b/packages/opencode/src/kilocode/cli/cmd/daemon.ts @@ -1,10 +1,12 @@ import type { Argv } from "yargs" +import type { Daemon } from "@/kilocode/daemon/daemon" +import type { resolveNetworkOptions } from "@/cli/network" import { cmd } from "@/cli/cmd/cmd" -import { explicitNetworkOptions, withNetworkOptions, resolveNetworkOptions } from "@/cli/network" -import { AppRuntime } from "@/effect/app-runtime" -import { Daemon } from "@/kilocode/daemon/daemon" -import { warnPort } from "@/kilocode/cli/port-warning" +import { explicitNetworkOptions, withNetworkOptions } from "@/cli/network" +// Keep the top-level import graph light: this module is registered eagerly at CLI +// startup, so implementation dependencies are imported inside handlers (same +// deferral pattern as upstream opencode#30453). function withJson(yargs: Argv) { return yargs.option("json", { describe: "print daemon details as JSON", @@ -75,6 +77,7 @@ async function hold(enabled: boolean, json: boolean, run: (signal?: AbortSignal) await run() return } + const { Daemon } = await import("@/kilocode/daemon/daemon") await Daemon.foreground(async (signal) => { const state = await run(signal) if (!signal.aborted && !json) console.log("Press Ctrl+C to stop the Kilo daemon.") @@ -82,6 +85,11 @@ async function hold(enabled: boolean, json: boolean, run: (signal?: AbortSignal) }) } +async function network(args: { [key: string]: unknown }) { + const { warnedNetworkOptions } = await import("@/kilocode/cli/port-warning") + return warnedNetworkOptions(args as Parameters[0]) +} + function start(command: string) { return cmd({ command, @@ -89,8 +97,8 @@ function start(command: string) { builder: (yargs) => withForeground(withJson(withNetworkOptions(yargs))), handler: async (args) => { await hold(Boolean(args.foreground), Boolean(args.json), async (signal) => { - const opts = await AppRuntime.runPromise(resolveNetworkOptions(args)) - warnPort(opts.port) + const opts = await network(args) + const { Daemon } = await import("@/kilocode/daemon/daemon") const daemon = await Daemon.ensure(opts, explicitNetworkOptions()) const result = daemon.result const state = result.state @@ -121,6 +129,7 @@ const StatusCommand = cmd({ describe: "show local kilo daemon status", builder: (yargs) => withJson(yargs), handler: async (args) => { + const { Daemon } = await import("@/kilocode/daemon/daemon") print(await Daemon.status(), Boolean(args.json)) }, }) @@ -130,6 +139,7 @@ export const StopCommand = cmd({ describe: "stop the local kilo daemon", builder: (yargs) => withJson(yargs), handler: async (args) => { + const { Daemon } = await import("@/kilocode/daemon/daemon") const result = await Daemon.stop() if (args.json) { print(result, true) @@ -145,8 +155,8 @@ const RestartCommand = cmd({ builder: (yargs) => withForeground(withJson(withNetworkOptions(yargs))), handler: async (args) => { await hold(Boolean(args.foreground), Boolean(args.json), async (signal) => { - const opts = await AppRuntime.runPromise(resolveNetworkOptions(args)) - warnPort(opts.port) + const opts = await network(args) + const { Daemon } = await import("@/kilocode/daemon/daemon") const result = await Daemon.restart(opts) const state = result.state if (!state) throw new Error("Kilo daemon did not provide process state") diff --git a/packages/opencode/src/kilocode/cli/cmd/profile.ts b/packages/opencode/src/kilocode/cli/cmd/profile.ts index 7119395c2f..924d4fde0f 100644 --- a/packages/opencode/src/kilocode/cli/cmd/profile.ts +++ b/packages/opencode/src/kilocode/cli/cmd/profile.ts @@ -1,11 +1,23 @@ import type { Argv } from "yargs" +import type { Info as AuthInfo } from "../../../auth" +import type { KilocodeBalance, KilocodeProfile } from "@kilocode/kilo-gateway" import { cmd } from "../../../cli/cmd/cmd" import { UI } from "../../../cli/ui" -import { Auth, type Info as AuthInfo } from "../../../auth" -import { makeRuntime } from "../../../effect/run-service" -import { fetchBalance, fetchProfile, type KilocodeBalance, type KilocodeProfile } from "@kilocode/kilo-gateway" -const runtime = makeRuntime(Auth.Service, Auth.defaultLayer) +// Keep the top-level import graph light: this module is registered eagerly at CLI +// startup, so the auth runtime and gateway fetches happen inside the handler (same +// deferral pattern as upstream opencode#30453). The runtime is created lazily on +// first use rather than at module load. +let runtime: Awaited> | undefined +async function load() { + const { Auth } = await import("../../../auth") + const { makeRuntime } = await import("../../../effect/run-service") + return makeRuntime(Auth.Service, Auth.defaultLayer) +} +async function stored(providerID: string) { + runtime ??= await load() + return runtime.runPromise((svc) => svc.get(providerID)) +} interface Info { name: string | null @@ -64,7 +76,7 @@ export const ProfileCommand = cmd({ }) export async function handle(args: Args) { - const get = args.getAuth ?? ((id: string) => runtime.runPromise((svc) => svc.get(id))) + const get = args.getAuth ?? stored const auth = await get("kilo") const error = args.error ?? UI.error const exit = args.exit ?? ((code: number) => (process.exitCode = code)) @@ -75,6 +87,7 @@ export async function handle(args: Args) { return } + const { fetchBalance, fetchProfile } = await import("@kilocode/kilo-gateway") const org = auth.accountId ?? null const result = await (async () => { try { diff --git a/packages/opencode/src/kilocode/cli/cmd/roll-call.ts b/packages/opencode/src/kilocode/cli/cmd/roll-call.ts index 2c932bfde8..37b1871653 100644 --- a/packages/opencode/src/kilocode/cli/cmd/roll-call.ts +++ b/packages/opencode/src/kilocode/cli/cmd/roll-call.ts @@ -1,14 +1,35 @@ import type { Argv } from "yargs" -import { provide } from "../../instance" -import { Provider } from "../../../provider/provider" +import type { Provider } from "../../../provider/provider" +import type { generateText } from "ai" import { ProviderTransform } from "../../../provider/transform" import { cmd } from "../../../cli/cmd/cmd" import { UI } from "../../../cli/ui" -import { AppRuntime } from "../../../effect/app-runtime" -import { RuntimeFlags } from "../../../effect/runtime-flags" -import { generateText } from "ai" import { randomUUID } from "crypto" +// Keep the top-level import graph light: this module is registered eagerly at CLI +// startup, so implementation dependencies (provider service, AppRuntime, the `ai` +// SDK) are imported lazily (same deferral pattern as upstream opencode#30453). +// Resolved once per process: roll-call's per-model path must not re-await import +// resolution for every model. +let cache: ReturnType | undefined +function loadDeps() { + return Promise.all([ + import("../../../effect/app-runtime"), + import("../../../provider/provider"), + import("../../../effect/runtime-flags"), + import("ai"), + ]).then(([runtime, provider, flags, ai]) => ({ + AppRuntime: runtime.AppRuntime, + Provider: provider.Provider, + RuntimeFlags: flags.RuntimeFlags, + generateText: ai.generateText, + })) +} +function deps() { + cache ??= loadDeps() + return cache +} + const HEADERS = ["Model", "Access", "Snippet", "Latency"] const PADDING = 9 @@ -127,11 +148,13 @@ interface Result { errorMessage: string | null } -function list() { +async function list() { + const { AppRuntime, Provider } = await deps() return AppRuntime.runPromise(Provider.Service.use((svc) => svc.list())) } -function lang(model: Provider.Model) { +async function lang(model: Provider.Model) { + const { AppRuntime, Provider } = await deps() return AppRuntime.runPromise(Provider.Service.use((svc) => svc.getLanguage(model))) } @@ -140,6 +163,7 @@ export function outputLimit(model: Provider.Model, outputTokenMax?: number) { } export async function handle(args: ArgumentsCamelCase) { + const { provide } = await import("../../instance") const load = args.list ?? list if (args.parallel < 1) { @@ -286,6 +310,7 @@ async function call( start: number, ): Promise> { try { + const { AppRuntime, RuntimeFlags, generateText } = await deps() const language = await lang(model) const sessionID = randomUUID() const options = ProviderTransform.options({ model, sessionID }) diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx index b3c337c09c..543750842f 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx @@ -282,7 +282,10 @@ export function init() { ? "Disable auto-approve mode" : "Enable auto-approve mode" }, + desc: "Toggle auto-approve for all permission prompts, saved to global config", category: "System", + slashName: "auto-approve", + slashAliases: ["autoapprove", "approve-all", "approveall"], run: async () => { const enabled = isAllowEverything(sync.data.config.permission) const result = await sdk.client.permission.allowEverything({ enable: !enabled }) diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/component/prompt/vim.ts b/packages/opencode/src/kilocode/cli/cmd/tui/component/prompt/vim.ts index 2b91606c86..020d0be379 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/component/prompt/vim.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui/component/prompt/vim.ts @@ -877,8 +877,3 @@ export function handleVisualKey(doc: VimDoc, state: VimState, input: VimKey): Vi state.countDigits = "" return { handled: true } } - -export function enterInsert(state: VimState) { - state.mode = "insert" - resetPending(state) -} diff --git a/packages/opencode/src/kilocode/cli/port-warning.ts b/packages/opencode/src/kilocode/cli/port-warning.ts index afba3dd563..ff441e3912 100644 --- a/packages/opencode/src/kilocode/cli/port-warning.ts +++ b/packages/opencode/src/kilocode/cli/port-warning.ts @@ -1,4 +1,5 @@ import { Daemon } from "@/kilocode/daemon/daemon" +import type { resolveNetworkOptions } from "@/cli/network" export function warnPort(port: number) { if (port === 0) return @@ -9,3 +10,14 @@ export function warnPort(port: number) { ) } } + +// Shared resolve-and-warn used by the daemon and console commands so their +// network option handling cannot drift apart. Imported lazily by callers, so +// the AppRuntime chain is only loaded when one of those commands runs. +export async function warnedNetworkOptions(args: Parameters[0]) { + const { AppRuntime } = await import("@/effect/app-runtime") + const { resolveNetworkOptions } = await import("@/cli/network") + const opts = await AppRuntime.runPromise(resolveNetworkOptions(args)) + warnPort(opts.port) + return opts +} diff --git a/packages/opencode/src/kilocode/cli/setup.ts b/packages/opencode/src/kilocode/cli/setup.ts index 561e5c5406..e3c54c3065 100644 --- a/packages/opencode/src/kilocode/cli/setup.ts +++ b/packages/opencode/src/kilocode/cli/setup.ts @@ -1,14 +1,6 @@ import type { Argv } from "yargs" import * as Log from "@opencode-ai/core/util/log" -import { Global } from "@opencode-ai/core/global" import { InstallationBuildKind, InstallationVersion } from "@opencode-ai/core/installation/version" -import { Telemetry } from "@kilocode/kilo-telemetry" -import { migrateLegacyKiloAuth, ENV_FEATURE, ENV_VERSION } from "@kilocode/kilo-gateway" -import { AppRuntime } from "@/effect/app-runtime" -import { Config } from "@/config/config" -import { Auth } from "@/auth" -import { InstanceRuntime } from "@/project/instance-runtime" -import { SessionExport } from "@/kilocode/session-export" import { KiloShutdown } from "@/kilocode/cli/shutdown" import { createHelpCommand } from "@/kilocode/help-command" import { KiloConsoleCommand } from "@/kilocode/cli/cmd/console" @@ -19,8 +11,6 @@ import { DaemonCommand } from "@/kilocode/cli/cmd/daemon" import { DevSetupCommand, DevAliasCommand } from "@/kilocode/cli/dev-setup" import { RemoteCommand } from "@/cli/cmd/remote" import { ConfigCommand as ConfigCLICommand } from "@/cli/cmd/config" -import { JsonMigration } from "@/kilocode/storage/json-migration" -import { KiloLog } from "@/kilocode/log" const log = Log.create({ service: "kilocode.cli" }) @@ -45,6 +35,13 @@ KiloShutdown.register(async () => { // All Kilo-specific CLI customization lives here so the shared upstream entrypoint // (src/index.ts) only needs a handful of thin call-sites behind kilocode_change markers. // This keeps index.ts close to upstream and reduces merge conflicts on every sync. +// +// Startup cost note: this module is imported eagerly from src/index.ts, so its static +// import graph must stay light. Heavy dependencies (telemetry, gateway auth migration, +// AppRuntime, config, auth, session-export, JSON migration) are dynamically imported +// inside the function that needs them, following the deferral pattern upstream applied +// in opencode#30453. The registered command modules must follow the same rule: a light +// top level, with implementation imports inside their handlers. export namespace KiloCli { // Register only the Kilo-specific commands. Upstream commands stay in index.ts's chain so // upstream merges that add or remove commands keep working without touching this file. @@ -73,22 +70,35 @@ export namespace KiloCli { // Runs from the upstream `.middleware`, before any command handler. Env tagging is additive so // it never has to modify upstream's own env assignments. export async function bootstrap(): Promise { + const { KiloLog } = await import("@/kilocode/log") await KiloLog.init() - if (!process.env[ENV_FEATURE]) process.env[ENV_FEATURE] = process.argv.includes("serve") ? "unknown" : "cli" - if (!process.env[ENV_VERSION]) process.env[ENV_VERSION] = InstallationVersion + + const gateway = await import("@kilocode/kilo-gateway") + if (!process.env[gateway.ENV_FEATURE]) + process.env[gateway.ENV_FEATURE] = process.argv.includes("serve") ? "unknown" : "cli" + if (!process.env[gateway.ENV_VERSION]) process.env[gateway.ENV_VERSION] = InstallationVersion process.env.KILO = "1" // Must run before AppRuntime initializes the SQLite database, or the marker // exists before legacy JSON can be imported. + const { JsonMigration } = await import("@/kilocode/storage/json-migration") await JsonMigration.bootstrap() + const { AppRuntime } = await import("@/effect/app-runtime") + const { Config } = await import("@/config/config") const cfg = await AppRuntime.runPromise(Config.Service.use((c) => c.getGlobal())) + + const { Global } = await import("@opencode-ai/core/global") + const { Telemetry } = await import("@kilocode/kilo-telemetry") await Telemetry.init({ dataPath: Global.Path.data, version: InstallationVersion, enabled: cfg.experimental?.openTelemetry !== false, }) + const { Auth } = await import("@/auth") + const { migrateLegacyKiloAuth } = gateway + // Migrate legacy Kilo CLI auth (~/.kilocode/cli/config.json) into auth.json if present. await migrateLegacyKiloAuth( async () => (await AppRuntime.runPromise(Auth.Service.use((s) => s.get("kilo")))) !== undefined, @@ -103,12 +113,17 @@ export namespace KiloCli { } Telemetry.trackCliStart() + // Overlap the event upload with command execution so exit is not delayed by + // a network round trip (#10242). + Telemetry.flushInBackground() } // Runs from the `finally` block on every exit path. export async function shutdown(): Promise { + const { Telemetry } = await import("@kilocode/kilo-telemetry") const code = typeof process.exitCode === "number" ? process.exitCode : undefined Telemetry.trackCliExit(code) + const { SessionExport } = await import("@/kilocode/session-export") try { await SessionExport.shutdown() // Bound telemetry shutdown so an unreachable endpoint (offline, firewall, @@ -121,6 +136,7 @@ export namespace KiloCli { } } finally { await KiloShutdown.run() + const { InstanceRuntime } = await import("@/project/instance-runtime") await InstanceRuntime.disposeAllInstances() // safety net (no-op if already disposed) } } diff --git a/packages/opencode/src/kilocode/cloud/contracts.ts b/packages/opencode/src/kilocode/cloud/contracts.ts index a4716e547e..a78b846b8c 100644 --- a/packages/opencode/src/kilocode/cloud/contracts.ts +++ b/packages/opencode/src/kilocode/cloud/contracts.ts @@ -163,7 +163,6 @@ export const GetMessageResultOutputSchema = z } }) -export type RepositoryInput = z.infer export type AgentStartRequest = z.infer export type AgentSendRequest = z.infer export type GetMessageResultInput = z.infer diff --git a/packages/opencode/src/kilocode/config/config.ts b/packages/opencode/src/kilocode/config/config.ts index a33445494b..f98a0b8ffa 100644 --- a/packages/opencode/src/kilocode/config/config.ts +++ b/packages/opencode/src/kilocode/config/config.ts @@ -37,34 +37,18 @@ export namespace KilocodeConfig { // ── Config file constants ──────────────────────────────────────────── - /** Kilo-specific config file names (highest-to-lowest precedence within kilo). */ - export const KILO_CONFIG_FILES = ["kilo.jsonc", "kilo.json"] as const - /** All config file names in precedence order (kilo + opencode). */ export const ALL_CONFIG_FILES = ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"] as const /** Config directory suffixes in update-target preference order. */ export const KILO_DIR_SUFFIXES = [".kilo", ".kilocode"] as const - /** Path patterns for resolving kilo agent names from file paths. */ - export const AGENT_PATTERNS = ["/.kilo/agent/", "/.kilo/agents/", "/.kilocode/agent/", "/.kilocode/agents/"] as const - - /** Path patterns for resolving kilo command names from file paths. */ - export const COMMAND_PATTERNS = [ - "/.kilo/command/", - "/.kilo/commands/", - "/.kilocode/command/", - "/.kilocode/commands/", - ] as const - /** - * Choose the project config file that Config.update should patch. - * - * This mirrors the Kilo project-config load chain: prefer existing config files - * in ancestor config directories, then existing root config files, and create - * `.kilo/kilo.jsonc` when no project config exists yet. + * List every project config file the read chain can merge: config files in + * ancestor config directories, then root config files, in update-target + * preference order. */ - export const projectConfigUpdateTarget = Effect.fn("KilocodeConfig.projectConfigUpdateTarget")(function* (input: { + export const projectConfigFiles = Effect.fn("KilocodeConfig.projectConfigFiles")(function* (input: { fs: FSUtil.Interface directory: string worktree?: string @@ -75,8 +59,7 @@ export namespace KilocodeConfig { const roots = yield* input.fs .up({ targets: [...ALL_CONFIG_FILES], start: input.directory, stop: input.worktree }) .pipe(Effect.orDie) - const files = [...dirs.flatMap((dir) => ALL_CONFIG_FILES.map((file) => path.join(dir, file))), ...roots] - return files.find((file) => existsSync(file)) ?? path.join(input.directory, ".kilo", "kilo.jsonc") + return [...dirs.flatMap((dir) => ALL_CONFIG_FILES.map((file) => path.join(dir, file))), ...roots] }) export const updateProjectConfig = Effect.fn("KilocodeConfig.updateProjectConfig")(function* (input: { @@ -89,22 +72,108 @@ export namespace KilocodeConfig { patch: (input: string, config: Config.Info) => string writable: (config: Config.Info) => Config.Info }) { - const file = yield* projectConfigUpdateTarget(input) + const files = yield* projectConfigFiles(input) + const file = files.find((item) => existsSync(item)) ?? path.join(input.directory, ".kilo", "kilo.jsonc") const source = yield* input.read(file) const before = source ?? "{}" const patch = input.writable(input.config) if (file.endsWith(".jsonc")) { - if (source === undefined && Object.keys(mergeConfig({}, patch)).length === 0) return - const updated = input.patch(before, patch) - yield* input.fs.writeWithDirs(file, updated).pipe(Effect.orDie) - return + if (!(source === undefined && Object.keys(mergeConfig({}, patch)).length === 0)) { + const updated = input.patch(before, patch) + yield* input.fs.writeWithDirs(file, updated).pipe(Effect.orDie) + } + } else { + const existing = input.parse(before, file) + const merged = mergeConfig(input.writable(existing), patch) + if (!(source === undefined && Object.keys(merged).length === 0)) { + yield* input.fs.writeWithDirs(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) + } } - const existing = input.parse(before, file) - const merged = mergeConfig(input.writable(existing), patch) - if (source === undefined && Object.keys(merged).length === 0) return - yield* input.fs.writeWithDirs(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) + // Reads merge every project config file, so a delete sentinel applied only + // to the update target leaves lower-precedence copies of the key visible. + yield* propagateUnset({ fs: input.fs, files, exclude: file, patch }) + }) + + /** Collect the leaf paths of null delete sentinels in a config patch. */ + export function unsetPaths(patch: unknown, prefix: string[] = []): string[][] { + if (!isRecord(patch)) return [] + return Object.entries(patch).flatMap(([key, value]) => { + const parts = [...prefix, key] + if (value === null) return [parts] + return unsetPaths(value, parts) + }) + } + + const blocked = new Set(["__proto__", "constructor", "prototype"]) + + function sentinel(out: Record, parts: string[]) { + const [head, ...tail] = parts + if (!head || blocked.has(head)) return + if (tail.length === 0) { + out[head] = null + return + } + const next = isRecord(out[head]) ? out[head] : {} + out[head] = next + sentinel(next, tail) + } + + function has(input: unknown, parts: string[]) { + let cur = input + for (const part of parts) { + if (!isRecord(cur) || !(part in cur)) return false + cur = cur[part] + } + return true + } + + /** + * Remove null delete-sentinel keys from every layered config file that still + * contains them. Reads merge all candidate files, so deleting a key from only + * the primary write target leaves lower-precedence copies of it visible and + * the "unset" appears to have no effect. Returns true when a file changed. + */ + export const propagateUnset = Effect.fn("KilocodeConfig.propagateUnset")(function* (input: { + fs: FSUtil.Interface + files: readonly string[] + exclude: string + patch: Config.Info + }) { + const paths = unsetPaths(input.patch) + if (paths.length === 0) return false + let changed = false + for (const file of input.files) { + if (file === input.exclude || !existsSync(file)) continue + const text = yield* input.fs.readFileStringSafe(file).pipe(Effect.orDie) + if (!text) continue + const parsed = parseJsonc(text) + const hits = paths.filter((parts) => has(parsed, parts)) + if (hits.length === 0) continue + if (file.endsWith(".jsonc")) { + const updated = hits.reduce( + (acc, parts) => + applyEdits(acc, modify(acc, parts, undefined, { formattingOptions: { insertSpaces: true, tabSize: 2 } })), + text, + ) + if (updated === text) continue + yield* input.fs.writeFileString(file, updated).pipe(Effect.orDie) + changed = true + continue + } + const patch = hits.reduce( + (acc, parts) => { + sentinel(acc, parts) + return acc + }, + {} as Record, + ) + const next = mergeConfig(parsed as Config.Info, patch as Config.Info) + yield* input.fs.writeFileString(file, JSON.stringify(next, null, 2)).pipe(Effect.orDie) + changed = true + } + return changed }) export function scopeIndexing(info: Config.Info, scope: "global" | "local"): Config.Info { @@ -333,7 +402,8 @@ export namespace KilocodeConfig { // ── Bash permission migration ──────────────────────────────────────── - const GLOBAL_CONFIG_FILES = ["config.json", "kilo.json", "kilo.jsonc", "opencode.json", "opencode.jsonc"] + /** Global config file names in read-merge order (lowest-to-highest precedence). */ + export const GLOBAL_CONFIG_FILES = ["config.json", "kilo.json", "kilo.jsonc", "opencode.json", "opencode.jsonc"] /** * Migrate bash permission for existing users before config is consumed. diff --git a/packages/opencode/src/kilocode/config/overlay.ts b/packages/opencode/src/kilocode/config/overlay.ts index 2d576c3bce..ec806bf053 100644 --- a/packages/opencode/src/kilocode/config/overlay.ts +++ b/packages/opencode/src/kilocode/config/overlay.ts @@ -88,6 +88,7 @@ export namespace KilocodeConfigOverlay { ["disabled_providers"], ["watcher", "ignore"], ["instructions"], + ["web_search"], ["indexing", "enabled"], ["indexing", "provider"], ["indexing", "model"], diff --git a/packages/opencode/src/kilocode/memory/turn.ts b/packages/opencode/src/kilocode/memory/turn.ts index c41068c0e3..d9fdc79ae7 100644 --- a/packages/opencode/src/kilocode/memory/turn.ts +++ b/packages/opencode/src/kilocode/memory/turn.ts @@ -78,7 +78,9 @@ export namespace MemoryLifecycle { if (!enabled) return yield* MemoryTurn.close({ sessionID: evt.properties.sessionID, - reason: evt.properties.reason, + // A superseded turn handed off to a queued follow-up after draining + // its step; for digest purposes it was cut short like an interrupt. + reason: evt.properties.reason === "superseded" ? "interrupted" : evt.properties.reason, sessions: input.sessions, summary: input.summary, provider: input.provider, diff --git a/packages/opencode/src/kilocode/permission/drain.ts b/packages/opencode/src/kilocode/permission/drain.ts index 78f960c867..499f4f83e1 100644 --- a/packages/opencode/src/kilocode/permission/drain.ts +++ b/packages/opencode/src/kilocode/permission/drain.ts @@ -33,6 +33,8 @@ export function drainCovered( // Never auto-resolve config file edit permissions const skill = ConfigProtection.globalSkillPattern(entry.info) if (ConfigProtection.isRequest(entry.info) && !skill) continue + // Never auto-resolve a skill shell batch; it must get an explicit reply. + if (entry.info.metadata?.["skillShell"] === true) continue const actions = entry.info.patterns.map((pattern: string) => { const rule = skill ? Permission.evaluate(entry.info.permission, skill, approved) diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 5a11aea98c..7fd01715ff 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -158,7 +158,6 @@ export async function generateHandover(input: { export namespace PlanFollowup { const log = Log.create({ service: "plan.followup" }) - export const PLAN_PREFIX = "Implement the following plan:" export const ANSWER_NEW_SESSION = "Start new session" export const ANSWER_CONTINUE = "Continue here" export const ANSWER_KEEP_REFINING = "Keep refining" diff --git a/packages/opencode/src/kilocode/remote-attachments.ts b/packages/opencode/src/kilocode/remote-attachments.ts index 9008065cc9..b329dbb47d 100644 --- a/packages/opencode/src/kilocode/remote-attachments.ts +++ b/packages/opencode/src/kilocode/remote-attachments.ts @@ -66,7 +66,6 @@ export namespace RemoteAttachments { sql: "text/plain", } export const BINARY_MIME = "application/octet-stream" - export const TEXT_PLAIN = "text/plain" // Hard cap on attachment bytes (5 MB + 1 byte so the helper aborts // strictly when the body exceeds the agreed ceiling). export const MAX_BYTES = 5 * 1024 * 1024 + 1 diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts b/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts index e5b0dbc2dc..208cd1f021 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts @@ -22,7 +22,6 @@ export const BackgroundProcessPaths = { } as const export const Params = Schema.Struct({ processID: BackgroundProcess.ID }) -export const SessionParams = Schema.Struct({ sessionID: SessionID }) export const BackgroundProcessApi = HttpApi.make("background-process") .add( diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/session-import.ts b/packages/opencode/src/kilocode/server/httpapi/groups/session-import.ts index 596ca4f57d..9aca9d4581 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/session-import.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/session-import.ts @@ -219,13 +219,6 @@ export const SessionImportPaths = { part: `${root}/part`, } as const -export const SessionImportPayloads = { - Project: ProjectSchema, - Session: SessionSchema, - Message: MessageSchema, - Part: PartSchema, -} as const - export const SessionImportApi = HttpApi.make("session-import") .add( HttpApiGroup.make("session-import") diff --git a/packages/opencode/src/kilocode/session/event.ts b/packages/opencode/src/kilocode/session/event.ts index 47025588a8..e8a851211e 100644 --- a/packages/opencode/src/kilocode/session/event.ts +++ b/packages/opencode/src/kilocode/session/event.ts @@ -2,7 +2,10 @@ import { BusEvent } from "@/bus/bus-event" import { MessageID, SessionID } from "@/session/schema" import { Schema } from "effect" -const CloseReason = Schema.Literals(["completed", "error", "interrupted"]) +// "superseded": the turn handed off to a queued follow-up after draining its +// current step. Distinct from "interrupted" so clients do not surface a +// premature-stop warning for a deliberate queue handoff. +const CloseReason = Schema.Literals(["completed", "error", "interrupted", "superseded"]) export const KiloSessionEvent = { TurnOpen: BusEvent.define( diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index a1fa29b806..ee05f45358 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -1,7 +1,6 @@ // kilocode_change - new file import path from "path" import fs from "fs/promises" -import { StringDecoder } from "string_decoder" import { Cause, Effect, Exit, Fiber, Scope } from "effect" import { SessionID, PartID } from "@/session/schema" import { MessageV2 } from "@/session/message-v2" @@ -377,25 +376,6 @@ export namespace KiloSessionPrompt { } } - /** - * Creates StringDecoder-based helpers for shell stdout/stderr that correctly - * handle multi-byte UTF-8 characters split across chunks. - */ - export function createShellDecoders() { - const stdout = new StringDecoder("utf8") - const stderr = new StringDecoder("utf8") - return { - /** Decode a chunk from the given stream. */ - write(stream: "stdout" | "stderr", chunk: Buffer) { - return stream === "stdout" ? stdout.write(chunk) : stderr.write(chunk) - }, - /** Flush any trailing buffered bytes from both decoders. */ - flush() { - return stdout.end() + stderr.end() - }, - } - } - /** * Ensures the plan file directory exists. Pre-checks with `Filesystem.isDir` * because `fs.mkdir(recursive: true)` still throws `EEXIST` on Windows @@ -455,12 +435,6 @@ export namespace KiloSessionPrompt { add(`\n${body}\n`) } - /** - * Returns the CODE_SWITCH prompt text (plan-to-code transition). - * Used when switching from plan agent to code agent. - */ - export const CODE_SWITCH_TEXT = CODE_SWITCH - /** * Determines the close reason for a session turn. * Checks for an explicit reason first (e.g. set on error during runLoop), diff --git a/packages/opencode/src/kilocode/skill/discovery-validate.ts b/packages/opencode/src/kilocode/skill/discovery-validate.ts new file mode 100644 index 0000000000..1887189267 --- /dev/null +++ b/packages/opencode/src/kilocode/skill/discovery-validate.ts @@ -0,0 +1,45 @@ +import { posix, win32 } from "node:path" + +// Segment/relative-path validation for remotely-discovered skills. The remote index controls +// skill.name and each file path, so validate them before they become cache write targets: reject +// traversal, absolute paths, URLs, and null bytes. Mirrors core v2 SkillDiscovery. + +export function isSafeSegment(value: string) { + return ( + value.length > 0 && + value !== "." && + value !== ".." && + !value.includes("/") && + !value.includes("\\") && + !value.includes("\0") + ) +} + +export function isSafeRelativePath(value: string) { + const segments = value.split("/") + return ( + value.length > 0 && + !value.includes("\\") && + !value.includes("\0") && + !value.includes("?") && + !value.includes("#") && + !URL.canParse(value) && + !posix.isAbsolute(value) && + !win32.isAbsolute(value) && + segments.every((segment) => { + try { + const decoded = decodeURIComponent(segment) + return ( + decoded.length > 0 && + decoded !== "." && + decoded !== ".." && + !decoded.includes("/") && + !decoded.includes("\\") && + !decoded.includes("\0") + ) + } catch { + return false + } + }) + ) +} diff --git a/packages/opencode/src/kilocode/skill/trust.ts b/packages/opencode/src/kilocode/skill/trust.ts new file mode 100644 index 0000000000..fbafa6e6a9 --- /dev/null +++ b/packages/opencode/src/kilocode/skill/trust.ts @@ -0,0 +1,28 @@ +import { realpathSync } from "fs" +import path from "path" + +// A skill discovered under a trusted directory (~/.agents, ~/.claude, config dirs, +// KILO_CONFIG_DIR) mints trust: shell execution after one approval, and unconfined +// {env:}/{file:} substitution. Symlinks are followed during the scan, so a link from a +// trusted dir into the current project (a commonly suggested convenience) would otherwise +// grant project-controlled markdown that trust. Resolve the real path and drop trust when +// it lands inside the project, so project content is never trusted regardless of symlinks. +export function trustedInProject(match: string, projectRoot: string | undefined): boolean { + if (!projectRoot) return false + const real = (() => { + try { + return realpathSync.native(match) + } catch { + return match + } + })() + const root = (() => { + try { + return realpathSync.native(projectRoot) + } catch { + return projectRoot + } + })() + const rel = path.relative(root, real) + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) +} diff --git a/packages/opencode/src/kilocode/skills/display.ts b/packages/opencode/src/kilocode/skills/display.ts new file mode 100644 index 0000000000..dbfa54b1ce --- /dev/null +++ b/packages/opencode/src/kilocode/skills/display.ts @@ -0,0 +1,35 @@ +// Markers inlined in place of a `!`cmd`` placeholder when it is not executed. Shared by the +// skill tool (inject.ts) and the slash-command path (session/prompt.ts) so both render identically. +export const SKILL_SHELL_DISABLED = "[skill shell execution disabled by policy]" +export const SKILL_SHELL_UNTRUSTED = "[skill shell execution disabled for untrusted skill]" + +// Render a skill command for a permission prompt as a single, tamper-evident line. +// Escape control chars (CR/LF/ESC/etc.) so a command can't repaint the terminal, and +// bidi/format controls (U+202A-202E, U+2066-2069, U+200E/F, U+2028/9) so a Trojan-Source +// style reorder can't make the visible text differ from what will execute. +const CONTROL = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g + +export function displayCommand(command: string) { + return command.replace(CONTROL, (ch) => { + if (ch === "\n") return "\\n" + if (ch === "\r") return "\\r" + if (ch === "\t") return "\\t" + const code = ch.charCodeAt(0) + return code <= 0xff ? "\\x" + code.toString(16).padStart(2, "0") : "\\u" + code.toString(16).padStart(4, "0") + }) +} + +// Presentation for a skill-shell permission prompt: the title (naming the skill when known) +// and the verbatim, escaped commands to show. Reads metadata.commands (never the decomposed +// patterns, which drop `cd` segments and split pipelines) so the display matches what executes. +// Returns undefined when the request is not a skill-shell batch. +export function skillShellPrompt(metadata: Record | undefined) { + if (metadata?.["skillShell"] !== true) return undefined + const raw = metadata["commands"] + const commands = (Array.isArray(raw) ? raw : []).filter((c): c is string => typeof c === "string").map(displayCommand) + const skill = typeof metadata["skill"] === "string" ? metadata["skill"] : undefined + return { + title: skill ? `Run shell commands from skill "${skill}"?` : "Run these skill commands?", + commands, + } +} diff --git a/packages/opencode/src/kilocode/skills/inject.ts b/packages/opencode/src/kilocode/skills/inject.ts new file mode 100644 index 0000000000..07f45863cf --- /dev/null +++ b/packages/opencode/src/kilocode/skills/inject.ts @@ -0,0 +1,198 @@ +import { Effect } from "effect" +import { ConfigMarkdown } from "@/config/markdown" +import { Process } from "@/util/process" +import { SKILL_SHELL_DISABLED, SKILL_SHELL_UNTRUSTED } from "@/kilocode/skills/display" +import type * as Tool from "@/tool/tool" + +// Shell injection for skill bodies mirrors Claude's "dynamic context injection": +// a `!`cmd`` placeholder in SKILL.md is replaced by the command's stdout before +// the content reaches the model. Unlike the slash-command path, this runs for +// model-initiated skill loads, so it is gated on three independent controls: +// +// 1. Trust: only skills from trusted sources (global ~/.claude, ~/.agents, +// KILO_CONFIG_DIR, and builtins) may execute. Untrusted project/downloaded +// skills never spawn a process. +// 2. Kill-switch: `disabled` (KILO_DISABLE_SKILL_SHELL) turns injection off +// entirely, matching Claude's disableSkillShellExecution. +// 3. Batch approval: every command in the file is decomposed with the same +// tree-sitter scan the bash tool uses (per sub-command patterns plus any +// out-of-project directories), then presented once, up front, in a single +// permission prompt. The `skillShell` marker forces this prompt regardless +// of any allow/auto-approve rule; a deny rule or plan-mode veto on any +// sub-command still blocks. Approve runs the batch; reject aborts the load. +// +// Trust and the kill-switch also gate the slash-command path (`/skill`, session/prompt.ts), +// which is user-initiated. Batch approval (control 3) is specific to this model-initiated +// tool path — the slash-command path is not prompted because the user invoked it directly. +// +// Substitution runs exactly once. Command output is inlined as plain text and is +// never re-scanned, so a command cannot emit a `!`cmd`` placeholder that a later +// pass would execute (second-order injection). + +// Execution bounds: model-initiated commands must not hang the load, blow up +// context, or overrun the batch. +const TIMEOUT_MS = 2 * 60 * 1000 // per-command +const BUDGET_MS = 5 * 60 * 1000 // aggregate across the batch +const MAX_OUTPUT_BYTES = 32 * 1024 +const MAX_COMMANDS = 32 +const LIMIT_NOTE = "[skill shell command limit reached]" + +export namespace SkillInject { + export type Decompose = (input: { + command: string + cwd: string + shell: string + }) => Effect.Effect<{ patterns: string[]; dirs: string[] }> + + export type Options = { + content: string + trusted: boolean + disabled: boolean + cwd: string + skill: string + shell: string + ctx: Tool.Context + decompose: Decompose + } + + export const render = Effect.fn("SkillInject.render")(function* (opts: Options) { + // Placeholders inside fenced code blocks are documentation examples, not live commands. + const fenced = fences(opts.content) + const live = ConfigMarkdown.shell(opts.content).filter((m) => !fenced(m.index)) + if (live.length === 0) return opts.content + + // Defense-in-depth ordering: policy checks first, approval gate last. `replace` only + // rewrites live (unfenced) placeholders; fenced ones stay as literal text. + const replace = (value: (command: string) => string) => rewrite(opts.content, fenced, value) + if (opts.disabled) return replace(() => SKILL_SHELL_DISABLED) + if (!opts.trusted) return replace(() => SKILL_SHELL_UNTRUSTED) + + // `shell` is resolved by the caller via Shell.acceptable(cfg.shell), which + // rejects shells the tree-sitter bash scanner can't parse (fish/nu), keeping + // the parse used for the permission decision aligned with execution. + const shell = opts.shell + // Deduplicate identical commands, then cap the batch so a skill can't queue + // an unbounded number of processes. + const commands = Array.from(new Set(live.map(([, cmd]) => cmd))).slice(0, MAX_COMMANDS) + + // Decompose each command into sub-command patterns + out-of-project dir globs + // via the shared bash scan, so plan-mode denies and external_directory checks + // apply per sub-command instead of matching the raw string as one glob. Also + // authorize the verbatim command: decomposition drops cd/set-location segments + // and strips chaining metacharacters, so a payload like `cd $HOME; cat secret` + // would otherwise slip past the metachar deny rules (`*;*`, `*|*`, `*\n*`) and + // hide the escape. Keeping the raw string as a pattern makes those rules fire. + const patterns = new Set() + const dirs = new Set() + for (const command of commands) { + patterns.add(command) + const scan = yield* opts.decompose({ command, cwd: opts.cwd, shell }) + for (const pattern of scan.patterns) patterns.add(pattern) + for (const dir of scan.dirs) dirs.add(dir) + } + + // Fail closed: an empty pattern set would make the bash ask below auto-approve + // (Permission.ask iterates patterns, so forceAsk/veto never run for an empty + // list). Each command contributes its verbatim string above, so this is + // unreachable — but abort rather than risk a silent, unprompted execution. + if (patterns.size === 0) return yield* Effect.die(new Error("skill shell produced no authorizable commands")) + + // Single up-front approval. `patterns` are the decomposed sub-commands used for + // rule matching; `metadata.commands` is the verbatim per-placeholder list the + // prompt displays, so what is shown is exactly what runs (decomposition drops + // cd/set-location segments and splits pipelines, which must not hide from the + // user). `skillShell` forces the prompt over allow/YOLO rules; a deny/veto on + // any sub-command propagates as a defect and aborts. + const metadata = { skillShell: true, skill: opts.skill, commands } + if (dirs.size > 0) { + yield* opts.ctx.ask({ + permission: "external_directory", + patterns: Array.from(dirs), + always: [], + metadata, + }) + } + yield* opts.ctx.ask({ + permission: "bash", + patterns: Array.from(patterns), + always: [], + metadata, + }) + + // Run each command in the instance directory, bounded per-command by ctx.abort (ESC) + // and a timeout, and across the batch by an aggregate wall-clock budget, with output + // truncated so it can't blow up or poison the prompt. + const outputs = new Map() + const deadline = Date.now() + BUDGET_MS + for (const command of commands) { + if (Date.now() >= deadline) { + outputs.set(command, "[skill shell batch time budget exceeded]") + continue + } + outputs.set(command, yield* run(command, shell, opts.cwd, opts.ctx.abort)) + } + + // A placeholder that was capped out of `commands` isn't in `outputs`; mark it rather + // than silently inlining an empty string. + return replace((command) => outputs.get(command) ?? LIMIT_NOTE) + }) + + const run = Effect.fn("SkillInject.run")(function* (command: string, shell: string, cwd: string, abort: AbortSignal) { + const timeout = new AbortController() + // A cleared timer bounds the run without leaking a pending 2-minute timeout per command; + // ESC (ctx.abort) still kills the child via the same combined signal. + const signal = AbortSignal.any([abort, timeout.signal]) + const timer = setTimeout(() => timeout.abort(), TIMEOUT_MS) + const result = yield* Effect.promise(() => + Process.text([command], { shell, cwd, abort: signal, nothrow: true }).catch(() => undefined), + ).pipe(Effect.ensuring(Effect.sync(() => clearTimeout(timer)))) + + // With nothrow the promise resolves even when the child was killed, inlining partial + // stdout; detect the kill via the signals so an aborted/timed-out command is marked. + if (abort.aborted) return "[skill shell command aborted]" + if (timeout.signal.aborted) return "[skill shell command timed out]" + if (!result) return "[skill shell command failed]" + // A failing command with empty stdout would inline ""; surface a marker with any stderr. + if (result.code !== 0 && result.text.length === 0) { + const err = result.stderr.toString().trim() + return err ? "[skill shell command failed]\n" + truncate(err) : "[skill shell command failed]" + } + return truncate(result.text) + }) + + // Byte-accurate truncation: slice on a Buffer so a multibyte tail can't exceed the cap. + function truncate(text: string) { + const buf = Buffer.from(text) + if (buf.byteLength <= MAX_OUTPUT_BYTES) return text + return buf.toString("utf8", 0, MAX_OUTPUT_BYTES) + "\n[skill shell output truncated]" + } + + // Rewrite only live (unfenced) placeholders in the ORIGINAL content, substituting once and + // never re-scanning the result, so inlined output containing `!`cmd`` stays inert and a + // fenced documentation example is left as literal text. + function rewrite(content: string, fenced: (index: number) => boolean, value: (command: string) => string) { + return content.replace(ConfigMarkdown.SHELL_REGEX, (match, command: string, index: number) => + fenced(index) ? match : value(command), + ) + } + + // Return a predicate that reports whether a character offset falls inside a fenced code + // block (``` or ~~~), so placeholders in documentation examples are treated as inert. + function fences(content: string): (index: number) => boolean { + const ranges: Array<[number, number]> = [] + const fence = /^[ \t]*(`{3,}|~{3,})[^\n]*$/gm + let open: { start: number; marker: string } | undefined + for (const m of content.matchAll(fence)) { + const marker = m[1] + // CommonMark: a closing fence uses the same char and is at least as long as the opener, + // so an inner shorter/different fence stays content. Keep the real opener length. + if (!open) open = { start: m.index, marker } + else if (marker[0] === open.marker[0] && marker.length >= open.marker.length) { + ranges.push([open.start, m.index + m[0].length]) + open = undefined + } + } + if (open) ranges.push([open.start, content.length]) // unterminated fence runs to EOF + return (index: number) => ranges.some(([s, e]) => index >= s && index < e) + } +} diff --git a/packages/opencode/src/kilocode/text-stream.ts b/packages/opencode/src/kilocode/text-stream.ts index 82f2f6cd7c..bfaabd164e 100644 --- a/packages/opencode/src/kilocode/text-stream.ts +++ b/packages/opencode/src/kilocode/text-stream.ts @@ -62,11 +62,6 @@ export function abortable(stream: Readable, signal?: AbortSignal) { return signal ? addAbortSignal(signal, stream) : stream } -/** UTF-8 text stream backed by an already-open file. */ -export function openUtf8(open: () => Readable, signal?: AbortSignal): Readable { - return utf8(open, signal).stream -} - export function safeSlice(text: string, end: number) { const sliced = text.slice(0, end) const last = sliced.charCodeAt(sliced.length - 1) diff --git a/packages/opencode/src/kilocode/tool/websearch-kilo-exa.ts b/packages/opencode/src/kilocode/tool/websearch-kilo-exa.ts new file mode 100644 index 0000000000..1e590b6c18 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/websearch-kilo-exa.ts @@ -0,0 +1,74 @@ +// kilocode_change - new file +import { Duration, Effect, Schema } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { KILO_API_BASE } from "@kilocode/kilo-gateway" + +export const KILO_EXA_URL = `${KILO_API_BASE}/api/exa/search` +export const MAX_KILO_EXA_RESULTS = 10 + +const ExaResult = Schema.Struct({ + title: Schema.optional(Schema.String), + url: Schema.String, + publishedDate: Schema.optional(Schema.String), + author: Schema.optional(Schema.String), + highlights: Schema.optional(Schema.Array(Schema.String)), +}) + +const ExaResponse = Schema.Struct({ + results: Schema.Array(ExaResult), +}) + +const NO_RESULTS = "No search results found. Please try a different query." + +const formatResults = (data: Schema.Schema.Type): string => { + if (data.results.length === 0) return NO_RESULTS + return data.results + .map((r, i) => { + const head = `[${i + 1}] ${r.title ?? r.url}\n${r.url}${r.publishedDate ? ` (${r.publishedDate})` : ""}` + const hl = r.highlights?.length ? `\n${r.highlights.map((h) => `> ${h}`).join("\n")}` : "" + return `${head}${hl}` + }) + .join("\n\n") +} + +export type KiloExaParams = { + query: string + type?: string + numResults?: number +} + +export const callKiloExa = Effect.fn("WebSearchKiloExa.call")(function* ( + http: HttpClient.HttpClient, + params: KiloExaParams, + kiloToken: string, +) { + const numResults = Math.min(params.numResults ?? MAX_KILO_EXA_RESULTS, MAX_KILO_EXA_RESULTS) + const request = yield* HttpClientRequest.post(KILO_EXA_URL).pipe( + HttpClientRequest.bearerToken(kiloToken), + HttpClientRequest.acceptJson, + HttpClientRequest.bodyJson({ + query: params.query, + type: params.type ?? "auto", + numResults, + contents: { highlights: true }, + }), + ) + const response = yield* http.execute(request).pipe( + Effect.timeoutOrElse({ + duration: Duration.seconds(25), + orElse: () => Effect.die(new Error("kilo exa request timed out")), + }), + ) + const status = response.status + if (status === 401 || status === 403) { + return yield* Effect.die(new Error(`Kilo exa request unauthorized (${status}); sign in with \`kilo auth login\``)) + } + if (status < 200 || status >= 300) { + const body = yield* response.text + return yield* Effect.die(new Error(`Kilo exa request failed (${status}): ${body.slice(0, 200)}`)) + } + const data = yield* response.json + const decode = Schema.decodeUnknownEffect(ExaResponse) + const parsed = yield* decode(data).pipe(Effect.orDie) + return formatResults(parsed) +}) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 03cc92a353..0617fa8548 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -145,6 +145,7 @@ function subset(permission: string, ruleset: Ruleset) { function covered(entry: PendingEntry, approved: Ruleset, local: Ruleset) { if (ConfigProtection.isRequest(entry.info)) return false + if (entry.info.metadata?.["skillShell"] === true) return false // kilocode_change - skill batch needs an explicit reply return entry.info.patterns.every((pattern) => { if (veto(entry.info.permission, pattern, entry.hardRuleset)) return false return resolve(entry.info.permission, pattern, entry.ruleset, approved, local).action === "allow" @@ -211,6 +212,7 @@ const layer = Layer.effect( : false // kilocode_change end + const forceAsk = request.metadata?.["skillShell"] === true // kilocode_change for (const pattern of request.patterns) { const rule = resolve(request.permission, pattern, ruleset, approved, local) // kilocode_change — include session-scoped rules yield* Effect.logInfo("evaluated", { permission: request.permission, pattern, action: rule }) @@ -224,6 +226,12 @@ const layer = Layer.effect( ruleset: subset(request.permission, ruleset), // kilocode_change }) } + // kilocode_change start - skill shell forces a prompt instead of honoring an allow/auto-approve rule + if (forceAsk) { + needsAsk = true + continue + } + // kilocode_change end // kilocode_change start - override "allow" to "ask" for protected config paths if (rule.action === "allow" && (!isProtected || trusted)) { approvedRule = rule // remember the winning rule so callers can explain the auto-approval @@ -280,6 +288,18 @@ const layer = Layer.effect( const existing = pending.get(input.requestID) if (!existing) return yield* new PermissionV1.NotFoundError({ requestID: input.requestID }) + // kilocode_change start - skill-shell batches must be answered by a human; ignore machine approvals + // (auto-approve/YOLO clients omit `interactive`) so the prompt stays pending for a real decision. + // Log rather than fail silently: a genuine human client sets `interactive`, so a refused reply here + // means an auto-approver tried to answer — the request intentionally stays pending for a human. + if (existing.info.metadata?.["skillShell"] === true && input.reply !== "reject" && input.interactive !== true) { + yield* Effect.logWarning("skill shell approval refused: requires an interactive human reply", { + id: input.requestID, + }) + return + } + // kilocode_change end + pending.delete(input.requestID) yield* events.publish(Event.Replied, { sessionID: existing.info.sessionID, diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index b171da91d5..fcda856407 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -29,7 +29,7 @@ const ALLOWED_MODELS = new Set([ // kilocode_change end ]) // kilocode_change start -const DISALLOWED_MODELS = new Set(["gpt-5.5-pro"]) +const DISALLOWED_MODELS = new Set(["gpt-5.5-pro", "gpt-5.6"]) // kilocode_change end interface PkceCodes { diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts index daaa43534f..b9457ffc03 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts @@ -12,6 +12,7 @@ const root = "/permission" const ReplyPayload = Schema.Struct({ reply: PermissionV1.Reply, message: Schema.optional(Schema.String), + interactive: Schema.optional(Schema.Boolean), // kilocode_change - human-answered flag; gates skill-shell approvals }) // kilocode_change start diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts index ce9ee63f4b..b317cd794c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts @@ -30,6 +30,7 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permiss requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message, + interactive: ctx.payload.interactive, // kilocode_change }) .pipe( Effect.catchTag("Permission.NotFoundError", (error) => diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 96c1decbaf..77bce50390 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -5,6 +5,7 @@ import path from "path" import { SessionV1 } from "@opencode-ai/core/v1/session" import os from "os" import { KiloSessionPrompt } from "@/kilocode/session/prompt" // kilocode_change +import { SKILL_SHELL_DISABLED, SKILL_SHELL_UNTRUSTED } from "@/kilocode/skills/display" // kilocode_change import { KiloSessionMessageOrder } from "@/kilocode/session/message-order" // kilocode_change import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change import { KiloSession } from "@/kilocode/session" // kilocode_change @@ -1798,9 +1799,11 @@ export const layer = Layer.effect( // kilocode_change start — break out so a newer queued prompt can take over // instead of starting another LLM step for the now-superseded turn. The // current handle.process has fully drained (tokens + inline tool calls) by - // the time we get here, so nothing is cut off. + // the time we get here, so nothing is cut off. The close reason is + // "superseded", not "interrupted": this is a deliberate queue handoff, + // not a premature stop, so clients must not flash an interruption warning. if (KiloSessionPromptQueue.hasFollowup(sessionID)) { - closeReasons.set(sessionID, "interrupted") + closeReasons.set(sessionID, "superseded") return "break" as const } // kilocode_change end @@ -1983,7 +1986,15 @@ export const layer = Layer.effect( } const shellMatches = ConfigMarkdown.shell(template) - if (shellMatches.length > 0) { + // kilocode_change start - skill templates run !`cmd`` only when trusted and the kill-switch is off, + // mirroring the skill tool's gate (the slash-command path is user-initiated, so it is not prompted). + const skillTemplate = cmd.source === "skill" + const skillShellBlocked = skillTemplate && (cmd.trusted !== true || flags.disableSkillShell) + if (shellMatches.length > 0 && skillShellBlocked) { + const note = cmd.trusted !== true ? SKILL_SHELL_UNTRUSTED : SKILL_SHELL_DISABLED + template = template.replace(bashRegex, () => note) + } else if (shellMatches.length > 0) { + // kilocode_change end const cfg = yield* config.get() const sh = Shell.preferred(cfg.shell) // kilocode_change start diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index b56a67610f..27467e4143 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -6,6 +6,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr import { withTransientReadRetry } from "@/util/effect-http-client" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" +import { isSafeSegment, isSafeRelativePath } from "@/kilocode/skill/discovery-validate" // kilocode_change const skillConcurrency = 4 const fileConcurrency = 8 @@ -48,8 +49,10 @@ const layer: Layer.Layer !skill.files.includes("SKILL.md")) - yield* Effect.forEach( - missing, - (skill) => Effect.logWarning("skill entry missing SKILL.md", { url: index, skill: skill.name }), - { discard: true }, - ) - const list = data.skills.filter((skill) => skill.files.includes("SKILL.md")) + // kilocode_change start - the remote index controls skill.name and file, so validate every segment, + // pin file downloads to the index origin, and confine writes to the cache (mirrors core v2 SkillDiscovery) + const contained = (parent: string, child: string) => { + const rel = path.relative(parent, child) + return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel) + } + const plan = (skill: IndexSkill) => { + if (!skill.files.includes("SKILL.md")) return "skill entry missing SKILL.md" + if (!isSafeSegment(skill.name)) return "skipping skill with unsafe name" + const root = path.join(cache, skill.name) + if (!contained(cache, root)) return "skipping skill with unsafe name" + const skillUrl = new URL(`${encodeURIComponent(skill.name)}/`, source) + const files: { url: string; rel: string }[] = [] + for (const file of skill.files) { + if (!isSafeRelativePath(file)) return "skipping skill with unsafe file path" + const resource = URL.parse(file, skillUrl) ?? undefined + if (!resource || resource.origin !== source.origin) return "skipping skill with cross-origin file" + if (!contained(root, path.join(root, file))) return "skipping skill with unsafe file path" + files.push({ url: resource.href, rel: file }) + } + return { name: skill.name, version: skill.version, root, files } + } + const planned: { + name: string + version: string | undefined + root: string + files: { url: string; rel: string }[] + }[] = [] + for (const skill of data.skills) { + const result = plan(skill) + if (typeof result === "string") yield* Effect.logWarning(result, { url: index, skill: skill.name }) + else planned.push(result) + } + // kilocode_change end + + // kilocode_change start - download each validated, origin-pinned, cache-confined plan const dirs = yield* Effect.forEach( - list, + planned, (skill) => Effect.gen(function* () { - const root = path.join(cache, skill.name) + const { root, version } = skill const versionFile = path.join(root, ".opencode-version") - const version = skill.version + const fetchInto = (target: string) => + Effect.forEach(skill.files, (file) => download(file.url, path.join(target, file.rel)), { + concurrency: fileConcurrency, + }) const current = version === undefined ? undefined : yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined))) if (version === undefined || current === version) { - yield* Effect.forEach( - skill.files, - (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)), - { concurrency: fileConcurrency, discard: true }, - ) + yield* fetchInto(root) } else { const token = crypto.randomUUID() const staging = `${root}.tmp-${token}` const backup = `${root}.old-${token}` yield* Effect.gen(function* () { - const downloaded = yield* Effect.forEach( - skill.files, - (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(staging, file)), - { concurrency: fileConcurrency }, - ) + const downloaded = yield* fetchInto(staging) if (!downloaded.every(Boolean)) return if (!(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie))) return yield* fs.writeFileString(path.join(staging, ".opencode-version"), version) @@ -127,6 +154,7 @@ const layer: Layer.Layer dir !== null) }) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 192bd330e6..b6e3e4fc94 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -20,6 +20,7 @@ import { Git } from "@/git" // kilocode_change import { isRecord } from "@/util/record" import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change import { escapeHtml } from "@/util/html" +import { trustedInProject } from "../kilocode/skill/trust" // kilocode_change const CLAUDE_EXTERNAL_DIR = ".claude" const AGENTS_EXTERNAL_DIR = ".agents" @@ -35,6 +36,7 @@ export const Info = Schema.Struct({ description: Schema.optional(Schema.String), location: Schema.String, content: Schema.String, + trusted: Schema.optional(Schema.Boolean), // kilocode_change - gate skill shell injection to trusted sources }) export type Info = Schema.Schema.Type @@ -151,6 +153,7 @@ const add = Effect.fnUntraced(function* (state: State, match: Match, events: Eve description: md.data.description, location: match.path, // kilocode_change content: md.content, + trusted: match.trusted, // kilocode_change } }) @@ -158,7 +161,7 @@ const scan = Effect.fnUntraced(function* ( state: ScanState, root: string, pattern: string, - opts?: { dot?: boolean; scope?: string; trusted?: boolean; root?: string; sourceRoot?: string }, // kilocode_change + opts?: { dot?: boolean; scope?: string; trusted?: boolean; root?: string; sourceRoot?: string; projectRoot?: string }, // kilocode_change ) { const matches = yield* Effect.tryPromise({ try: () => @@ -180,12 +183,14 @@ const scan = Effect.fnUntraced(function* ( ) for (const match of matches) { - // kilocode_change start + // kilocode_change start - a trusted match whose realpath resolves inside the project (e.g. a + // symlink from ~/.agents/skills into the repo) must not mint trust for project-controlled content + const trusted = (opts?.trusted ?? false) && !trustedInProject(match, opts?.projectRoot) state.matches.set(match, { path: match, - trusted: opts?.trusted ?? false, - root: opts?.root, - sourceRoot: opts?.sourceRoot, + trusted, + root: trusted ? opts?.root : (opts?.root ?? opts?.projectRoot), + sourceRoot: trusted ? opts?.sourceRoot : (opts?.sourceRoot ?? opts?.projectRoot), }) // kilocode_change end state.dirs.add(path.dirname(match)) @@ -213,7 +218,7 @@ const discoverSkills = Effect.fnUntraced(function* ( for (const dir of externalDirs) { const root = path.join(global.home, dir) if (!(yield* fsys.isDir(root))) continue - yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global", trusted: true }) // kilocode_change + yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global", trusted: true, projectRoot }) // kilocode_change } // kilocode_change start @@ -250,6 +255,7 @@ const discoverSkills = Effect.fnUntraced(function* ( trusted, root: trusted ? undefined : projectRoot, sourceRoot: trusted ? undefined : sourceRoot, + projectRoot, }) // kilocode_change end } @@ -266,7 +272,11 @@ const discoverSkills = Effect.fnUntraced(function* ( // kilocode_change start - trust follows the config source that declared the path, never the selected path. const origin = cfg.skill_path_origins?.[item] const trusted = origin?.trusted === true && path.isAbsolute(expanded) - yield* scan(state, dir, SKILL_PATTERN, { trusted, root: trusted ? undefined : (origin?.root ?? projectRoot) }) + yield* scan(state, dir, SKILL_PATTERN, { + trusted, + root: trusted ? undefined : (origin?.root ?? projectRoot), + projectRoot, + }) // kilocode_change end } @@ -295,6 +305,7 @@ const loadSkills = Effect.fnUntraced(function* ( description: skill.description, location: BUILTIN_LOCATION, content: skill.content, + trusted: true, // kilocode_change - builtin skills ship in the binary } } // kilocode_change end diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index f4088af943..5ab4dc1a7a 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -337,9 +337,11 @@ const layer = Layer.effect( // kilocode_change end const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { + const cfg = yield* config.get() // kilocode_change const filtered = (yield* all()).filter((tool) => { if (!KiloToolRegistry.available(tool, input.agent)) return false // kilocode_change if (tool.id === WebSearchTool.id) { + if (cfg.web_search === true) return true // kilocode_change return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel }) } diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index f07e545b65..26da3a767a 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -433,7 +433,24 @@ export const ShellPermission = Effect.gen(function* () { ) }) - return { ask: check, resolve } + // kilocode_change start - expose the tree-sitter scan (sub-command patterns + external-dir globs) for skill-shell batching + const dirGlob = (dir: string) => + process.platform === "win32" ? FSUtil.normalizePathPattern(path.join(dir, "*")) : path.join(dir, "*") + const decompose = Effect.fn("ShellTool.decompose")(function* (input: { command: string; cwd: string; shell: string }) { + const instance = yield* InstanceState.context + const ps = Shell.ps(input.shell) + return yield* Effect.scoped( + Effect.gen(function* () { + const tree = yield* Effect.acquireRelease(parse(input.command, ps), (tree) => Effect.sync(() => tree.delete())) + const scan = yield* collect(tree.rootNode, input.cwd, ps, input.shell, instance) + if (!containsPath(input.cwd, instance)) scan.dirs.add(input.cwd) + return { patterns: Array.from(scan.patterns), dirs: Array.from(scan.dirs, dirGlob) } + }), + ) + }) + // kilocode_change end + + return { ask: check, resolve, decompose } // kilocode_change - decompose for skill-shell }) // kilocode_change end diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 4fdc4d3d5b..6a5b54c6a6 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -4,6 +4,14 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep" import { Skill } from "../skill" import * as Tool from "./tool" import DESCRIPTION from "./skill.txt" +// kilocode_change start - gate + run shell injection in skill bodies +import { Config } from "@/config/config" +import { Shell } from "@opencode-ai/core/shell" +import { InstanceState } from "@/effect/instance-state" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { ShellPermission } from "./shell" +import { SkillInject } from "@/kilocode/skills/inject" +// kilocode_change end export const Parameters = Schema.Struct({ name: Schema.String.annotate({ description: "The name of the skill from available_skills" }), @@ -14,6 +22,9 @@ export const SkillTool = Tool.define( Effect.gen(function* () { const skill = yield* Skill.Service const ripgrep = yield* Ripgrep.Service + const flags = yield* RuntimeFlags.Service // kilocode_change + const permission = yield* ShellPermission // kilocode_change - decompose skill commands like the bash tool + const config = yield* Config.Service // kilocode_change - resolve a parseable shell for injection return { description: DESCRIPTION, @@ -31,6 +42,20 @@ export const SkillTool = Tool.define( metadata: {}, }) + // kilocode_change start - render `!`cmd`` shell injection, gated by trust + kill-switch + batch approval + const cfg = yield* config.get() + const content = yield* SkillInject.render({ + content: info.content, + trusted: info.trusted === true, + disabled: flags.disableSkillShell, + cwd: yield* InstanceState.directory, + skill: info.name, + shell: Shell.acceptable(cfg.shell), + ctx, + decompose: permission.decompose, + }) + // kilocode_change end + // kilocode_change start - built-in skills have no filesystem directory if (info.location === Skill.BUILTIN_LOCATION) { return { @@ -39,7 +64,7 @@ export const SkillTool = Tool.define( ``, `# Skill: ${info.name}`, "", - info.content.trim(), + content.trim(), // kilocode_change "", ].join("\n"), metadata: { @@ -67,7 +92,7 @@ export const SkillTool = Tool.define( ``, `# Skill: ${info.name}`, "", - info.content.trim(), + content.trim(), // kilocode_change "", `Base directory for this skill: ${base}`, "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index 12da7fbb3a..9a812cfc62 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,16 +1,20 @@ -import { Effect, Schema } from "effect" +import { Effect, Option, Schema } from "effect" // kilocode_change - Option added for kilo-exa transport dispatch import { HttpClient } from "effect/unstable/http" import * as Tool from "./tool" import * as McpWebSearch from "./mcp-websearch" +import * as KiloExa from "@/kilocode/tool/websearch-kilo-exa" // kilocode_change - Kilo-REST Exa transport import DESCRIPTION from "./websearch.txt" import { checksum } from "@opencode-ai/core/util/encode" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { RuntimeFlags } from "@/effect/runtime-flags" +import { Auth } from "@/auth" // kilocode_change - source Kilo bearer for Kilo-REST transport + +const MAX_RESULTS = 10 // kilocode_change - cap numResults across all transports export const Parameters = Schema.Struct({ query: Schema.String.annotate({ description: "Websearch query" }), numResults: Schema.optional(Schema.Number).annotate({ - description: "Number of search results to return (default: 8)", + description: "Number of search results to return (default: 8, maximum: 10)", // kilocode_change - note MAX_RESULTS cap }), livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({ description: @@ -24,12 +28,12 @@ export const Parameters = Schema.Struct({ }), }) -const WebSearchProviderSchema = Schema.Literals(["exa", "parallel"]) +const WebSearchProviderSchema = Schema.Literals(["exa", "parallel", "kilo-exa"]) // kilocode_change - kilo-exa env override export type WebSearchProvider = Schema.Schema.Type export function selectWebSearchProvider(sessionID: string, flags = { exa: false, parallel: false }): WebSearchProvider { const override = process.env.KILO_WEBSEARCH_PROVIDER - if (override === "exa" || override === "parallel") return override + if (override === "exa" || override === "parallel" || override === "kilo-exa") return override // kilocode_change - kilo-exa env override if (flags.parallel) return "parallel" if (flags.exa) return "exa" @@ -38,7 +42,7 @@ export function selectWebSearchProvider(sessionID: string, flags = { exa: false, export function webSearchProviderLabel(provider: unknown) { if (provider === "parallel") return "Parallel Web Search" - if (provider === "exa") return "Exa Web Search" + if (provider === "exa" || provider === "kilo-exa") return "Exa Web Search" // kilocode_change - kilo-exa shares label return "Web Search" } @@ -88,7 +92,7 @@ function callProvider( { query: params.query, type: params.type || "auto", - numResults: params.numResults || 8, + numResults: Math.min(params.numResults || 8, MAX_RESULTS), // kilocode_change - cap at MAX_RESULTS livecrawl: params.livecrawl || "fallback", contextMaxCharacters: params.contextMaxCharacters, }, @@ -101,6 +105,7 @@ export const WebSearchTool = Tool.define( Effect.gen(function* () { const http = yield* HttpClient.HttpClient const flags = yield* RuntimeFlags.Service + const authSvc = yield* Auth.Service // kilocode_change - source Kilo bearer for Kilo-REST transport return { get description() { @@ -114,7 +119,36 @@ export const WebSearchTool = Tool.define( parallel: flags.enableParallel, }) const title = webSearchProviderLabel(provider) - yield* ctx.metadata({ title: `${title} "${params.query}"`, metadata: { provider } }) + // kilocode_change start - Kilo-REST Exa transport + // Precedence: + // provider="kilo-exa" -> kilo-rest (auth required) + // provider="exa" + EXA_API_KEY -> mcp-exa-byok (BYOK wins) + // provider="exa" + Kilo auth -> kilo-rest (new default for authed users) + // provider="exa" + no auth -> mcp-exa-unauth (preserves current fallback) + // provider="parallel" -> mcp-parallel (unchanged) + const kiloToken = yield* Effect.gen(function* () { + if (provider !== "exa" && provider !== "kilo-exa") return undefined as string | undefined + const info = yield* authSvc.get("kilo") + if (!info) return undefined + return info.type === "api" ? info.key : info.type === "oauth" ? info.access : undefined + }) + const transport = + provider === "kilo-exa" + ? "kilo-rest" + : provider === "parallel" + ? "mcp-parallel" + : provider === "exa" && process.env.EXA_API_KEY + ? "mcp-exa-byok" + : provider === "exa" && kiloToken + ? "kilo-rest" + : "mcp-exa-unauth" + // kilocode_change end + // kilocode_change start - add transport to metadata + yield* ctx.metadata({ + title: `${title} "${params.query}"`, + metadata: { provider, transport }, + }) + // kilocode_change end yield* ctx.ask({ permission: "websearch", @@ -130,12 +164,26 @@ export const WebSearchTool = Tool.define( }, }) - const result = yield* callProvider(http, provider, params, ctx) + // kilocode_change start - dispatch Kilo-REST transport + const result = yield* transport === "kilo-rest" + ? kiloToken + ? KiloExa.callKiloExa( + http, + { + query: params.query, + type: params.type, + numResults: params.numResults, + }, + kiloToken, + ) + : Effect.die(new Error("KILO_WEBSEARCH_PROVIDER=kilo-exa requires Kilo auth; run `kilo auth login`")) + : callProvider(http, provider, params, ctx) + // kilocode_change end return { output: result ?? "No search results found. Please try a different query.", title: `${title}: ${params.query}`, - metadata: { provider }, + metadata: { provider, transport }, // kilocode_change - add transport } }).pipe(Effect.orDie), } diff --git a/packages/opencode/test/acp/permission.test.ts b/packages/opencode/test/acp/permission.test.ts index b7dc5c093b..1ad8508051 100644 --- a/packages/opencode/test/acp/permission.test.ts +++ b/packages/opencode/test/acp/permission.test.ts @@ -180,7 +180,11 @@ describe("acp permissions", () => { { optionId: "reject", kind: "reject_once", name: "Reject" }, ], }) - expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }]) + // kilocode_change start - human selections are marked interactive + expect(harness.replies).toEqual([ + { requestID: "perm_1", reply: "once", directory: "/workspace", interactive: true }, + ]) + // kilocode_change end }) it("uses permission metadata for non-shell titles", async () => { @@ -329,6 +333,39 @@ describe("acp permissions", () => { }) }) + // kilocode_change start - skill shell batches surface their command list and cannot be persisted + it("forwards skill shell commands and omits the persist option", async () => { + const harness = createHarness() + await createSession(harness.session, "ses_a") + + harness.subscription.handle( + permissionAsked("ses_a", "perm_skill", { + permission: "bash", + // metadata.commands carries the verbatim command list the injector sends + metadata: { skillShell: true, commands: ["git status", "printf hi"] }, + tool: { messageID: "msg_1", callID: "call_1" }, + }), + ) + + await pollUntil(() => harness.replies.length === 1, "skill shell permission was never replied") + + expect(harness.requests[0]).toMatchObject({ + toolCall: { + title: "Run skill shell commands", + rawInput: { skillShell: true, commands: ["git status", "printf hi"] }, + }, + // no allow_always: skill shell is never persisted + options: [ + { optionId: "once", kind: "allow_once", name: "Allow" }, + { optionId: "reject", kind: "reject_once", name: "Reject" }, + ], + }) + expect(harness.requests[0].options.some((o) => o.kind === "allow_always")).toBe(false) + // the human selection is marked interactive so the server accepts the approval + expect(harness.replies[0]).toMatchObject({ requestID: "perm_skill", reply: "once", interactive: true }) + }) + // kilocode_change end + it("rejects non-selected outcomes", async () => { const harness = createHarness(() => Promise.resolve({ outcome: { outcome: "cancelled" } })) await createSession(harness.session, "ses_a") diff --git a/packages/opencode/test/cli/run/permission.shared.test.ts b/packages/opencode/test/cli/run/permission.shared.test.ts index aa843ccbbc..75eb76f2ba 100644 --- a/packages/opencode/test/cli/run/permission.shared.test.ts +++ b/packages/opencode/test/cli/run/permission.shared.test.ts @@ -6,6 +6,7 @@ import { permissionCancel, permissionEscape, permissionInfo, + permissionOptions, // kilocode_change permissionReject, permissionRun, } from "@/cli/cmd/run/permission.shared" @@ -29,6 +30,7 @@ describe("run permission shared", () => { expect(out.reply).toEqual({ requestID: "perm-1", reply: "once", + interactive: true, // kilocode_change }) }) @@ -41,6 +43,7 @@ describe("run permission shared", () => { expect(permissionRun(next.state, "perm-1", "confirm").reply).toEqual({ requestID: "perm-1", reply: "always", + interactive: true, // kilocode_change }) expect(permissionRun(next.state, "perm-1", "cancel").state).toMatchObject({ @@ -57,6 +60,7 @@ describe("run permission shared", () => { expect(out).toEqual({ requestID: "perm-1", reply: "reject", + interactive: true, // kilocode_change message: "use rg", }) @@ -130,6 +134,13 @@ describe("run permission shared", () => { }) }) + // kilocode_change start - skill-shell options + test("skill shell offers only Allow / Reject (never Allow always)", () => { + expect(permissionOptions("permission", true)).toEqual(["once", "reject"]) + expect(permissionOptions("permission")).toEqual(["once", "always", "reject"]) + }) + // kilocode_change end + test("formats always-allow copy for wildcard and explicit patterns", () => { expect(permissionAlwaysLines(req({ permission: "bash", always: ["*"] }))).toEqual([ "This will allow bash until Kilo is restarted.", diff --git a/packages/opencode/test/kilocode/acp/permission.test.ts b/packages/opencode/test/kilocode/acp/permission.test.ts new file mode 100644 index 0000000000..8923f365a1 --- /dev/null +++ b/packages/opencode/test/kilocode/acp/permission.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import { SkillShellPrompt } from "@/kilocode/acp/permission" + +describe("SkillShellPrompt", () => { + test("detects the skillShell metadata flag", () => { + expect(SkillShellPrompt.is({ skillShell: true })).toBe(true) + expect(SkillShellPrompt.is({ skillShell: false })).toBe(false) + expect(SkillShellPrompt.is({})).toBe(false) + expect(SkillShellPrompt.is(undefined)).toBe(false) + }) + + test("offers only allow-once and reject, never allow-always", () => { + expect(SkillShellPrompt.options.map((o) => o.optionId)).toEqual(["once", "reject"]) + expect(SkillShellPrompt.options.some((o) => o.kind === "allow_always")).toBe(false) + }) +}) diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index 257250f6aa..2a4c8306d7 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -148,6 +148,14 @@ describe("global config updates", () => { }) }) +describe("kilocode web search config", () => { + test("accepts enabling web search for all providers", () => { + const config = Schema.decodeUnknownSync(Config.Info)({ web_search: true }) + + expect(config.web_search).toBe(true) + }) +}) + describe("kilocode indexing config", () => { test("ignores retired semantic indexing flags in existing configs", async () => { await using tmp = await tmpdir({ git: true }) @@ -465,6 +473,145 @@ describe("subagent variant overrides", () => { }) }) +describe("unset propagation across layered config files", () => { + const getGlobal = () => + Effect.runPromise(Config.Service.use((svc) => svc.getGlobal()).pipe(Effect.scoped, Effect.provide(layer))) + + test("removes subagent_model from every global config file when unset", async () => { + await using globalTmp = await tmpdir() + const json = path.join(globalTmp.path, "kilo.json") + const jsonc = path.join(globalTmp.path, "kilo.jsonc") + const jsoncText = ["{", " // Keep this comment.", ' "username": "marius"', "}", ""].join("\n") + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear() + await disposeAllInstances() + + try { + await writeConfig(globalTmp.path, { subagent_model: "kilo/openai/gpt-5" }, "kilo.json") + await Filesystem.write(jsonc, jsoncText) + + await saveGlobal(decode({ subagent_model: null })) + + // The key must be gone from the lower-precedence kilo.json as well, or + // the read chain keeps resolving it and the unset appears to do nothing. + expect(JSON.parse(await Bun.file(json).text())).not.toHaveProperty("subagent_model") + // The primary target had no key, so it must remain byte-identical. + expect(await Bun.file(jsonc).text()).toBe(jsoncText) + + await clear() + expect((await getGlobal()).subagent_model).toBeUndefined() + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + + test("removes nested sentinels from jsonc siblings while preserving comments", async () => { + await using globalTmp = await tmpdir() + const opencode = path.join(globalTmp.path, "opencode.jsonc") + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear() + await disposeAllInstances() + + try { + await Filesystem.write(path.join(globalTmp.path, "kilo.jsonc"), '{ "username": "marius" }\n') + await Filesystem.write( + opencode, + [ + "{", + " // Preserve this comment while clearing overrides.", + ' "agent": {', + ' "explore": {', + ' "model": "kilo/anthropic/claude-sonnet-4-6",', + ' "description": "Keep me"', + " }", + " }", + "}", + ].join("\n"), + ) + + await saveGlobal(decode({ agent: { explore: { model: null } } })) + + const written = await Bun.file(opencode).text() + expect(written).toContain("// Preserve this comment while clearing overrides.") + expect(written).not.toContain('"model"') + expect(written).toContain('"description": "Keep me"') + + await clear() + expect((await getGlobal()).agent?.explore?.model).toBeUndefined() + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + + test("does not rewrite sibling files on set or when the key is absent", async () => { + await using globalTmp = await tmpdir() + const json = path.join(globalTmp.path, "kilo.json") + const jsonText = JSON.stringify({ subagent_model: "kilo/old-model", username: "marius" }, null, 2) + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear() + await disposeAllInstances() + + try { + await Filesystem.write(path.join(globalTmp.path, "kilo.jsonc"), '{ "username": "marius" }\n') + await Filesystem.write(json, jsonText) + + // Sets only write to the primary target; lower-precedence copies stay + // untouched and are simply shadowed by the higher-precedence value. + await saveGlobal(decode({ subagent_model: "kilo/new-model" })) + expect(await Bun.file(json).text()).toBe(jsonText) + + // Unsetting an absent key must not rewrite the sibling either. + await saveGlobal(decode({ small_model: null })) + expect(await Bun.file(json).text()).toBe(jsonText) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + + test("removes subagent_model from every project config file when unset", async () => { + await using tmp = await tmpdir({ git: true }) + await Filesystem.write( + path.join(tmp.path, ".kilo", "kilo.json"), + JSON.stringify({ subagent_model: "kilo/openai/gpt-5" }), + ) + await Filesystem.write(path.join(tmp.path, ".kilo", "kilo.jsonc"), '{\n "username": "keep"\n}\n') + + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + await saveProject(decode({ subagent_model: null })) + expect((await load()).subagent_model).toBeUndefined() + }, + }) + + const json = JSON.parse(await Bun.file(path.join(tmp.path, ".kilo", "kilo.json")).text()) + expect(json).not.toHaveProperty("subagent_model") + // The primary target only gained nothing; the delete was a no-op there. + const jsonc = JSON.parse(await Bun.file(path.join(tmp.path, ".kilo", "kilo.jsonc")).text()) + expect(jsonc).not.toHaveProperty("subagent_model") + expect(jsonc.username).toBe("keep") + }) + + test("collects null sentinel paths from nested patches", () => { + expect( + KilocodeConfig.unsetPaths({ + subagent_model: null, + agent: { explore: { model: null, variant: "high" } }, + username: "marius", + }), + ).toEqual([["subagent_model"], ["agent", "explore", "model"]]) + }) +}) + describe("agent config", () => { test("accepts delete sentinels for agent model and variant overrides", () => { const patch = decode({ agent: { explore: { model: null, variant: null } } }) diff --git a/packages/opencode/test/kilocode/npm-publish.test.ts b/packages/opencode/test/kilocode/npm-publish.test.ts index 62e9434986..33318925ce 100644 --- a/packages/opencode/test/kilocode/npm-publish.test.ts +++ b/packages/opencode/test/kilocode/npm-publish.test.ts @@ -1,6 +1,16 @@ import { describe, expect, test } from "bun:test" import { NpmPublish } from "../../script/kilocode/npm-publish" +describe("npm publish aliases", () => { + test("promotes stable releases to the rc channel", () => { + expect(NpmPublish.aliases("latest")).toEqual(["rc"]) + }) + + test("does not promote prereleases", () => { + expect(NpmPublish.aliases("rc")).toEqual([]) + }) +}) + describe("npm publish retry", () => { test("returns after the first successful attempt", async () => { const calls = { run: 0, exists: 0, sleep: 0 } diff --git a/packages/opencode/test/kilocode/permission/skill-shell.test.ts b/packages/opencode/test/kilocode/permission/skill-shell.test.ts new file mode 100644 index 0000000000..f4aa00bb5c --- /dev/null +++ b/packages/opencode/test/kilocode/permission/skill-shell.test.ts @@ -0,0 +1,217 @@ +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { expect } from "bun:test" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" +import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" +import { Permission } from "@/permission" +import { testEffect } from "../../lib/effect" +import { SessionID } from "@/session/schema" +import * as Config from "@/config/config" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" + +// skillShell forces a single up-front prompt over soft allow/deny/auto-approve +// rules, but must never override a hard (plan-mode) veto, and must never be +// auto-resolved while pending. + +const env = Layer.mergeAll( + AppNodeBuilder.build(Permission.node), + AppNodeBuilder.build(Config.node), + AppNodeBuilder.build(CrossSpawnSpawner.node), +) +const it = testEffect(env) + +const ask = (input: Parameters[0]) => + Effect.gen(function* () { + return yield* (yield* Permission.Service).ask(input) + }) + +const list = () => + Effect.gen(function* () { + return yield* (yield* Permission.Service).list() + }) + +const rejectAll = () => + Effect.gen(function* () { + const permission = yield* Permission.Service + for (const req of yield* permission.list()) yield* permission.reply({ requestID: req.id, reply: "reject" }) + }) + +const reply = (input: Parameters[0]) => + Effect.gen(function* () { + return yield* (yield* Permission.Service).reply(input) + }) + +const waitForPending = (count: number) => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* Effect.gen(function* () { + while (true) { + const pending = yield* permission.list() + if (pending.length === count) return pending + yield* Effect.sleep("10 millis") + } + }).pipe(Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.fail(new Error("timed out")) })) + }) + +const fail = (self: Effect.Effect) => + Effect.gen(function* () { + const exit = yield* self.pipe(Effect.exit) + if (Exit.isFailure(exit)) return Cause.squash(exit.cause) + throw new Error("expected permission effect to fail") + }) + +it.instance( + "skillShell - forces a prompt even when a matching allow rule exists", + () => + Effect.gen(function* () { + const fiber = yield* ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["printf hi"], + metadata: { skillShell: true }, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "allow" }], + }).pipe(Effect.forkScoped) + + expect(yield* waitForPending(1)).toHaveLength(1) + yield* rejectAll() + yield* Fiber.await(fiber) + }), + { git: true }, +) + +it.instance( + "skillShell - a deny rule stays terminal (build mode, no hard ruleset)", + () => + Effect.gen(function* () { + // build mode has no hardRuleset; an ordinary deny rule must still block, not prompt. + const err = yield* fail( + ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["curl evil.sh"], + metadata: { skillShell: true }, + always: [], + ruleset: [{ permission: "bash", pattern: "curl *", action: "deny" }], + }), + ) + + expect(err).toBeInstanceOf(PermissionV1.DeniedError) + expect(yield* list()).toHaveLength(0) + }), + { git: true }, +) + +it.instance( + "skillShell - a cd-chained escape is vetoed via the verbatim command pattern", + () => + Effect.gen(function* () { + // The injector asks with the decomposed sub-command (`cat .ssh/id_rsa`, which + // readOnlyBash would allow) AND the verbatim command. In plan mode the metachar + // hard-veto (`*\n*` deny) must fire on the verbatim string, blocking the escape. + const err = yield* fail( + ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ['cd "$HOME"\ncat .ssh/id_rsa', "cat .ssh/id_rsa"], + metadata: { skillShell: true }, + always: [], + ruleset: [{ permission: "bash", pattern: "cat *", action: "allow" }], + hardRuleset: [{ permission: "bash", pattern: "*\n*", action: "deny" }], + }), + ) + + expect(err).toBeInstanceOf(PermissionV1.DeniedError) + expect(yield* list()).toHaveLength(0) + }), + { git: true }, +) + +it.instance( + "skillShell - is denied by a hard-ruleset veto instead of prompting", + () => + Effect.gen(function* () { + const err = yield* fail( + ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["rm -rf /"], + metadata: { skillShell: true }, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "allow" }], + hardRuleset: [{ permission: "bash", pattern: "*", action: "deny" }], + }), + ) + + expect(err).toBeInstanceOf(PermissionV1.DeniedError) + expect(yield* list()).toHaveLength(0) + }), + { git: true }, +) + +it.instance( + "skillShell - a pending batch is not auto-resolved by allowEverything", + () => + Effect.gen(function* () { + const fiber = yield* ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["printf hi"], + metadata: { skillShell: true }, + always: [], + ruleset: [], + }).pipe(Effect.forkScoped) + + expect(yield* waitForPending(1)).toHaveLength(1) + yield* (yield* Permission.Service).allowEverything({ enable: true }) + // still pending: YOLO cannot silently approve a skill batch + expect(yield* list()).toHaveLength(1) + yield* rejectAll() + yield* Fiber.await(fiber) + }), + { git: true }, +) + +it.instance( + "skillShell - a machine approval (no interactive flag) is ignored and stays pending", + () => + Effect.gen(function* () { + const fiber = yield* ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["printf hi"], + metadata: { skillShell: true }, + always: [], + ruleset: [], + }).pipe(Effect.forkScoped) + + const [pending] = yield* waitForPending(1) + // An auto-approver replies without `interactive`; the server must ignore it. + yield* reply({ requestID: pending.id, reply: "once" }) + expect(yield* list()).toHaveLength(1) + yield* rejectAll() + yield* Fiber.await(fiber) + }), + { git: true }, +) + +it.instance( + "skillShell - an interactive approval resolves the request", + () => + Effect.gen(function* () { + const fiber = yield* ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["printf hi"], + metadata: { skillShell: true }, + always: [], + ruleset: [], + }).pipe(Effect.forkScoped) + + const [pending] = yield* waitForPending(1) + yield* reply({ requestID: pending.id, reply: "once", interactive: true }) + // human approval clears the prompt and the ask succeeds + expect(yield* list()).toHaveLength(0) + yield* Fiber.await(fiber) + }), + { git: true }, +) diff --git a/packages/opencode/test/kilocode/server/config-overlay.test.ts b/packages/opencode/test/kilocode/server/config-overlay.test.ts index d3351b0d50..a21deb1e5d 100644 --- a/packages/opencode/test/kilocode/server/config-overlay.test.ts +++ b/packages/opencode/test/kilocode/server/config-overlay.test.ts @@ -201,6 +201,42 @@ describe("config overlay routes", () => { }) }) + test.serial("resolves and reverts project websearch overrides", async () => { + await using global = await tmpdir() + await using project = await tmpdir() + await setGlobal(global.path, { web_search: true }) + + await json( + await req(project.path, "/config/overlay", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ scope: "project", set: { web_search: false } }), + }), + ) + const overridden = await json(await req(project.path, "/config/overlay?scope=project")) + expect(overridden.fields.web_search).toMatchObject({ + source: "project", + inherited: false, + overridden: true, + value: false, + }) + + await json( + await req(project.path, "/config/overlay", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ scope: "project", unset: [["web_search"]] }), + }), + ) + const inherited = await json(await req(project.path, "/config/overlay?scope=project")) + expect(inherited.fields.web_search).toMatchObject({ + source: "global", + inherited: true, + overridden: false, + value: true, + }) + }) + test.serial("marks global indexing values inherited in project scope", async () => { await using global = await tmpdir() await using project = await tmpdir() diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index 8375ebf795..e1e37d6f4a 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -751,6 +751,7 @@ export const kiloScenarios: Scenario[] = [ .json(200, (body) => check(body === true, "telemetry enabled update should return true")), http.protected .post("/instance/reload", "instance.reload") + .skipValidAuthProbe() .mutating() .seeded((ctx) => ctx.session({ title: "Reload" })) .at((ctx) => ({ diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index dd5f90a017..d6a6178802 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -549,6 +549,96 @@ describe("session prompt queue", () => { } }) + test("closes a queued-handoff turn as superseded, not interrupted", async () => { + const ready = Promise.withResolvers() + const release = Promise.withResolvers() + const server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url) + if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) + + // Hold every stream open until the follow-up prompt is queued, so + // runLoop deterministically takes the hasFollowup break once its + // current step drains. Forked title/summary calls get held too; they + // are Effect.ignore'd and drain once released. + ready.resolve() + const stream = reply({ text: "reply", wait: release.promise }) + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + }, + }) + + try { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify(providerCfg(server.url.origin))) + }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => + scoped(tmp.path, async (prompt) => { + const closed: KiloSession.CloseReason[] = [] + const unsubscribe = Bus.subscribe(KiloSession.Event.TurnClose, (event) => { + closed.push(event.properties.reason) + }) + + const session = await sessions.create({ title: "Superseded close reason" }) + const first = Effect.runPromise( + prompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "first prompt" }], + }), + ) + + // A request reaching the mock implies the turn loop is running + // (forked title/summary calls fire from step 1 of the loop). + await ready.promise + const second = Effect.runPromise( + prompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "second prompt" }], + }), + ) + + // Wait until the follow-up is actually queued behind the in-flight + // turn, then let the first stream drain so runLoop hands off. + await Effect.runPromise( + pollWithTimeout( + Effect.sync(() => (KiloSessionPromptQueue.hasFollowup(session.id) ? (true as const) : undefined)), + "follow-up prompt never queued behind the in-flight turn", + "3 seconds", + ), + ) + release.resolve() + + expect((await first).info.role).toBe("assistant") + expect((await second).info.role).toBe("assistant") + // Bus delivery is a microtask chain; flush a macrotask so the last + // TurnClose callback lands before asserting. + await new Promise((resolve) => setTimeout(resolve, 0)) + unsubscribe() + + expect(closed).toHaveLength(2) + // The first turn drained its stream cleanly and handed off to the + // queued follow-up; it must not look like a user interruption to + // clients (they flash a "Turn interrupted" warning on that reason). + expect(closed[0]).toBe("superseded") + expect(closed[1]).toBe("completed") + }), + }) + } finally { + server.stop(true) + } + }, 20_000) + test("bridges legacy instance context for prompts after a completed turn", async () => { const calls: number[] = [] const server = Bun.serve({ diff --git a/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts b/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts index 3b0c2cc21f..2c8b954a63 100644 --- a/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts +++ b/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts @@ -55,4 +55,28 @@ Skill content. }, ), ) + + // The slash-command path runs a template's `!`cmd`` shell without a permission + // prompt, so it must only do so for trusted skills. A project-local skill is + // untrusted, and Command.Info carries the flag the prompt executor gates on. + it.live("marks project skills untrusted so their slash-command shell is disabled", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".kilo", "skill", "proj", "SKILL.md"), + `---\nname: proj\ndescription: proj.\n---\n\nRun: !\`printf hi\`\n`, + ), + ) + + const command = yield* Command.Service + const proj = yield* command.get("proj") + + expect(proj?.source).toBe("skill") + expect(proj?.trusted).toBe(false) + }), + { git: true }, + ), + ) }) diff --git a/packages/opencode/test/kilocode/skill/discovery-validate.test.ts b/packages/opencode/test/kilocode/skill/discovery-validate.test.ts new file mode 100644 index 0000000000..75c5dc8511 --- /dev/null +++ b/packages/opencode/test/kilocode/skill/discovery-validate.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test" +import { isSafeSegment, isSafeRelativePath } from "@/kilocode/skill/discovery-validate" + +describe("isSafeSegment", () => { + test("accepts a plain skill name", () => { + expect(isSafeSegment("git-status")).toBe(true) + }) + + test("rejects traversal, separators, empties, and null bytes", () => { + for (const value of ["", ".", "..", "a/b", "a\\b", "a\0b"]) { + expect(isSafeSegment(value)).toBe(false) + } + }) +}) + +describe("isSafeRelativePath", () => { + test("accepts nested relative paths", () => { + expect(isSafeRelativePath("SKILL.md")).toBe(true) + expect(isSafeRelativePath("scripts/setup.sh")).toBe(true) + }) + + test("rejects traversal segments", () => { + expect(isSafeRelativePath("../evil")).toBe(false) + expect(isSafeRelativePath("a/../../b")).toBe(false) + }) + + test("rejects absolute paths on either platform", () => { + expect(isSafeRelativePath("/etc/passwd")).toBe(false) + expect(isSafeRelativePath("C:\\Windows")).toBe(false) + }) + + test("rejects URLs, query/fragment, backslashes, and null bytes", () => { + for (const value of ["http://evil.test/x", "a?b", "a#b", "a\\b", "a\0b"]) { + expect(isSafeRelativePath(value)).toBe(false) + } + }) + + test("rejects percent-encoded traversal", () => { + expect(isSafeRelativePath("%2e%2e/evil")).toBe(false) + expect(isSafeRelativePath("%2f")).toBe(false) + }) +}) diff --git a/packages/opencode/test/kilocode/skills/display.test.ts b/packages/opencode/test/kilocode/skills/display.test.ts new file mode 100644 index 0000000000..1d32a2d729 --- /dev/null +++ b/packages/opencode/test/kilocode/skills/display.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "bun:test" +import { displayCommand, skillShellPrompt } from "@/kilocode/skills/display" + +describe("displayCommand", () => { + it("escapes control characters so a command cannot repaint the prompt", () => { + // CR/ESC would otherwise let the visible text differ from what executes + const out = displayCommand("echo ok\r\x1b[2Krm -rf /\nnext") + expect(out).toBe("echo ok\\r\\x1b[2Krm -rf /\\nnext") + expect(out).not.toMatch(/[\u0000-\u001f]/) + }) + + it("escapes bidi/format controls so Trojan-Source reordering can't hide intent", () => { + // RLO (U+202E) + PDI (U+2069) would visually reorder the command in the prompt + const out = displayCommand("echo \u202esafe\u2069 rm -rf /") + expect(out).toBe("echo \\u202esafe\\u2069 rm -rf /") + expect(out).not.toMatch(/[\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/) + }) + + it("leaves ordinary commands unchanged", () => { + expect(displayCommand("git status --short")).toBe("git status --short") + }) +}) + +describe("skillShellPrompt", () => { + it("returns undefined when the request is not a skill-shell batch", () => { + expect(skillShellPrompt(undefined)).toBeUndefined() + expect(skillShellPrompt({ skillShell: false })).toBeUndefined() + expect(skillShellPrompt({})).toBeUndefined() + }) + + it("names the skill and returns verbatim, escaped commands", () => { + const out = skillShellPrompt({ skillShell: true, skill: "git-status", commands: ["git status", "echo \u202ex"] }) + expect(out).toEqual({ + title: 'Run shell commands from skill "git-status"?', + commands: ["git status", "echo \\u202ex"], + }) + }) + + it("falls back to a generic title and drops non-string commands", () => { + const out = skillShellPrompt({ skillShell: true, commands: ["ok", 42, null] }) + expect(out).toEqual({ title: "Run these skill commands?", commands: ["ok"] }) + }) +}) diff --git a/packages/opencode/test/kilocode/skills/inject.test.ts b/packages/opencode/test/kilocode/skills/inject.test.ts new file mode 100644 index 0000000000..0497ef4818 --- /dev/null +++ b/packages/opencode/test/kilocode/skills/inject.test.ts @@ -0,0 +1,355 @@ +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Shell } from "@opencode-ai/core/shell" +import { Effect, Exit, Layer } from "effect" +import { afterEach, describe, expect } from "bun:test" +import fs from "fs" +import os from "os" +import path from "path" +import type { Tool } from "@/tool/tool" +import { SkillTool } from "@/tool/skill" +import { SkillInject } from "@/kilocode/skills/inject" +import { ToolRegistry } from "@/tool/registry" +import { disposeAllInstances, TestInstance } from "../../fixture/fixture" +import { SessionID, MessageID } from "@/session/schema" +import { testEffect } from "../../lib/effect" + +// Global (~/.claude) skills are trusted, but Global.Service snapshots the home +// path when its layer is built, so KILO_TEST_HOME must be set before the runtime +// layer below is constructed — not inside a test body. +const HOME = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "skill-inject-home-"))) +process.env.KILO_TEST_HOME = HOME + +const baseCtx: Omit = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, +} + +afterEach(async () => { + await disposeAllInstances() +}) + +const it = testEffect( + Layer.mergeAll(AppNodeBuilder.build(ToolRegistry.node), AppNodeBuilder.build(CrossSpawnSpawner.node)), +) + +// Shell injection spawns real processes; skip on windows CI like the sibling suite. +const unix = process.platform !== "win32" ? it.instance : it.instance.skip + +afterEach(async () => { + // reset discovered skills between tests by clearing the global skill dir + await fs.promises.rm(path.join(HOME, ".agents"), { recursive: true, force: true }) +}) + +// Global ~/.agents skills are trusted (and, unlike ~/.claude, not gated by the +// KILO_DISABLE_CLAUDE_CODE flag the test env sets); project .kilo skills are untrusted. +function writeGlobalSkill(name: string, body: string) { + return Effect.promise(() => + Bun.write( + path.join(HOME, ".agents", "skills", name, "SKILL.md"), + `---\nname: ${name}\ndescription: ${name} test skill.\n---\n\n${body}\n`, + ), + ) +} + +function writeProjectSkill(dir: string, name: string, body: string) { + return Effect.promise(() => + Bun.write( + path.join(dir, ".kilo", "skill", name, "SKILL.md"), + `---\nname: ${name}\ndescription: ${name} test skill.\n---\n\n${body}\n`, + ), + ) +} + +function loadSkill(name: string, ask: Tool.Context["ask"]) { + return Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } + const tool = (yield* registry.tools({ + providerID: "opencode" as any, + modelID: "gpt-5" as any, + agent, + })).find((t) => t.id === SkillTool.id) + if (!tool) throw new Error("Skill tool not found") + return yield* tool.execute({ name }, { ...baseCtx, ask }) + }) +} + +describe("skill shell injection", () => { + unix("runs the batch after a single forced approval listing every command", () => + Effect.gen(function* () { + yield* writeGlobalSkill("trusted-shell", "A: !`printf one` B: !`printf two`") + + const requests: Array> = [] + const result = yield* loadSkill("trusted-shell", (req) => + Effect.sync(() => { + requests.push(req) + }), + ) + + expect(result.output).toContain("A: one B: two") + // one skill-load ask plus exactly one batch bash ask carrying all commands + const bash = requests.filter((r) => r.permission === "bash") + expect(bash.length).toBe(1) + expect(bash[0].metadata?.["skillShell"]).toBe(true) + // patterns drive rule matching; metadata.commands is the verbatim list the prompt renders + expect(bash[0].patterns).toEqual(["printf one", "printf two"]) + expect(bash[0].metadata?.["commands"]).toEqual(["printf one", "printf two"]) + }), + ) + + unix("runs commands in the instance directory", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + yield* writeGlobalSkill("cwd-shell", "Here: !`pwd`") + + const result = yield* loadSkill("cwd-shell", () => Effect.void) + + // pwd resolves to the instance dir (realpath), not the server process cwd + expect(result.output).toContain(path.basename(dir)) + }), + ) + + unix("authorizes both the decomposed sub-commands and the verbatim command", () => + Effect.gen(function* () { + // A chained placeholder is asked with per-sub-command patterns (so deny/veto + // rules apply to each) AND the verbatim string (so the metachar deny rules + // `*;*`/`*|*`/`*\n*` fire and cd/set-location escapes can't hide). The prompt + // still displays the verbatim placeholder. + yield* writeGlobalSkill("compound-shell", "Out: !`cat README.md; printf hi`") + + const requests: Array> = [] + yield* loadSkill("compound-shell", (req) => + Effect.sync(() => { + requests.push(req) + }), + ) + + const bash = requests.filter((r) => r.permission === "bash") + expect(bash.length).toBe(1) + expect(bash[0].patterns).toContain("cat README.md") + expect(bash[0].patterns).toContain("printf hi") + // the raw chained string is authorized too, so metachar deny rules can match it + expect(bash[0].patterns).toContain("cat README.md; printf hi") + expect(bash[0].metadata?.["commands"]).toEqual(["cat README.md; printf hi"]) + }), + ) + + unix("still prompts for a cd-only command (no empty-pattern auto-approve)", () => + Effect.gen(function* () { + // `cd` decomposes to no sub-command patterns; without the verbatim command the + // bash ask would carry an empty pattern list and Permission.ask would silently + // auto-approve (forceAsk never runs on an empty list). The raw command keeps + // the prompt firing. + yield* writeGlobalSkill("cd-only", "Out: !`cd sub`") + + const requests: Array> = [] + yield* loadSkill("cd-only", (req) => + Effect.sync(() => { + requests.push(req) + }), + ) + + const bash = requests.filter((r) => r.permission === "bash") + expect(bash.length).toBe(1) + expect(bash[0].patterns).toContain("cd sub") + }), + ) + + unix("aborts the entire skill load when the batch is rejected", () => + Effect.gen(function* () { + yield* writeGlobalSkill("denied-shell", "Secret: !`printf leaked`") + + const exit = yield* loadSkill("denied-shell", (req) => + // Reject the batch. Tools wrap ctx.ask with Effect.orDie, so this reaches + // the injector as a defect and must abort the whole skill load. + req.permission === "bash" + ? Effect.fail(new PermissionV1.RejectedError()).pipe(Effect.orDie) + : Effect.void, + ).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + + unix("does not run shell injection for untrusted project skills", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + yield* writeProjectSkill(dir, "untrusted-shell", "Value: !`printf shouldnotrun`") + + const requests: Array> = [] + const result = yield* loadSkill("untrusted-shell", (req) => + Effect.sync(() => { + requests.push(req) + }), + ) + + expect(result.output).toContain("[skill shell execution disabled for untrusted skill]") + expect(result.output).not.toContain("shouldnotrun") + // no bash permission ask because nothing was scanned or spawned + expect(requests.some((r) => r.permission === "bash")).toBe(false) + }), + ) + + unix("does not trust a global skill symlinked into the project", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + // A SKILL.md that lives in the project, symlinked into the trusted ~/.agents/skills dir + // (a suggested convenience), must not mint trust for project-controlled markdown. + const projectSkillDir = path.join(dir, "skills", "linked") + yield* Effect.promise(async () => { + await Bun.write( + path.join(projectSkillDir, "SKILL.md"), + "---\nname: linked\ndescription: linked test skill.\n---\n\nValue: !`printf shouldnotrun`\n", + ) + const linkDir = path.join(HOME, ".agents", "skills", "linked") + await fs.promises.mkdir(path.dirname(linkDir), { recursive: true }) + await fs.promises.symlink(projectSkillDir, linkDir, "dir") + }) + + const requests: Array> = [] + const result = yield* loadSkill("linked", (req) => + Effect.sync(() => { + requests.push(req) + }), + ) + + // realpath is inside the project → treated as untrusted, no execution, no bash ask + expect(result.output).toContain("[skill shell execution disabled for untrusted skill]") + expect(result.output).not.toContain("shouldnotrun") + expect(requests.some((r) => r.permission === "bash")).toBe(false) + }), + ) + + unix("does not execute placeholders inside fenced code blocks", () => + Effect.gen(function* () { + // The fenced placeholder is a documentation example and must stay literal; only the + // live one runs. + yield* writeGlobalSkill("fenced-shell", "Live: !`printf LIVE`\n\n```\nExample: !`printf FENCED`\n```\n") + + const requests: Array> = [] + const result = yield* loadSkill("fenced-shell", (req) => + Effect.sync(() => { + requests.push(req) + }), + ) + + expect(result.output).toContain("Live: LIVE") + // the fenced example is left verbatim, not executed + expect(result.output).toContain("Example: !`printf FENCED`") + expect(result.output).not.toContain("Example: FENCED") + // only the live command is authorized + const bash = requests.filter((r) => r.permission === "bash") + expect(bash[0]?.patterns).toEqual(["printf LIVE"]) + }), + ) + + unix("treats a placeholder inside a nested (```` wrapping ```) fence as inert", () => + Effect.gen(function* () { + // The common "wrap a ``` example in a ```` fence" pattern must not execute the inner + // example; a shorter inner fence does not close the longer outer one. + const body = "Live: !`printf LIVE`\n\n````md\n```bash\nExample: !`printf FENCED`\n```\n````\n" + yield* writeGlobalSkill("nested-fence", body) + + const requests: Array> = [] + const result = yield* loadSkill("nested-fence", (req) => + Effect.sync(() => { + requests.push(req) + }), + ) + + expect(result.output).toContain("Live: LIVE") + expect(result.output).toContain("!`printf FENCED`") + expect(result.output).not.toContain("Example: FENCED") + const bash = requests.filter((r) => r.permission === "bash") + expect(bash[0]?.patterns).toEqual(["printf LIVE"]) + }), + ) + + unix("does not re-execute shell placeholders emitted by command output", () => + Effect.gen(function* () { + // The command emits a literal placeholder `!echo pwned` + // built from octal escapes so the SKILL.md itself contains no nested + // backticks. If render re-scanned command output, `echo pwned` would run. + yield* writeGlobalSkill("nested-shell", "Out: !`printf '!\\140echo pwned\\140'`") + + const result = yield* loadSkill("nested-shell", () => Effect.void) + + expect(result.output).toContain("!`echo pwned`") + // "pwned" must appear only inside the inert placeholder, never executed alone. + expect(result.output).not.toMatch(/Out:\s*pwned\s*$/m) + }), + ) + + unix("truncates oversized command output before inlining", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + // render directly (bypassing the tool's own output truncation) to assert the injector caps output + const rendered = yield* SkillInject.render({ + content: "Out: !`yes x | head -c 65536`", + trusted: true, + disabled: false, + cwd: dir, + skill: "big-shell", + shell: Shell.acceptable(), + ctx: { ...baseCtx, ask: () => Effect.void } as Tool.Context, + decompose: ({ command }) => Effect.succeed({ patterns: [command], dirs: [] }), + }) + + expect(rendered).toContain("[skill shell output truncated]") + expect(rendered.length).toBeLessThan(40000) + }), + ) +}) + +// The disabled (kill-switch) and untrusted branches must short-circuit before +// asking permission or spawning. Passing an ask/spawner that throws proves +// neither is reached. +describe("SkillInject.render gating", () => { + const boom = () => { + throw new Error("must not be reached") + } + const ctx = { ...baseCtx, ask: () => Effect.sync(boom) } as Tool.Context + const decompose = (() => Effect.sync(boom)) as unknown as SkillInject.Decompose + + const run = (opts: { trusted: boolean; disabled: boolean; content?: string }) => + Effect.runPromise( + SkillInject.render({ + content: opts.content ?? "Value: !`printf ran`", + trusted: opts.trusted, + disabled: opts.disabled, + cwd: "/tmp", + skill: "test", + shell: Shell.acceptable(), + ctx, + decompose, + }), + ) + + it.effect("kill-switch replaces every command without asking or running it", () => + Effect.gen(function* () { + const out = yield* Effect.promise(() => run({ trusted: true, disabled: true })) + expect(out).toBe("Value: [skill shell execution disabled by policy]") + }), + ) + + it.effect("untrusted skills never ask or run their commands", () => + Effect.gen(function* () { + const out = yield* Effect.promise(() => run({ trusted: false, disabled: false })) + expect(out).toBe("Value: [skill shell execution disabled for untrusted skill]") + }), + ) + + it.effect("content without placeholders is returned unchanged", () => + Effect.gen(function* () { + const out = yield* Effect.promise(() => run({ trusted: true, disabled: false, content: "no commands here" })) + expect(out).toBe("no commands here") + }), + ) +}) diff --git a/packages/opencode/test/kilocode/tool/websearch-kilo-exa.test.ts b/packages/opencode/test/kilocode/tool/websearch-kilo-exa.test.ts new file mode 100644 index 0000000000..5756e7c671 --- /dev/null +++ b/packages/opencode/test/kilocode/tool/websearch-kilo-exa.test.ts @@ -0,0 +1,180 @@ +// kilocode_change - new file +import { describe, expect, test } from "bun:test" +import { Effect, Exit, Layer } from "effect" +import { HttpBody, HttpClient, HttpClientResponse } from "effect/unstable/http" +import { + KILO_EXA_URL, + MAX_KILO_EXA_RESULTS, + type KiloExaParams, + callKiloExa, +} from "../../../src/kilocode/tool/websearch-kilo-exa" + +type Recorded = { + url?: string + method?: string + authorization?: string + body?: string +} + +const readBody = async (body: HttpBody.HttpBody): Promise => { + if (body._tag === "Uint8Array") return new TextDecoder().decode(body.body) + if (body._tag === "Raw") return JSON.stringify(body.body) + return "" +} + +const jsonResponse = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }) + +const okJson = (body: unknown) => jsonResponse(200, body) + +const fakeHttp = (respond: (status: number) => Response, recorded?: Recorded): HttpClient.HttpClient => + HttpClient.make((request) => + Effect.gen(function* () { + const url = request.url + const method = request.method + const authorization = request.headers["authorization"] + const body = yield* Effect.promise(() => readBody(request.body)) + if (recorded) { + recorded.url = url + recorded.method = method + recorded.authorization = authorization + recorded.body = body + } + return HttpClientResponse.fromWeb( + request as unknown as Parameters[0], + respond(200), + ) + }), + ) + +const runCall = async ( + params: KiloExaParams, + respond: (status: number) => Response, + recorded?: Recorded, + kiloToken = "kilo-test-token", +) => + Effect.runPromiseExit( + Effect.gen(function* () { + const http = fakeHttp(respond, recorded) + return yield* callKiloExa(http, params, kiloToken) + }), + ) + +describe("callKiloExa request shape", () => { + test("posts to KILO_EXA_URL with bearer token and highlights-only contents", async () => { + const recorded: Recorded = {} + const exit = await runCall({ query: "drone" }, () => okJson({ results: [] }), recorded) + expect(Exit.isSuccess(exit)).toBe(true) + expect(recorded.url).toContain("/api/exa/search") + expect(recorded.method).toBe("POST") + expect(recorded.authorization).toBe("Bearer kilo-test-token") + const parsed = JSON.parse(recorded.body!) + expect(parsed.query).toBe("drone") + expect(parsed.type).toBe("auto") + expect(parsed.numResults).toBe(MAX_KILO_EXA_RESULTS) + expect(parsed.contents).toEqual({ highlights: true }) + }) + + test("uses caller numResults when below cap", async () => { + const recorded: Recorded = {} + await runCall({ query: "x", numResults: 3 }, () => okJson({ results: [] }), recorded) + expect(JSON.parse(recorded.body!).numResults).toBe(3) + }) + + test("clamps numResults at MAX_KILO_EXA_RESULTS", async () => { + const recorded: Recorded = {} + await runCall({ query: "x", numResults: 25 }, () => okJson({ results: [] }), recorded) + expect(JSON.parse(recorded.body!).numResults).toBe(MAX_KILO_EXA_RESULTS) + }) + + test("passes through caller type", async () => { + const recorded: Recorded = {} + await runCall({ query: "x", type: "deep" }, () => okJson({ results: [] }), recorded) + expect(JSON.parse(recorded.body!).type).toBe("deep") + }) + + test("KILO_EXA_URL is built from KILO_API_BASE", () => { + expect(KILO_EXA_URL).toMatch(/\/api\/exa\/search$/) + }) +}) + +describe("callKiloExa response formatting", () => { + const okValue = (exit: Exit.Exit): string => { + if (Exit.isFailure(exit)) throw new Error("expected success") + return (exit as Extract).value as string + } + + test("formats results with title, url, date and highlights", async () => { + const exit = await runCall({ query: "x" }, () => + okJson({ + results: [ + { + title: "A drone", + url: "https://example.com/a", + publishedDate: "2025-01-02T00:00:00.000Z", + highlights: ["first", "second"], + }, + ], + }), + ) + const text = okValue(exit) + expect(text).toContain("[1] A drone") + expect(text).toContain("https://example.com/a") + expect(text).toContain("(2025-01-02T00:00:00.000Z)") + expect(text).toContain("> first") + expect(text).toContain("> second") + }) + + test("falls back to url when title is missing", async () => { + const exit = await runCall({ query: "x" }, () => okJson({ results: [{ url: "https://example.com/no-title" }] })) + expect(okValue(exit)).toContain("[1] https://example.com/no-title") + }) + + test("returns NO_RESULTS message on empty results", async () => { + const exit = await runCall({ query: "x" }, () => okJson({ results: [] })) + expect(okValue(exit)).toBe("No search results found. Please try a different query.") + }) + + test("ignores costDollars on the response (cost accounting out of scope)", async () => { + const exit = await runCall({ query: "x" }, () => + okJson({ + results: [{ url: "https://example.com" }], + costDollars: { total: 0.007, search: { neural: 0.007 } }, + requestId: "req-123", + }), + ) + expect(Exit.isSuccess(exit)).toBe(true) + }) +}) + +describe("callKiloExa error handling", () => { + test("dies with auth-required message on 401", async () => { + const exit = await runCall({ query: "x" }, () => jsonResponse(401, {})) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isSuccess(exit)) return + expect(String((exit as Extract).cause)).toContain("unauthorized") + expect(String((exit as Extract).cause)).toContain("kilo auth login") + }) + + test("dies with auth-required message on 403", async () => { + const exit = await runCall({ query: "x" }, () => jsonResponse(403, {})) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isSuccess(exit)) return + expect(String((exit as Extract).cause)).toContain("unauthorized") + }) + + test("dies with status code on other non-2xx", async () => { + const exit = await runCall({ query: "x" }, () => jsonResponse(500, { error: "boom" })) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isSuccess(exit)) return + expect(String((exit as Extract).cause)).toContain("500") + }) + + test("dies when response body is not valid ExaResponse shape", async () => { + const exit = await runCall({ query: "x" }, () => okJson({ nope: true })) + expect(Exit.isFailure(exit)).toBe(true) + }) +}) diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index a40b995358..a264bd376a 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -148,6 +148,14 @@ describe("plugin.codex", () => { id: "gpt-5.5", api: { id: "gpt-5.5" }, } as never, + "gpt-5.6": { + id: "gpt-5.6", + api: { id: "gpt-5.6" }, + } as never, + "gpt-5.6-sol": { + id: "gpt-5.6-sol", + api: { id: "gpt-5.6-sol" }, + } as never, "gpt-5.4-mini": { id: "gpt-5.4-mini", api: { id: "gpt-5.4-mini" }, @@ -164,6 +172,8 @@ describe("plugin.codex", () => { } as never, { auth: { type: "oauth" } } as never) expect(provider).not.toHaveProperty(["gpt-5.5-pro"]) expect(provider).toHaveProperty(["gpt-5.5"]) + expect(provider).not.toHaveProperty(["gpt-5.6"]) + expect(provider).toHaveProperty(["gpt-5.6-sol"]) expect(provider).toHaveProperty(["gpt-5.4-mini"]) expect(provider).toHaveProperty(["gpt-5.1-codex"]) expect(provider).not.toHaveProperty(["other-model"]) diff --git a/packages/opencode/test/server/httpapi-exercise/dsl.ts b/packages/opencode/test/server/httpapi-exercise/dsl.ts index 60d41576f0..959b1a2141 100644 --- a/packages/opencode/test/server/httpapi-exercise/dsl.ts +++ b/packages/opencode/test/server/httpapi-exercise/dsl.ts @@ -27,6 +27,7 @@ class ScenarioBuilder { seed: () => Effect.succeed(undefined as S), request: (ctx) => ({ path, headers: ctx.headers() }), authProbe: undefined, + validAuthProbe: true, // kilocode_change capture: "full", mutates: false, reset: true, @@ -54,6 +55,12 @@ class ScenarioBuilder { return this.clone({ authProbe }) } + // kilocode_change start - blocking routes only prove they reject missing credentials so no valid request leaks into app disposal + skipValidAuthProbe() { + return this.clone({ validAuthProbe: false }) + } + // kilocode_change end + mutating() { return this.clone({ mutates: true }) } @@ -158,6 +165,7 @@ class ScenarioBuilder { project: state.project, seed: state.seed, authProbe: state.authProbe, + validAuthProbe: state.validAuthProbe, // kilocode_change // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired request/state type inside the builder. request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }), // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired assertion/state type inside the builder. diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 557ece3522..51c8692506 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1765,6 +1765,7 @@ const scenarios: Scenario[] = [ .json(200, boolean, "status"), http.protected .get("/tui/control/next", "tui.control.next") + .skipValidAuthProbe() // kilocode_change - valid requests intentionally block waiting for queued TUI input .mutating() .seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } })) .json( diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index 6ccc54b0c8..5f790e5ccf 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -49,6 +49,7 @@ function runAuth(scenario: ActiveScenario) { const result = yield* callAuthProbe(scenario, "missing") if (scenario.auth === "protected") { if (result.status !== 401) throw new Error(`auth expected 401, got ${result.status}`) + if (!scenario.validAuthProbe) return // kilocode_change - blocking routes skip the valid probe; a leaked valid request hangs final app disposal const authed = yield* callAuthProbe(scenario, "valid") if (authed.status === 401) throw new Error("auth rejected valid credentials") return diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index adbc41a0fe..4f26227cf8 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -88,6 +88,7 @@ export type ActiveScenario = { seed: (ctx: ScenarioContext) => Effect.Effect request: (ctx: ScenarioContext, state: unknown) => RequestSpec authProbe: RequestSpec | undefined + validAuthProbe: boolean // kilocode_change expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect compare: Comparison capture: CaptureMode @@ -104,6 +105,7 @@ export type BuilderState = { seed: (ctx: ScenarioContext) => Effect.Effect request: (ctx: SeededContext) => RequestSpec authProbe: RequestSpec | undefined + validAuthProbe: boolean // kilocode_change capture: CaptureMode mutates: boolean reset: boolean diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index bdb60dfddc..5bd82e8fe2 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -37,6 +37,18 @@ beforeAll(async () => { return new Response(mutableContent) } if (url.pathname === "/mutable/mutable/old.md") return new Response("old reference") + // kilocode_change start - serve a crafted index whose skill name escapes the cache via `../` + if (url.pathname === "/evil/index.json") { + return Response.json({ skills: [{ name: "../../../.agents/skills/evil", files: ["SKILL.md"] }] }) + } + if (url.pathname.endsWith("/.agents/skills/evil/SKILL.md")) { + return new Response("---\nname: evil\ndescription: evil.\n---\npwned") + } + // A file entry pointing at another origin (exfil/arbitrary-host download) must be rejected. + if (url.pathname === "/cross-origin/index.json") { + return Response.json({ skills: [{ name: "x", files: ["SKILL.md", "https://evil.example/payload"] }] }) + } + // kilocode_change end // route /.well-known/skills/* to the fixture directory if (url.pathname.startsWith("/.well-known/skills/")) { @@ -128,6 +140,29 @@ describe("Discovery.pull", () => { }), ) + // kilocode_change start - path-traversal in the remote index must not plant a trusted skill + it.live("rejects a skill name that escapes the cache directory", () => + Effect.gen(function* () { + const fsys = yield* FSUtil.Service + const discovery = yield* Discovery.Service + const dirs = yield* discovery.pull(`http://localhost:${server.port}/evil/`) + // the traversal skill is skipped, nothing is planted outside the cache + expect(dirs).toEqual([]) + const escaped = path.join(cacheDir, "../../../.agents/skills/evil/SKILL.md") + expect(yield* fsys.existsSafe(escaped)).toBe(false) + }), + ) + + it.live("rejects a skill file that points at another origin", () => + Effect.gen(function* () { + const discovery = yield* Discovery.Service + // a file entry resolving to a different host must be dropped (no download, skill skipped) + const dirs = yield* discovery.pull(`http://localhost:${server.port}/cross-origin/`) + expect(dirs).toEqual([]) + }), + ) + // kilocode_change end + it.live("caches downloaded files on second pull", () => Effect.gen(function* () { // clear dir and downloadCount diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index f4951dfa51..368225175b 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -462,7 +462,7 @@ exports[`tool parameters JSON Schema (wire shape) websearch 1`] = ` "type": "string", }, "numResults": { - "description": "Number of search results to return (default: 8)", + "description": "Number of search results to return (default: 8, maximum: 10)", "type": "number", }, "query": { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index e102388b3f..f376077fd0 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -29,6 +29,7 @@ const configLayer = TestConfig.layer({ type RegistryLayerOptions = { flags?: Partial plugin?: Layer.Layer + config?: Parameters[0] // kilocode_change } // Fake Plugin.Service that returns a single plugin whose `tool` map contains @@ -58,7 +59,7 @@ const brokenPluginLayer = Layer.succeed( const root = LayerNode.group([ToolRegistry.node, Agent.node]) const registryLayer = (opts: RegistryLayerOptions = {}) => { const replacements = [ - [Config.node, configLayer], + [Config.node, opts.config ? TestConfig.layer(opts.config) : configLayer], // kilocode_change [RuntimeFlags.node, RuntimeFlags.layer(opts.flags ?? {})], ] as const if (!opts.plugin) return LayerNode.compile(root, replacements) @@ -69,6 +70,17 @@ const it = testEffect(registryLayer()) const scout = testEffect(registryLayer({ flags: { experimentalScout: true } })) // kilocode_change const withBrokenPlugin = testEffect(registryLayer({ plugin: brokenPluginLayer })) // kilocode_change start +const websearch = testEffect( + registryLayer({ + config: { + get: () => + Effect.succeed({ + web_search: true, + provider: { openai: { options: { apiKey: "test-openai-key" } } }, + }), + }, + }), +) const sandboxed = testEffect(registryLayer({ flags: { experimentalLspTool: true } })) // kilocode_change end @@ -88,6 +100,38 @@ function sandboxProfile(): Profile { describe("tool.registry", () => { // kilocode_change start + it.instance("hides websearch for a third-party provider by default", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agent = yield* Agent.Service + const build = yield* agent.get("build") + if (!build) return yield* Effect.die(new Error("build agent not found")) + const tools = yield* registry.tools({ + providerID: ProviderV2.ID.openai, + modelID: ModelV2.ID.make("test"), + agent: build, + }) + + expect(tools.map((tool) => tool.id)).not.toContain("websearch") + }), + ) + + websearch.instance("shows websearch for a configured third-party provider when enabled", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agent = yield* Agent.Service + const build = yield* agent.get("build") + if (!build) return yield* Effect.die(new Error("build agent not found")) + const tools = yield* registry.tools({ + providerID: ProviderV2.ID.openai, + modelID: ModelV2.ID.make("test"), + agent: build, + }) + + expect(tools.map((tool) => tool.id)).toContain("websearch") + }), + ) + sandboxed.instance("preserves built-in network classification through production tool definition processing", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service diff --git a/packages/plugin-atomic-chat/package.json b/packages/plugin-atomic-chat/package.json index e3ee0bab74..2409e9af79 100644 --- a/packages/plugin-atomic-chat/package.json +++ b/packages/plugin-atomic-chat/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin-atomic-chat", - "version": "7.4.16", + "version": "7.4.17", "description": "Kilo Code plugin for Atomic Chat: auto-detection and dynamic model discovery (OpenAI-compatible local API)", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index c627523f97..cd2cb42960 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/protocol/src/groups/pty.ts b/packages/protocol/src/groups/pty.ts index fce7421228..013f8e791c 100644 --- a/packages/protocol/src/groups/pty.ts +++ b/packages/protocol/src/groups/pty.ts @@ -9,6 +9,7 @@ import { LocationQuery, locationQueryOpenApi } from "./location" export const PTY_CONNECT_TICKET_QUERY = "ticket" export const PTY_CONNECT_TOKEN_HEADER = "x-kilo-ticket" export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1" +export const PTY_REPLAY_EXITED_QUERY = "replayExited" const PTY_CONNECT_PATH = /^\/api\/pty\/[^/]+\/connect$/ @@ -129,7 +130,13 @@ export const PtyGroup = HttpApiGroup.make("server.pty") ...operation, parameters: [ ...(operation.parameters ?? []), - ...["location[directory]", "location[workspace]", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({ + ...[ + "location[directory]", + "location[workspace]", + "cursor", + PTY_CONNECT_TICKET_QUERY, + PTY_REPLAY_EXITED_QUERY, + ].map((name) => ({ in: "query", name, schema: { type: "string" }, diff --git a/packages/schema/src/v1/permission.ts b/packages/schema/src/v1/permission.ts index 9096f4f249..1ed967c581 100644 --- a/packages/schema/src/v1/permission.ts +++ b/packages/schema/src/v1/permission.ts @@ -38,7 +38,12 @@ export type Request = typeof Request.Type export const Reply = Schema.Literals(["once", "always", "reject"]) export type Reply = typeof Reply.Type -export const ReplyBody = Schema.Struct({ reply: Reply, message: Schema.optional(Schema.String) }).annotate({ +export const ReplyBody = Schema.Struct({ + reply: Reply, + message: Schema.optional(Schema.String), + // kilocode_change - set by clients when a human answered the prompt; the server refuses machine approvals of skill-shell batches + interactive: Schema.optional(Schema.Boolean), +}).annotate({ identifier: "PermissionReplyBody", }) export type ReplyBody = typeof ReplyBody.Type diff --git a/packages/script/package.json b/packages/script/package.json index 879b34567f..a4b2c4545b 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.4.16", + "version": "7.4.17", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index df6dda0453..5f486c399c 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 9798bef601..d052f80975 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3771,6 +3771,7 @@ export class Permission extends HeyApiClient { workspace?: string reply?: "once" | "always" | "reject" message?: string + interactive?: boolean }, options?: Options, ) { @@ -3784,6 +3785,7 @@ export class Permission extends HeyApiClient { { in: "query", key: "workspace" }, { in: "body", key: "reply" }, { in: "body", key: "message" }, + { in: "body", key: "interactive" }, ], }, ], @@ -10856,6 +10858,7 @@ export class Pty2 extends HeyApiClient { "location[workspace]"?: string cursor?: string ticket?: string + replayExited?: string }, options?: Options, ) { @@ -10869,6 +10872,7 @@ export class Pty2 extends HeyApiClient { { in: "query", key: "location[workspace]" }, { in: "query", key: "cursor" }, { in: "query", key: "ticket" }, + { in: "query", key: "replayExited" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fb02a42dae..a1911f44e2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -108,6 +108,7 @@ export type Event = | EventInteractiveTerminalData | EventInteractiveTerminalDeleted | EventSandboxStatusChanged + | EventLspClientDiagnostics | EventSuggestionShown | EventSuggestionAccepted | EventSuggestionDismissed @@ -117,7 +118,6 @@ export type Event = | EventKilocodeNotebookRequested | EventKilocodeNotebookCancelled | EventKiloSessionsRemoteStatusChanged - | EventLspClientDiagnostics | EventMemoryStatus1 | EventMemoryUpdated1 | EventMemoryError1 @@ -1137,6 +1137,7 @@ export type GlobalEvent = { | EventInteractiveTerminalData | EventInteractiveTerminalDeleted | EventSandboxStatusChanged + | EventLspClientDiagnostics | EventSuggestionShown | EventSuggestionAccepted | EventSuggestionDismissed @@ -1146,7 +1147,6 @@ export type GlobalEvent = { | EventKilocodeNotebookRequested | EventKilocodeNotebookCancelled | EventKiloSessionsRemoteStatusChanged - | EventLspClientDiagnostics | EventMemoryStatus | EventMemoryUpdated | EventMemoryError @@ -2644,6 +2644,7 @@ export type Config = { tools?: { [key: string]: boolean } + web_search?: boolean attachment?: AttachmentConfig enterprise?: { url?: string @@ -3048,6 +3049,7 @@ export type Command = { agent?: string model?: string source?: "command" | "mcp" | "skill" + trusted?: boolean template: string subtask?: boolean hints: Array @@ -4717,7 +4719,7 @@ export type EventSessionTurnClose = { properties: { sessionID: string parentID?: string - reason: "completed" | "error" | "interrupted" + reason: "completed" | "error" | "interrupted" | "superseded" } } @@ -4824,6 +4826,15 @@ export type EventSandboxStatusChanged = { } } +export type EventLspClientDiagnostics = { + id: string + type: "lsp.client.diagnostics" + properties: { + serverID: string + path: string + } +} + export type EventSuggestionShown = { id: string type: "suggestion.shown" @@ -4923,15 +4934,6 @@ export type EventKiloSessionsRemoteStatusChanged = { } } -export type EventLspClientDiagnostics = { - id: string - type: "lsp.client.diagnostics" - properties: { - serverID: string - path: string - } -} - export type EventMemoryStatus = { id: string type: "memory.status" @@ -11622,6 +11624,7 @@ export type AppSkillsResponses = { description?: string location: string content: string + trusted?: boolean }> } @@ -12521,6 +12524,7 @@ export type PermissionReplyData = { body?: { reply: "once" | "always" | "reject" message?: string + interactive?: boolean } path: { requestID: string @@ -20228,6 +20232,7 @@ export type V2PtyConnectData = { "location[workspace]"?: string cursor?: string ticket?: string + replayExited?: string } url: "/api/pty/{ptyID}/connect" } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 17e553149b..25bda0075b 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3137,6 +3137,9 @@ }, "content": { "type": "string" + }, + "trusted": { + "type": "boolean" } }, "required": ["name", "location", "content"], @@ -5281,6 +5284,9 @@ }, "message": { "type": "string" + }, + "interactive": { + "type": "boolean" } }, "required": ["reply"], @@ -24473,6 +24479,13 @@ "schema": { "type": "string" } + }, + { + "in": "query", + "name": "replayExited", + "schema": { + "type": "string" + } } ], "security": [], @@ -25590,6 +25603,9 @@ { "$ref": "#/components/schemas/EventSandboxStatusChanged" }, + { + "$ref": "#/components/schemas/EventLspClientDiagnostics" + }, { "$ref": "#/components/schemas/EventSuggestionShown" }, @@ -25617,9 +25633,6 @@ { "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" }, - { - "$ref": "#/components/schemas/EventLspClientDiagnostics" - }, { "$ref": "#/components/schemas/EventMemoryStatus1" }, @@ -28687,6 +28700,9 @@ { "$ref": "#/components/schemas/EventSandboxStatusChanged" }, + { + "$ref": "#/components/schemas/EventLspClientDiagnostics" + }, { "$ref": "#/components/schemas/EventSuggestionShown" }, @@ -28714,9 +28730,6 @@ { "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" }, - { - "$ref": "#/components/schemas/EventLspClientDiagnostics" - }, { "$ref": "#/components/schemas/EventMemoryStatus" }, @@ -33450,6 +33463,9 @@ "type": "boolean" } }, + "web_search": { + "type": "boolean" + }, "attachment": { "$ref": "#/components/schemas/AttachmentConfig" }, @@ -34680,6 +34696,9 @@ "type": "string", "enum": ["command", "mcp", "skill"] }, + "trusted": { + "type": "boolean" + }, "template": { "type": "string" }, @@ -39925,7 +39944,7 @@ }, "reason": { "type": "string", - "enum": ["completed", "error", "interrupted"] + "enum": ["completed", "error", "interrupted", "superseded"] } }, "required": ["sessionID", "reason"], @@ -40259,6 +40278,33 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventLspClientDiagnostics": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.client.diagnostics"] + }, + "properties": { + "type": "object", + "properties": { + "serverID": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": ["serverID", "path"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventSuggestionShown": { "type": "object", "properties": { @@ -40555,33 +40601,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventLspClientDiagnostics": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["lsp.client.diagnostics"] - }, - "properties": { - "type": "object", - "properties": { - "serverID": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": ["serverID", "path"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventMemoryStatus": { "type": "object", "properties": { diff --git a/packages/server/package.json b/packages/server/package.json index 58edc7fb34..b2e1e4d564 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "7.4.16", + "version": "7.4.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/server/src/handlers/pty.ts b/packages/server/src/handlers/pty.ts index cda2cf43e9..abf7021407 100644 --- a/packages/server/src/handlers/pty.ts +++ b/packages/server/src/handlers/pty.ts @@ -13,6 +13,7 @@ import { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE, + PTY_REPLAY_EXITED_QUERY, } from "@opencode-ai/protocol/groups/pty" import { response } from "../location" import { PtyEnvironment } from "../pty-environment" @@ -182,6 +183,7 @@ export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) => cursor, onData: (chunk) => Queue.offerUnsafe(outbox, chunk), onEnd: () => Queue.offerUnsafe(outbox, new Socket.CloseEvent(1000)), + allowExited: url.searchParams.get(PTY_REPLAY_EXITED_QUERY) === "1", // kilocode_change }) .pipe( Effect.catchTags({ diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 5aea5cf63d..536eafb7e9 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -27,7 +27,7 @@ "vite": "catalog:", "@opencode-ai/session-ui": "workspace:*" }, - "version": "7.4.16", + "version": "7.4.17", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/tui/package.json b/packages/tui/package.json index 344179e4d0..87fe7949af 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "7.4.16", + "version": "7.4.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/src/feature-plugins/sidebar/context.tsx b/packages/tui/src/feature-plugins/sidebar/context.tsx index e5bd16e8af..fd1c3bb39b 100644 --- a/packages/tui/src/feature-plugins/sidebar/context.tsx +++ b/packages/tui/src/feature-plugins/sidebar/context.tsx @@ -1,7 +1,7 @@ import type { AssistantMessage } from "@kilocode/sdk/v2" import type { TuiPlugin, TuiPluginApi } from "@kilocode/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" -import { createMemo } from "solid-js" +import { createMemo, createSignal, Show } from "solid-js" // kilocode_change const id = "internal:sidebar-context" @@ -11,6 +11,9 @@ const money = new Intl.NumberFormat("en-US", { }) function View(props: { api: TuiPluginApi; session_id: string }) { + // kilocode_change start + const [open, setOpen] = createSignal(true) + // kilocode_change end const theme = () => props.api.theme.current const msg = createMemo(() => props.api.state.session.messages(props.session_id)) const session = createMemo(() => props.api.state.session.get(props.session_id)) @@ -44,12 +47,25 @@ function View(props: { api: TuiPluginApi; session_id: string }) { return ( - - Context - - {state().tokens.toLocaleString()} tokens - {state().percent ?? 0}% used - {money.format(cost())} spent + {/* kilocode_change start */} + setOpen((x) => !x)}> + {open() ? "▼" : "▶"} + + Context + + + {" "} + ({state().percent ?? 0}% · {money.format(cost())}) + + + + + + {state().tokens.toLocaleString()} tokens + {state().percent ?? 0}% used + {money.format(cost())} spent + + {/* kilocode_change end */} ) } diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 6e712ccd34..17ad94972f 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -19,6 +19,7 @@ import { ConfigProtection } from "@/kilocode/permission/config-paths" import { splitDiffHunks } from "@/kilocode/tui/diff" import { normalizeUrls } from "@/kilocode/util/url" import { MemoryPermissionRegistry } from "@/kilocode/cli/cmd/tui/routes/session/memory-permission" +import { skillShellPrompt } from "@/kilocode/skills/display" // kilocode_change end import { KILO_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap" import { usePathFormatter } from "../../context/path-format" @@ -192,6 +193,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? requestID: props.request.id, directory: props.directory, workspace: project.workspace.current(), + interactive: true, // kilocode_change - human answered this prompt }) }} /> @@ -291,6 +293,20 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? } if (permission === "bash") { + // kilocode_change start - skill shell batches show the verbatim, escaped commands + skill title + const skillShell = skillShellPrompt(props.request.metadata) + if (skillShell) { + return { + icon: "#", + title: skillShell.title, + body: ( + + {(cmd) => {"$ " + cmd}} + + ), + } + } + // kilocode_change end // kilocode_change start const meta = props.request.metadata ?? {} const desc = @@ -446,10 +462,12 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? ) - // kilocode_change start - hide "Always allow" for protected Kilo configuration access - const options: Record = props.request.metadata?.[ConfigProtection.DISABLE_ALWAYS_KEY] - ? { once: "Allow once", reject: "Reject" } - : { once: "Allow once", always: "Allow always", reject: "Reject" } + // kilocode_change start - skill shell batches are never persisted: only Allow / Reject + const options: Record = props.request.metadata?.["skillShell"] + ? { once: "Allow", reject: "Reject" } + : props.request.metadata?.[ConfigProtection.DISABLE_ALWAYS_KEY] + ? { once: "Allow once", reject: "Reject" } + : { once: "Allow once", always: "Allow always", reject: "Reject" } // kilocode_change end const body = ( @@ -483,6 +501,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? requestID: props.request.id, directory: props.directory, workspace: project.workspace.current(), + interactive: true, // kilocode_change - human answered this prompt }) }} /> diff --git a/packages/ui/package.json b/packages/ui/package.json index 1b56274ad9..718ec711b3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "repository": { diff --git a/script/upstream/package.json b/script/upstream/package.json index 254cd0b36e..753ce3fa52 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.4.16", + "version": "7.4.17", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo",