diff --git a/.changeset/agent-manager-project-reorder.md b/.changeset/agent-manager-project-reorder.md new file mode 100644 index 00000000000..bde727ca1ee --- /dev/null +++ b/.changeset/agent-manager-project-reorder.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Reorder projects in the Agent Manager sidebar with drag and drop. The order is kept after a restart. diff --git a/.changeset/agent-manager-setup-per-worktree.md b/.changeset/agent-manager-setup-per-worktree.md deleted file mode 100644 index 8dc87d5ecf0..00000000000 --- a/.changeset/agent-manager-setup-per-worktree.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Run the Agent Manager worktree setup script for a new worktree even when the setup script of another worktree is still running in the VS Code terminal, and show an error when the setup task cannot start. diff --git a/.changeset/agent-session-explicit-intent.md b/.changeset/agent-session-explicit-intent.md new file mode 100644 index 00000000000..6d1a1c9715b --- /dev/null +++ b/.changeset/agent-session-explicit-intent.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Tune the `agent_manager` and `task` tool instructions so agents only start new Agent Manager sessions or worktrees when the user explicitly asks, or after confirming with the user first. diff --git a/.changeset/align-spinner-identicon-grid.md b/.changeset/align-spinner-identicon-grid.md new file mode 100644 index 00000000000..e05b80bdf27 --- /dev/null +++ b/.changeset/align-spinner-identicon-grid.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Align the loading spinner with the subagent identicon. The spinner now uses the same 5x5 dot grid as subagent avatars, so loading and subagent indicators share one cell layout. The four corner cells stay empty and the same twinkle animation is kept. diff --git a/.changeset/bedrock-tool-result-images.md b/.changeset/bedrock-tool-result-images.md new file mode 100644 index 00000000000..766cc3e0d6a --- /dev/null +++ b/.changeset/bedrock-tool-result-images.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep Amazon Bedrock GPT-6 and GPT-5.6 sessions working after a tool returns an image. diff --git a/.changeset/board-in-session-dock.md b/.changeset/board-in-session-dock.md new file mode 100644 index 00000000000..c846cefce44 --- /dev/null +++ b/.changeset/board-in-session-dock.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Move the agent board button next to the background agents above the prompt. It shows the latest posts and marks new ones, and stays available after agents are cleared or the window reloads. diff --git a/.changeset/browser-ide-theme.md b/.changeset/browser-ide-theme.md new file mode 100644 index 00000000000..939ba1b6c0c --- /dev/null +++ b/.changeset/browser-ide-theme.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Match the Integrated Browser color scheme to the current IDE theme so pages that support `prefers-color-scheme` render in dark or light to match the editor. diff --git a/.changeset/browser-shift-click-external.md b/.changeset/browser-shift-click-external.md new file mode 100644 index 00000000000..f0d2489aa2d --- /dev/null +++ b/.changeset/browser-shift-click-external.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Open chat links in the system browser with Shift-click when the Integrated Browser handles link clicks diff --git a/.changeset/cloud-session-shell-tmp.md b/.changeset/cloud-session-shell-tmp.md new file mode 100644 index 00000000000..a6505716eff --- /dev/null +++ b/.changeset/cloud-session-shell-tmp.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Cloud sessions: the bash tool now advertises a session-scoped temp dir covered by the injected permission rules instead of the denied shared temp dir. diff --git a/.changeset/command-frontmatter-instance.md b/.changeset/command-frontmatter-instance.md new file mode 100644 index 00000000000..ea437e56b9d --- /dev/null +++ b/.changeset/command-frontmatter-instance.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Stop reporting a false frontmatter parse error when saving command markdown files diff --git a/.changeset/config-overlay-shadowed-error.md b/.changeset/config-overlay-shadowed-error.md new file mode 100644 index 00000000000..1c37250d730 --- /dev/null +++ b/.changeset/config-overlay-shadowed-error.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Report an error instead of silently keeping the old value when a config change is overridden by a higher-priority file such as `opencode.json`. Config validation and write failures now return the file and issue details instead of an opaque server error. diff --git a/.changeset/conn-reset-normal-retry.md b/.changeset/conn-reset-normal-retry.md new file mode 100644 index 00000000000..cd0c203d62d --- /dev/null +++ b/.changeset/conn-reset-normal-retry.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Retry provider connection resets (`Connection reset by server`) through the normal retry path with backoff instead of pausing the session waiting for network reconnection. Connection resets are transient server-side failures when the error is marked retryable; only genuine network disconnections still trigger the offline wait. diff --git a/.changeset/custom-zai-thinking.md b/.changeset/custom-zai-thinking.md new file mode 100644 index 00000000000..fb8832d121e --- /dev/null +++ b/.changeset/custom-zai-thinking.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Avoid enabling thinking automatically for custom providers whose names contain zai or zhipuai. diff --git a/.changeset/fix-compaction-terminal-errors.md b/.changeset/fix-compaction-terminal-errors.md new file mode 100644 index 00000000000..2f53770f086 --- /dev/null +++ b/.changeset/fix-compaction-terminal-errors.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Report empty summaries and unrecoverable context overflow during compaction instead of silently stopping. Preserve conversation history and explain when to retry compaction or start a new session. diff --git a/.changeset/fix-permission-unknown-route.md b/.changeset/fix-permission-unknown-route.md new file mode 100644 index 00000000000..ad87f1c84ed --- /dev/null +++ b/.changeset/fix-permission-unknown-route.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix permission prompts from subagents failing with "Failed to update permissions" and getting stuck diff --git a/.changeset/fix-vscode-drive-root-startup.md b/.changeset/fix-vscode-drive-root-startup.md new file mode 100644 index 00000000000..49ebb86f722 --- /dev/null +++ b/.changeset/fix-vscode-drive-root-startup.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix backend startup when a Windows drive root is opened as a workspace. diff --git a/.changeset/fix-windows-arm64-error.md b/.changeset/fix-windows-arm64-error.md new file mode 100644 index 00000000000..e972d657a6a --- /dev/null +++ b/.changeset/fix-windows-arm64-error.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Improve missing binary error message on Windows ARM64 to provide actionable workarounds (x64 emulation or WSL2). diff --git a/.changeset/goal-command-alias.md b/.changeset/goal-command-alias.md new file mode 100644 index 00000000000..e2192b11de0 --- /dev/null +++ b/.changeset/goal-command-alias.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Expose a custom or plugin command named `goal` as `/goal:command` instead of dropping it. `/goal` still starts a session goal, and the configuration warning now names the alias so the command stays reachable. diff --git a/.changeset/jetbrains-mcp-oauth-sign-in.md b/.changeset/jetbrains-mcp-oauth-sign-in.md new file mode 100644 index 00000000000..21462542e5f --- /dev/null +++ b/.changeset/jetbrains-mcp-oauth-sign-in.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Sign in to remote MCP servers from JetBrains. MCP Settings now highlights servers that need authentication, only shows actions valid for the current connection state, keeps selected-row actions visible when focus moves elsewhere, shows the real sign-in error, lets you cancel an in-progress sign-in without losing credentials you already had, and offers a copyable authorization URL when a browser cannot be opened automatically. Sign-in opens the browser on your own machine, so it also works in remote development. Installing an MCP server from the Marketplace that needs sign-in now offers to start it right away, and the prompt shows a session-issues menu with a sign-in action when a configured MCP server needs authentication. Removing either half of a Marketplace MCP and companion-skill bundle from MCP or Skills Settings now confirms and removes the whole bundle. Remote MCP servers that require a pre-registered OAuth client can now be configured directly from the edit dialog. diff --git a/.changeset/jetbrains-prompt-navigator.md b/.changeset/jetbrains-prompt-navigator.md new file mode 100644 index 00000000000..4457ba378e7 --- /dev/null +++ b/.changeset/jetbrains-prompt-navigator.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Add a prompt navigator to the chat transcript for jumping between prompts. diff --git a/.changeset/jetbrains-timeline-navigation.md b/.changeset/jetbrains-timeline-navigation.md new file mode 100644 index 00000000000..5a1a5f48d93 --- /dev/null +++ b/.changeset/jetbrains-timeline-navigation.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Restore clicking session timeline bars to navigate to the matching transcript content in JetBrains diff --git a/.changeset/jetbrains-worktree-pr-badge-after-agent-stops.md b/.changeset/jetbrains-worktree-pr-badge-after-agent-stops.md new file mode 100644 index 00000000000..ace6101f4fe --- /dev/null +++ b/.changeset/jetbrains-worktree-pr-badge-after-agent-stops.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show a worktree's pull request badge as soon as the agent that opened the pull request stops, instead of up to ten minutes later diff --git a/.changeset/kilocode-message-diagnostics.md b/.changeset/kilocode-message-diagnostics.md new file mode 100644 index 00000000000..63029fb14e8 --- /dev/null +++ b/.changeset/kilocode-message-diagnostics.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Log structural diagnostics (message part sequences, tool-call/result pairing, schema issue paths) when the AI SDK rejects an assembled request, instead of failing with a bare `ModelMessage[] schema` error. diff --git a/.changeset/mcp-interrupted-startup.md b/.changeset/mcp-interrupted-startup.md new file mode 100644 index 00000000000..d743f57a320 --- /dev/null +++ b/.changeset/mcp-interrupted-startup.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Stop MCP servers from piling up when a request is cancelled while they are still starting. Servers that had already started are now shut down, and the next request starts MCP again instead of failing until the backend restarts. diff --git a/.changeset/native-browser-feedback.md b/.changeset/native-browser-feedback.md new file mode 100644 index 00000000000..e971bafd640 --- /dev/null +++ b/.changeset/native-browser-feedback.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show page cursor feedback in the Integrated Browser, including text fields, resize handles, and drag controls, without changing page scrolling or compositing. diff --git a/.changeset/open-browser-external.md b/.changeset/open-browser-external.md new file mode 100644 index 00000000000..61fc63b41b1 --- /dev/null +++ b/.changeset/open-browser-external.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support opening the current Integrated Browser page in the external default browser diff --git a/.changeset/openai-compatible-dimension-fallback.md b/.changeset/openai-compatible-dimension-fallback.md new file mode 100644 index 00000000000..1db6b3b865d --- /dev/null +++ b/.changeset/openai-compatible-dimension-fallback.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-indexing": patch +--- + +Support OpenAI-compatible indexing endpoints that reject the optional `dimensions` parameter when their native vectors match the configured size. Report a clear error when the endpoint returns a different vector size. diff --git a/.changeset/per-conversation-prompt-history.md b/.changeset/per-conversation-prompt-history.md deleted file mode 100644 index 94fe1a5ead0..00000000000 --- a/.changeset/per-conversation-prompt-history.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Add an experimental "Per-Conversation Prompt History" setting that keeps prompt input history separate for each conversation diff --git a/.changeset/pr-panel-open-browser-icon.md b/.changeset/pr-panel-open-browser-icon.md new file mode 100644 index 00000000000..76eadd1e9de --- /dev/null +++ b/.changeset/pr-panel-open-browser-icon.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Use the standard open-in-browser icon for the Agent Manager PR panel's external link button, so it matches the open-in-browser affordance used elsewhere in the app. diff --git a/.changeset/reserved-command-name-clash.md b/.changeset/reserved-command-name-clash.md new file mode 100644 index 00000000000..00fbcc3a849 --- /dev/null +++ b/.changeset/reserved-command-name-clash.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep slash commands working when a plugin or your config defines a command named `goal`. Such a command clashes with Kilo's own `/goal` and used to hide every slash command, including `/init`, `/review`, and your own, leaving autocomplete empty. The clashing command is now skipped and reported as a configuration warning, so the CLI, VS Code, and JetBrains all tell you which command was ignored and why, while the rest keep working. A command list that fails to load also shows a warning in the CLI instead of silently showing no commands. diff --git a/.changeset/segmented-prompt-selectors.md b/.changeset/segmented-prompt-selectors.md new file mode 100644 index 00000000000..a5b17b2166f --- /dev/null +++ b/.changeset/segmented-prompt-selectors.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Show the agent, model, and reasoning selectors as one split control in the prompt toolbar and the Agent Manager New Worktree dialog. While one picker is open, move the pointer to another part to switch to it, or use the Left and Right arrow keys. The reasoning level shows only when it is set and there is room. Otherwise it collapses into the trailing arrow, and a changed level shows for a moment. On narrow sidebars, less used actions move into a "More actions" menu one at a time, while voice input and send stay visible. The toolbar always stays one row, so a long model name truncates and does not hide actions. diff --git a/.changeset/serve-shutdown-deadline.md b/.changeset/serve-shutdown-deadline.md new file mode 100644 index 00000000000..379ab664bc8 --- /dev/null +++ b/.changeset/serve-shutdown-deadline.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Force `kilo serve` to exit after five seconds if graceful shutdown stalls, so editor shutdown cannot leave the backend running indefinitely. diff --git a/.changeset/single-load-payload-prune.md b/.changeset/single-load-payload-prune.md new file mode 100644 index 00000000000..6b52142d0d9 --- /dev/null +++ b/.changeset/single-load-payload-prune.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Reduce the work done on every agent step in long sessions once the request passes the payload pruning limit. The session transcript is no longer reloaded and re-serialized after pruning, and pruning no longer reads the whole session history again. Plan mode also keeps its plan file instructions on those steps. diff --git a/.changeset/sticky-diff-header-leak.md b/.changeset/sticky-diff-header-leak.md new file mode 100644 index 00000000000..8fd84df3c73 --- /dev/null +++ b/.changeset/sticky-diff-header-leak.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix a thin line of diff content showing above the sticky file header in the Agent Manager diff panel and review when VS Code is zoomed. diff --git a/.changeset/subagent-open-tooltip.md b/.changeset/subagent-open-tooltip.md new file mode 100644 index 00000000000..b2a3ad3a984 --- /dev/null +++ b/.changeset/subagent-open-tooltip.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Add a tooltip to the subagent open button in the chat transcript. It shows whether the subagent opens in an editor tab (sidebar) or in the Agent Manager panel. diff --git a/.changeset/svg-attachment-prompt-loss.md b/.changeset/svg-attachment-prompt-loss.md new file mode 100644 index 00000000000..c175d6e215c --- /dev/null +++ b/.changeset/svg-attachment-prompt-loss.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-jetbrains": patch +--- + +Fix SVG and other non-raster image attachments failing the whole prompt instead of being read as source. Fix a rejected or failed send losing the typed message with no way to recover it, and add a Dismiss action to standalone error cards that have no transcript tail to retry. diff --git a/.changeset/task-bare-model-name.md b/.changeset/task-bare-model-name.md new file mode 100644 index 00000000000..532335d9f93 --- /dev/null +++ b/.changeset/task-bare-model-name.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Task tool: a bare model name in `subagent_model` or an agent `model` now resolves to the matching provider model instead of failing with a "Model not found" error or being ignored. The parent session's provider is preferred when several providers offer a model with the same name. diff --git a/.changeset/tui-permission-eviction.md b/.changeset/tui-permission-eviction.md new file mode 100644 index 00000000000..f2286d32440 --- /dev/null +++ b/.changeset/tui-permission-eviction.md @@ -0,0 +1,4 @@ +--- +"@kilocode/cli": patch +--- +Fix permission and question prompts disappearing when switching sessions in the TUI. Approval prompts for running subagents and sessions now persist across session switches and are restored when switching back, so sessions are never left blocked on an unanswered prompt. diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index b2655cabae6..791769bf9b1 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -2,6 +2,26 @@ name: Bug report description: Report an issue that should be fixed labels: ["bug"] body: + # kilocode_change start + - type: dropdown + id: component + attributes: + label: Component + description: Which part of Kilo is this about? It helps us route your report. + options: + - VS Code extension + - CLI / TUI + - Agent Manager + - JetBrains plugin + - Desktop or web app + - Documentation + - Kilo Gateway or account + - SDK + - Other / not sure + validations: + required: true + # kilocode_change end + - type: textarea id: description attributes: diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 92e6c47570a..0e679c00ae7 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -18,3 +18,23 @@ body: description: What do you want to change or add? What are the benefits of implementing this? Try to be detailed so we can understand your request better :) validations: required: true + + # kilocode_change start + - type: dropdown + id: component + attributes: + label: Component + description: Which part of Kilo is this about? It helps us route your request. + options: + - VS Code extension + - CLI / TUI + - Agent Manager + - JetBrains plugin + - Desktop or web app + - Documentation + - Kilo Gateway or account + - SDK + - Other / not sure + validations: + required: true + # kilocode_change end diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index a7b774a40b1..250f4efa7db 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -10,3 +10,9 @@ query-filters: - path-alert tags contain: security security-severity: /^(9(\.[0-9])?|10(\.0)?)$/ + +# CodeQL classifies extensionless files with a `bun`/`node` shebang as plain +# JavaScript, so TypeScript syntax in these dev-only scripts (e.g. `as const`) +# is reported as "Could not process some files due to syntax errors". +paths-ignore: + - packages/opencode/script/run-workspace-server diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 784a6035a34..c58b6159d33 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,27 +1,35 @@ # kilocode_change - new file version: 2 updates: - # Bun workspace root (packages/*). Known overlap: packages/kilo-docs also - # has its own pnpm block below, since this scan can't see its separate - # pnpm-lock.yaml. Both may target the same package.json line; whichever PR - # merges first makes the other stale (caught by stale-bot-pr-notify.yml). + # Bun workspace root (packages/*). Version updates are OFF (limit 0): this + # scan covers ~17 upstream-owned packages that share one bun.lock, and + # Dependabot cannot scope a workspace scan by path. Nearly every PR it opened + # touched upstream files and was closed by hand (dependabot-auto-merge.yml + # skips them by design). Those dependencies come in with upstream merges. + # A limit of 0 does not disable security updates: alerts still open PRs, and + # security-findings-notify.yml reports them. Those PRs mostly edit upstream + # manifests, and a root `overrides` or catalog pin can make them a no-op + # (check the resolved version in bun.lock before merging). packages/kilo-docs + # has its own block below, since this scan can't see its separate + # pnpm-lock.yaml. - package-ecosystem: "bun" directory: "/" schedule: interval: "weekly" - cooldown: - default-days: 7 - open-pull-requests-limit: 10 + open-pull-requests-limit: 0 labels: - "dependencies" commit-message: prefix: "chore" include: "scope" + # One PR for all security fixes in this scan, instead of one per alert. + # Deliberately not named "*-minor-patch": these PRs are never auto-merged, + # and this group has no update-types filter because a fix may need a major. groups: - bun-minor-patch: - update-types: - - "minor" - - "patch" + security-updates: + applies-to: security-updates + patterns: + - "*" # packages/kilo-docs deploys separately via pnpm; see note above. - package-ecosystem: "npm" @@ -35,7 +43,33 @@ updates: - "dependencies" commit-message: prefix: "chore(kilo-docs)" + # Majors are never proposed. They can change peer specs or type targets + # (for example the @types/node major, which no longer matched the Node + # versions we target). Bump them by hand, on purpose. + ignore: + - dependency-name: "*" + update-types: + - "version-update:semver-major" + # Related packages move together. Group names must end in "-minor-patch" + # so dependabot-auto-merge.yml recognises them. A dependency joins the + # first group that matches it. groups: + react-minor-patch: + patterns: + - "react" + - "react-dom" + - "@types/react" + - "@types/react-dom" + update-types: + - "minor" + - "patch" + vitest-minor-patch: + patterns: + - "vitest" + - "@vitest/*" + update-types: + - "minor" + - "patch" kilo-docs-minor-patch: update-types: - "minor" @@ -47,12 +81,57 @@ updates: interval: "weekly" cooldown: default-days: 7 - open-pull-requests-limit: 5 + # One slot per group below (6), so no group waits for another to merge. + open-pull-requests-limit: 6 labels: - "dependencies" commit-message: prefix: "chore(jetbrains)" + # Majors are never proposed. They can change the build tooling or test + # APIs (for example okhttp 4 -> 5 deprecates mockwebserver, and a Gradle + # wrapper or Kotlin plugin major changes the build itself). Bump them by + # hand, on purpose. + ignore: + - dependency-name: "*" + update-types: + - "version-update:semver-major" + # One bad update must not block the rest, and coupled artifacts must move + # together (same "-minor-patch" suffix and first-match rules as above). groups: + intellij-minor-patch: + patterns: + - "org.jetbrains.intellij.platform*" + - "rpc" + update-types: + - "minor" + - "patch" + kotlin-minor-patch: + patterns: + - "org.jetbrains.kotlin*" + - "org.jetbrains.kotlinx*" + update-types: + - "minor" + - "patch" + junit-minor-patch: + patterns: + - "junit*" + - "org.junit*" + update-types: + - "minor" + - "patch" + okhttp-minor-patch: + patterns: + - "com.squareup.okhttp3*" + update-types: + - "minor" + - "patch" + markdown-minor-patch: + patterns: + - "org.commonmark*" + - "org.nibor.autolink*" + update-types: + - "minor" + - "patch" jetbrains-minor-patch: update-types: - "minor" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a8415564209..5c752da4e4d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,3 +1,5 @@ + + ## Issue diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 99bf408a988..c28260b4841 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -6,39 +6,68 @@ Workflows that automate dependency remediation and give the team Slack visibilit |---|---|---| | `dependabot-auto-merge.yml` | `schedule`: hourly, plus `workflow_dispatch` | Actions tab → "Dependabot auto-merge" → Run workflow, or `gh workflow run dependabot-auto-merge.yml` | | `security-findings-notify.yml` | `schedule`: every 6 hours, plus `workflow_dispatch` | Actions tab → "Security findings notify" → Run workflow, or `gh workflow run security-findings-notify.yml` | +| `outdated-kilo-deps.yml` | `schedule`: monthly on the 1st at 09:23 UTC, plus `workflow_dispatch` | Actions tab → "Outdated Kilo dependencies" → Run workflow, or `gh workflow run outdated-kilo-deps.yml` | | `stale-bot-pr-notify.yml` | `schedule`: daily at 13:00 UTC, plus `workflow_dispatch` | Actions tab → "Stale bot PR notify" → Run workflow, or `gh workflow run stale-bot-pr-notify.yml` | +| `stale-alerts-report.yml` | `schedule`: weekly on Mondays at 08:41 UTC, plus `workflow_dispatch` | Actions tab → "Stale alerts report" → Run workflow, or `gh workflow run stale-alerts-report.yml` | | `../dependabot.yml` | Not a workflow — read directly by GitHub's Dependabot service | No manual run; check **Insights → Dependency graph → Dependabot** | ## Setup -- Add repo secret `SECURITY_ALERTS_SLACK_WEBHOOK` (a Slack incoming webhook URL) — required by `security-findings-notify.yml` and `stale-bot-pr-notify.yml`. -- Optional repo variables to override defaults: `SECURITY_SLA_CRITICAL_DAYS` (15), `SECURITY_SLA_HIGH_DAYS` (30), `STALE_BOT_PR_DAYS` (3). +- Add repo secret `SECURITY_ALERTS_SLACK_WEBHOOK` (a Slack incoming webhook URL) — required by `security-findings-notify.yml`, `stale-bot-pr-notify.yml`, `outdated-kilo-deps.yml` and `stale-alerts-report.yml`. +- Optional repo secret `SECURITY_SCAN_TOKEN`: a token with the `secret_scanning_alerts` scope. Without it, `security-findings-notify.yml` tries the default token and skips secret scanning with a warning if that fails. +- Optional repo variables to override defaults: `SECURITY_SLA_CRITICAL_DAYS` (15), `SECURITY_SLA_HIGH_DAYS` (30), `SECURITY_SLA_MEDIUM_DAYS` (90), `SECURITY_SLA_LOW_DAYS` (180), `SECURITY_MIN_SEVERITY` (`high`; set to `medium` or `low` to report more), `STALE_BOT_PR_DAYS` (3). ## How it works ### PRs are opened by Dependabot's own service, not by any workflow here -`dependabot.yml` is not a workflow — it's a config file that GitHub's Dependabot backend reads directly. None of the 3 workflow files in this directory ever open a PR; they only act on PRs that already exist. +`dependabot.yml` is not a workflow — it's a config file that GitHub's Dependabot backend reads directly. None of the workflow files in this directory ever open a PR; they only act on PRs or alerts that already exist, or report on dependencies. -Dependabot re-scans on the `schedule: interval` set per ecosystem block, and once immediately whenever `dependabot.yml` itself changes on the default branch. Per ecosystem (`bun`, `npm` for kilo-docs, `gradle` for kilo-jetbrains), it checks each dependency for updates, classifies each as `major`, `minor`, or `patch`, bundles everything matching a `groups: *-minor-patch` rule into one combined PR, and opens every major bump as its own individual PR. These are normal PRs from that point on — required CI and branch protection apply like any other PR. +Dependabot re-scans on the `schedule: interval` set per ecosystem block, and once immediately whenever `dependabot.yml` itself changes on the default branch. Per ecosystem (`npm` for kilo-docs, `gradle` for kilo-jetbrains), it checks each dependency for updates, bundles minor and patch bumps into `groups: *-minor-patch` PRs (related packages share a group, so they move together), and ignores every major bump. Majors are bumped by hand. The `bun` block has `open-pull-requests-limit: 0`, so it opens no version-update PRs, only security-alert PRs, which a `security-updates` group bundles into one PR. These are normal PRs from that point on — required CI and branch protection apply like any other PR. -### The 3 workflows each poll independently on their own schedule +### The 5 workflows each poll independently on their own schedule They don't call each other or trigger off PR creation. Each one wakes up on its own cron and reads whatever state currently exists on GitHub: | Workflow | Wakes up | Reads | Does | |---|---|---|---| | `dependabot-auto-merge.yml` | Hourly | Open `app/dependabot` PRs with a grouped `*-minor-patch` title | Enables GitHub's native auto-merge flag if every changed file besides shared `bun.lock` is under a `kilo`-named path; otherwise leaves it and logs a warning. | -| `security-findings-notify.yml` | Every 6h | GitHub's Dependabot **Alerts** (the vulnerability list, separate from the PR list above) | Posts new/at-risk/breached critical & high alerts to Slack, independent of whether a fix PR exists. | -| `stale-bot-pr-notify.yml` | Daily, 13:00 UTC | All open bot-authored PRs (not limited to Dependabot) | Flags any that are conflicting or long-unreviewed to Slack. | +| `security-findings-notify.yml` | Every 6h | GitHub's Dependabot **Alerts** (the vulnerability list, separate from the PR list above), **code scanning** (CodeQL) alerts and **secret scanning** alerts | Posts new/at-risk/breached alerts at or above `SECURITY_MIN_SEVERITY` (default high) to Slack, independent of whether a fix PR exists. A leaked secret always counts as critical. | +| `outdated-kilo-deps.yml` | Monthly, 1st at 09:23 UTC | `bun outdated` for `packages/kilo-*` (not kilo-docs; it has its own Dependabot block) | Posts the count of outdated major/minor/patch dependencies to Slack, with the major and minor ones listed. Fills the gap left by turning off the root `bun` version updates. | +| `stale-bot-pr-notify.yml` | Daily, 13:00 UTC | All open bot-authored PRs (not limited to Dependabot) | Flags any that are conflicting, failing a required check or long-unreviewed to Slack. Several PRs that upgrade the same package collapse into one "likely duplicates" line. | +| `stale-alerts-report.yml` | Weekly, Mondays 08:41 UTC | Open Dependabot alerts for npm packages, compared with `bun.lock` and `pnpm-lock.yaml` in the checkout | Posts alerts that look stale to Slack: the manifest is gone, the package left the lockfile, or the lockfile resolves only versions outside the vulnerable range. It dismisses nothing. | + +### Where the sources come from + +| Source | Used by | Notes | +|---|---|---| +| Dependabot alerts | `security-findings-notify.yml`, `stale-alerts-report.yml` | Needs the `vulnerability-alerts: read` permission. | +| Code scanning alerts | `security-findings-notify.yml` | Needs `security-events: read`. Alerts without a security severity are quality findings and are skipped. If the API returns an error, the run logs a warning and continues. | +| Secret scanning alerts | `security-findings-notify.yml` | The default `GITHUB_TOKEN` cannot read these on every plan. If the API returns an error, the run logs a warning and continues. To enable this source, add the repo secret `SECURITY_SCAN_TOKEN` (a token with the `secret_scanning_alerts` scope). The workflow uses it for this one call only. | +| Findings of the Kilo Security Agent (the "Unreachable" and "Exploitable" labels) | None | They live in the private `Kilo-Org/cloud` stack. A repo workflow cannot read them. | + +The helper scripts are `script/kilocode/bot-prs.ts` (duplicate grouping) and `script/kilocode/stale-alerts.ts` (stale alert check). Both have tests next to them. Run `bun test bot-prs.test.ts stale-alerts.test.ts` from `script/kilocode/`. ### Enabling auto-merge isn't the same as merging `dependabot-auto-merge.yml` only flips a flag on the PR. GitHub itself completes the merge later, automatically, once both the required human approval and all required status checks pass. If either never happens, the PR just sits with auto-merge armed but unfulfilled — nothing forces it through. +## Slack posts + +Four of the five workflows post to Slack. All of them use the `SECURITY_ALERTS_SLACK_WEBHOOK` secret, so every post lands in the one channel that webhook was created for. A run that has nothing to report posts nothing. + +| Workflow | Posts? | What a post contains | When it posts | +|---|---|---|---| +| `dependabot-auto-merge.yml` | No | Nothing. Its results are only in the Actions log, including a warning when it skips a PR that touches shared code. | Never. | +| `security-findings-notify.yml` | Yes | One message per alert (critical and high by default): a label (`New finding`, `SLA at risk` or `SLA breached`), the source (`Dependabot`, `code scanning` or `secret scanning`), the severity, days open, a link to the alert with its GHSA ID or rule ID, and the summary. A critical alert that breaches its SLA starts with `@here`. | Checked every 6 hours. Each alert posts once per status (new, then at risk, then breached). A breached alert repeats once a day while it stays open. | +| `stale-bot-pr-notify.yml` | Yes | One combined message, "Bot PRs needing attention". Each line has the PR number and title as a link, the author, the age in days, and a reason: `conflicting`, `CI failing` (a required check failed) or `unreviewed`. PRs that upgrade the same package are one line that lists every PR and says to keep one. | Checked daily at 13:00 UTC. Posts only if at least one PR is flagged. A PR repeats every day until it is fixed. | +| `stale-alerts-report.yml` | Yes | One message, "Open Dependabot alerts that look stale: N". Each line has the alert link, the package, the manifest and the reason. The full list is in the job summary. | Checked weekly. Posts only if at least one alert looks stale. | +| `outdated-kilo-deps.yml` | Yes | One short summary, "Outdated Kilo-owned dependencies: N major, N minor, N patch". It lists the major and minor updates with current and latest versions. Patch updates are only counted. A link to the run has the full list. | Checked monthly, on the 1st at 09:23 UTC. Posts only if something is outdated. | + ## Fork-safety notes kilocode is a fork of opencode sharing one `bun.lock` with upstream-owned `@opencode-ai/*` packages. Two things exist specifically to avoid friction with upstream syncs: - `dependabot-auto-merge.yml` only auto-merges a PR if every changed file (besides the shared `bun.lock`) lives under a `kilo`-named path. Anything touching shared/upstream code is left for a human. +- The root `bun` block has version updates turned off. It scans ~17 upstream-owned packages and cannot be scoped by path, so its PRs almost always touched upstream files. Those dependencies come in with upstream merges. - `dependabot.yml` deliberately does **not** cover `packages/opencode/Dockerfile` or `.github/workflows/**`: both are shared/upstream paths that the auto-merge guard above would never approve anyway, and `.github/workflows/**` changes routinely fail this repo's `kilocode_change` annotation check unless they land inside an existing marker block. Those paths are covered by `security-findings-notify.yml` instead, which only reads GitHub's alerts, it never proposes a PR. diff --git a/.github/workflows/check-opencode-annotations.yml b/.github/workflows/check-opencode-annotations.yml index aa41b12d099..402b296ce0b 100644 --- a/.github/workflows/check-opencode-annotations.yml +++ b/.github/workflows/check-opencode-annotations.yml @@ -11,6 +11,12 @@ on: - "packages/*/src/kilo-*/**" - "packages/plugin-atomic-chat/**" - "packages/opencode/**" + - "packages/core/**" + - "packages/llm/**" + - "packages/schema/**" + - "packages/protocol/**" + - "packages/server/**" + - "packages/tui/**" - "packages/script/**" - "packages/shared/**" - "packages/storybook/**" diff --git a/.github/workflows/codeql-kotlin.yml b/.github/workflows/codeql-kotlin.yml index 18a72c6c394..060e136914a 100644 --- a/.github/workflows/codeql-kotlin.yml +++ b/.github/workflows/codeql-kotlin.yml @@ -58,6 +58,9 @@ jobs: languages: java-kotlin build-mode: manual config-file: ./.github/codeql/codeql-config.yml + # Leave headroom for the runner agent and OS. CodeQL otherwise claims ~14.6 GB of the + # 16 GB runner, and the query phase OOM-kills the whole VM. + ram: 12288 - name: Build Java/Kotlin shell: bash @@ -66,6 +69,19 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} + # The Gradle and Kotlin daemons stay resident for the rest of the job -- setup-gradle only + # stops them in its post step, which runs after CodeQL. Their heap competes with query + # evaluation and takes the runner down with it. + - name: Stop Gradle and Kotlin daemons + shell: bash + working-directory: packages/kilo-jetbrains + run: | + ./gradlew --stop || echo "gradlew --stop failed; continuing" + pkill -f KotlinCompileDaemon || echo "no Kotlin compile daemon to stop" + sleep 5 + free -m + ps -eo pid,rss,comm --sort=-rss | sed -n '1,15p' + - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 with: diff --git a/.github/workflows/community-label.yml b/.github/workflows/community-label.yml new file mode 100644 index 00000000000..9a0ebd32383 --- /dev/null +++ b/.github/workflows/community-label.yml @@ -0,0 +1,47 @@ +# kilocode_change - new file +name: Community label + +# Labels new PRs and issues so maintainers can tell community work from team +# work and see which part of the repo it touches. See docs/community-triage.md. +# +# pull_request_target runs with write access, even for fork PRs. That is safe +# here because the job only checks out the base branch and never runs code from +# the PR. It reads PR metadata (title, author, file names) as data. + +on: + pull_request_target: + types: [opened, reopened, ready_for_review, edited] + issues: + types: [opened, edited] + +concurrency: + group: community-label-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + label: + if: github.repository == 'Kilo-Org/kilocode' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Label + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + # Optional. Lets the job see private org members. See the docs. + MEMBER_TOKEN: ${{ secrets.COMMUNITY_TRIAGE_MEMBER_TOKEN }} + MAINTAINERS: ${{ vars.COMMUNITY_MAINTAINERS }} + run: bun run script/kilocode/community-triage.ts label diff --git a/.github/workflows/community-sweep.yml b/.github/workflows/community-sweep.yml new file mode 100644 index 00000000000..ae88b0e2f02 --- /dev/null +++ b/.github/workflows/community-sweep.yml @@ -0,0 +1,67 @@ +# kilocode_change - new file +name: Community sweep + +# Twice a week, the evening before the maintainer triage slots (Friday and +# Tuesday). Works out whose turn it is on each community PR, keeps the status +# labels in sync, closes PRs that got no reply for 14 days and posts the queue. +# See docs/community-triage.md. + +on: + schedule: + - cron: "0 17 * * 1,4" # Monday and Thursday, 17:00 UTC + workflow_dispatch: + inputs: + close: + description: "Close PRs with no reply for 14 days. Off means log only." + type: boolean + default: false + +concurrency: + group: community-sweep + cancel-in-progress: false + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + sweep: + if: github.repository == 'Kilo-Org/kilocode' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Sweep + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + MEMBER_TOKEN: ${{ secrets.COMMUNITY_TRIAGE_MEMBER_TOKEN }} + MAINTAINERS: ${{ vars.COMMUNITY_MAINTAINERS }} + # Closing stays off until the repo variable is set to "true". + CLOSE_ENABLED: ${{ github.event_name == 'workflow_dispatch' && format('{0}', inputs.close) || vars.COMMUNITY_AUTO_CLOSE_ENABLED }} + SLACK_PAYLOAD: ${{ runner.temp }}/slack-payload.json + run: bun run script/kilocode/community-triage.ts sweep + + # A step cannot read its own env in `if`, so a small step exposes the + # secret to itself only and passes on whether it is set. + - name: Check Slack webhook + id: slack + env: + SLACK_WEBHOOK: ${{ secrets.COMMUNITY_TRIAGE_SLACK_WEBHOOK }} + run: echo "enabled=${{ env.SLACK_WEBHOOK != '' }}" >> "$GITHUB_OUTPUT" + + - name: Notify Slack + if: steps.slack.outputs.enabled == 'true' + uses: slackapi/slack-github-action@v2.0.0 + with: + webhook: ${{ secrets.COMMUNITY_TRIAGE_SLACK_WEBHOOK }} + webhook-type: incoming-webhook + payload-file-path: ${{ runner.temp }}/slack-payload.json diff --git a/.github/workflows/community-triage-test.yml b/.github/workflows/community-triage-test.yml new file mode 100644 index 00000000000..6577c809fa6 --- /dev/null +++ b/.github/workflows/community-triage-test.yml @@ -0,0 +1,36 @@ +# kilocode_change - new file +name: Community triage test + +# Runs the unit tests for the community triage script. Only runs when the +# script, its tests or the workflows that use it change. + +on: + pull_request: + paths: + - script/kilocode/community-triage*.ts + - .github/workflows/community-*.yml + workflow_dispatch: + +concurrency: + group: community-triage-test-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Test + working-directory: script/kilocode + run: bun test community-triage.test.ts diff --git a/.github/workflows/outdated-kilo-deps.yml b/.github/workflows/outdated-kilo-deps.yml new file mode 100644 index 00000000000..90221f6340e --- /dev/null +++ b/.github/workflows/outdated-kilo-deps.yml @@ -0,0 +1,53 @@ +# kilocode_change - new file +name: Outdated Kilo dependencies + +# Dependabot's root `bun` scan has version updates turned off (it mostly +# touched upstream files), so nothing else says when a Kilo-owned package falls +# behind. Once a month, list what `bun outdated` finds for packages/kilo-* +# (kilo-docs and kilo-jetbrains keep their own Dependabot blocks) and post a +# short summary to Slack. Read-only: it never opens a PR. + +on: + schedule: + - cron: "23 9 1 * *" # 1st of the month, offset to avoid the top of the hour + workflow_dispatch: + +concurrency: + group: outdated-kilo-deps + cancel-in-progress: false + +permissions: + contents: read + +jobs: + report: + if: github.repository == 'Kilo-Org/kilocode' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - id: report + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + mkdir -p .cache + bun run script/kilocode/outdated.ts > .cache/report.json + echo "count=$(jq -r .count .cache/report.json)" >> "$GITHUB_OUTPUT" + jq -r '.full' .cache/report.json >> "$GITHUB_STEP_SUMMARY" + # Build real JSON via jq instead of splicing text into a YAML payload. + jq '{text}' .cache/report.json > .cache/slack-payload.json + + - if: steps.report.outputs.count != '0' + name: Notify Slack + uses: slackapi/slack-github-action@v2.0.0 + with: + webhook: ${{ secrets.SECURITY_ALERTS_SLACK_WEBHOOK }} + webhook-type: incoming-webhook + payload-file-path: .cache/slack-payload.json diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aff7babb110..91a5779aac4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -472,6 +472,10 @@ jobs: # build-cli already uploaded unsigned Windows zips to the draft release; # replace them with signed copies so release assets match what npm/VSIX ship. + # One asset per invocation with retries, for the same reason build-cli uploads + # that way: a dropped connection can commit the asset anyway, and the retry + # inside gh then dies on "ReleaseAsset.name already exists". --clobber clears + # that asset at the start of the next attempt. - name: Re-upload signed Windows release zips shell: pwsh run: | @@ -481,7 +485,15 @@ jobs: Compress-Archive -Path (Join-Path $dir.FullName "bin\*") -DestinationPath $archive -Force $archives += $archive } - gh release upload "v${{ needs.build-cli.outputs.version }}" $archives --clobber + foreach ($archive in $archives) { + foreach ($attempt in 1..4) { + gh release upload "v${{ needs.build-cli.outputs.version }}" $archive --clobber + if ($LASTEXITCODE -eq 0) { break } + if ($attempt -eq 4) { throw "Could not upload $archive after 4 attempts" } + Write-Host "upload: $archive failed (attempt $attempt/4), retrying in 10s" + Start-Sleep -Seconds 10 + } + } env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} diff --git a/.github/workflows/security-findings-notify.yml b/.github/workflows/security-findings-notify.yml index 24510904544..0d76ff36991 100644 --- a/.github/workflows/security-findings-notify.yml +++ b/.github/workflows/security-findings-notify.yml @@ -1,9 +1,12 @@ # kilocode_change - new file name: Security findings notify -# Posts open Dependabot security alerts (critical/high) to Slack, and pings -# the channel when a critical finding breaches its resolution SLA. Read-only; -# follows the cron pattern from watch-opencode-releases.yml. +# Posts open security alerts to Slack, and pings the channel when a critical +# finding breaches its resolution SLA. Read-only; follows the cron pattern from +# watch-opencode-releases.yml. Three sources: Dependabot alerts, code scanning +# (CodeQL) alerts and secret scanning alerts. Dependabot and code scanning +# alerts below SECURITY_MIN_SEVERITY (default: high) are skipped. A leaked +# secret always counts as critical. on: schedule: @@ -17,6 +20,7 @@ concurrency: permissions: contents: read vulnerability-alerts: read + security-events: read jobs: list-findings: @@ -29,32 +33,72 @@ jobs: - id: build env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Optional. The default token cannot read secret scanning alerts on + # every plan. A token with the secret_scanning_alerts scope is used + # for that one call only. Without it, the call uses GH_TOKEN. + SECRET_SCAN_TOKEN: ${{ secrets.SECURITY_SCAN_TOKEN }} REPO: ${{ github.repository }} # 15 matches an observed real critical deadline; 30 is a guess. CRIT_DAYS: ${{ vars.SECURITY_SLA_CRITICAL_DAYS || '15' }} HIGH_DAYS: ${{ vars.SECURITY_SLA_HIGH_DAYS || '30' }} + # Only used when SECURITY_MIN_SEVERITY lets medium or low through. + MEDIUM_DAYS: ${{ vars.SECURITY_SLA_MEDIUM_DAYS || '90' }} + LOW_DAYS: ${{ vars.SECURITY_SLA_LOW_DAYS || '180' }} + # critical, high, medium or low. Anything below it is not reported. + MIN_SEVERITY: ${{ vars.SECURITY_MIN_SEVERITY || 'high' }} run: | # --method GET is required: gh api auto-switches to POST whenever # -f is passed, which would 404 against this GET-only endpoint. - alerts=$(gh api --paginate --method GET -f state=open -f per_page=100 "repos/$REPO/dependabot/alerts") + # --paginate prints one array per page, so merge them into one. + set -o pipefail + fetch() { + gh api --paginate --method GET -f state=open -f per_page=100 "repos/$REPO/$1" | jq -cs 'add // []' + } + dependabot=$(fetch dependabot/alerts) + + # Code scanning returns 404 when it is not enabled, and secret + # scanning is not readable with the default GITHUB_TOKEN on every + # plan (set the SECURITY_SCAN_TOKEN secret to fix that). A source that cannot be read is skipped with a warning, so it + # never hides the Dependabot findings. + if ! code=$(fetch code-scanning/alerts 2>/dev/null); then + echo "::warning::could not read code scanning alerts, skipping them" + code='[]' + fi + if ! secrets=$(GH_TOKEN="${SECRET_SCAN_TOKEN:-$GH_TOKEN}" fetch secret-scanning/alerts 2>/dev/null); then + echo "::warning::could not read secret scanning alerts, skipping them" + secrets='[]' + fi + now=$(date -u +%s) - matrix=$(echo "$alerts" | jq -c --argjson now "$now" --argjson critDays "$CRIT_DAYS" --argjson highDays "$HIGH_DAYS" ' - map(select(.security_advisory.severity == "critical" or .security_advisory.severity == "high")) - | map( - . as $a - | $a.security_advisory.severity as $sev - | (if $sev == "critical" then $critDays else $highDays end) as $threshold - | (($now - ($a.created_at | fromdateiso8601)) / 86400) as $age - | { - number: $a.number, - severity: $sev, - ghsa_id: $a.security_advisory.ghsa_id, - summary: $a.security_advisory.summary, - html_url: $a.html_url, - age_days: ($age | floor), - status: (if $age >= $threshold then "breach" elif $age >= ($threshold * 0.7) then "warning" else "new" end) - } - ) + matrix=$(jq -cn --argjson now "$now" \ + --argjson critDays "$CRIT_DAYS" --argjson highDays "$HIGH_DAYS" \ + --argjson medDays "$MEDIUM_DAYS" --argjson lowDays "$LOW_DAYS" \ + --arg min "$MIN_SEVERITY" \ + --argjson dependabot "$dependabot" --argjson code "$code" --argjson secrets "$secrets" ' + def rank: {critical: 4, high: 3, medium: 2, low: 1}[.] // 0; + def days: if . == "critical" then $critDays elif . == "high" then $highDays elif . == "medium" then $medDays else $lowDays end; + def item($kind; $number; $sev; $id; $summary; $url; $created): + (($now - ($created | fromdateiso8601)) / 86400) as $age + | ($sev | days) as $threshold + | { + kind: $kind, + number: $number, + severity: $sev, + ghsa_id: $id, + summary: $summary, + html_url: $url, + age_days: ($age | floor), + status: (if $age >= $threshold then "breach" elif $age >= ($threshold * 0.7) then "warning" else "new" end) + }; + ( + ($dependabot | map(item("dependabot"; .number; .security_advisory.severity; .security_advisory.ghsa_id; .security_advisory.summary; .html_url; .created_at))) + # Code scanning alerts without a security severity are quality + # findings, not vulnerabilities, so they are left out. + + ($code | map(select(.rule.security_severity_level != null) + | item("code"; .number; .rule.security_severity_level; .rule.id; (.rule.description + " in " + (.most_recent_instance.location.path // "unknown file")); .html_url; .created_at))) + + ($secrets | map(item("secret"; .number; "critical"; (.secret_type_display_name // .secret_type); "Exposed secret, rotate it now"; .html_url; .created_at))) + ) + | map(select((.severity | rank) >= ($min | rank))) ') echo "matrix=$matrix" >> "$GITHUB_OUTPUT" @@ -73,15 +117,21 @@ jobs: # injection from free-text advisory summaries. - id: key env: + KIND: ${{ matrix.kind }} NUMBER: ${{ matrix.number }} STATUS: ${{ matrix.status }} run: | + # Alert numbers repeat across sources, so non-Dependabot keys carry + # the source. Dependabot keeps the original "sec-alert" prefix, so + # alerts already notified are not announced again. + prefix="sec-alert" + [ "$KIND" != "dependabot" ] && prefix="sec-${KIND}" if [ "$STATUS" = "breach" ]; then - # Re-notify once per day while a critical/high stays breached, + # Re-notify once per day while an alert stays breached, # instead of once ever, so an unresolved breach keeps nagging. - echo "value=sec-alert-${NUMBER}-breach-$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT" + echo "value=${prefix}-${NUMBER}-breach-$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT" else - echo "value=sec-alert-${NUMBER}-${STATUS}" >> "$GITHUB_OUTPUT" + echo "value=${prefix}-${NUMBER}-${STATUS}" >> "$GITHUB_OUTPUT" fi - id: cache @@ -94,6 +144,7 @@ jobs: name: Build Slack payload id: message env: + KIND: ${{ matrix.kind }} SEVERITY: ${{ matrix.severity }} STATUS: ${{ matrix.status }} AGE_DAYS: ${{ matrix.age_days }} @@ -110,8 +161,13 @@ jobs: warning) label=":hourglass_flowing_sand: SLA at risk" ;; *) label=":mag: New finding" ;; esac - text=$(printf '%s%s (%s severity, open %sd): <%s|%s> %s' \ - "$mention" "$label" "$SEVERITY" "$AGE_DAYS" "$HTML_URL" "$GHSA_ID" "$SUMMARY") + case "$KIND" in + code) source="code scanning" ;; + secret) source="secret scanning" ;; + *) source="Dependabot" ;; + esac + text=$(printf '%s%s [%s] (%s severity, open %sd): <%s|%s> %s' \ + "$mention" "$label" "$source" "$SEVERITY" "$AGE_DAYS" "$HTML_URL" "$GHSA_ID" "$SUMMARY") mkdir -p .cache jq -n --arg text "$text" '{text: $text}' > .cache/slack-payload.json diff --git a/.github/workflows/stale-alerts-report.yml b/.github/workflows/stale-alerts-report.yml new file mode 100644 index 00000000000..d0e4b1c2292 --- /dev/null +++ b/.github/workflows/stale-alerts-report.yml @@ -0,0 +1,62 @@ +# kilocode_change - new file +name: Stale alerts report + +# Dependabot normally closes an alert when the fix lands. Some stay open after +# the manifest is deleted or the lockfile moves past the vulnerable range, and +# keep showing in the security views and the SLA reports. Once a week, compare +# each open npm alert with the lockfiles in this checkout and post the ones +# that look stale to Slack, so a person can dismiss them. Read-only: it never +# dismisses an alert. + +on: + schedule: + - cron: "41 8 * * 1" # Mondays, offset to avoid the top of the hour + workflow_dispatch: + +concurrency: + group: stale-alerts-report + cancel-in-progress: false + +permissions: + contents: read + vulnerability-alerts: read + +jobs: + report: + if: github.repository == 'Kilo-Org/kilocode' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - id: report + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -o pipefail + mkdir -p .cache + # --method GET is required: gh api auto-switches to POST whenever + # -f is passed. --paginate prints one array per page, so merge them. + gh api --paginate --method GET -f state=open -f per_page=100 "repos/$REPO/dependabot/alerts" \ + | jq -cs 'add // []' > .cache/alerts.json + bun run script/kilocode/stale-alerts.ts .cache/alerts.json > .cache/report.json + echo "count=$(jq -r .count .cache/report.json)" >> "$GITHUB_OUTPUT" + jq -r '.full' .cache/report.json >> "$GITHUB_STEP_SUMMARY" + # Build real JSON via jq instead of splicing text into a YAML payload. + jq '{text}' .cache/report.json > .cache/slack-payload.json + + - if: steps.report.outputs.count != '0' + name: Notify Slack + uses: slackapi/slack-github-action@v2.0.0 + with: + webhook: ${{ secrets.SECURITY_ALERTS_SLACK_WEBHOOK }} + webhook-type: incoming-webhook + payload-file-path: .cache/slack-payload.json diff --git a/.github/workflows/stale-bot-pr-notify.yml b/.github/workflows/stale-bot-pr-notify.yml index 13020f9574a..c6b53ea865c 100644 --- a/.github/workflows/stale-bot-pr-notify.yml +++ b/.github/workflows/stale-bot-pr-notify.yml @@ -7,7 +7,8 @@ name: Stale bot PR notify # failing -- dependabot-auto-merge.yml only arms auto-merge once and never # checks back, so nothing else would ever catch that case. Once a day, flag # any open bot PR that's conflicting, has a failing required check, or has -# sat unreviewed too long. +# sat unreviewed too long. Bots can open several PRs for one fix, so PRs that +# upgrade the same package are reported as one line (script/kilocode/bot-prs.ts). on: schedule: @@ -28,6 +29,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + - id: build env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -90,16 +99,15 @@ jobs: flagged=$(jq -c -s '.' .cache/flagged.ndjson) echo "flagged=$flagged" >> "$GITHUB_OUTPUT" - # Build real JSON via jq instead of splicing PR titles (free text) - # into a YAML payload through `${{ }}`. - echo "$flagged" | jq ' - if length == 0 then {text: ""} else - {text: (["*Bot PRs needing attention:*"] + map( - "- <" + .url + "|#" + (.number | tostring) + " " + .title + "> (" - + .author.login + ", " + (.age_days | tostring) + "d old, " + .reason + ")" - )) | join("\n")} - end - ' > .cache/slack-payload.json + # Duplicate fix PRs (several PRs upgrading one package) are found + # across all open bot PRs, so keep the full bot list for the script. + echo "$prs" | jq -c 'map(select(.author.is_bot == true))' > .cache/bots.json + echo "$flagged" > .cache/flagged.json + + # bot-prs.ts builds the Slack JSON, so PR titles (free text) never + # reach the payload through Actions expressions. It collapses PRs + # that touch the same package into one line. + bun run script/kilocode/bot-prs.ts .cache/bots.json .cache/flagged.json > .cache/slack-payload.json - if: steps.build.outputs.flagged != '[]' name: Notify Slack diff --git a/.github/workflows/validate-cli-smoke.yml b/.github/workflows/validate-cli-smoke.yml new file mode 100644 index 00000000000..5c354ab7d16 --- /dev/null +++ b/.github/workflows/validate-cli-smoke.yml @@ -0,0 +1,114 @@ +# kilocode_change - new file +# Reproduce the release-only compiled TUI PTY smoke on any branch, without cutting a release. +# +# publish.yml owns the `validate-cli-unix` / `validate-cli-windows` jobs that run +# `kilo --pure __pty-smoke` against the compiled binary, but publish.yml is +# workflow_dispatch-only and goes on to publish, so that gate never runs on a branch +# or PR. test.yml only covers the pty-smoke *unit* tests (synthetic renderers from +# source), not the standalone binary. +# +# This builds the CLI for the host platform only (--single) and runs the same smoke +# step, covering the four targets that failed in runs 37371543591 / 37336767041: +# darwin-arm64, darwin-x64, windows-x64, linux-x64. No signing, no release, no +# publish, no ~1 GB artifact hand-off. +# +# Bubblewrap bundling is skipped so the Linux build does not need the Zig toolchain +# that publish.yml installs; the PTY smoke does not exercise the sandbox helper. + +name: validate-cli-smoke + +on: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + smoke: + name: Compiled PTY smoke (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64 + runner: macos-15 + package: "@kilocode/cli-darwin-arm64" + - target: darwin-x64 + runner: macos-15-intel + package: "@kilocode/cli-darwin-x64" + - target: linux-x64 + runner: ubuntu-24.04 + package: "@kilocode/cli-linux-x64" + - target: windows-x64 + runner: windows-2025 + package: "@kilocode/cli-windows-x64" + steps: + - uses: actions/checkout@v6 + + - uses: ./.github/actions/setup-bun + + - name: Build CLI for this host + working-directory: packages/opencode + run: bun run script/build.ts --single --skip-install + env: + KILO_VERSION: 0.0.0-smoke + KILO_SKIP_BUNDLED_BWRAP: "1" + + - name: Run compiled PTY smoke + if: runner.os != 'Windows' + working-directory: packages/opencode + run: | + binary="./dist/${{ matrix.package }}/bin/kilo" + "$binary" --version + root="$(mktemp -d)" + trap 'rm -rf "$root"' EXIT + ( + unset KILO_MODELS_PATH KILO_MODELS_URL KILO_CONFIG KILO_CONFIG_DIR + export XDG_DATA_HOME="$root/data" + export XDG_CACHE_HOME="$root/cache" + export XDG_CONFIG_HOME="$root/config" + export XDG_STATE_HOME="$root/state" + export KILO_PTY_SMOKE=1 + export KILO_DISABLE_MODELS_FETCH=1 + export KILO_DISABLE_PROJECT_CONFIG=1 + export KILO_CONFIG_CONTENT='{"enabled_providers":["anthropic"]}' + export ANTHROPIC_API_KEY=dummy + "$binary" --pure __pty-smoke + ) + + - name: Run compiled PTY smoke (Windows) + if: runner.os == 'Windows' + working-directory: packages/opencode + shell: pwsh + run: | + $package = "${{ matrix.package }}".Replace("/", "\") + $binary = ".\dist\$package\bin\kilo.exe" + & $binary --version + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $root = Join-Path $env:RUNNER_TEMP ([guid]::NewGuid().ToString()) + New-Item -ItemType Directory -Force -Path $root | Out-Null + try { + foreach ($name in "KILO_MODELS_PATH", "KILO_MODELS_URL", "KILO_CONFIG", "KILO_CONFIG_DIR") { + Remove-Item "Env:$name" -ErrorAction SilentlyContinue + } + $env:XDG_DATA_HOME = Join-Path $root "data" + $env:XDG_CACHE_HOME = Join-Path $root "cache" + $env:XDG_CONFIG_HOME = Join-Path $root "config" + $env:XDG_STATE_HOME = Join-Path $root "state" + $env:KILO_PTY_SMOKE = "1" + $env:KILO_DISABLE_MODELS_FETCH = "1" + $env:KILO_DISABLE_PROJECT_CONFIG = "1" + $env:KILO_CONFIG_CONTENT = '{"enabled_providers":["anthropic"]}' + $env:ANTHROPIC_API_KEY = "dummy" + & $binary --pure __pty-smoke + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } finally { + Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue + } diff --git a/.husky/pre-push b/.husky/pre-push index efe6a35a751..0b51ba74d68 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -40,6 +40,12 @@ while read -r local_ref local_oid remote_ref remote_oid; do fi done +# In a linked worktree git exports GIT_DIR to hooks without GIT_WORK_TREE (githooks(5)). Turbo runs +# git status inside each package, and with GIT_DIR set that directory becomes the work tree root, so +# every tracked file looks deleted and each status call writes tens of MB. Let git find the repository +# from the working directory instead. +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE + bun typecheck --filter='!@kilocode/kilo-jetbrains' if [ "$jetbrains" -eq 1 ]; then diff --git a/AGENTS.md b/AGENTS.md index 433a39794ae..d11275fd596 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,8 @@ Default to a single-word name for variables, parameters, and helper functions. R You MUST avoid using `mocks` as much as possible. Tests MUST test actual implementation, do not duplicate logic into a test. +For VS Code UI changes, the optional shared self-test harness is at https://github.com/Kilo-Org/vscode-extension-self-test (the `vscode-self-test` skill). Offer it and let the user decide whether to install or use it. + ## Markdown Tables Do not pad markdown table cells for column alignment. Use the compact form with single-space-padded content cells and a minimal separator row: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69612910fe0..b473c33a092 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,11 +12,13 @@ There are lots of ways to contribute to the project: - **Feature Requests:** Suggest new features or improvements - **Community Support:** Help other users in the community +Community pull requests are triaged twice a week. See [Community triage](docs/community-triage.md) for what to expect and how long we wait for replies. + The Kilo Community is [on Discord](https://kilo.ai/discord). ## Prerequisites -- **Bun 1.3.14+** — required for all packages. +- **Bun 1.4.2+** — required for all packages. - **Java 21** — required by the JetBrains plugin. The root `bun turbo typecheck` and `bun turbo test:ci` commands include `@kilocode/kilo-jetbrains` and will fail without Java 21. The preferred way to install Java is via [SDKMAN](https://sdkman.io/install): @@ -41,7 +43,7 @@ The Kilo Community is [on Discord](https://kilo.ai/discord). ## Developing Kilo CLI -- **Requirements:** Bun 1.3.14+, Java 21 (see [Prerequisites](#prerequisites) above) +- **Requirements:** Bun 1.4.2+, Java 21 (see [Prerequisites](#prerequisites) above) - Install dependencies and start the CLI from the repo root: ```bash diff --git a/artifacts/glm52-rise-video/package.json b/artifacts/glm52-rise-video/package.json index fccdab264af..5c9d0b410d3 100644 --- a/artifacts/glm52-rise-video/package.json +++ b/artifacts/glm52-rise-video/package.json @@ -21,5 +21,5 @@ "@types/react-dom": "^19.2.3", "typescript": "^5.8.2" }, - "version": "7.8.3" + "version": "7.8.8" } diff --git a/bun.lock b/bun.lock index 1e21c3805e2..e243e6fd00a 100644 --- a/bun.lock +++ b/bun.lock @@ -38,7 +38,7 @@ }, "packages/client": { "name": "@opencode-ai/client", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@opencode-ai/protocol": "workspace:*", "@opencode-ai/schema": "workspace:*", @@ -62,7 +62,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -76,7 +76,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.8.3", + "version": "7.8.8", "bin": { "opencode": "./bin/opencode", }, @@ -174,7 +174,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -188,7 +188,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "effect": "catalog:", }, @@ -200,7 +200,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -221,7 +221,7 @@ }, "packages/httpapi-codegen": { "name": "@opencode-ai/httpapi-codegen", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "effect": "catalog:", "prettier": "3.6.2", @@ -234,7 +234,7 @@ }, "packages/kilo-console": { "name": "@kilocode/kilo-console", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@kilocode/kilo-indexing": "workspace:*", "@kilocode/kilo-web-ui": "workspace:*", @@ -256,7 +256,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -266,7 +266,7 @@ "@xyflow/react": "12.10.2", "js-yaml": "^4.3.2", "mermaid": "11.17.2", - "next": "^16.3.6", + "next": "^16.3.8", "posthog-js": "^1.335.3", "prismjs": "^1.30.0", "react": "^19.2.4", @@ -286,7 +286,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.88", @@ -320,7 +320,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.8.3", + "version": "7.8.8", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -330,7 +330,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -362,11 +362,11 @@ }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", - "version": "7.8.1", + "version": "7.8.7", }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "effect": "catalog:", "zod": "catalog:", @@ -380,7 +380,7 @@ }, "packages/kilo-sandbox": { "name": "@kilocode/sandbox", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@anthropic-ai/sandbox-runtime": "catalog:", "effect": "catalog:", @@ -395,7 +395,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -409,7 +409,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "0.13.11", @@ -447,7 +447,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-gateway": "workspace:*", @@ -482,7 +482,7 @@ "openai": "^4.85.4", "playwright-core": "1.57.0", "quick-lru": "^7.0.0", - "simple-git": "3.36.0", + "simple-git": "4.0.2", "solid-js": "^1.9.11", "uri-js": "^4.4.1", "virtua": "catalog:", @@ -506,7 +506,7 @@ "@types/ws": "8.18.1", "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.5.2", - "@vscode/vsce": "^3.7.1", + "@vscode/vsce": "^4.0.0", "babel-preset-solid": "^1.9.9", "esbuild": "^0.27.2", "esbuild-plugin-solid": "^0.6.0", @@ -527,7 +527,7 @@ }, "packages/kilo-web-ui": { "name": "@kilocode/kilo-web-ui", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@kilocode/kilo-ui": "workspace:*", "@kobalte/core": "catalog:", @@ -544,7 +544,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -563,7 +563,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.8.3", + "version": "7.8.8", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -608,7 +608,7 @@ "@kilocode/sandbox": "workspace:*", "@kilocode/sdk": "workspace:*", "@lydell/node-pty": "catalog:", - "@modelcontextprotocol/sdk": "1.29.0", + "@modelcontextprotocol/sdk": "1.31.0", "@npmcli/arborist": "9.4.0", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", @@ -674,7 +674,7 @@ "remeda": "catalog:", "ripgrep": "0.3.1", "semver": "^7.6.3", - "simple-git": "3.36.0", + "simple-git": "4.0.2", "solid-js": "catalog:", "strip-ansi": "7.1.2", "tree-sitter-bash": "0.25.0", @@ -732,7 +732,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@ai-sdk/provider": "3.0.8", "@kilocode/sdk": "workspace:*", @@ -761,7 +761,7 @@ }, "packages/plugin-atomic-chat": { "name": "@kilocode/plugin-atomic-chat", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@kilocode/plugin": "workspace:*", }, @@ -775,7 +775,7 @@ }, "packages/protocol": { "name": "@opencode-ai/protocol", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@opencode-ai/schema": "workspace:*", "effect": "catalog:", @@ -788,7 +788,7 @@ }, "packages/schema": { "name": "@opencode-ai/schema", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "effect": "catalog:", }, @@ -800,7 +800,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "semver": "^7.6.3", }, @@ -811,7 +811,7 @@ }, "packages/sdk-next": { "name": "@opencode-ai/sdk-next", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", @@ -826,7 +826,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "cross-spawn": "catalog:", }, @@ -841,7 +841,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -856,7 +856,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -873,12 +873,10 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "diff": "catalog:", - "dompurify": "3.4.13", + "dompurify": "3.4.16", "fuzzysort": "catalog:", - "katex": "0.16.27", "luxon": "catalog:", "marked": "catalog:", - "marked-katex-extension": "5.1.6", "marked-shiki": "catalog:", "morphdom": "2.7.8", "motion": "12.34.5", @@ -892,7 +890,6 @@ "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", - "@types/katex": "0.16.7", "@types/luxon": "catalog:", "@typescript/native-preview": "catalog:", "typescript": "catalog:", @@ -901,7 +898,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.8.3", + "version": "7.8.8", "devDependencies": { "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -926,7 +923,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", @@ -952,7 +949,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.8.3", + "version": "7.8.8", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -968,12 +965,11 @@ "@solidjs/router": "catalog:", "@typescript/native-preview": "catalog:", "diff": "catalog:", - "dompurify": "3.4.13", + "dompurify": "3.4.16", "fuzzysort": "catalog:", - "katex": "0.16.27", + "katex": "0.19.0", "luxon": "catalog:", "marked": "catalog:", - "marked-katex-extension": "5.1.6", "marked-shiki": "catalog:", "mermaid": "11.17.2", "morphdom": "2.7.8", @@ -1022,9 +1018,9 @@ "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch", "@ai-sdk/openai-compatible@2.0.48": "patches/@ai-sdk%2Fopenai-compatible@2.0.48.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", - "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", - "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", "bun-pty@0.4.8": "patches/bun-pty@0.4.8.patch", + "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", + "@modelcontextprotocol/sdk@1.31.0": "patches/@modelcontextprotocol%2Fsdk@1.31.0.patch", "pacote@21.5.1": "patches/pacote@21.5.1.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@ai-sdk/anthropic@3.0.111": "patches/@ai-sdk%2Fanthropic@3.0.111.patch", @@ -1050,10 +1046,11 @@ "@xmldom/xmldom": ">=0.8.12", "defu": "6.1.6", "diff": "8.0.4", - "dompurify": "3.4.13", + "dompurify": "3.4.16", "effect": "catalog:", "fastify": ">=5.8.3", "happy-dom": ">=20.8.9", + "katex": "0.19.0", "lodash": "4.18.1", "path-to-regexp": ">=8.4.0", "picomatch": ">=2.3.2", @@ -1089,7 +1086,7 @@ "@tanstack/solid-virtual": "3.13.32", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.14", + "@types/bun": "1.4.2", "@types/cross-spawn": "6.0.6", "@types/luxon": "3.7.1", "@types/node": "24.12.4", @@ -1098,7 +1095,7 @@ "ai": "6.0.235", "cross-spawn": "7.0.6", "diff": "8.0.4", - "dompurify": "3.4.13", + "dompurify": "3.4.16", "drizzle-kit": "1.0.0-rc.2", "drizzle-orm": "1.0.0-rc.2", "effect": "4.0.0-beta.83", @@ -1302,10 +1299,6 @@ "@axe-core/playwright": ["@axe-core/playwright@4.11.3", "", { "dependencies": { "axe-core": "~4.11.4" }, "peerDependencies": { "playwright-core": ">= 1.0.0" } }, "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w=="], - "@azu/format-text": ["@azu/format-text@1.0.2", "", {}, "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg=="], - - "@azu/style-format": ["@azu/style-format@1.0.1", "", { "dependencies": { "@azu/format-text": "^1.0.1" } }, "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g=="], - "@azure/abort-controller": ["@azure/abort-controller@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg=="], "@azure/core-auth": ["@azure/core-auth@1.11.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg=="], @@ -1318,6 +1311,8 @@ "@azure/core-paging": ["@azure/core-paging@1.7.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-7GEAoIsaoBr6KELNRb8nypowCqvk8dnCHFCYg4XD4lOQGY2GqjQg5IhkRjyBFRO18CGSMq05PaNqSOE9GQro3g=="], + "@azure/core-process": ["@azure/core-process@1.0.0", "", {}, "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg=="], + "@azure/core-rest-pipeline": ["@azure/core-rest-pipeline@1.25.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" } }, "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA=="], "@azure/core-tracing": ["@azure/core-tracing@1.4.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ=="], @@ -1326,7 +1321,7 @@ "@azure/core-xml": ["@azure/core-xml@1.6.0", "", { "dependencies": { "fast-xml-parser": "^5.5.9", "tslib": "^2.8.1" } }, "sha512-e7lX/dk//F6Qf7BB6PTY4+p2yuOQtyOeHGyapYHNwqSp2OnYpwQt49A/Nin2XmKBQ69pwagR4k/lQBq8lbHQkA=="], - "@azure/identity": ["@azure/identity@4.13.1", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^5.5.0", "@azure/msal-node": "^5.1.0", "open": "^10.1.0", "tslib": "^2.2.0" } }, "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw=="], + "@azure/identity": ["@azure/identity@4.13.3", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-process": "^1.0.0", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^5.5.0", "@azure/msal-node": "^6.0.0", "open": "^10.1.0", "tslib": "^2.2.0" } }, "sha512-zGQPtqvXPgSA8yfV2CkIQ1qirqk0p9AIVpC5uEkdXQYcKl07QHvyaGYRnZOk0AsQUmxNb4wfkcwY5di8Z5xa9A=="], "@azure/logger": ["@azure/logger@1.4.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA=="], @@ -1334,7 +1329,7 @@ "@azure/msal-common": ["@azure/msal-common@16.12.0", "", {}, "sha512-hgLgfRdbG2AmhXPygebf1KYJEvse86+ZZLWufdiTKaGRYEUqOzHdlf6AS1IiuUCHWbynkgbHc451jSNkbfhWlg=="], - "@azure/msal-node": ["@azure/msal-node@5.5.0", "", { "dependencies": { "@azure/msal-common": "16.12.0", "jsonwebtoken": "^9.0.0" } }, "sha512-A/2WIsuH0vsC6JVkkafjS4kHpi2LDR4AzDT0kJ+oIRtXYeYtvGQ2pwN2X88thQPhSek+82ela3MprsKXWQRrhQ=="], + "@azure/msal-node": ["@azure/msal-node@6.0.1", "", { "dependencies": { "@azure/msal-common": "16.14.1", "jsonwebtoken": "^9.0.0" } }, "sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ=="], "@azure/storage-blob": ["@azure/storage-blob@12.33.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.3", "@azure/core-http-compat": "^2.2.0", "@azure/core-lro": "^2.2.0", "@azure/core-paging": "^1.6.2", "@azure/core-rest-pipeline": "^1.19.1", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/core-xml": "^1.4.5", "@azure/logger": "^1.1.4", "@azure/storage-common": "^12.4.1", "events": "^3.0.0", "tslib": "^2.8.1" } }, "sha512-2SX8oP8PyblUcAFZSg39c8Ls+tFjavM6sBeV+qpw33mRzRhI/5hrFJmJ/x0H9xx5l6ECPvgSP8uPxqTeVbHNIA=="], @@ -1624,57 +1619,57 @@ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="], - "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="], + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="], + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], - "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="], + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="], - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="], + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="], + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], @@ -1798,7 +1793,7 @@ "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.31.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw=="], "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], @@ -1812,27 +1807,53 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + "@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="], + + "@napi-rs/keyring-darwin-arm64": ["@napi-rs/keyring-darwin-arm64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ=="], + + "@napi-rs/keyring-darwin-x64": ["@napi-rs/keyring-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw=="], + + "@napi-rs/keyring-freebsd-x64": ["@napi-rs/keyring-freebsd-x64@1.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw=="], + + "@napi-rs/keyring-linux-arm-gnueabihf": ["@napi-rs/keyring-linux-arm-gnueabihf@1.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ=="], + + "@napi-rs/keyring-linux-arm64-gnu": ["@napi-rs/keyring-linux-arm64-gnu@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg=="], + + "@napi-rs/keyring-linux-arm64-musl": ["@napi-rs/keyring-linux-arm64-musl@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw=="], + + "@napi-rs/keyring-linux-riscv64-gnu": ["@napi-rs/keyring-linux-riscv64-gnu@1.3.0", "", { "os": "linux", "cpu": "none" }, "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ=="], + + "@napi-rs/keyring-linux-x64-gnu": ["@napi-rs/keyring-linux-x64-gnu@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A=="], + + "@napi-rs/keyring-linux-x64-musl": ["@napi-rs/keyring-linux-x64-musl@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw=="], + + "@napi-rs/keyring-win32-arm64-msvc": ["@napi-rs/keyring-win32-arm64-msvc@1.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw=="], + + "@napi-rs/keyring-win32-ia32-msvc": ["@napi-rs/keyring-win32-ia32-msvc@1.3.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ=="], + + "@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg=="], + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="], - "@next/env": ["@next/env@16.3.6", "", {}, "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ=="], + "@next/env": ["@next/env@16.3.8", "", {}, "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg=="], "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="], @@ -2260,22 +2281,10 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q=="], - "@secretlint/config-creator": ["@secretlint/config-creator@10.2.2", "", { "dependencies": { "@secretlint/types": "^10.2.2" } }, "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ=="], - - "@secretlint/config-loader": ["@secretlint/config-loader@10.2.2", "", { "dependencies": { "@secretlint/profiler": "^10.2.2", "@secretlint/resolver": "^10.2.2", "@secretlint/types": "^10.2.2", "ajv": "^8.17.1", "debug": "^4.4.1", "rc-config-loader": "^4.1.3" } }, "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ=="], - "@secretlint/core": ["@secretlint/core@13.0.5", "", { "dependencies": { "@secretlint/profiler": "13.0.5", "@secretlint/types": "13.0.5", "debug": "^4.4.3", "structured-source": "^4.0.0" } }, "sha512-GOT2sDxb3Gz1kLx1/1zfSbnzjQrmZ/8rB62jcz98uA2eavtMNkwgybsaW0toNIaGGCVvnfYJ35pwRR6UG8lJRQ=="], - "@secretlint/formatter": ["@secretlint/formatter@10.2.2", "", { "dependencies": { "@secretlint/resolver": "^10.2.2", "@secretlint/types": "^10.2.2", "@textlint/linter-formatter": "^15.2.0", "@textlint/module-interop": "^15.2.0", "@textlint/types": "^15.2.0", "chalk": "^5.4.1", "debug": "^4.4.1", "pluralize": "^8.0.0", "strip-ansi": "^7.1.0", "table": "^6.9.0", "terminal-link": "^4.0.0" } }, "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA=="], - - "@secretlint/node": ["@secretlint/node@10.2.2", "", { "dependencies": { "@secretlint/config-loader": "^10.2.2", "@secretlint/core": "^10.2.2", "@secretlint/formatter": "^10.2.2", "@secretlint/profiler": "^10.2.2", "@secretlint/source-creator": "^10.2.2", "@secretlint/types": "^10.2.2", "debug": "^4.4.1", "p-map": "^7.0.3" } }, "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ=="], - "@secretlint/profiler": ["@secretlint/profiler@13.0.5", "", {}, "sha512-FAQIuD+sHgBqUl3zf4PN/zPW4aMDo5Ou9z3lMqL7vWSDvujhqwqolyLbdK/FKsmpdE8L87vfiabL2XvWqufZ7Q=="], - "@secretlint/resolver": ["@secretlint/resolver@10.2.2", "", {}, "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w=="], - - "@secretlint/secretlint-formatter-sarif": ["@secretlint/secretlint-formatter-sarif@10.2.2", "", { "dependencies": { "node-sarif-builder": "^3.2.0" } }, "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ=="], - "@secretlint/secretlint-rule-no-dotenv": ["@secretlint/secretlint-rule-no-dotenv@10.2.2", "", { "dependencies": { "@secretlint/types": "^10.2.2" } }, "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg=="], "@secretlint/secretlint-rule-preset-recommend": ["@secretlint/secretlint-rule-preset-recommend@13.0.5", "", {}, "sha512-dxOmTLf1w7I6rQEFPL8Hkdz6dR1FQXF0bzmTahH/Wyezy89TMPLL+8LbZ2YD8trkmYBnS37zUmOsZT6rMVftKQ=="], @@ -2318,11 +2327,9 @@ "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], - "@simple-git/args-pathspec": ["@simple-git/args-pathspec@1.0.3", "", {}, "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA=="], + "@simple-git/args-pathspec": ["@simple-git/args-pathspec@1.0.4", "", {}, "sha512-EtMX6XjRWSastG2SdkmSQByPtJ9dx/NIjnHbpQPCYt62j4RmSx5rgLTGpw0YCjF5h191gZOmBfheOT23cRSFdw=="], - "@simple-git/argv-parser": ["@simple-git/argv-parser@1.1.1", "", { "dependencies": { "@simple-git/args-pathspec": "^1.0.3" } }, "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw=="], - - "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="], + "@simple-git/argv-parser": ["@simple-git/argv-parser@2.0.1", "", { "dependencies": { "@simple-git/args-pathspec": "^1.0.4" } }, "sha512-M++IaVWrN+vYalilOHfwvT2IL3wYZtVEBQpmX6DLSOJLtxbZ+SUeIDE3IiPogzhPACLajwtxfa5rp+ZX1FczzQ=="], "@smithy/config-resolver": ["@smithy/config-resolver@4.6.16", "", { "dependencies": { "@smithy/core": "^3.31.1", "tslib": "^2.6.2" } }, "sha512-XMDfb7LTrlUeI9PpfMa6xzRv+yMtYFsr1LqTsR3CGpSSaaPIR5ldlAiXyLjFYmbh1/DD5NxHmDybdh6lJjJM+Q=="], @@ -2512,16 +2519,6 @@ "@testing-library/user-event": ["@testing-library/user-event@14.6.3", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g=="], - "@textlint/ast-node-types": ["@textlint/ast-node-types@15.8.0", "", {}, "sha512-5CiH9COYmovWmExQgs7763DzX6Gy9zjkjJ7JxCC95wyTcjwQn/8poNF6fv3qzRlmx8CRRde8DHr9FcgAAiPzgw=="], - - "@textlint/linter-formatter": ["@textlint/linter-formatter@15.8.0", "", { "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", "@textlint/module-interop": "15.8.0", "@textlint/resolver": "15.8.0", "@textlint/types": "15.8.0", "debug": "^4.4.3", "js-yaml": "^4.3.0", "lodash": "^4.18.1", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "table": "^6.9.0", "text-table": "^0.2.0" } }, "sha512-+oU3A235NATv6Lzi4xa4kJ65PuNJlIxesaO4AvDhDWA9FWm7y4XKWaoQCW1esgaQQ6dwnUiFKArQ8TcJ86mC4w=="], - - "@textlint/module-interop": ["@textlint/module-interop@15.8.0", "", {}, "sha512-rt+OR1WYGoLOY8HkA/aBPrqufF6yUUEsKEAh7XohTsT3lp9IyZFT6zOIbjul9P4FAzsmSPkcrYjVx3Bz/IUfkg=="], - - "@textlint/resolver": ["@textlint/resolver@15.8.0", "", {}, "sha512-E88tzfX3K8Jykk+38aJ9cy8RquD8ABVOPTO2rFEESq0wcg8x6/ypdAS8ZgR7OKiGqlRF0hkO/m5PbQwVfKM3VA=="], - - "@textlint/types": ["@textlint/types@15.8.0", "", { "dependencies": { "@textlint/ast-node-types": "15.8.0" } }, "sha512-Anhc6y5736YIsvqae0U6k0YmB2M/QVHkEeOv2aydAn/WIkdI69dCOiDbe3/+RagS3qstFTSFWJzNRA2lUjv19w=="], - "@thisbeyond/solid-dnd": ["@thisbeyond/solid-dnd@0.7.5", "", { "peerDependencies": { "solid-js": "^1.5" } }, "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw=="], "@ts-morph/common": ["@ts-morph/common@0.28.1", "", { "dependencies": { "minimatch": "^10.0.1", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.14" } }, "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g=="], @@ -2558,7 +2555,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="], @@ -2668,8 +2665,6 @@ "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], - "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], - "@types/npm-package-arg": ["@types/npm-package-arg@6.1.4", "", {}, "sha512-vDgdbMy2QXHnAruzlv68pUtXCjmqUk3WrBAsRboRovsOmxbfn/WiYCjmecyKjGztnMps5dWp4Uq2prp+Ilo17Q=="], "@types/npm-registry-fetch": ["@types/npm-registry-fetch@8.0.9", "", { "dependencies": { "@types/node": "*", "@types/node-fetch": "*", "@types/npm-package-arg": "*", "@types/npmlog": "*", "@types/ssri": "*" } }, "sha512-7NxvodR5Yrop3pb6+n8jhJNyzwOX0+6F+iagNEoi9u1CGxruYAwZD8pvGc9prIkL0+FdX5Xp0p80J9QPrGUp/g=="], @@ -2690,8 +2685,6 @@ "@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="], - "@types/sarif": ["@types/sarif@2.1.7", "", {}, "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ=="], - "@types/scheduler": ["@types/scheduler@0.26.0", "", {}, "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA=="], "@types/semver": ["@types/semver@7.8.0", "", {}, "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ=="], @@ -2784,9 +2777,9 @@ "@vscode/test-electron": ["@vscode/test-electron@2.5.2", "", { "dependencies": { "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.5", "jszip": "^3.10.1", "ora": "^8.1.0", "semver": "^7.6.2" } }, "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg=="], - "@vscode/vsce": ["@vscode/vsce@3.9.2", "", { "dependencies": { "@azure/identity": "^4.1.0", "@secretlint/node": "^10.1.2", "@secretlint/secretlint-formatter-sarif": "^10.1.2", "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", "@vscode/vsce-sign": "^2.0.0", "azure-devops-node-api": "^12.5.0", "chalk": "^4.1.2", "cheerio": "^1.0.0-rc.9", "cockatiel": "^3.1.2", "commander": "^12.1.0", "form-data": "^4.0.0", "glob": "^13.0.6", "hosted-git-info": "^4.0.2", "jsonc-parser": "^3.2.0", "leven": "^3.1.0", "markdown-it": "^14.1.0", "mime": "^1.3.4", "minimatch": "^10.2.2", "parse-semver": "^1.1.1", "read": "^1.0.7", "secretlint": "^10.1.2", "semver": "^7.5.2", "tmp": "^0.2.3", "typed-rest-client": "^1.8.4", "url-join": "^4.0.1", "xml2js": "^0.5.0", "yauzl": "^3.2.1", "yazl": "^2.2.2" }, "optionalDependencies": { "keytar": "^7.7.0" }, "bin": { "vsce": "vsce" } }, "sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA=="], + "@vscode/vsce": ["@vscode/vsce@4.0.0", "", { "dependencies": { "@azure/identity": "^4.13.2", "@napi-rs/keyring": "^1.3.0", "@secretlint/core": "^10.2.2", "@secretlint/secretlint-rule-no-dotenv": "^10.2.2", "@secretlint/secretlint-rule-preset-recommend": "^10.2.2", "@secretlint/source-creator": "^10.2.2", "@secretlint/types": "^10.2.2", "@vscode/vsce-sign": "^2.1.0", "azure-devops-node-api": "^12.5.0", "cockatiel": "^3.2.1", "commander": "^12.1.0", "hosted-git-info": "^4.1.0", "jsonc-parser": "^3.3.1", "marked": "^18.0.11", "mime": "^1.6.0", "minimatch": "^10.2.6", "parse5": "^8.0.1", "proper-lockfile": "^4.1.2", "read": "^1.0.7", "semver": "^7.8.5", "tinyglobby": "^0.2.17", "typed-rest-client": "^1.8.11", "url-join": "^4.0.1", "xml2js": "^0.5.0", "yauzl": "^3.4.0", "yazl": "^2.5.1" }, "bin": { "vsce": "vsce" } }, "sha512-NImwuLaenMmb5D5Jer9/lzi/F9ZQUBOp8Azhj/BVYcTFgixv8KehFXqEUDjQlD2tAiw2E6dDGyjTuAB//di60A=="], - "@vscode/vsce-sign": ["@vscode/vsce-sign@2.0.9", "", { "optionalDependencies": { "@vscode/vsce-sign-alpine-arm64": "2.0.6", "@vscode/vsce-sign-alpine-x64": "2.0.6", "@vscode/vsce-sign-darwin-arm64": "2.0.6", "@vscode/vsce-sign-darwin-x64": "2.0.6", "@vscode/vsce-sign-linux-arm": "2.0.6", "@vscode/vsce-sign-linux-arm64": "2.0.6", "@vscode/vsce-sign-linux-x64": "2.0.6", "@vscode/vsce-sign-win32-arm64": "2.0.6", "@vscode/vsce-sign-win32-x64": "2.0.6" } }, "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g=="], + "@vscode/vsce-sign": ["@vscode/vsce-sign@2.1.0", "", { "optionalDependencies": { "@vscode/vsce-sign-alpine-arm64": "2.0.6", "@vscode/vsce-sign-alpine-x64": "2.0.6", "@vscode/vsce-sign-darwin-arm64": "2.0.6", "@vscode/vsce-sign-darwin-x64": "2.0.6", "@vscode/vsce-sign-linux-arm": "2.0.6", "@vscode/vsce-sign-linux-arm64": "2.0.6", "@vscode/vsce-sign-linux-x64": "2.0.6", "@vscode/vsce-sign-win32-arm64": "2.0.6", "@vscode/vsce-sign-win32-x64": "2.0.6" } }, "sha512-9AQrqazrBgTgRSuwleLVXUrIUphY02/SFCh2TKYoLV/xifJAdblhdmEmw5gUrYSPQ3sRwNs9iyCMD14sATEE6g=="], "@vscode/vsce-sign-alpine-arm64": ["@vscode/vsce-sign-alpine-arm64@2.0.6", "", { "os": "none", "cpu": "arm64" }, "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q=="], @@ -2858,8 +2851,6 @@ "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], - "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -2896,8 +2887,6 @@ "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], - "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], - "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], "async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="], @@ -2958,8 +2947,6 @@ "binaryextensions": ["binaryextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw=="], - "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], - "bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="], "blueimp-md5": ["blueimp-md5@2.19.0", "", {}, "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w=="], @@ -2998,7 +2985,7 @@ "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -3042,10 +3029,6 @@ "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], - "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], - - "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], - "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -3242,12 +3225,8 @@ "decimal.js": ["decimal.js@10.5.0", "", {}, "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw=="], - "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], - "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], - "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], - "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], @@ -3302,7 +3281,7 @@ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], - "dompurify": ["dompurify@3.4.13", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ=="], + "dompurify": ["dompurify@3.4.16", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ=="], "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], @@ -3334,10 +3313,6 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], - - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "engine.io-client": ["engine.io-client@6.6.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q=="], "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], @@ -3350,8 +3325,6 @@ "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -3410,8 +3383,6 @@ "execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], - "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], - "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], @@ -3524,8 +3495,6 @@ "friendly-words": ["friendly-words@1.3.1", "", { "dependencies": { "ava": "^5.3.1", "express": "^4.18.2", "lodash.samplesize": "^4.2.0" } }, "sha512-gLlK15jM/U/oFtYkw4At0cVS0kWst41BRPG4EnhP/L7ZGn8rnOSlLuffIvO/JIK06TYx7abRpNMTzkwpHL+kEA=="], - "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], - "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], @@ -3560,8 +3529,6 @@ "giget": ["giget@3.3.1", "", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg=="], - "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -3660,8 +3627,6 @@ "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], - "index-to-position": ["index-to-position@1.2.0", "", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="], - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], @@ -3804,9 +3769,7 @@ "jwt-decode": ["jwt-decode@3.1.2", "", {}, "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="], - "katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="], - - "keytar": ["keytar@7.9.0", "", { "dependencies": { "node-addon-api": "^4.3.0", "prebuild-install": "^7.0.1" } }, "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ=="], + "katex": ["katex@0.19.0", "", { "dependencies": { "commander": "^15.0.0" }, "bin": { "katex": "cli.js" } }, "sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -3824,8 +3787,6 @@ "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], - "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], @@ -3856,8 +3817,6 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="], - "load-json-file": ["load-json-file@7.0.1", "", {}, "sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -3888,8 +3847,6 @@ "lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="], - "lodash.truncate": ["lodash.truncate@4.4.2", "", {}, "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw=="], - "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], @@ -3920,12 +3877,8 @@ "map-age-cleaner": ["map-age-cleaner@0.1.3", "", { "dependencies": { "p-defer": "^1.0.0" } }, "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w=="], - "markdown-it": ["markdown-it@14.3.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.5.0", "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw=="], - "marked": ["marked@18.0.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA=="], - "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="], - "marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="], "matcher": ["matcher@5.0.0", "", { "dependencies": { "escape-string-regexp": "^5.0.0" } }, "sha512-s2EMBOWtXFc8dgqvoAzKJXxNHibcdJMV0gwqKUaw9E2JBJuGUK7DrNKrA6g/i+v72TT16+6sVm5mS3thaMLQUw=="], @@ -3936,8 +3889,6 @@ "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], - "mdurl": ["mdurl@2.1.0", "", {}, "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg=="], - "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], "mem": ["mem@9.0.2", "", { "dependencies": { "map-age-cleaner": "^0.1.3", "mimic-fn": "^4.0.0" } }, "sha512-F2t4YIv9XQUBHt6AOJ0y7lSmP1+cY7Fm1DRh9GClTGzKST7UWLMx6ly9WZdLH/G/ppM5RL4MlQfRT71ri9t19A=="], @@ -3976,8 +3927,6 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], - "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], @@ -4002,8 +3951,6 @@ "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], - "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], - "mocha": ["mocha@11.8.0", "", { "dependencies": { "browser-stdout": "^1.3.1", "chokidar": "^4.0.1", "debug": "^4.3.5", "diff": "^7.0.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", "glob": "^10.4.5", "he": "^1.2.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", "minimatch": "^9.0.5", "ms": "^2.1.3", "picocolors": "^1.1.1", "serialize-javascript": "^6.0.2", "strip-json-comments": "^3.1.1", "supports-color": "^8.1.1", "workerpool": "^9.2.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1", "yargs-unparser": "^2.0.0" }, "bin": { "mocha": "bin/mocha.js", "_mocha": "bin/_mocha" } }, "sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw=="], "moo": ["moo@0.5.3", "", {}, "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA=="], @@ -4034,17 +3981,13 @@ "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], - "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], - "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "nearley": ["nearley@2.20.1", "", { "dependencies": { "commander": "^2.19.0", "moo": "^0.5.0", "railroad-diagrams": "^1.0.0", "randexp": "0.4.6" }, "bin": { "nearleyc": "bin/nearleyc.js", "nearley-test": "bin/nearley-test.js", "nearley-unparse": "bin/nearley-unparse.js", "nearley-railroad": "bin/nearley-railroad.js" } }, "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "next": ["next@16.3.6", "", { "dependencies": { "@next/env": "16.3.6", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.6", "@next/swc-darwin-x64": "16.3.6", "@next/swc-linux-arm64-gnu": "16.3.6", "@next/swc-linux-arm64-musl": "16.3.6", "@next/swc-linux-x64-gnu": "16.3.6", "@next/swc-linux-x64-musl": "16.3.6", "@next/swc-win32-arm64-msvc": "16.3.6", "@next/swc-win32-x64-msvc": "16.3.6", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw=="], - - "node-abi": ["node-abi@3.94.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g=="], + "next": ["next@16.3.8", "", { "dependencies": { "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.8", "@next/swc-darwin-x64": "16.3.8", "@next/swc-linux-arm64-gnu": "16.3.8", "@next/swc-linux-arm64-musl": "16.3.8", "@next/swc-linux-x64-gnu": "16.3.8", "@next/swc-linux-x64-musl": "16.3.8", "@next/swc-win32-arm64-msvc": "16.3.8", "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw=="], "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], @@ -4064,14 +4007,10 @@ "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], - "node-sarif-builder": ["node-sarif-builder@3.4.0", "", { "dependencies": { "@types/sarif": "^2.1.7", "fs-extra": "^11.1.1" } }, "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg=="], - "nofilter": ["nofilter@3.1.0", "", {}, "sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g=="], "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], - "normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], - "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "npm-bundled": ["npm-bundled@5.0.0", "", { "dependencies": { "npm-normalize-package-bin": "^5.0.0" } }, "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw=="], @@ -4172,17 +4111,9 @@ "parse-conflict-json": ["parse-conflict-json@5.0.1", "", { "dependencies": { "json-parse-even-better-errors": "^5.0.0", "just-diff": "^6.0.0", "just-diff-apply": "^5.2.0" } }, "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ=="], - "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], - "parse-ms": ["parse-ms@3.0.0", "", {}, "sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw=="], - "parse-semver": ["parse-semver@1.1.1", "", { "dependencies": { "semver": "^5.1.0" } }, "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ=="], - - "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], - - "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], - - "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -4206,7 +4137,7 @@ "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "path-type": ["path-type@6.0.0", "", {}, "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ=="], + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -4242,8 +4173,6 @@ "plur": ["plur@5.1.0", "", { "dependencies": { "irregular-plurals": "^3.3.0" } }, "sha512-VP/72JeXqak2KiOzjgKtQen5y3IZHn+9GOuLDafPv0eXa47xq0At93XahYBs26MsifCQ4enGKwbjBTKgb9QJXg=="], - "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], - "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "poe-oauth": ["poe-oauth@0.0.14", "", {}, "sha512-EHteyy5PsT8cl9zVjYcCQsm6FmLgqfwBkGj6IzUA83d9aWc57bH+jj2ItwJlf6RBhK+DN5N8YVlwEc0gOLb5fw=="], @@ -4266,8 +4195,6 @@ "preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="], - "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], @@ -4292,6 +4219,8 @@ "promise-call-limit": ["promise-call-limit@3.0.2", "", {}, "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], @@ -4300,12 +4229,8 @@ "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], - "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], - "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], @@ -4332,10 +4257,6 @@ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - - "rc-config-loader": ["rc-config-loader@4.1.4", "", { "dependencies": { "debug": "^4.4.3", "js-yaml": "^4.1.1", "json5": "^2.2.3", "require-from-string": "^2.0.2" } }, "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ=="], - "rc9": ["rc9@3.0.1", "", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="], "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], @@ -4350,8 +4271,6 @@ "read-cmd-shim": ["read-cmd-shim@6.0.0", "", {}, "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A=="], - "read-pkg": ["read-pkg@9.0.1", "", { "dependencies": { "@types/normalize-package-data": "^2.4.3", "normalize-package-data": "^6.0.0", "parse-json": "^8.0.0", "type-fest": "^4.6.0", "unicorn-magic": "^0.1.0" } }, "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA=="], - "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], @@ -4402,6 +4321,8 @@ "ret": ["ret@0.1.15", "", {}, "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg=="], + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], @@ -4442,8 +4363,6 @@ "schemes": ["schemes@1.4.0", "", { "dependencies": { "extend": "^3.0.0" } }, "sha512-ImFy9FbCsQlVgnE3TCWmLPCFnVzx0lHL/l+umHplDqAKd0dzFpnS6lFZIpagBlYhKwzVmlV36ec0Y1XTu8JBAQ=="], - "secretlint": ["secretlint@10.2.2", "", { "dependencies": { "@secretlint/config-creator": "^10.2.2", "@secretlint/formatter": "^10.2.2", "@secretlint/node": "^10.2.2", "@secretlint/profiler": "^10.2.2", "debug": "^4.4.1", "globby": "^14.1.0", "read-pkg": "^9.0.1" }, "bin": "./bin/secretlint.js" }, "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg=="], - "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], @@ -4470,7 +4389,7 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="], + "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -4494,11 +4413,7 @@ "sigstore": ["sigstore@4.1.1", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", "@sigstore/sign": "^4.1.1", "@sigstore/tuf": "^4.0.2", "@sigstore/verify": "^3.1.1" } }, "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w=="], - "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], - - "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], - - "simple-git": ["simple-git@3.36.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "@simple-git/args-pathspec": "^1.0.3", "@simple-git/argv-parser": "^1.1.0", "debug": "^4.4.0" } }, "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q=="], + "simple-git": ["simple-git@4.0.2", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "@simple-git/args-pathspec": "1.0.4", "@simple-git/argv-parser": "2.0.1", "debug": "^4.4.0" } }, "sha512-l0sIsv9VrPwovKELFDBOj/lhmIcbMvxBk2B+hRhsMM4/VL3gKtuZvYnBNixVo+N/0HnqQx2cBdLvxnAbygHpqA=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], @@ -4536,14 +4451,12 @@ "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="], - "spdx-correct": ["spdx-correct@3.2.0", "", { "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" } }, "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA=="], - "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], "spdx-expression-parse": ["spdx-expression-parse@5.0.0", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ=="], @@ -4628,14 +4541,10 @@ "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], - "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], - "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="], - "table": ["table@6.9.0", "", { "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1" } }, "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A=="], - "table-layout": ["table-layout@4.1.1", "", { "dependencies": { "array-back": "^6.2.2", "wordwrapjs": "^5.1.0" } }, "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA=="], "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], @@ -4644,8 +4553,6 @@ "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], - "tar-fs": ["tar-fs@2.1.5", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw=="], - "tar-stream": ["tar-stream@3.2.0", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="], "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], @@ -4654,14 +4561,10 @@ "term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="], - "terminal-link": ["terminal-link@4.0.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "supports-hyperlinks": "^3.2.0" } }, "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA=="], - "test-exclude": ["test-exclude@7.0.2", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" } }, "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw=="], "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], - "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], - "textextensions": ["textextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ=="], "thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], @@ -4682,8 +4585,6 @@ "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], - "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toad-cache": ["toad-cache@3.7.4", "", {}, "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg=="], @@ -4720,8 +4621,6 @@ "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], - "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - "turbo": ["turbo@2.10.2", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.2", "@turbo/darwin-arm64": "2.10.2", "@turbo/linux-64": "2.10.2", "@turbo/linux-arm64": "2.10.2", "@turbo/windows-64": "2.10.2", "@turbo/windows-arm64": "2.10.2" }, "bin": { "turbo": "bin/turbo" } }, "sha512-wTExrNrRjB8qzIcg+ZLm0A3GFNLDsWNwdS/RBXB0FPrBDyzk3i96Yx+TxWZC7a0k1SIreFB8ciUbxjmEqTH8IQ=="], "turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="], @@ -4742,8 +4641,6 @@ "typical": ["typical@4.0.0", "", {}, "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw=="], - "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], - "ulid": ["ulid@3.0.1", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-dPJyqPzx8preQhqq24bBG1YNkvigm87K8kVEHCD+ruZg24t6IFEFv00xMWfxcC4djmFtiTLdFuADn4+DOz6R7Q=="], "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], @@ -4752,8 +4649,6 @@ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], - "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], @@ -4792,8 +4687,6 @@ "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], - "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], - "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -4838,8 +4731,6 @@ "well-known-symbols": ["well-known-symbols@2.0.0", "", {}, "sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q=="], - "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], - "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -5132,6 +5023,8 @@ "@azure/identity/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "@azure/msal-node/@azure/msal-common": ["@azure/msal-common@16.14.1", "", {}, "sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -5290,20 +5183,6 @@ "@qdrant/js-client-rest/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - "@secretlint/config-creator/@secretlint/types": ["@secretlint/types@10.2.2", "", {}, "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg=="], - - "@secretlint/config-loader/@secretlint/profiler": ["@secretlint/profiler@10.2.2", "", {}, "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig=="], - - "@secretlint/config-loader/@secretlint/types": ["@secretlint/types@10.2.2", "", {}, "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg=="], - - "@secretlint/formatter/@secretlint/types": ["@secretlint/types@10.2.2", "", {}, "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg=="], - - "@secretlint/node/@secretlint/core": ["@secretlint/core@10.2.2", "", { "dependencies": { "@secretlint/profiler": "^10.2.2", "@secretlint/types": "^10.2.2", "debug": "^4.4.1", "structured-source": "^4.0.0" } }, "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw=="], - - "@secretlint/node/@secretlint/profiler": ["@secretlint/profiler@10.2.2", "", {}, "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig=="], - - "@secretlint/node/@secretlint/types": ["@secretlint/types@10.2.2", "", {}, "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg=="], - "@secretlint/secretlint-rule-no-dotenv/@secretlint/types": ["@secretlint/types@10.2.2", "", {}, "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg=="], "@secretlint/source-creator/@secretlint/types": ["@secretlint/types@10.2.2", "", {}, "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg=="], @@ -5356,14 +5235,6 @@ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], - "@textlint/linter-formatter/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], - - "@textlint/linter-formatter/pluralize": ["pluralize@2.0.0", "", {}, "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw=="], - - "@textlint/linter-formatter/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "@textlint/linter-formatter/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -5376,14 +5247,16 @@ "@vscode/test-cli/yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], + "@vscode/vsce/@secretlint/core": ["@secretlint/core@10.2.2", "", { "dependencies": { "@secretlint/profiler": "^10.2.2", "@secretlint/types": "^10.2.2", "debug": "^4.4.1", "structured-source": "^4.0.0" } }, "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw=="], + "@vscode/vsce/@secretlint/secretlint-rule-preset-recommend": ["@secretlint/secretlint-rule-preset-recommend@10.2.2", "", {}, "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA=="], - "@vscode/vsce/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - - "@vscode/vsce/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + "@vscode/vsce/@secretlint/types": ["@secretlint/types@10.2.2", "", {}, "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg=="], "@vscode/vsce/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + "@vscode/vsce/marked": ["marked@18.0.14", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A=="], + "@vscode/vsce/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], "ai-gateway-provider/@ai-sdk/cohere": ["@ai-sdk/cohere@3.0.61", "", { "dependencies": { "@ai-sdk/provider": "3.0.16", "@ai-sdk/provider-utils": "4.0.51" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-N2WmRvzq3oJ/HMPnFh72XP9Gg6Y2NZNwIjzC8MkKyQDAKAo4YI0S+uzn0WjYxcBXlDBXm3ZlRGaNkdWuY4QUTQ=="], @@ -5422,12 +5295,10 @@ "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + "babel-plugin-jsx-dom-expressions/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], - "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - - "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], @@ -5442,12 +5313,6 @@ "chalk-template/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "cheerio/htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], - - "cheerio/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], - - "cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], - "chromium-bidi/zod": ["zod@3.23.8", "", {}, "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="], "clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -5472,8 +5337,6 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], - "dir-glob/path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], - "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "drizzle-kit/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], @@ -5482,8 +5345,6 @@ "effect/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "engine.io-client/ws": ["ws@8.21.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw=="], "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -5538,9 +5399,7 @@ "jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - - "keytar/node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="], + "katex/commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], "kilo-code/openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="], @@ -5566,16 +5425,8 @@ "log-symbols/is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], - "markdown-it/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - - "markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - - "marked-katex-extension/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], - "matcher/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "mermaid/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], - "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -5604,10 +5455,6 @@ "node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "node-sarif-builder/fs-extra": ["fs-extra@11.4.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA=="], - - "normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], - "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], @@ -5622,9 +5469,7 @@ "parent-module/callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "parse-semver/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], - - "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "parse5/entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], "pkg-conf/find-up": ["find-up@6.3.0", "", { "dependencies": { "locate-path": "^7.1.0", "path-exists": "^5.0.0" } }, "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw=="], @@ -5632,24 +5477,16 @@ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "prebuild-install/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], - "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - - "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], - - "rc-config-loader/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], - - "read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], - "read-yaml-file/js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="], "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], @@ -5662,10 +5499,6 @@ "safe-regex2/ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], - "secretlint/@secretlint/profiler": ["@secretlint/profiler@10.2.2", "", {}, "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig=="], - - "secretlint/globby": ["globby@14.1.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^2.1.0", "fast-glob": "^3.3.3", "ignore": "^7.0.3", "path-type": "^6.0.0", "slash": "^5.1.0", "unicorn-magic": "^0.3.0" } }, "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA=="], - "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -5676,8 +5509,6 @@ "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], - "spdx-correct/spdx-expression-parse": ["spdx-expression-parse@3.0.1", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q=="], - "sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="], "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], @@ -5696,22 +5527,10 @@ "supertap/js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="], - "supports-hyperlinks/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "table/slice-ansi": ["slice-ansi@4.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ=="], - - "table/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "table/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "table-layout/array-back": ["array-back@6.2.3", "", {}, "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw=="], "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], - - "tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], - "test-exclude/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], @@ -5720,8 +5539,6 @@ "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "validate-npm-package-license/spdx-expression-parse": ["spdx-expression-parse@3.0.1", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q=="], - "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="], "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], @@ -5730,8 +5547,6 @@ "vitest/why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -6012,12 +5827,6 @@ "@tailwindcss/vite/@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "@textlint/linter-formatter/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - - "@textlint/linter-formatter/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "@textlint/linter-formatter/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "@vscode/test-cli/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "@vscode/test-cli/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], @@ -6034,7 +5843,7 @@ "@vscode/test-cli/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "@vscode/vsce/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "@vscode/vsce/@secretlint/core/@secretlint/profiler": ["@secretlint/profiler@10.2.2", "", {}, "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig=="], "@vscode/vsce/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], @@ -6080,6 +5889,8 @@ "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "babel-plugin-jsx-dom-expressions/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "babel-plugin-module-resolver/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], "babel-plugin-module-resolver/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], @@ -6204,8 +6015,6 @@ "log-symbols/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - "mocha/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "mocha/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], @@ -6218,12 +6027,6 @@ "mocha/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "node-sarif-builder/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - - "node-sarif-builder/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - - "normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -6244,8 +6047,6 @@ "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], - "rc-config-loader/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], "rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], @@ -6254,8 +6055,6 @@ "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "secretlint/globby/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], - "storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], "storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], @@ -6264,12 +6063,6 @@ "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "table/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "table/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "tar-fs/tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "test-exclude/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "test-exclude/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], diff --git a/bunfig.toml b/bunfig.toml index 61bb6e0fdb3..24808fcbea1 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Keep Kilo's longer supply-chain quarantine while allowing packages that must track coordinated releases. minimumReleaseAge = 410520 # seconds (~4.75 days / ~114 hours) -minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] +minimumReleaseAgeExcludes = ["@types/bun", "bun-types", "mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] [test] root = "./do-not-run-tests-from-root" diff --git a/docs/community-triage.md b/docs/community-triage.md new file mode 100644 index 00000000000..03ba3893acc --- /dev/null +++ b/docs/community-triage.md @@ -0,0 +1,159 @@ +# Community triage + +How we handle pull requests and issues from people outside the Kilo team. + +The goal is simple. Maintainers see the community work that matters, and contributors know what happens next. + +Background: [Slack discussion](https://anaconda.slack.com/archives/C0BJ2GQ40HM/p1791377022461359). + +## Overview + +```mermaid +flowchart LR + A[PR or issue opened] --> B[community-label.yml] + B --> C[Labels: community, needs-triage, area:*] + C --> D[community-sweep.yml
Mon + Thu 17:00 UTC] + D --> E[Status labels synced] + D --> F[Queue posted to job summary and Slack] + F --> G[Tuesday: Bruno] + F --> H[Friday: Andrea] + G --> I[Review, label, comment] + H --> I + I --> D +``` + +Two workflows do the work. Both use `script/kilocode/community-triage.ts`. + +| Workflow | Runs | Does | +|---|---|---| +| `community-label.yml` | When a PR or issue opens or is edited | Adds `community`, `needs-triage` (new items only, not reopened ones) and `area:*` labels | +| `community-sweep.yml` | Monday and Thursday, 17:00 UTC | Syncs status labels, closes stale PRs, posts the queue | + +The sweep runs the evening before each maintainer slot. Andrea starts Friday and Bruno starts Tuesday with a fresh queue. + +## Who is a maintainer + +An author or commenter is a maintainer when one of these is true: + +- GitHub shows them as `OWNER`, `MEMBER` or `COLLABORATOR`. +- Their login is in the repo variable `COMMUNITY_MAINTAINERS` (comma separated). +- The optional secret `COMMUNITY_TRIAGE_MEMBER_TOKEN` (`read:org`) shows they are in the org. + +The default token cannot see private org members. They look like normal contributors. Set the variable or the secret so their PRs are not labeled `community`. + +Bots are ignored. + +## Labels + +Set by the workflows: + +| Label | Meaning | +|---|---| +| `community` | Author is not a maintainer | +| `area:vscode`, `area:cli`, `area:agent-manager`, `area:jetbrains`, `area:desktop`, `area:docs`, `area:gateway`, `area:sdk`, `area:ui`, `area:i18n` | Part of the repo. Comes from the title scope, the changed paths, or the issue "Component" dropdown | +| `needs-triage` | No maintainer has reacted yet | +| `needs-review` | The contributor replied or pushed. A maintainer should look again | +| `awaiting-contributor` | A maintainer reacted last. The 14 day clock runs | +| `ci-failing` | Checks on the last commit failed | +| `needs-reimplementation` | `high-value` PR with no reply for 14 days. The team takes it over | + +Set by hand: + +| Label | Meaning | +|---|---| +| `high-value` | We want this change. The sweep never closes it | +| `low-value` | Triaged. Not a priority | +| `keep-open` | The sweep never closes this PR | + +The sweep owns the status labels. It adds and removes them on its own. Do not manage them by hand. + +The sweep also removes `community` and the status labels from open PRs whose author is a maintainer. This fixes labels set before the maintainer list was known. It only handles PRs. + +`area:*` labels are only added, never removed. Fix a wrong one by hand. + +## Whose turn is it + +The sweep reads the comments, reviews, review replies, commits and reopen events of each open community PR. Drafts are skipped. A reopen by the author counts as a reply. The sweep ignores commit dates more than 5 minutes in the future. + +The sweep reads only the latest events. If it finds no maintainer reaction on a PR with more events than it can read, it labels the PR `needs-review` so a person decides. + +```mermaid +stateDiagram-v2 + [*] --> triage: opened, no maintainer reaction + triage --> awaiting: maintainer comments or requests changes + triage --> approved: maintainer approves + awaiting --> review: contributor comments or pushes + review --> awaiting: maintainer comments or requests changes + review --> approved: maintainer approves + approved --> review: contributor pushes again + awaiting --> closed: 14 days, not high-value + awaiting --> adopted: 14 days, high-value + adopted --> [*]: team re-implements or fixes it + closed --> [*] +``` + +| State | Label | Rule | +|---|---|---| +| triage | `needs-triage` | No maintainer reaction yet, and no `high-value` or `low-value` label | +| review | `needs-review` | The contributor acted after the last maintainer reaction, or a `high-value` or `low-value` label exists | +| approved | none | The last maintainer reaction is an approval | +| awaiting | `awaiting-contributor` | The last maintainer reaction asked for something and the contributor has not answered | + +The 14 day clock starts at the time of the last maintainer reaction. + +## The 14 day rule + +When a PR has been `awaiting-contributor` for 14 days: + +| PR | Result | +|---|---| +| Has `high-value` | Gets `needs-reimplementation` and shows in the digest under "Adopt". Add it to the standup doc and pick an owner | +| Has `keep-open` | Nothing happens | +| Anything else | Closed with a comment | + +Before the sweep closes or adopts a PR, it checks the full history for a contributor reply. This catches replies on long PRs. + +Closing is **off by default**. Until it is on, the sweep only logs what it would close. To turn it on, set the repo variable `COMMUNITY_AUTO_CLOSE_ENABLED` to `true`. To try it by hand, run the sweep from the Actions tab and tick "close". + +When a PR first gets `awaiting-contributor`, the sweep also posts one comment that gives the due date. That comment is also off until closing is on. The sweep posts at most 20 comments per run (`MAX_COMMENTS`). + +`kilo-auto-close.yml` is separate. It does not read these labels. It still closes any PR with no activity for 30 days, including `high-value` and `keep-open` PRs. + +## The maintainer routine + +Open the latest "Community sweep" run. The job summary has the queue. A copy goes to Slack when `COMMUNITY_TRIAGE_SLACK_WEBHOOK` is set. + +1. **Adopt**: pick an owner and add the PR to the standup doc. +2. **Needs triage**: decide. Add `high-value` or `low-value`, or comment on it. Not sure? Put it in the standup doc. +3. **Needs review**: the contributor replied. Review again. +4. **Approved**: merge. +5. **Waiting for contributor**: nothing to do. + +Friday and Tuesday are three to four days apart. This gives contributors time to reply between reviews. + +Useful searches: + +- All community PRs: `is:pr is:open label:community` +- Needs a maintainer: `is:pr is:open label:needs-triage,needs-review` +- One area: `is:pr is:open label:community label:area:vscode` + +## Issues + +New issues get the `community` label, `needs-triage` and an `area:*` label. The area comes from the required "Component" dropdown in the bug and feature templates. The answer "Other / not sure" adds no `area:*` label. The sweep only handles PRs. + +## Setup + +| Name | Kind | Needed | Purpose | +|---|---|---|---| +| `COMMUNITY_AUTO_CLOSE_ENABLED` | Variable | No | `true` turns on closing and comments | +| `COMMUNITY_MAINTAINERS` | Variable | No | Logins treated as maintainers | +| `COMMUNITY_TRIAGE_MEMBER_TOKEN` | Secret | No | Token with `read:org`. Finds private org members | +| `COMMUNITY_TRIAGE_SLACK_WEBHOOK` | Secret | No | Posts the queue to Slack | + +Labels are created by the workflows. Run "Community sweep" once by hand after merge to create them and label the open PRs. + +## Safety + +- `community-label.yml` uses `pull_request_target`. It checks out the base branch only. It never runs PR code. +- The sweep never closes drafts, `high-value` PRs or `keep-open` PRs. +- Closed PRs can be reopened by the contributor. diff --git a/nix/bun.nix b/nix/bun.nix index a8808953494..2a998856064 100644 --- a/nix/bun.nix +++ b/nix/bun.nix @@ -14,19 +14,19 @@ let sources = { "aarch64-linux" = { name = "bun-linux-aarch64"; - hash = "sha256-on/7Y6gxA3WDbg1vZorhf6jY0YuIw3yCHGUzGXOhmjs="; + hash = "sha256-VDKLvC2cjgyfiSxUTWbFeoO4QTnjSQnl7oF1jxrI/ac="; }; "x86_64-linux" = { name = "bun-linux-x64"; - hash = "sha256-lR7iruhV8IWVruxiJSJqKY0/6oOj3NZGXAnLzN9+hI8="; + hash = "sha256-NjaPrvdSeHXV/6UuU81IAhdB8qg+tiCKjdZAaNQiqRM="; }; "aarch64-darwin" = { name = "bun-darwin-aarch64"; - hash = "sha256-2LliIYKK1vl6x6wKt+lYcjQa92MAHogD6CZ2UsJlJiA="; + hash = "sha256-kJh6OhbX21VtiGrD1VHnttPt8KHPQ6yu1iLoZ2vh0S8="; }; "x86_64-darwin" = { name = "bun-darwin-x64"; - hash = "sha256-QYPfM3RiPlurMVxUfPoJdFM81FfYa3O2OfeoeXTNZjM="; + hash = "sha256-gFINfhdSYwjJGF0mFnmsbSd5jTgDoOn3/5Ehq4r/sBI="; }; }; source = diff --git a/nix/hashes.json b/nix/hashes.json index e114e2fcb76..9fd7668b300 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-GPvjJ+tR9oaGS/M6gcmMmRlI3D65nZHsY5CBhfakn1c=", - "aarch64-linux": "sha256-Krg1Gjnu8byyMf+3VbSSZ95xLwzv/4+dCmLW4NijqIc=", - "aarch64-darwin": "sha256-BSSXijWYB7W5MHedZJhmCknWVM4Dwz09UM4Fky2Xnvg=", - "x86_64-darwin": "sha256-Dbw8wNVbUE3a4+uInDE9mEDxG5S5cZhT+RVL/OcEhi0=" + "x86_64-linux": "sha256-yy/c43V7291kA8K9/10N0wUEpteFb9yJK0WeY3OYqQw=", + "aarch64-linux": "sha256-T2PFStBWDJLTGytuUSLf9qK0wl+98SIyvO2HSPZH/1c=", + "aarch64-darwin": "sha256-OZt/MMm+SliHd+W5keq1EX59BSsaSxPzEG1PsuR/174=", + "x86_64-darwin": "sha256-WO8Duy0t2EbV8aWpqvOc0HTLscNVxDRdf7Vr5FQcCeY=" } } diff --git a/package.json b/package.json index 0cbbb6ac524..138386d03a0 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.14", + "packageManager": "bun@1.4.2", "scripts": { "dev": "KILO_CLIENT=cli bun run --cwd packages/opencode --conditions=node src/index.ts", "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", @@ -36,7 +36,7 @@ "@effect/platform-node": "4.0.0-beta.83", "@anthropic-ai/sandbox-runtime": "0.0.63", "@npmcli/arborist": "9.4.0", - "@types/bun": "1.3.14", + "@types/bun": "1.4.2", "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@opentui/core": "0.5.11", @@ -54,7 +54,7 @@ "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", "diff": "8.0.4", - "dompurify": "3.4.13", + "dompurify": "3.4.16", "drizzle-kit": "1.0.0-rc.2", "drizzle-orm": "1.0.0-rc.2", "effect": "4.0.0-beta.83", @@ -159,7 +159,8 @@ "fastify": ">=5.8.3", "vite": "7.3.5", "diff": "8.0.4", - "dompurify": "3.4.13", + "dompurify": "3.4.16", + "katex": "0.19.0", "happy-dom": ">=20.8.9", "@opentui/core": "catalog:", "@opentui/solid": "catalog:", @@ -176,7 +177,7 @@ "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", - "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", + "@modelcontextprotocol/sdk@1.31.0": "patches/@modelcontextprotocol%2Fsdk@1.31.0.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch", @@ -191,6 +192,6 @@ "@ai-sdk/openai-compatible@2.0.48": "patches/@ai-sdk%2Fopenai-compatible@2.0.48.patch", "bun-pty@0.4.8": "patches/bun-pty@0.4.8.patch" }, - "version": "7.8.3", + "version": "7.8.8", "peerDependencies": {} } diff --git a/packages/client/package.json b/packages/client/package.json index 1daa5cb96e0..37bf690fb7b 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -38,5 +38,5 @@ "@typescript/native-preview": "catalog:", "effect": "catalog:" }, - "version": "7.8.3" + "version": "7.8.8" } diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 5791cb1cecc..182c465dfcc 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "7.8.3", + "version": "7.8.8", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/containers/bun-node/Dockerfile b/packages/containers/bun-node/Dockerfile index 7d728889d90..c5fc7161d28 100644 --- a/packages/containers/bun-node/Dockerfile +++ b/packages/containers/bun-node/Dockerfile @@ -6,7 +6,7 @@ FROM ${REGISTRY}/build/base:24.04 SHELL ["/bin/bash", "-lc"] ARG NODE_VERSION=24.4.0 -ARG BUN_VERSION=1.3.14 +ARG BUN_VERSION=1.4.2 ENV BUN_INSTALL=/opt/bun ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin diff --git a/packages/core/package.json b/packages/core/package.json index 68ca04e7d20..248b5212746 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.8.3", + "version": "7.8.8", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 5702b043d08..20d35479f8b 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -69,6 +69,8 @@ export interface GlobInput { readonly follow?: boolean readonly signal?: AbortSignal readonly validate?: Effect.Effect // kilocode_change - bind approved searches at spawn + readonly noRequireGit?: boolean // kilocode_change - honor ignore files outside a git repository + readonly exclude?: readonly string[] // kilocode_change - additional exclusion globs } export interface GrepInput extends KiloGrep.Options { @@ -93,6 +95,7 @@ export interface SearchResult { readonly items: readonly A[] readonly truncated: boolean readonly partial: boolean + readonly invalidPattern?: boolean // kilocode_change - distinguish malformed globs from transient errors } // kilocode_change end @@ -100,8 +103,12 @@ export class Service extends Context.Service()("@opencode/v2 const failure = (message: string, cause?: unknown) => new Error({ message, cause }) +// kilocode_change start - also classify invalid globs for the marketplace scan const isInvalidPattern = (stderr: string) => - stderr.includes("regex parse error") || stderr.includes("error parsing regex") + stderr.includes("regex parse error") || + stderr.includes("error parsing regex") || + stderr.includes("error parsing glob") +// kilocode_change end const layer = Layer.effect( Service, @@ -206,6 +213,7 @@ const layer = Layer.effect( cwd: input.cwd, limit: input.limit, signal: input.signal, + pattern: input.pattern, // kilocode_change - surface malformed globs instead of a bare partial result timeout: 2 * 60 * 1000, // kilocode_change validate: input.validate, // kilocode_change - preserve spawn-bound target validation args: [ @@ -213,7 +221,9 @@ const layer = Layer.effect( "--files", ...(input.hidden ? ["--hidden"] : []), ...(input.follow ? ["--follow"] : []), + ...(input.noRequireGit ? ["--no-require-git"] : []), // kilocode_change `--glob=${input.pattern}`, + ...(input.exclude ?? []).map((glob) => `--glob=!${glob}`), // kilocode_change "--glob=!**/.git/**", ".", ], @@ -236,7 +246,11 @@ const layer = Layer.effect( ), })), // kilocode_change end - Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))), + // kilocode_change start - a malformed glob narrows the result, not the whole request + Effect.catchTag("Ripgrep.InvalidPatternError", () => + Effect.succeed({ items: [], truncated: false, partial: true, invalidPattern: true }), + ), + // kilocode_change end ), find: (input) => run({ diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index cc34080b31d..c47baacbd0a 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -203,6 +203,10 @@ export const Info = Schema.Struct({ description: "Model-specific variant overrides for task-tool subagents, keyed by provider/model. Valid overrides take precedence over saved, agent-specific, and inherited variants.", }), + memory_model: Schema.optional(Schema.NullOr(Schema.String)).annotate({ + description: + "Model for automatic project memory saves in the format of provider/model. If unset or unavailable, memory uses the session model.", + }), default_agent: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: "Default agent to use when none is specified. Must be a primary agent. Falls back to 'code' if not set or if the specified agent is invalid.", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index dcc1c4e4d98..eedcdb10ea5 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.8.3", + "version": "7.8.8", "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 0ead09f1be8..c658147ea4e 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.8.3", + "version": "7.8.8", "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 d20336e5e2a..69dc6c0b89e 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.8.3" +version = "7.8.8" 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.8.3/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.8.8/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.8.3/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.8.8/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.8.3/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.8.8/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.8.3/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.8.8/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.8.3/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.8.8/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index b3d9e1eea64..f68b92ff953 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.8.3", + "version": "7.8.8", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/httpapi-codegen/package.json b/packages/httpapi-codegen/package.json index 9d76f52e131..d41707953d6 100644 --- a/packages/httpapi-codegen/package.json +++ b/packages/httpapi-codegen/package.json @@ -20,5 +20,5 @@ "@types/bun": "catalog:", "@typescript/native-preview": "catalog:" }, - "version": "7.8.3" + "version": "7.8.8" } diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index 064bca999c0..b1ffe61f22f 100755 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-console", - "version": "7.8.3", + "version": "7.8.8", "private": true, "type": "module", "scripts": { diff --git a/packages/kilo-docs/components/SideNav.tsx b/packages/kilo-docs/components/SideNav.tsx index a59e917ea57..d27fa852663 100644 --- a/packages/kilo-docs/components/SideNav.tsx +++ b/packages/kilo-docs/components/SideNav.tsx @@ -16,6 +16,7 @@ const sectionNavItems: SectionNav = { contributing: Nav.ContributingNav, "ai-providers": Nav.AiProvidersNav, gateway: Nav.GatewayNav, + desktop: Nav.DesktopNav, } // Main nav items with their section keys @@ -28,6 +29,7 @@ const mainNavItems = [ { label: "Automate", href: "/automate", sectionKey: "automate" }, { label: "Deploy & Secure", href: "/deploy-secure", sectionKey: "deploy-secure" }, { label: "AI Gateway", href: "/gateway", sectionKey: "gateway" }, + { label: "Kilo Desktop", href: "/desktop", sectionKey: "desktop" }, { label: "Contributing", href: "/contributing", sectionKey: "contributing" }, ] diff --git a/packages/kilo-docs/components/SvgIcon.tsx b/packages/kilo-docs/components/SvgIcon.tsx new file mode 100644 index 00000000000..85767a96030 --- /dev/null +++ b/packages/kilo-docs/components/SvgIcon.tsx @@ -0,0 +1,31 @@ +interface SvgIconProps { + src: string + size?: string + label?: string +} + +/** + * Renders a monochrome SVG from the public folder as a CSS mask filled with + * `currentColor`, so the glyph inherits the surrounding text color and adapts to + * light/dark — unlike the ``-based {% icon %} tag. Use for small line icons + * (e.g., Lucide). Renders on the server, so there is no fetch or flash on load. + */ +export function SvgIcon({ src, size = "1em", label }: SvgIconProps) { + const mask = `url(${JSON.stringify(src)}) center / contain no-repeat` + return ( + + ) +} diff --git a/packages/kilo-docs/components/TopNav.tsx b/packages/kilo-docs/components/TopNav.tsx index 29070a6164a..73897e486a6 100644 --- a/packages/kilo-docs/components/TopNav.tsx +++ b/packages/kilo-docs/components/TopNav.tsx @@ -7,6 +7,7 @@ import { ThemeToggle } from "./ThemeToggle" interface NavItem { label: string href: string + badge?: string } interface DropdownItem { @@ -31,6 +32,7 @@ const mainNavItems: NavItem[] = [ { label: "Automate", href: "/automate" }, { label: "Deploy & Secure", href: "/deploy-secure" }, { label: "Kilo Gateway", href: "/gateway" }, + { label: "Kilo Desktop", href: "/desktop", badge: "Beta" }, { label: "Contributing", href: "/contributing" }, ] @@ -198,12 +200,30 @@ function NavTab({ item, isActive }: { item: NavItem; isActive: boolean }) { {item.label} - {isActive && } + {item.badge ? ( + + {item.badge} + + ) : null} + {isActive && } ) } diff --git a/packages/kilo-docs/components/index.js b/packages/kilo-docs/components/index.js index 26fed7dc325..9422f63e5da 100644 --- a/packages/kilo-docs/components/index.js +++ b/packages/kilo-docs/components/index.js @@ -5,6 +5,7 @@ export * from "./CopyPageButton" export * from "./CopyLine" export * from "./Heading" export * from "./Icon" +export * from "./SvgIcon" export * from "./Image" export * from "./ImageGallery" export * from "./KiloCodeIcon" diff --git a/packages/kilo-docs/lib/nav/desktop.ts b/packages/kilo-docs/lib/nav/desktop.ts new file mode 100644 index 00000000000..d3185047116 --- /dev/null +++ b/packages/kilo-docs/lib/nav/desktop.ts @@ -0,0 +1,37 @@ +import { NavSection } from "../types" + +export const DesktopNav: NavSection[] = [ + { + title: "Introduction", + links: [ + { href: "/desktop/overview", children: "Overview" }, + { href: "/desktop/installation", children: "Installation" }, + { href: "/desktop/quickstart", children: "Quickstart" }, + { href: "/desktop/what-you-can-ask", children: "What you can ask" }, + ], + }, + { + title: "Features", + links: [ + { href: "/desktop/features", children: "Overview" }, + { href: "/desktop/features/notebooks", children: "Notebooks" }, + { href: "/desktop/features/browser", children: "In-app browser" }, + { href: "/desktop/features/terminal", children: "Terminal" }, + { href: "/desktop/features/git", children: "Git integration" }, + { href: "/desktop/features/local-inference", children: "Local inference" }, + { href: "/desktop/features/environments", children: "Conda environments" }, + { href: "/desktop/features/files", children: "Files" }, + ], + }, + { + title: "Settings", + links: [ + { href: "/desktop/settings/general", children: "General settings" }, + { href: "/desktop/settings/ai", children: "AI settings" }, + ], + }, + { + title: "Help", + links: [{ href: "/desktop/troubleshooting", children: "Troubleshooting" }], + }, +] diff --git a/packages/kilo-docs/lib/nav/index.ts b/packages/kilo-docs/lib/nav/index.ts index e0514ac71ad..de0ac323d91 100644 --- a/packages/kilo-docs/lib/nav/index.ts +++ b/packages/kilo-docs/lib/nav/index.ts @@ -5,6 +5,7 @@ import { CollaborateNav } from "./collaborate" import { ContributingNav } from "./contributing" import { CustomizeNav } from "./customize" import { DeploySecureNav } from "./deploy-secure" +import { DesktopNav } from "./desktop" import { GatewayNav } from "./gateway" import { GettingStartedNav } from "./getting-started" import { ToolsNav } from "./tools" @@ -19,5 +20,6 @@ export const Nav = { ContributingNav, AiProvidersNav, GatewayNav, + DesktopNav, ToolsNav, } diff --git a/packages/kilo-docs/markdoc/tags/index.ts b/packages/kilo-docs/markdoc/tags/index.ts index 7e2a84d4f17..57fc7a1b184 100644 --- a/packages/kilo-docs/markdoc/tags/index.ts +++ b/packages/kilo-docs/markdoc/tags/index.ts @@ -4,6 +4,7 @@ export * from "./codicon.markdoc" export * from "./copy-line.markdoc" export * from "./tabs.markdoc" export * from "./icon.markdoc" +export * from "./svg-icon.markdoc" export * from "./image.markdoc" export * from "./image-gallery.markdoc" export * from "./kilo-code-icon.markdoc" diff --git a/packages/kilo-docs/markdoc/tags/svg-icon.markdoc.ts b/packages/kilo-docs/markdoc/tags/svg-icon.markdoc.ts new file mode 100644 index 00000000000..c4d62675838 --- /dev/null +++ b/packages/kilo-docs/markdoc/tags/svg-icon.markdoc.ts @@ -0,0 +1,22 @@ +import { SvgIcon } from "../../components" + +export const svgIcon = { + render: SvgIcon, + selfClosing: true, + attributes: { + src: { + type: String, + required: true, + description: "Path to the inline SVG (e.g., '/docs/img/desktop/code.svg')", + }, + size: { + type: String, + default: "1em", + description: "Icon size (CSS width/height value)", + }, + label: { + type: String, + description: "Accessible label; omit for decorative icons that sit next to their text", + }, + }, +} diff --git a/packages/kilo-docs/next.config.js b/packages/kilo-docs/next.config.js index 38971120c19..acd8682a905 100644 --- a/packages/kilo-docs/next.config.js +++ b/packages/kilo-docs/next.config.js @@ -20,6 +20,11 @@ module.exports = withMarkdoc(/* config: https://markdoc.io/docs/nextjs#options * basePath: false, permanent: true, }, + { + source: "/desktop", + destination: "/desktop/overview", + permanent: true, + }, ...previousDocsRedirects, ] }, diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 5eabef355f0..27c3ba00caa 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.8.3", + "version": "7.8.8", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", @@ -20,7 +20,7 @@ "@xyflow/react": "12.10.2", "js-yaml": "^4.3.2", "mermaid": "11.17.2", - "next": "^16.3.6", + "next": "^16.3.8", "posthog-js": "^1.335.3", "prismjs": "^1.30.0", "react": "^19.2.4", diff --git a/packages/kilo-docs/pages/404.tsx b/packages/kilo-docs/pages/404.tsx index 3dfc29873c9..daf2c39e192 100644 --- a/packages/kilo-docs/pages/404.tsx +++ b/packages/kilo-docs/pages/404.tsx @@ -146,7 +146,7 @@ export default function Custom404() { } .prompt { - color: #f8f674; + color: var(--kilo-yellow); } .command { @@ -171,7 +171,7 @@ export default function Custom404() { .error-bar { width: 4px; height: 80px; - background: #f8f674; + background: var(--kilo-yellow); margin-right: 1.5rem; border-radius: 2px; } @@ -180,7 +180,7 @@ export default function Custom404() { font-family: "JetBrains Mono", monospace; font-size: 4.5rem; font-weight: 700; - color: #f8f674; + color: var(--kilo-yellow); line-height: 1; } diff --git a/packages/kilo-docs/pages/api/llms.txt.ts b/packages/kilo-docs/pages/api/llms.txt.ts index c87ec12a918..53c3a194e4e 100644 --- a/packages/kilo-docs/pages/api/llms.txt.ts +++ b/packages/kilo-docs/pages/api/llms.txt.ts @@ -125,6 +125,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) { title: "Contributing", nav: Nav.ContributingNav }, { title: "AI Providers", nav: Nav.AiProvidersNav }, { title: "Gateway", nav: Nav.GatewayNav }, + { title: "Desktop", nav: Nav.DesktopNav }, { title: "Tools", nav: Nav.ToolsNav }, ] diff --git a/packages/kilo-docs/pages/automate/agent-manager-projects.md b/packages/kilo-docs/pages/automate/agent-manager-projects.md index c276c435fbb..bf917ec88f4 100644 --- a/packages/kilo-docs/pages/automate/agent-manager-projects.md +++ b/packages/kilo-docs/pages/automate/agent-manager-projects.md @@ -5,18 +5,10 @@ description: "Manage Agent Manager sessions across multiple Git repositories" # Multi-project Agent Manager -Multi-project Agent Manager lets you manage sessions and worktrees from multiple Git repositories in one Agent Manager panel. The feature is experimental and disabled by default. When it is disabled, Agent Manager keeps its existing single-project behavior. +Agent Manager lets you manage sessions and worktrees from multiple Git repositories in one panel. Multi-project support is always available, with no experimental setting required. One Agent Manager project represents one canonical Git repository. A project is not a collection of repositories, and the VS Code workspace folder does not define a project. -## Enable multi-project mode - -1. Open [Kilo Code Settings](/docs/getting-started/settings#experimental-features). -2. Open the **Experimental** tab. -3. Enable **Multi-Project Agent Manager**. - -The setting is also available as `kilo-code.new.experimental.multiProject`. It is an application-scoped VS Code setting and defaults to `false`. - ## Default project The first folder in your VS Code workspace is always the **default project**. Agent Manager derives it from the current workspace folder at run time, so it is not an entry in the saved project list and you cannot remove it. It appears first in the **PROJECTS** list, and it has its own Local context, worktrees, sections, sessions, scripts, and settings like any other project. @@ -32,7 +24,7 @@ The project footer below the list provides these actions: - **Open local folder...** opens a folder picker. Agent Manager resolves the selected folder to its canonical Git repository root and adds that repository. A folder inside an existing repository attaches that repository, and a linked worktree attaches its primary checkout. - **Clone repository...** clones a repository with the VS Code Git extension into a parent folder that you choose, then adds it. -Multi-project mode must be enabled and the VS Code window must be trusted before you can add, create, or clone a repository. Adding a repository does not add it to `vscode.workspaceFolders`. Agent Manager identifies a project by its canonical Git root, so adding the same repository again opens the existing project instead of creating a duplicate. +The VS Code window must be trusted before you can add, create, or clone a repository. Adding a repository does not add it to `vscode.workspaceFolders`. Agent Manager identifies a project by its canonical Git root, so adding the same repository again opens the existing project instead of creating a duplicate. If you select a folder that is not a Git repository, **Open local folder...** asks whether to initialize Git and create an empty first commit before it adds the project. diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index 598db06664c..156b9edffde 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -541,7 +541,17 @@ Use **Select element** to attach an element reference to your next message. It i The agent can open the application with `browser_open`. Its automation browser accepts only HTTP URLs on `localhost` or `127.0.0.1` and blocks other origins, including external assets and APIs on separate ports. Serve the resources the agent needs from the same loopback origin. These restrictions apply to the automation browser, not the independent visible preview iframe. -Google Chrome must be installed for the default runtime. To use an already-installed compatible Playwright Chromium browser instead, turn off **Use System Chrome** under **Settings > Web Tools**. +Kilo uses system Google Chrome by default. In WSL, if Chrome is missing and you have not explicitly set a browser preference, Kilo tries an installed compatible Playwright Chromium browser. Set **Use System Chrome** under **Settings > Experimental** to choose Chrome (on) or Chromium (off). Chromium must match the extension's bundled `playwright-core` version. + +The Integrated Browser does not install browsers or operating system packages. To install Chromium manually, run the extension's bundled Playwright CLI with VS Code Server's Node inside WSL: + +```sh +"" "/dist/node_modules/playwright-core/cli.js" install chromium +``` + +Use the paths for your active VS Code Server and Kilo extension. Linux system libraries may also need manual installation. Keep browser sandbox protection enabled. + +In a VS Code WSL window, the browser runs in the WSL extension host. Chrome installed on Windows is not Linux Chrome. Install Chrome inside WSL and keep **Use System Chrome** on, or use matching Chromium with the setting off. This setting is application-scoped, so configure it in Windows-side user settings, not WSL remote settings. `localhost` refers to the extension host, so keep your application's server running inside WSL. ## Terminals diff --git a/packages/kilo-docs/pages/automate/extending/plugins.md b/packages/kilo-docs/pages/automate/extending/plugins.md index 34f731753c2..52884f11aa7 100644 --- a/packages/kilo-docs/pages/automate/extending/plugins.md +++ b/packages/kilo-docs/pages/automate/extending/plugins.md @@ -363,7 +363,7 @@ Every hook is optional. Return only the ones you care about. | Hook | Description | |---|---| -| `config` | Receives the fully-resolved config at startup. Read-only — useful for inspection. | +| `config` | Receives the fully-resolved config at startup. Mutate it in place to add or change agents, commands, providers, and other config — this is how a plugin contributes its own slash commands. A command named `goal` is reserved for [session goals](/docs/code-with-ai/agents/goals): Kilo ignores it under that name, warns, and exposes it as `/goal:command` instead. | | `event` | Called for **every** event on the internal bus (see [Events](#events)). | ### Tools diff --git a/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md b/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md index 0fe389dea8b..00af3b3ad69 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md @@ -81,6 +81,17 @@ Double-click the current session title at the top of the chat to edit it inline. You can also rename local sessions from **History** using the edit button or the session's context menu. +**Switching session tabs:** + +When more than one session is open, the sidebar and Kilo editor tabs show a session tab strip. Use these shortcuts while the chat has focus: + +| Shortcut (macOS) | Shortcut (Windows/Linux) | Action | +|---|---|---| +| `Cmd+Alt+Left` / `Right` | `Ctrl+Alt+Left` / `Right` | Previous / next session tab | +| `Cmd+Shift+Left` / `Right` | `Ctrl+Shift+Left` / `Right` | Move the focused session tab left / right | + +Hover a session tab next to the active tab to see its shortcut. When the prompt is empty, its placeholder also shows the shortcut. To hide these prompt hints, turn off **Show shortcut hints** in **Settings > Display**. To change the keys, search for **Previous Session Tab** or **Next Session Tab** in **Keyboard Shortcuts**. These are the same keys that switch tabs in the Agent Manager, and each window acts on the surface that has focus. + {% /tab %} {% tab label="CLI" %} diff --git a/packages/kilo-docs/pages/code-with-ai/agents/goals.md b/packages/kilo-docs/pages/code-with-ai/agents/goals.md index 3beb82202d7..4fa522a5597 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/goals.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/goals.md @@ -103,7 +103,7 @@ Goal-composer mode accepts multiline objectives and file or image attachments. I ## Limits and integration -- A custom command or an MCP prompt named `goal` is reserved. Kilo rejects it and reports an error; rename it. +- A custom command named `goal` (from your config or a plugin) is reserved for this feature. Kilo ignores it, reports a configuration warning, and makes it available as `/goal:command` instead of running it as `/goal`. - The goal is stored under the `kilo.goal` session metadata key. - Headless mode supports only status and controls through `kilo run --command goal`, which accepts no argument, `pause`, or `clear`. The agent can still start or resume a goal itself with the `goal` tool. diff --git a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md index 7595b492443..1a7af35bb58 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md @@ -23,6 +23,8 @@ You can configure models separately for different tasks in the VS Code extension - **Subagent model** — the default model for subagents launched by the `task` tool. Configured with the `subagent_model` key in `kilo.jsonc`, or the **Subagent Model** field on the **Settings → Models** tab. - If left unset, inherits whichever model the parent agent session is currently using. - **Autocomplete model** — the model used for inline code completions as you type. See [Autocomplete: Provider and Model](/docs/code-with-ai/features/autocomplete#provider-and-model) for how to configure it. +- **Memory model** — the model used for automatic [project memory](/docs/customize/context/memory#use-a-different-model-for-auto-save) saves. Configured with the `memory_model` key in `kilo.jsonc`. + - If left unset, uses whichever model the session is currently using. - **Compaction model** - the model used to summarize context. Set `agent.compaction.model` in `kilo.jsonc`, or choose **Compaction model** under **Settings → Models**. If unset, compaction uses the current session's model. See [Context Condensing](/docs/customize/context/context-condensing#use-a-different-model-for-compaction). ### Configuring Local Usage @@ -57,8 +59,8 @@ While the specifics change constantly, some principles stay consistent: - When the selected model supports variants, type `/variant` to open the reasoning effort selector. - Press `Shift+Tab` in the prompt input to cycle to the next reasoning effort variant, wrapping after the last one. This works in the sidebar chat, the Agent Manager prompt, and the New Worktree dialog, and the variant selector tooltip shows the shortcut on hover. To keep `Shift+Tab` for keyboard focus navigation instead, disable the `kilo-code.new.chat.shiftTabCyclesVariant` setting (also available under **Settings → Display**). - Set per-agent defaults and a global default in the **Settings** panel (Models tab), or directly in the `kilo.jsonc` config file. -- **Model precedence:** Session override → Last picked per agent → Per-agent config → Global config → [Auto Free](/docs/code-with-ai/agents/auto-model#tiers) (note: Auto Free may route to providers that log prompts — see the Auto Model page for details). -- The model selector remembers the last model you picked for each agent, so switching agents restores your previous choice. A manual pick always beats config settings. +- **Model precedence:** Pick for the current session or draft and agent → Per-agent config → Global config → Organization recommendation (then the first available Kilo model), or the most recently used available model for individual users → [Auto Free](/docs/code-with-ai/agents/auto-model#tiers) (note: Auto Free may route to providers that log prompts — see the Auto Model page for details). +- The model selector remembers the model you picked for each agent within a session or draft, so switching agents and back restores your choice. New sessions start from config or the default; existing sessions recover each agent's model and reasoning effort from its latest user message. A manual pick beats config only within the session or draft and agent where it was made. {% /tab %} {% tab label="CLI" %} @@ -129,6 +131,8 @@ When an agent delegates work to a subagent (via the `task` tool), the subagent * This sets the `explore` subagent to always use Haiku regardless of the parent's model. Any subagent without a `model` override uses whatever model the invoking agent is running. +A bare model name without a provider prefix also works — for example `"model": "codestral (latest)"`. Kilo resolves the name against your configured providers and prefers the provider of the parent session when several providers offer a model with the same name. This applies to `agent..model` and `subagent_model`. + {% /tab %} {% tab label="VSCode" %} diff --git a/packages/kilo-docs/pages/code-with-ai/features/task-todo-list.md b/packages/kilo-docs/pages/code-with-ai/features/task-todo-list.md index a1fc77369a0..204c5852ec3 100644 --- a/packages/kilo-docs/pages/code-with-ai/features/task-todo-list.md +++ b/packages/kilo-docs/pages/code-with-ai/features/task-todo-list.md @@ -50,12 +50,12 @@ Kilo updates the list when: ## Where todo lists appear -**1. Task Header Summary** -Quick progress overview with your next important item +**1. Session Dock** +A progress chip above the prompt shows how many items are done and the item Kilo works on now -{% image src="/docs/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png" alt="Task header summary showing todo list progress" width="420" /%} +{% image src="/docs/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-session-dock-todo-states-chromium-linux.png" alt="Todo progress chip in the session dock, next to the working indicator and the session actions" width="420" /%} -Click the task header summary to expand the full list inline and jump to the current item. +While Kilo works, the chip sits next to the working indicator. When the session is idle, it sits next to the session actions. Click the chip to see the full list. **2. Interactive Tool Block** Full todo interface in chat where you can: diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md b/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md index 7a8ffbe9a48..31bce0705f6 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md @@ -88,6 +88,14 @@ For Kilo Pass pricing, billing, and account management details, use the [Kilo Pa On Android, you can buy, restore, and change Kilo Pass tiers through Google Play. Tier changes take effect at the next renewal; your current tier and credits stay in place until then. Google manages cancellation and payment methods for passes purchased through Google Play. +### Buying credits in the app + +The personal credits section shows an **Add credits** entry at any balance, which opens a **Buy credits** screen with your current balance and four preset packs: $10, $50, $100, and $500. The stores sell fixed products, not arbitrary amounts, so the app offers these preset packs rather than a custom amount. Each row shows the credit amount and the store's localized price; a pack the store cannot price is disabled and reads **Price unavailable**. + +Selecting a pack opens the store's own purchase sheet — StoreKit on iOS, Google Play Billing on Android. The app never grants credits itself: credits are added only after the backend validates the purchase with the store. A successful purchase shows a confirmation and refreshes your balance, and a cancelled purchase shows no error. If a purchase fails, the screen shows one inline message with a retry action; if the store is unreachable, it shows one banner naming that store while keeping the four packs visible. A purchase the backend has not yet credited is recovered the next time the app connects, and a refunded pack has its credits reversed. + +For other ways to add credits, see [Adding Credits](/docs/getting-started/adding-credits). + {% imageGallery columns="3" width="220px" %} {% image src="/docs/img/mobile-apps/home.webp" alt="Kilo Code mobile home screen showing active agent sessions" caption="Start coding tasks and resume active sessions from the mobile home screen." /%} diff --git a/packages/kilo-docs/pages/contributing/development-environment.md b/packages/kilo-docs/pages/contributing/development-environment.md index e2f77c6cc9c..89a1bbb63fe 100644 --- a/packages/kilo-docs/pages/contributing/development-environment.md +++ b/packages/kilo-docs/pages/contributing/development-environment.md @@ -16,7 +16,7 @@ This document will help you set up your development environment and understand h Before you begin, make sure you have the following installed: 1. **Git** - For version control -2. **Bun 1.3.14+** - Required for installing dependencies and running scripts +2. **Bun 1.4.2+** - Required for installing dependencies and running scripts 3. **Visual Studio Code** - Our recommended IDE for development 4. **Java 21** - Required only when running JetBrains plugin checks or repo-level checks that include `@kilocode/kilo-jetbrains` diff --git a/packages/kilo-docs/pages/customize/context/memory.md b/packages/kilo-docs/pages/customize/context/memory.md index 74cbc91a73e..7ca0cddec5a 100644 --- a/packages/kilo-docs/pages/customize/context/memory.md +++ b/packages/kilo-docs/pages/customize/context/memory.md @@ -64,6 +64,18 @@ When memory is enabled and auto-save is on, Kilo reviews completed turns and sav Explicit saves are not throttled. +### Use a different model for auto-save + +Auto-save can use a different model than your main session. By default it runs on the model your session is using. To customize it, set `memory_model` in `kilo.jsonc`: + +```jsonc +{ + "memory_model": "kilo/kilo-auto/small" +} +``` + +A smaller or faster model is usually enough for this background work, so you can keep a more capable model for the session itself. If `memory_model` is unset, malformed, or unavailable, auto-save uses the session model. The setting does not affect explicit saves or recall, which run in your session. + ## Explicit memory Use explicit commands when you want to control what is stored: diff --git a/packages/kilo-docs/pages/customize/custom-modes.md b/packages/kilo-docs/pages/customize/custom-modes.md index 442777eee7d..a6ce037cb06 100644 --- a/packages/kilo-docs/pages/customize/custom-modes.md +++ b/packages/kilo-docs/pages/customize/custom-modes.md @@ -175,7 +175,7 @@ Pin a specific model using the `provider/model` format: model: anthropic/claude-sonnet-4-20250514 ``` -The model selector also **remembers the last model you picked for each agent** across sessions. A config-pinned `model` acts as the default when no manual pick exists. To reset a pick and let the config take over, use the **reset button** in the model selector (visible when your active model differs from what the config specifies). +The model selector remembers the model you picked for each agent within a session or draft. A config-pinned `model` is the default for new sessions; a manual pick beats config only for the agent and session or draft where it was made. Switching to another agent uses that agent's pick or configured default, and switching back restores your choice. Existing sessions recover each agent's model from its latest user message rather than a shared model preference. ### `steps` diff --git a/packages/kilo-docs/pages/customize/workflows.md b/packages/kilo-docs/pages/customize/workflows.md index 8b3e4777ad0..cbc22e7d48d 100644 --- a/packages/kilo-docs/pages/customize/workflows.md +++ b/packages/kilo-docs/pages/customize/workflows.md @@ -60,6 +60,10 @@ You are helping submit a pull request... | `variant` | Reasoning effort variant override (for example `low` or `high`), for models that support variants | | `subtask` | When `true`, runs as a sub-agent session | +### Reserved Names + +`goal` is reserved for [session goals](/docs/code-with-ai/agents/goals). A command named `goal` in your config or from a plugin is ignored under that name — Kilo reports a configuration warning and keeps `/goal` for its own command, but the command you defined is still reachable as `/goal:command`, the same way a skill that collides with a command is offered as `/name:skill`. + ### Model and Reasoning Variant Each workflow can run with its own model and reasoning effort variant. In the VS Code extension, open **Settings → Agent Behaviour → Workflows**, expand a workflow, and choose a model and variant. The selection is saved as a command override in your global config, so the workflow's template file stays unchanged. diff --git a/packages/kilo-docs/pages/desktop/features.md b/packages/kilo-docs/pages/desktop/features.md new file mode 100644 index 00000000000..4c67725aa2c --- /dev/null +++ b/packages/kilo-docs/pages/desktop/features.md @@ -0,0 +1,16 @@ +--- +title: "Features" +description: "Explore Kilo Desktop's capabilities: notebooks, an in-app browser, terminal, git, local inference, environments, and files." +--- + +# Features + +Kilo Desktop's capabilities appear alongside your chat, scoped to the workspace you're working in. Explore each one: + +- [Notebooks](/docs/desktop/features/notebooks) — analyze data in interactive Python notebooks. +- [In-app browser](/docs/desktop/features/browser) — browse the web or review changes to your app next to your chat. +- [Terminal](/docs/desktop/features/terminal) — run commands in an interactive shell. +- [Git integration](/docs/desktop/features/git) — review changes and switch branches. +- [Local inference](/docs/desktop/features/local-inference) — run models on your own machine. +- [Conda environments](/docs/desktop/features/environments) — manage conda environments and packages. +- [Files](/docs/desktop/features/files) — browse and edit workspace files. diff --git a/packages/kilo-docs/pages/desktop/features/browser.md b/packages/kilo-docs/pages/desktop/features/browser.md new file mode 100644 index 00000000000..a82a7921c10 --- /dev/null +++ b/packages/kilo-docs/pages/desktop/features/browser.md @@ -0,0 +1,26 @@ +--- +title: "In-app browser" +description: "Browse the web next to your chat, including pages the agent opens for you, in Kilo Desktop." +--- + +# In-app browser + +Browse the web in a tab next to your chat — check documentation, dashboards, or a local dev server without switching to a separate application. Because it lives inside the chat, the browser is wired into your work with the agent. + +## Pages the agent opens + +The agent can open a page for you — a local dev server it just started, or documentation it's pointing you to. If a tab is already showing that URL, the agent focuses it instead of opening another. It opens the page to show it to you, but it's not able to read the page contents. + +## Links from chat + +Selecting an `http(s)` link in a chat message opens it in a browser tab of that chat, rather than sending you to a separate app. To use your system browser instead, hold **⌘** (macOS) or **Ctrl** (Windows/Linux) as you click. + +## Browse on your own + +Type a URL and press Enter to load a page; the tab has the controls you'd expect. + +- **Back**, **forward**, and **refresh**. +- A menu with **Hard reload** and **Open DevTools**. +- **Zoom** from 25% to 500%, with a reset. + +Open more than one browser tab at a time; each keeps its own page as you switch between them, and its label and icon update to match the site you're on. diff --git a/packages/kilo-docs/pages/desktop/features/environments.md b/packages/kilo-docs/pages/desktop/features/environments.md new file mode 100644 index 00000000000..06ec3b92831 --- /dev/null +++ b/packages/kilo-docs/pages/desktop/features/environments.md @@ -0,0 +1,84 @@ +--- +title: "Conda Environments" +description: "Create, import, and manage conda environments and their packages from Kilo Desktop." +--- + +# Conda environments + +Manage conda environments and their packages from within the app, so you can set up the Python environment your work needs without dropping to a separate terminal. You'll find **Conda Environments** in [Settings](/docs/desktop/settings/general#conda-environments), under **General**. + +{% callout type="info" %} +Conda environments require a [Kilo account](/docs/desktop/settings/ai#ai-providers). Sign in to create and manage them. +{% /callout %} + +## Why conda? + +Scientific computing, machine learning, and AI work depends on more than Python packages. Libraries like NumPy, PyTorch, and TensorFlow rely on compiled code and native libraries — such as CUDA for GPUs, BLAS or MKL for linear algebra, and C, C++, and Fortran runtimes — that Python-only tools aren't designed to manage. Conda manages the full stack: + +- **Beyond Python** — install native libraries, GPU toolkits, compilers, and packages for other languages such as R, alongside your Python packages. Conda manages the Python version itself, too. +- **Prebuilt binaries** — packages arrive precompiled for Windows, macOS, and Linux, so you don't need to build libraries from source or install developer tools. +- **Compatible dependencies** — conda's solver resolves the entire environment at once, so every package and native library it installs works together. +- **Isolation** — each conda environment is self-contained, so projects that need different versions of Python, PyTorch, or CUDA don't interfere with each other. +- **Reproducibility** — export an environment to an `environment.yml` file to recreate it on another machine or share it with a teammate. + +To learn more, see [12 reasons to choose conda](https://www.anaconda.com/blog/12-reasons-to-choose-conda). + +## Conda concepts + +New to conda? Learn more about fundamental conda concepts in the official Anaconda documentation: + +- **[Environment](https://www.anaconda.com/docs/getting-started/concepts/what-is-an-environment)** — an isolated set of packages (and a Python version) for a project. +- **[Package](https://www.anaconda.com/docs/getting-started/concepts/what-is-a-package)** — an installable library or tool. +- **[Channel](https://www.anaconda.com/docs/getting-started/concepts/what-is-a-channel)** — where packages are downloaded from. +- **[environment.yml](https://www.anaconda.com/docs/getting-started/working-with-conda/environments#sharing-an-environment)** — a file that describes an environment so it can be recreated or shared. + +## Use a conda environment in your work + +Managing a conda environment here doesn't automatically "activate" it — there's no activate switch in the app. Instead, the agent connects it to your work. Ask an agent to find an appropriate conda environment for a notebook and point the notebook at it, then restart the notebook so it picks up the change. + +The most durable approach is to have the agent write an `environment.yml` into the workspace. The notebooks you already have, and any new ones you create there, then use that environment automatically. See [Notebooks](/docs/desktop/features/notebooks) for working with conda environments from a chat. + +## Browse your conda environments + +The page lists your conda environments by **name** and **prefix** (the full path on disk). Search by name or path, sort the list, and page through it. Click the {% svgIcon src="/docs/img/desktop/ellipsis.svg" /%} actions menu on an environment's row to **Clone** or **Delete** an environment. Select a row to open the environment and work with its packages. + +## Create a conda environment + +Click **New environment**, then select **Create New** to open the Create environment dialog. + +On the Create environment page, configure your environment: + +- **Location** — where the environment is created. +- **Name** — the environment's name. +- **Python version** — a specific version, or **Latest**. + +Under **Select packages**, search the list and check the packages to include. Each package has a version dropdown (defaulting to **latest**), and your choices collect in the **Selected Packages** panel. Use the {% svgIcon src="/docs/img/desktop/list-filter.svg" /%} **Channels** control to choose where packages come from (see [Choose a channel](#choose-a-channel)). Select **Create Environment** to build it. + +## Import from a file + +Click **New environment**, then select **Import file** to open the Import from file dialog. Navigate to or drop a conda environment file (`.yml` or `.yaml`). Optionally set a **name override** to use instead of the name declared in the file. + +## Work with a conda environment's packages + +Selecting an environment opens its detail view. The header shows the environment name, its **Python** version, and the full prefix path. Below it, a table lists every installed package with its **Version**, **Source** (conda or pip), and **Channel**. Search by name and sort by any column. + +### Update packages + +When newer versions are available, an **Update** button appears. Click the **Update** button to open the Update dialog. From there, you can update all packages at once, or filter the table down to just the packages that you want to update. A **Dependency changes** panel previews what the update pulls in and flags any **dependency conflict** it can't resolve, so you can adjust your selection and try again. Turn on **Back up before updating** to clone the environment first, so you can roll back if needed. + +Alternatively, on the packages table on the environment's details page, you can click the {% svgIcon src="/docs/img/desktop/ellipsis.svg" /%} actions menu on a package's row to **Update** or **Remove** just that one. **Update** is unavailable when a package is already at the latest available version. + +### Add packages + +From an open environment, select **+ Add Packages** to open the Add Packages dialog. Search for a package, and for each result pick a version (defaulting to **latest**). Your selections and their dependencies are added to the **Selected packages** list, and the {% svgIcon src="/docs/img/desktop/list-filter.svg" /%} **Channels** control sets where packages come from (see [Choose a channel](#choose-a-channel)). + +Before anything is applied, the **Dependency changes** panel previews the dependency changes the solver will make — packages that will be upgraded, downgraded, added, removed, or rebuilt — so there are no surprises. Select **Install** to apply the changes. A long-running install can be sent to **Run in background** so you can keep working while it finishes. + +## Choose a channel + +When you select packages, you choose which channels they install from with the {% svgIcon src="/docs/img/desktop/list-filter.svg" /%} **Channels** control: + +- **Anaconda `Main`** — secure, vetted packages; requires accepting Anaconda's terms of service. +- **conda-forge** — community-maintained and open-source. + +Any channels configured in your conda settings appear here too. diff --git a/packages/kilo-docs/pages/desktop/features/files.md b/packages/kilo-docs/pages/desktop/features/files.md new file mode 100644 index 00000000000..4cbf7c3ce8c --- /dev/null +++ b/packages/kilo-docs/pages/desktop/features/files.md @@ -0,0 +1,41 @@ +--- +title: "Files" +description: "Browse and edit your workspace files, mention them in chat, and open file links from the agent's responses in Kilo Desktop." +--- + +# Files + +Browse and edit the files in your chat's workspace from a **Files** tab, so you can read and change code alongside the conversation. The tab is scoped to the workspace, so what you see matches what the agent is working with. + +## Browse the workspace + +The file tree shows the files in your workspace. You can: + +- Search the tree to jump to a file by name. +- Refresh to re-scan the folder — the tree also updates on its own when files change on disk. +- See git status decorations (added, modified, deleted, renamed, or untracked) when the workspace is a git repository. + +## Open and edit files + +Select a file to open it in the editor with syntax highlighting. + +- Notebook (`.ipynb`) files open in a [Notebook](/docs/desktop/features/notebooks) tab. +- Markdown (`.md`) files can be viewed in a {% svgIcon src="/docs/img/desktop/eye.svg" /%} rendered preview or {% svgIcon src="/docs/img/desktop/code.svg" /%} as source code, with links to other workspace files clickable inline. +- Images open in a preview. +- Files that can't be shown — such as binaries or very large files — open read-only or explain why they can't be previewed. + +Save with `Cmd/Ctrl+S`. If the file changed on disk since you opened it, Kilo Desktop surfaces a conflict instead of overwriting, so you can reload the latest version, overwrite it with your changes, or cancel. Closing a file with unsaved changes prompts you first. + +## Mention files in chat + +Type `@` in the chat to search your workspace files and insert a mention. The mention resolves to the file's path, so the agent knows exactly which file you mean. + +## Open file links from responses + +When the agent references a file in its response — including a specific location, like `src/api.py:42` — it renders as a clickable link rather than plain text. Click it to open that file at that line in the Files tab. + +The agent can also open a file for you directly, jumping to the relevant line. + +## Open in another editor + +Prefer your usual editor? Open the current file in an external editor you have installed, such as VS Code, Cursor, Zed, or a JetBrains IDE. Kilo Desktop remembers your preferred editor for next time. diff --git a/packages/kilo-docs/pages/desktop/features/git.md b/packages/kilo-docs/pages/desktop/features/git.md new file mode 100644 index 00000000000..4afca65fc98 --- /dev/null +++ b/packages/kilo-docs/pages/desktop/features/git.md @@ -0,0 +1,55 @@ +--- +title: "Git integration" +description: "Review your uncommitted changes, switch branches, and run sessions in an isolated worktree, without leaving the chat in Kilo Desktop." +--- + +# Git integration + +Review your changes and manage branches for the chat's workspace without leaving the conversation. Git integration uses your system `git`, so make sure git is installed and on your `PATH`. + +## Review changes + +The **Changes** tab shows the diff of your uncommitted work against the last commit. Each file is a section you can expand or collapse, labeled with its path, a status icon (added, modified, or deleted), and its own added and removed counts. The diff refreshes on its own as the working tree changes. + +The **git diff tracker** in the top toolbar summarizes those changes at a glance — the number of changed files with added (green) and removed (red) line counts — and opens the **Changes** tab when you select it. + +Controls at the top of the tab let you: + +- {% svgIcon src="/docs/img/desktop/list-chevrons-down-up.svg" /%} **Collapse all files** to their headers, then expand just the ones you want. +- {% svgIcon src="/docs/img/desktop/settings.svg" /%} **Diff settings** control how diffs are displayed, such as switching between a unified and split layout. +- {% svgIcon src="/docs/img/desktop/panel-right-close.svg" /%} **Hide file tree** to give the diff more room, and show it again when you need it. Select a file in the file tree to jump to it in the diff. + +The tab also handles the cases where there's nothing to show: + +- **No changes** — a clean working tree shows an empty state. +- **Not a git repository** — offers to [initialize git](#set-up-git-for-a-folder) for the folder. +- **Diff too large** — pauses live updates with an option to load it on demand. + +## Commit, push, and open a pull request + +The commit control in the top toolbar turns your reviewed changes into commits and shares them. Its main button is a **quick action** that adapts to your branch's state, so it always offers the next sensible step. Open its dropdown to choose an action directly: + +- **Commit** — commit your current changes. Unavailable when the working tree is clean. +- **Push** — push your commits to the remote. Needs a checked-out branch with an `origin` remote and local commits to send. +- **Create PR** — open a pull request for the current branch. Needs committed work on a feature branch (not the default branch) with a GitHub `origin` remote. When a pull request already exists, the menu links to it as **PR #N**, color-coded by its state. + +## Switch and create branches + +A branch switcher in the chat lets you move between branches for the workspace. The list is grouped into your **default**, **recent**, and **other** branches, and you can search it. Selecting a branch checks it out; choosing a remote branch creates a local tracking branch for it. + +To start new work, create a branch from the switcher. You can base the new branch on your current branch or on the repository's default branch, and it's checked out as soon as it's created. + +## Work locally or in a new worktree + +Beside the workspace in the chat, a mode control sets where a session's changes go: + +- **Local repository** — work in the repository already on your machine, on whatever branch is checked out. This is the default. +- **New worktree** — create a separate copy for the session, based on a branch you pick, so your main checkout stays untouched while the agent works. + +Kilo Desktop remembers your choice per workspace. + +## Set up git for a folder + +If the workspace folder isn't a git repository yet, the Changes tab offers an **Initialize Git** action to create one, after which the diff and branch tools become available. + +The branch switcher appears for regular repositories. For repositories that can't be used safely — bare, unsafe, or with no commits yet — Kilo Desktop shows a short explanation instead of a broken or misleading control. diff --git a/packages/kilo-docs/pages/desktop/features/local-inference.md b/packages/kilo-docs/pages/desktop/features/local-inference.md new file mode 100644 index 00000000000..29a174e8f0c --- /dev/null +++ b/packages/kilo-docs/pages/desktop/features/local-inference.md @@ -0,0 +1,21 @@ +--- +title: "Local inference" +description: "Run models on your own machine with the local model server in Kilo Desktop." +--- + +# Local inference + +Run models on your own machine through a managed local model server, so you can work with a model without sending your prompts to a hosted provider. + +{% callout type="info" %} +The local model server requires a [Kilo account](/docs/desktop/settings/ai#ai-providers). Sign in to use it. +{% /callout %} + +## Set up a local model + +Sign in, turn the server on, and import a model from a GGUF file from the [Local Model Server](/docs/desktop/settings/ai#local-model-server) settings. That section covers the full set of server and per-model options. + +## Pick it in chat + +To use an imported model in a chat, open the model picker, then select {% svgIcon src="/docs/img/desktop/server.svg" /%} **Local Model Server**. Your imported models appear there. Choose one and use it like any other model. + diff --git a/packages/kilo-docs/pages/desktop/features/notebooks.md b/packages/kilo-docs/pages/desktop/features/notebooks.md new file mode 100644 index 00000000000..00eeaf42367 --- /dev/null +++ b/packages/kilo-docs/pages/desktop/features/notebooks.md @@ -0,0 +1,40 @@ +--- +title: "Notebooks" +description: "Analyze data in interactive Python notebooks alongside your chat in Kilo Desktop." +--- + +# Notebooks + +Open interactive Python notebooks in a tab next to your chat, so you can explore and visualize data without leaving the conversation. A notebook is a shared document you and the agent build together: you edit and run cells directly, while the agent reads, writes, and runs cells alongside you as you talk through the analysis. Notebooks run on a dedicated notebook runtime. + +{% image src="/docs/img/desktop/notebooks-with-chat.png" alt="A Python notebook open beside a chat in Kilo Desktop" width="800" /%} + +## Open a notebook + +A notebook opens as its own tab beside the chat. There are a few ways to get there: + +- **Ask the agent** to list the files in your workspace, then select an `.ipynb` file in its reply to open it. +- **From the [Files](/docs/desktop/features/files) tab**, search or browse the workspace and select an `.ipynb` file. +- **Ask the agent to open it** by name — the agent can open and list notebooks for you, so the one you're discussing stays in view. You can also create a new notebook by asking the agent. + +## Work with cells + +The toolbar at the top of the notebook has controls to {% svgIcon src="/docs/img/desktop/code.svg" /%} add a code cell, {% svgIcon src="/docs/img/desktop/text-initial.svg" /%} add a Markdown cell, {% svgIcon src="/docs/img/desktop/chevrons-right.svg" /%} run cells, {% svgIcon src="/docs/img/desktop/rotate-ccw.svg" /%} restart, restart and run all cells, and {% svgIcon src="/docs/img/desktop/square.svg" /%} interrupt a running cell. Run a cell to see its output inline, and view the status (such as **Idle**) on the right of the toolbar. + +{% image src="/docs/img/desktop/notebooks-toolbar-controls.png" alt="The notebook toolbar" width="800" /%} + +Each individual cell includes controls to {% svgIcon src="/docs/img/desktop/play.svg" /%} run it, switch it between {% svgIcon src="/docs/img/desktop/code.svg" /%} code and {% svgIcon src="/docs/img/desktop/text-initial.svg" /%} Markdown, {% svgIcon src="/docs/img/desktop/eye-off.svg" /%} hide its input or output, and {% svgIcon src="/docs/img/desktop/trash.svg" /%} delete it. + +## Edit with the agent + +Work on notebooks together with the agent. Ask it to make a change — fix a typo, add analysis, rework a chart — and it reads the notebook, edits or adds cells, and saves. When the notebook is open beside the chat, the results appear in its tab, and a dot marks each cell the agent changes so you can see at a glance what it edited. The notebook doesn't need to be open, though — the agent can edit any notebook in your workspace. + +## Environment and packages + +Each notebook uses a Python environment. Select the kernel button in the toolbar to see the details of that environment. New notebooks can get a shared `environment.yml` in their workspace; toggle this under [Settings → Notebooks](/docs/desktop/settings/general#notebooks). + +The agent can work with [conda environments](/docs/desktop/features/environments) too — ask it to find an appropriate conda environment for this notebook and point the notebook at it, and it will. A helpful pattern is to have the agent write an `environment.yml` into the workspace: then every notebook there, existing or new, picks up that environment automatically. + +## Runtime status + +The notebook runtime starts on demand. While it's starting you'll see a brief status; if it can't be reached, the tab shows a Retry button. \ No newline at end of file diff --git a/packages/kilo-docs/pages/desktop/features/terminal.md b/packages/kilo-docs/pages/desktop/features/terminal.md new file mode 100644 index 00000000000..acc938e991a --- /dev/null +++ b/packages/kilo-docs/pages/desktop/features/terminal.md @@ -0,0 +1,23 @@ +--- +title: "Terminal" +description: "Run commands in an interactive shell scoped to your workspace in Kilo Desktop." +--- + +# Terminal + +Run commands in an interactive shell next to your chat. Each terminal is a real shell session that starts in the workspace's root folder, so commands run against the same files the agent is working with. + +## Working with the agent + +The terminal is yours to drive, but it's connected to the chat: + +- **The agent can open a terminal for you**, starting in the workspace folder, for you to work in. It won't type or run commands in it (the agent runs its own commands through a separate, permission-gated shell). +- **The agent can read what your terminals print**, such as a dev server or test watcher you're running, to help you spot issues and offer assistance. + +## Work in multiple terminals + +Open more than one terminal at a time — each is numbered and runs independently, so a command in one doesn't affect another. The tab title updates to show the command that's currently running. + +## Sessions that survive reloads + +A terminal keeps its scrollback and stays running across window reloads. If the view reloads, it reconnects to the same session rather than starting over, so a long-running command keeps going. Closing a terminal, though, ends its session and stops whatever it's running. \ No newline at end of file diff --git a/packages/kilo-docs/pages/desktop/installation.md b/packages/kilo-docs/pages/desktop/installation.md new file mode 100644 index 00000000000..6b338bb5bef --- /dev/null +++ b/packages/kilo-docs/pages/desktop/installation.md @@ -0,0 +1,31 @@ +--- +title: "Installation" +description: "Install Kilo Desktop and complete first-time setup, including optional dependencies for git and local model features." +--- + +# Installation + +Kilo Desktop is distributed as a native installer for your operating system. Download the installer, run it, and launch the app. + +## System requirements + +- **Operating system:** macOS (Apple Silicon), Windows (`x64`), or Linux (`x64`). +- **Account:** a [Kilo account](/docs/desktop/settings/ai#ai-providers). Signing in is required to use the local model server and create Conda environments. + +## Install the app + +1. Download the [Kilo Desktop installer](https://kilo.ai/install) for your platform. +2. Run the installer and follow the prompts. +3. Launch Kilo Desktop. + +## Optional dependencies + +Some capabilities rely on tools already installed on your system: + +- **Git integration** uses your system `git`. Install git to review changes and switch branches from a chat. See [Git integration](/docs/desktop/features/git). +- **Conda environments** and **local inference** use a conda runtime that the app provisions for you. See [Conda environments](/docs/desktop/features/environments). +- **Local inference** also needs a model file in **GGUF format** (`.gguf`) that you provide. Import one from the [Local Model Server](/docs/desktop/settings/ai#local-model-server) settings. See [Local inference](/docs/desktop/features/local-inference). + +## After installing + +Continue to the [Quickstart](/docs/desktop/quickstart) page to learn how to open a workspace and start your first chat. diff --git a/packages/kilo-docs/pages/desktop/overview.md b/packages/kilo-docs/pages/desktop/overview.md new file mode 100644 index 00000000000..e17f969db92 --- /dev/null +++ b/packages/kilo-docs/pages/desktop/overview.md @@ -0,0 +1,34 @@ +--- +title: "Overview" +description: "Kilo Desktop brings Kilo's agents to your machine, uniting coding, notebooks, terminals, git, and local models all in one place." +--- + +# Overview + +{% callout type="generic" %} +Kilo Desktop is a desktop application that puts an agentic AI chat next to the tools you already use to write code and analyze data science notebooks. From a single conversation, you can ask the agent to write and edit code, create Python environments, explore data in notebooks, review git changes, and more. +{% /callout %} + +## What you can do + +- Create workspaces that use one or more folders on your machine. +- Chat with an AI agent scoped to a workspace. +- Explore and visualize data in interactive Python notebooks next to your chat and let the agent build, run, and iterate on them with you. +- Put the agent to work in your workspace: edit files, run terminal commands, review git changes, and browse the web, all from the conversation. +- Run models locally or connect to hosted providers through the [Kilo Gateway](/docs/gateway). +- Manage conda environments and packages without leaving the app. + +## How it works + +Kilo Desktop runs on the same agent engine as the rest of Kilo. It starts a local copy of the [Kilo CLI](/docs/code-with-ai/platforms/cli) on your machine and drives it through a chat interface, so your sessions use the same runtime that powers the CLI and the [VS Code extension](/docs/code-with-ai/platforms/vscode). + +Because the app is model-agnostic, you decide where inference happens. Connect hosted models through the [Kilo Gateway](/docs/gateway), bring your own provider API keys, or [run models locally](/docs/desktop/features/local-inference). You're never locked into a single provider and can choose which model works best for your current task. + +## Where to go next + +- [Installation](/docs/desktop/installation) — Install Kilo Desktop on your machine. +- [Quickstart](/docs/desktop/quickstart) — Open a workspace and start your first chat. +- [What you can ask](/docs/desktop/what-you-can-ask) — Example prompts mapped to capabilities. +- [Features](/docs/desktop/features) — The capabilities available via chat. +- [General settings](/docs/desktop/settings/general) — your account, appearance, and app preferences. +- [AI settings](/docs/desktop/settings/ai) — AI providers, agent behavior, and local models. \ No newline at end of file diff --git a/packages/kilo-docs/pages/desktop/quickstart.md b/packages/kilo-docs/pages/desktop/quickstart.md new file mode 100644 index 00000000000..bd3c90201d2 --- /dev/null +++ b/packages/kilo-docs/pages/desktop/quickstart.md @@ -0,0 +1,111 @@ +--- +title: "Quickstart" +description: "Sign in, open a workspace, and start your first agentic chat in Kilo Desktop." +--- + +# Quickstart + +This guide walks you through your first session in Kilo Desktop. If you haven't installed the app yet, see [Installation](/docs/desktop/installation). + +## Complete onboarding + +On first launch, a short setup wizard walks you through the setup steps. + +### 1. Review the terms of service + +Review and accept Kilo's [terms of service](https://kilo.ai/terms). + +### 2. Connect to Kilo + +Sign in to your **Kilo account** to use frontier models, credits, and organization settings through the [Kilo Gateway](/docs/gateway). You can skip this step and connect later from [Settings](/docs/desktop/settings/ai#ai-providers). + +{% callout type="note" %} +A Kilo account is also required to use [conda environments](/docs/desktop/features/environments) and the [local model server](/docs/desktop/features/local-inference). You can sign in during onboarding or later from the **Account** section in [Settings](/docs/desktop/settings/general#account). +{% /callout %} + +### 3. Choose an autonomy level + +Choose whether you want Kilo to operate with high autonomy or review its work first, pausing to show its plan and ask for your approval before it edits files or runs commands. High autonomy is the default behavior of the Kilo Code agent. + +### 4. Create a workspace + +A workspace scopes a chat to one or more folders on your machine. Add the folder(s) you want the agent to work in; the agent's file access, terminal, and git operations all run inside the workspace. You can add more workspaces anytime. + +## Navigate the home screen + +When Kilo Desktop opens, you land on a new chat in the workspace you created during setup. + +{% image src="/docs/img/desktop/quickstart-home-screen.png" alt="The Kilo Desktop home screen with the sidebar and a new chat" width="800" /%} + +### Sidebar + +The sidebar on the left manages your chats and workspaces. + +- **New chat** starts a fresh conversation. +- **Search** finds your past chats. +- **Workspaces** lists the folders you've added. Select the dropdown to switch to a different workspace or create a new one. Use the filter to organize the list — group by **Status**, **Workspace**, or **Updated**, order by **Updated** or **Title**, choose what each row shows, and filter by status (**Needs attention**, **Working**, **Idle**, or **Archived**). + +### Settings + +Access the app settings by clicking the {% svgIcon src="/docs/img/desktop/settings.svg" /%} settings icon at the bottom of the sidebar. See [General settings](/docs/desktop/settings/general) and [AI settings](/docs/desktop/settings/ai) for details. + +## Chat with the agent + +The rest of the window is where you chat with the agent. Type a message, use `@` to mention workspace files, or `/` to run a command. + +### Input controls + +The controls around the input let you: + +- **Add an attachment** with the **+** button. +- **Choose an agent** to set how it approaches your request. Pick one of the default agents: Code, Plan, Ask, Debug, or Architect. See [Using agents](/docs/code-with-ai/agents/using-agents) for what each one does. +- **Select autonomy level** for the agent. With **Manually approve**, Kilo asks before each file edit or command; with **Automatically approve**, it proceeds on its own without requesting permission. +- **Pick a model** for the chat. The available models depend on how you're signed in and which providers you've connected. The list can include [Kilo Gateway](/docs/gateway) models, provider models you've added, or a [local model](/docs/desktop/features/local-inference) you've added. +- **Select a workspace** to scope the chat to a workspace, add a **New workspace**, or choose **No workspace** to chat without one. +- **Set where changes go** with the git controls beside the workspace — work in your **Local** repository (switching or creating **branches** as needed), or run the session in a **new worktree**, an isolated copy created from a base branch. See [Git integration](/docs/desktop/features/git). + +### Send your first message + +Enter your message and send it to start the conversation. See [What you can ask](/docs/desktop/what-you-can-ask) for prompts to get you started. + +## Work in tab groups + +Tab groups let you work in the same workspace alongside the chat. Each tab group holds one or more tabs. Send a message to access the tab groups view. + +{% image src="/docs/img/desktop/quickstart-panels.png" alt="A chat with tab groups open beside it in Kilo Desktop" width="800" /%} + +### Open a tab group + +Open a new tab group with the **+ Add tab group** button at the top of a chat: + +{% image src="/docs/img/desktop/quickstart-add-tab-group-button.png" alt="The Add tab group button at the top of a chat" width="800" /%} + +Then, pick a tab to open in it: + +- **[Files](/docs/desktop/features/files)** — browse and edit files in the workspace. +- **[Changes](/docs/desktop/features/git)** — review uncommitted git changes. +- **[Notebook](/docs/desktop/features/notebooks)** — analyze data in an interactive Python notebook. +- **[Browser](/docs/desktop/features/browser)** — open web pages without leaving the app. +- **[Terminal](/docs/desktop/features/terminal)** — run commands in an interactive shell. + +### Arrange tabs and tab groups + +Use {% svgIcon src="/docs/img/desktop/plus.svg" /%} **Add tab** beside a tab group's tabs to add another tab to that group. Drag a tab to move it to another tab group. To move a whole tab group, drag it from the empty space to the right of its tabs. + +Each tab has its own controls: {% svgIcon src="/docs/img/desktop/maximize-2.svg" /%} **Expand** grows the tab to fill the workspace (select it again to collapse), {% svgIcon src="/docs/img/desktop/external-link.svg" /%} **Pop out** moves the tab into its own window, and the {% svgIcon src="/docs/img/desktop/x.svg" /%} close button removes it. + +By default, each tab group you open is added next to the others, and the tab groups get narrower to fit your screen as you open more. Turn on {% svgIcon src="/docs/img/desktop/gallery-horizontal.svg" /%} **Carousel layout** to keep the tab groups wider, then hold **Shift** and scroll with your mouse to pan across them. + +{% svgIcon src="/docs/img/desktop/bot.svg" /%} **Show chat** jumps you back to your conversation tab. + +### Save and reuse layouts + +When you find an arrangement of tab groups and tabs you like, save it as a **layout** and reuse it in other chats. A layout stores how your tab groups and tabs are arranged, not their contents — your chat history and the files, notebooks, or pages you had open aren't included. + +Use the {% svgIcon src="/docs/img/desktop/panels-top-left.svg" /%} **Layouts** button at the top right of a chat to: + +- **Save current layout** — name the arrangement and, optionally, **Use as default for new chats**. The dialog lists any open tabs it can't recreate in a new chat (such as a specific file, notebook, or browser page). +- **Apply a layout** — select a saved layout from the menu to rearrange the current chat to match. +- **Manage layouts** — set or clear the default, update a layout from the current arrangement, rename it, or delete it. + +The default layout applies to new chats only; existing chats stay as they are. \ No newline at end of file diff --git a/packages/kilo-docs/pages/desktop/settings/ai.md b/packages/kilo-docs/pages/desktop/settings/ai.md new file mode 100644 index 00000000000..dd43e7888ed --- /dev/null +++ b/packages/kilo-docs/pages/desktop/settings/ai.md @@ -0,0 +1,87 @@ +--- +title: "AI settings" +description: "Configure agent behavior, AI providers, and the local model server in Kilo Desktop." +--- + +# AI settings + +Open settings from the gear at the bottom of the sidebar, and use the search box at the top to filter them. The **AI** settings cover agent behavior, AI providers, and the local model server. + +## Agent behavior + +The **Agent Behavior** section is where you review and install the [agents](/docs/code-with-ai/agents/using-agents), [skills](/docs/customize/skills), and [MCP servers](/docs/automate/mcp/what-is-mcp) Kilo can use. + +Installed items are grouped by type, and you can search them or filter by **Agents**, **MCPs**, or **Skills**. You can also filter by {% svgIcon src="/docs/img/desktop/list-filter.svg" /%} **Workspace**. Each item shows the workspaces it applies to (such as **All Workspaces**) and its author. Expand an item to see its details and to remove it; an MCP also shows whether it's **Connected**. **Refresh** re-checks for changes. + +Select **+ Add** to open the **Kilo Marketplace**, where you can browse specialized agents, skills, and MCPs by category and install them. See [Marketplace](/docs/customize/marketplace) for the full catalog and how installing works. + +## AI providers + +The **AI Providers** section lists your enabled AI providers, and **Add AI Provider** (top-right) connects a new one. + +The **Kilo Gateway** is the default path to models. When you're signed in it shows your account email and available balance; when you're signed out, use **Sign In**. Manage your account from the [Account](/docs/desktop/settings/general#account) section. + +Other providers appear below the gateway: + +- **Local Model Server** — models running on your machine; **Manage** opens the [Local model server](#local-model-server) settings. +- **Providers you add** — anything you connect with an API key, each with edit and delete actions. +- **System credentials** — some providers (such as **Amazon Bedrock**) connect automatically using credentials already on your machine rather than a stored API key. Expand the card to see the environment variables that were detected (for example, `AWS_PROFILE`). These are managed outside the app, so they can't be edited or removed here. + +To add a provider, select **Add AI Provider** and either: + +- **Pick a popular provider**. Available providers are **Anthropic**, **DeepSeek**, **OpenAI**, **Google**, **OpenRouter**, and **Vercel AI Gateway**. Paste its **API key** to connect to the provider. +- **Add a Custom AI Provider** for any OpenAI- or Anthropic-compatible endpoint. To enable a custom AI provider, you'll need: + - The **Provider name** + - An **API format** (OpenAI Compatible, OpenAI Responses, or Anthropic Messages) + - The **Base URL** + - An optional **API key** (only needed when the endpoint requires authentication). + - The **Model ID(s)** the endpoint exposes. Flag whether each model supports **Reasoning** or **Images**. + + **Advanced settings** adds custom request headers — use `{env:MY_API_TOKEN}` in a header value to pull a secret from the environment. + +Hosted models run through the gateway, so signing in is required to use them. + +For details on the gateway itself, see [Models & Providers](/docs/gateway/models-and-providers). + +## Local model server + +The **Local Model Server** section runs models on your own machine and exposes them to Kilo through an OpenAI-compatible endpoint. The toggle at the top starts and stops the server; the status beside it reads **Idle**, **Starting…**, or **Running**. + +**System Monitor** shows live **CPU**, **RAM**, and **GPU** usage so you can gauge how much headroom a model has. + +**Connect** holds the details an external client needs once the server is running: a copyable **Server Address** (for example `http://127.0.0.1:51009/v1`) and a masked **API Key**. Both read *Unavailable* until the server starts. {% svgIcon src="/docs/img/desktop/cable.svg" /%} **Request info** opens example request URLs and `curl` commands for the endpoints. + +**Models** lists the models you've added and lets you configure each one. Select **Add model → Import GGUF** to add a model from a `.gguf` file on disk. Each model shows whether it's **Loaded** or **Not loaded**, and its {% svgIcon src="/docs/img/desktop/ellipsis.svg" /%} menu can remove it from the server or reveal the file on disk. + +### Model settings + +Select a model to open its settings, grouped into **General**, **Sampling**, and **Advanced** tabs. Every control has an automatic default so you only change what you need. After making a change, you can select the {% svgIcon src="/docs/img/desktop/rotate-ccw.svg" /%} reset button next to a control to return it to the default. Changes save automatically. + +**General** + +- **Coding tools and instructions** — off by default for faster local inference. Turn it on to give the model the full context and tools that Kilo Code uses by default. This is not advised for the smallest of models and should only be used with models that support tool-calling. +- **Context length** — the maximum number of tokens the model holds at once, including the conversation so far and the response being generated. Larger values use more memory and can slow generation. Ranges from **256** to **1,048,576** tokens. +- **Temperature** — how random the output is. Lower values are more predictable and focused; higher values are more varied and creative. Ranges from **0** to **2**. +- **Max tokens** — the maximum number of tokens the model generates in a single response. Unlimited by default; ranges from **1** to **1,048,576**. + +**Sampling** + +- **Top P** — limits sampling to the most likely tokens whose probabilities add up to this value (nucleus sampling). Lower values keep output focused; higher values allow more variety. Ranges from **0** to **1**. +- **Top K** — limits sampling to this many of the most likely tokens; **0** removes the limit. Ranges from **0** to **100**. +- **Min P** — drops tokens whose probability is below this fraction of the most likely token's probability. Ranges from **0** to **1**. +- **Repetition penalty** — discourages repeating the same sequences of tokens. Higher values penalize repetition more; **1** disables it. Ranges from **0** to **2**. +- **Presence penalty** — nudges the model toward tokens it hasn't used yet, encouraging new topics; **0** disables it. Ranges from **0** to **2**. + +**Advanced** + +- **Extra Arguments** — pass `llama.cpp` flags exactly as you would on the command line (for example `--n-gpu-layers 99`). Matching flags override the settings in the other tabs; app-managed flags are refused. + +### Runtime settings + +**Runtime** controls how the server itself runs: + +- **Server port** — leave blank to pick an available port automatically or enter a specific port. +- **Models kept loaded** — keep **One model** loaded to save memory, or **All models** you use. +- **Serve on local network** — let trusted devices on your Wi-Fi or LAN connect. + +See [Local inference](/docs/desktop/features/local-inference) for how local models appear in chats. diff --git a/packages/kilo-docs/pages/desktop/settings/general.md b/packages/kilo-docs/pages/desktop/settings/general.md new file mode 100644 index 00000000000..f6538d657f2 --- /dev/null +++ b/packages/kilo-docs/pages/desktop/settings/general.md @@ -0,0 +1,51 @@ +--- +title: "General settings" +description: "Manage your account, appearance, conda environments, notebooks, and privacy in Kilo Desktop." +--- + +# General settings + +Open settings from the gear at the bottom of the sidebar, and use the search box at the top to filter them. The **General** settings cover the app version, your account, appearance, conda environments, notebooks, and privacy. + +## About + +The **About** section shows the app's version and manages updates: + +- **Version** — the build you're running, with its update status. When a newer build has downloaded it reads *Version X is ready to install*; otherwise use **Check for updates** to look for one. +- **Update track** — choose whether to get **Stable** releases or **Preview** builds. + +When an update is ready, select **Restart to update** to relaunch and apply it. + +## Account + +The **Account** section manages your **Kilo** account. When you're signed in, it shows your account email, your **Available balance** (with a refresh), an **Organization** selector, and **Top up** and **Kilo Dashboard** links. See [Usage & Billing](/docs/gateway/usage-and-billing) for how organizations and balances work. + +## Appearance + +The **Appearance** section controls how the app looks: + +- **Theme** — switch between **Light**, **Dark**, or **System**. +- **Color theme** — choose the app's color theme from the dropdown, such as **Kilo**. +- **Accent color** — use the theme's accent (the **Theme** option) or pick your own from the color swatches. +- **Sidebar vibrancy** — toggle a translucent sidebar background on or off. +- **Interface text size** — adjust the text size in the sidebar, menus, and settings. +- **Chat text size** — adjust the text size of chat messages, the input, and tool output. + +## Conda Environments + +Create and manage conda environments and their packages. See [Conda environments](/docs/desktop/features/environments) for what you can do here. Requires a Kilo account. + +## Notebooks + +The **Notebooks** section has two settings: + +- **Automatically create environment.yml** — give new notebooks a shared conda environment in their workspace. +- **Trust new notebooks** — skip the dependency approval prompt for new notebooks. + +See [Notebooks](/docs/desktop/features/notebooks) for working with notebooks in a chat. + +## Privacy + +The **Privacy** section has a single **Share usage analytics and crash reports** toggle. When on, it shares usage analytics and crash reports with Kilo — including from the embedded Kilo service and conda environment manager. + +It also explains data handling for **free models**: because they may route your requests to providers that log prompts and outputs, don't submit personal or confidential data when using them. See [Using Kilo for Free](/docs/getting-started/using-kilo-for-free) for details, and use the chat model picker's filter to exclude free models. diff --git a/packages/kilo-docs/pages/desktop/troubleshooting.md b/packages/kilo-docs/pages/desktop/troubleshooting.md new file mode 100644 index 00000000000..c6bd6c9e1cd --- /dev/null +++ b/packages/kilo-docs/pages/desktop/troubleshooting.md @@ -0,0 +1,36 @@ +--- +title: "Troubleshooting" +description: "Fixes for common Kilo Desktop issues, including the local server, git, and local models." +--- + +# Troubleshooting + +If something isn't working in Kilo Desktop, start here. + +## The app can't reach the agent + +Kilo Desktop runs the agent through a local server (a `kilo serve` process) that starts on demand. If chats fail to respond: + +- Wait a moment after launch — the server starts the first time you open a chat and needs to report healthy before it accepts requests. +- Make sure you're signed in to your [Kilo account](/docs/desktop/settings/ai#ai-providers) so hosted models are available. +- Restart the app to relaunch the server. + +## The Changes tab or branch switcher isn't available + +Git integration needs your system `git`. If the tools are missing or not working: + +- Confirm `git` is installed and on your `PATH`. +- If the folder isn't a git repository, use **Initialize Git** in the Changes tab to create one. See [Git integration](/docs/desktop/features/git#set-up-git-for-a-folder). +- For bare or unsafe repositories, the branch switcher is intentionally hidden. Kilo Desktop shows a short explanation instead of a broken control. + +## A local model isn't listed in a chat + +Local models appear in the model picker only after you're signed in to your [Kilo account](/docs/desktop/settings/ai#ai-providers), the **Local Model Server** is enabled, and you've imported a model. Check the [Local Model Server](/docs/desktop/settings/ai#local-model-server) settings. + +## The agent can't find a file in a gitignored folder + +When the agent searches your workspace for files, it skips anything excluded by your `.gitignore`. If a notebook or other file lives in an ignored folder, the agent won't find it by searching — but it can still open and edit the file once you point it there: + +- **Open it from the [Files](/docs/desktop/features/files) tab.** The Files tab shows gitignored files and folders, so you can browse to the file and open it directly. +- **Mention it with `@`** in the chat and select the file from the list. +- **Give the agent the path**, for example: "Open `data/scratch/analysis.ipynb`." diff --git a/packages/kilo-docs/pages/desktop/what-you-can-ask.md b/packages/kilo-docs/pages/desktop/what-you-can-ask.md new file mode 100644 index 00000000000..abb0b478874 --- /dev/null +++ b/packages/kilo-docs/pages/desktop/what-you-can-ask.md @@ -0,0 +1,21 @@ +--- +title: "What you can ask" +description: "Example prompts for Kilo Desktop, mapped to the capability each one uses." +--- + +# What you can ask + +Kilo Desktop's agent works across the tools in your workspace. Describe what you want in plain language, and the agent uses the matching capability. The table below maps common intents to example prompts and the [feature](/docs/desktop/features) they use. + +| What you want | Example prompt | Capability | +|---|---|---| +| Analyze data interactively | "Load @sales.csv and plot monthly revenue in a notebook." | [Notebooks](/docs/desktop/features/notebooks) | +| Look something up on the web | "Open the pandas docs for `groupby` next to this chat." | [In-app browser](/docs/desktop/features/browser) | +| Review and manage changes | "Show me my uncommitted changes and create a branch for this work." | [Git integration](/docs/desktop/features/git) | +| Manage Python dependencies | "Create a conda environment with Python 3.12 and install pandas." | [Conda environments](/docs/desktop/features/environments) | +| Work with workspace files | "Read `src/api.py` and add input validation." | [Files](/docs/desktop/features/files) | + +## Tips + +- Reference workspace files directly with `@` in the chat to point the agent at a specific file. +- Keep each chat scoped to one workspace so the agent's tools operate on the right folders. Use the **Select workspace** control in the chat to choose the workspace you want the agent to work in — its file, terminal, and git tools all run inside the folders in that workspace. diff --git a/packages/kilo-docs/pages/index.tsx b/packages/kilo-docs/pages/index.tsx index cda1eedb3d4..093e8e212d7 100644 --- a/packages/kilo-docs/pages/index.tsx +++ b/packages/kilo-docs/pages/index.tsx @@ -463,14 +463,14 @@ export default function HomePage() { } .btn-primary { - background: #f8f674; + background: var(--kilo-yellow); color: #1a1a18; - border-color: #f8f674; + border-color: var(--kilo-yellow); } .btn-primary:hover { - background: #ffff8d; - border-color: #ffff8d; + background: var(--kilo-yellow-hover); + border-color: var(--kilo-yellow-hover); transform: translateY(-1px); } @@ -491,8 +491,8 @@ export default function HomePage() { } :global(.dark) .btn-secondary:hover { - color: #f8f674; - border-color: #f8f674; + color: var(--kilo-yellow); + border-color: var(--kilo-yellow); } .btn-primary { diff --git a/packages/kilo-docs/pnpm-lock.yaml b/packages/kilo-docs/pnpm-lock.yaml index 27b6fd40e64..96daf776b66 100644 --- a/packages/kilo-docs/pnpm-lock.yaml +++ b/packages/kilo-docs/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + katex: 0.19.0 + importers: .: @@ -19,7 +22,7 @@ importers: version: 0.5.9(@types/react@19.2.18)(react@19.2.8) '@markdoc/next.js': specifier: ^0.5.0 - version: 0.5.0(@markdoc/markdoc@0.5.9(@types/react@19.2.18)(react@19.2.8))(next@16.3.6(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 0.5.0(@markdoc/markdoc@0.5.9(@types/react@19.2.18)(react@19.2.8))(next@16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) '@vscode/codicons': specifier: ^0.0.44 version: 0.0.44 @@ -33,8 +36,8 @@ importers: specifier: 11.17.2 version: 11.17.2 next: - specifier: ^16.3.6 - version: 16.3.6(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^16.3.8 + version: 16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) posthog-js: specifier: ^1.335.3 version: 1.413.3 @@ -110,160 +113,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.4': - resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.4': - resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.4': - resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.3': - resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.3': - resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.3': - resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.3': - resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.3': - resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.3': - resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.3': - resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.3': - resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': - resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.3': - resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.4': - resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.4': - resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.4': - resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.4': - resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.4': - resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.4': - resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.4': - resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.4': - resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.4': - resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.4': - resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.4': - resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.4': - resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.4': - resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -306,57 +309,57 @@ packages: '@mermaid-js/parser@1.2.1': resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==} - '@next/env@16.3.6': - resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==} + '@next/env@16.3.8': + resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==} - '@next/swc-darwin-arm64@16.3.6': - resolution: {integrity: sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==} + '@next/swc-darwin-arm64@16.3.8': + resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.6': - resolution: {integrity: sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==} + '@next/swc-darwin-x64@16.3.8': + resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.6': - resolution: {integrity: sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==} + '@next/swc-linux-arm64-gnu@16.3.8': + resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.6': - resolution: {integrity: sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==} + '@next/swc-linux-arm64-musl@16.3.8': + resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.6': - resolution: {integrity: sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==} + '@next/swc-linux-x64-gnu@16.3.8': + resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.6': - resolution: {integrity: sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==} + '@next/swc-linux-x64-musl@16.3.8': + resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.6': - resolution: {integrity: sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==} + '@next/swc-win32-arm64-msvc@16.3.8': + resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.6': - resolution: {integrity: sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==} + '@next/swc-win32-x64-msvc@16.3.8': + resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -779,14 +782,14 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} - commander@8.3.0: - resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} - engines: {node: '>= 12'} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -968,8 +971,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - dompurify@3.4.13: - resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} + dompurify@3.4.16: + resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==} electron-to-chromium@1.5.402: resolution: {integrity: sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==} @@ -1046,8 +1049,8 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true - katex@0.16.47: - resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + katex@0.19.0: + resolution: {integrity: sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q==} hasBin: true khroma@2.1.0: @@ -1226,8 +1229,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - next@16.3.6: - resolution: {integrity: sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==} + next@16.3.8: + resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -1340,8 +1343,8 @@ packages: engines: {node: '>=10'} hasBin: true - sharp@0.35.4: - resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -1352,8 +1355,8 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} stackback@0.0.2: @@ -1576,108 +1579,108 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.35.4': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.35.4': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true - '@img/sharp-freebsd-wasm32@0.35.4': + '@img/sharp-freebsd-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.3': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-darwin-x64@1.3.3': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.3.3': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.3.3': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.3': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.3': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.3.3': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.3.3': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.3': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.35.4': + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.35.4': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.35.4': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.35.4': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.35.4': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.35.4': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.35.4': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.35.4': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.35.4': + '@img/sharp-wasm32@0.35.5': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.4': + '@img/sharp-webcontainers-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-arm64@0.35.4': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-ia32@0.35.4': + '@img/sharp-win32-ia32@0.35.5': optional: true - '@img/sharp-win32-x64@0.35.4': + '@img/sharp-win32-x64@0.35.5': optional: true '@jridgewell/gen-mapping@0.3.13': @@ -1706,41 +1709,41 @@ snapshots: '@types/react': 19.2.18 react: 19.2.8 - '@markdoc/next.js@0.5.0(@markdoc/markdoc@0.5.9(@types/react@19.2.18)(react@19.2.8))(next@16.3.6(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': + '@markdoc/next.js@0.5.0(@markdoc/markdoc@0.5.9(@types/react@19.2.18)(react@19.2.8))(next@16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': dependencies: '@markdoc/markdoc': 0.5.9(@types/react@19.2.18)(react@19.2.8) js-yaml: 4.3.2 - next: 16.3.6(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 '@mermaid-js/parser@1.2.1': dependencies: '@chevrotain/types': 11.1.2 - '@next/env@16.3.6': {} + '@next/env@16.3.8': {} - '@next/swc-darwin-arm64@16.3.6': + '@next/swc-darwin-arm64@16.3.8': optional: true - '@next/swc-darwin-x64@16.3.6': + '@next/swc-darwin-x64@16.3.8': optional: true - '@next/swc-linux-arm64-gnu@16.3.6': + '@next/swc-linux-arm64-gnu@16.3.8': optional: true - '@next/swc-linux-arm64-musl@16.3.6': + '@next/swc-linux-arm64-musl@16.3.8': optional: true - '@next/swc-linux-x64-gnu@16.3.6': + '@next/swc-linux-x64-gnu@16.3.8': optional: true - '@next/swc-linux-x64-musl@16.3.6': + '@next/swc-linux-x64-musl@16.3.8': optional: true - '@next/swc-win32-arm64-msvc@16.3.6': + '@next/swc-win32-arm64-msvc@16.3.8': optional: true - '@next/swc-win32-x64-msvc@16.3.6': + '@next/swc-win32-x64-msvc@16.3.8': optional: true '@oxc-project/types@0.150.0': {} @@ -1816,7 +1819,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 - source-map-js: 1.2.1 + source-map-js: 1.2.2 tailwindcss: 4.3.3 '@tailwindcss/oxide-android-arm64@4.3.3': @@ -2135,9 +2138,9 @@ snapshots: client-only@0.0.1: {} - commander@7.2.0: {} + commander@15.0.0: {} - commander@8.3.0: {} + commander@7.2.0: {} convert-source-map@2.0.0: {} @@ -2345,7 +2348,7 @@ snapshots: detect-libc@2.1.2: {} - dompurify@3.4.13: + dompurify@3.4.16: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -2403,9 +2406,9 @@ snapshots: dependencies: argparse: 2.0.1 - katex@0.16.47: + katex@0.19.0: dependencies: - commander: 8.3.0 + commander: 15.0.0 khroma@2.1.0: {} @@ -2533,10 +2536,10 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.21 - dompurify: 3.4.13 + dompurify: 3.4.16 es-toolkit: 1.50.0 fastdom: 1.0.12 - katex: 0.16.47 + katex: 0.19.0 khroma: 2.1.0 marked: 16.4.2 roughjs: 4.6.6 @@ -2546,9 +2549,9 @@ snapshots: nanoid@3.3.19: {} - next@16.3.6(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 16.3.6 + '@next/env': 16.3.8 '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.11.25 caniuse-lite: 1.0.30001810 @@ -2557,15 +2560,15 @@ snapshots: react-dom: 19.2.8(react@19.2.8) styled-jsx: 5.1.6(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.6 - '@next/swc-darwin-x64': 16.3.6 - '@next/swc-linux-arm64-gnu': 16.3.6 - '@next/swc-linux-arm64-musl': 16.3.6 - '@next/swc-linux-x64-gnu': 16.3.6 - '@next/swc-linux-x64-musl': 16.3.6 - '@next/swc-win32-arm64-msvc': 16.3.6 - '@next/swc-win32-x64-msvc': 16.3.6 - sharp: 0.35.4(@types/node@25.9.5) + '@next/swc-darwin-arm64': 16.3.8 + '@next/swc-darwin-x64': 16.3.8 + '@next/swc-linux-arm64-gnu': 16.3.8 + '@next/swc-linux-arm64-musl': 16.3.8 + '@next/swc-linux-x64-gnu': 16.3.8 + '@next/swc-linux-x64-musl': 16.3.8 + '@next/swc-win32-arm64-msvc': 16.3.8 + '@next/swc-win32-x64-msvc': 16.3.8 + sharp: 0.35.5(@types/node@25.9.5) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -2598,13 +2601,13 @@ snapshots: dependencies: nanoid: 3.3.19 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 postcss@8.5.28: dependencies: nanoid: 3.3.19 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 posthog-js@1.413.3: dependencies: @@ -2612,7 +2615,7 @@ snapshots: '@posthog/core': 1.46.9 '@posthog/types': 1.402.2 core-js: 3.50.0 - dompurify: 3.4.13 + dompurify: 3.4.16 fflate: 0.4.9 preact: 10.29.8 query-selector-shadow-dom: 1.0.1 @@ -2673,43 +2676,43 @@ snapshots: semver@7.8.5: optional: true - sharp@0.35.4(@types/node@25.9.5): + sharp@0.35.5(@types/node@25.9.5): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.4 - '@img/sharp-darwin-x64': 0.35.4 - '@img/sharp-freebsd-wasm32': 0.35.4 - '@img/sharp-libvips-darwin-arm64': 1.3.3 - '@img/sharp-libvips-darwin-x64': 1.3.3 - '@img/sharp-libvips-linux-arm': 1.3.3 - '@img/sharp-libvips-linux-arm64': 1.3.3 - '@img/sharp-libvips-linux-ppc64': 1.3.3 - '@img/sharp-libvips-linux-riscv64': 1.3.3 - '@img/sharp-libvips-linux-s390x': 1.3.3 - '@img/sharp-libvips-linux-x64': 1.3.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 - '@img/sharp-linux-arm': 0.35.4 - '@img/sharp-linux-arm64': 0.35.4 - '@img/sharp-linux-ppc64': 0.35.4 - '@img/sharp-linux-riscv64': 0.35.4 - '@img/sharp-linux-s390x': 0.35.4 - '@img/sharp-linux-x64': 0.35.4 - '@img/sharp-linuxmusl-arm64': 0.35.4 - '@img/sharp-linuxmusl-x64': 0.35.4 - '@img/sharp-webcontainers-wasm32': 0.35.4 - '@img/sharp-win32-arm64': 0.35.4 - '@img/sharp-win32-ia32': 0.35.4 - '@img/sharp-win32-x64': 0.35.4 + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 '@types/node': 25.9.5 optional: true siginfo@2.0.0: {} - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} stackback@0.0.2: {} diff --git a/packages/kilo-docs/pnpm-workspace.yaml b/packages/kilo-docs/pnpm-workspace.yaml index 55f54edf1dd..a08cc2aa919 100644 --- a/packages/kilo-docs/pnpm-workspace.yaml +++ b/packages/kilo-docs/pnpm-workspace.yaml @@ -1,2 +1,6 @@ allowBuilds: core-js: true + +# mermaid still pins katex ^0.16, which is affected by GHSA-238p-pmpm-9mq7. +overrides: + katex: 0.19.0 diff --git a/packages/kilo-docs/public/favicon/favicon.ico b/packages/kilo-docs/public/favicon/favicon.ico index 777c633b0b5..78f8647c16f 100644 Binary files a/packages/kilo-docs/public/favicon/favicon.ico and b/packages/kilo-docs/public/favicon/favicon.ico differ diff --git a/packages/kilo-docs/public/favicon/favicon.svg b/packages/kilo-docs/public/favicon/favicon.svg index 88982e215b7..038ae57b66c 100644 --- a/packages/kilo-docs/public/favicon/favicon.svg +++ b/packages/kilo-docs/public/favicon/favicon.svg @@ -1,6 +1,6 @@ - + diff --git a/packages/kilo-docs/public/globals.css b/packages/kilo-docs/public/globals.css index 38d34308ecb..78eb088370a 100644 --- a/packages/kilo-docs/public/globals.css +++ b/packages/kilo-docs/public/globals.css @@ -4,6 +4,8 @@ @custom-variant dark (&:where(.dark, .dark *)); :root { + --kilo-yellow: #ffdd00; + --kilo-yellow-hover: #ffe640; --top-nav-height: 105px; --border-color: #dce6e9; --bg-color: theme(colors.zinc.50); @@ -24,10 +26,10 @@ --text-header: #e9e9e9; --text-secondary: theme(colors.gray.300); --text-link: theme(colors.slate.400); - --text-brand: #f8f674; - --text-brand-hover: #f8f675; - --accent-color: #f8f675; - --accent-hover: #f8f675; + --text-brand: var(--kilo-yellow); + --text-brand-hover: var(--kilo-yellow); + --accent-color: var(--kilo-yellow); + --accent-hover: var(--kilo-yellow-hover); } *, @@ -150,14 +152,14 @@ a:hover { } .btn-primary { - background: #f8f674; + background: var(--kilo-yellow); color: #1a1a18 !important; - border-color: #f8f674; + border-color: var(--kilo-yellow); } .btn-primary:hover { - background: #ffff8d; - border-color: #ffff8d; + background: var(--kilo-yellow-hover); + border-color: var(--kilo-yellow-hover); color: #1a1a18 !important; transform: translateY(-1px); } @@ -169,8 +171,8 @@ a:hover { } .btn-secondary:hover { - color: #f8f674 !important; - border-color: #f8f674; + color: var(--kilo-yellow) !important; + border-color: var(--kilo-yellow); } /* Terminal Syntax Highlighting */ @@ -296,7 +298,7 @@ pre[class*="language-"] { /* ========================================================================== Dark mode syntax highlighting - Coordinated with site palette: #1a1a18 base, #f8f674 brand accent + Coordinated with site palette: #1a1a18 base, #ffdd00 brand accent ========================================================================== */ .dark pre[class*="language-"], @@ -361,7 +363,7 @@ pre[class*="language-"] { .dark .token.atrule, .dark .token.attr-value, .dark .token.keyword { - color: #d4c96b; /* warm yellow, near brand #f8f674 but less saturated */ + color: #d4c96b; /* muted yellow, same hue as brand #ffdd00 but less saturated */ } .dark .token.function, diff --git a/packages/kilo-docs/public/img/desktop/bot.svg b/packages/kilo-docs/public/img/desktop/bot.svg new file mode 100644 index 00000000000..54a72db4464 --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/bot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/cable.svg b/packages/kilo-docs/public/img/desktop/cable.svg new file mode 100644 index 00000000000..1acb40bd8c3 --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/cable.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/chevrons-right.svg b/packages/kilo-docs/public/img/desktop/chevrons-right.svg new file mode 100644 index 00000000000..9075f7a53d2 --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/chevrons-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/code.svg b/packages/kilo-docs/public/img/desktop/code.svg new file mode 100644 index 00000000000..58aaaefe1af --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/ellipsis.svg b/packages/kilo-docs/public/img/desktop/ellipsis.svg new file mode 100644 index 00000000000..0f3dd1aac82 --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/ellipsis.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/external-link.svg b/packages/kilo-docs/public/img/desktop/external-link.svg new file mode 100644 index 00000000000..8cb37c609ab --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/external-link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/eye-off.svg b/packages/kilo-docs/public/img/desktop/eye-off.svg new file mode 100644 index 00000000000..e5f0ba11290 --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/eye-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/eye.svg b/packages/kilo-docs/public/img/desktop/eye.svg new file mode 100644 index 00000000000..68f63df47fb --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/gallery-horizontal.svg b/packages/kilo-docs/public/img/desktop/gallery-horizontal.svg new file mode 100644 index 00000000000..b9e4d993be5 --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/gallery-horizontal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/list-chevrons-down-up.svg b/packages/kilo-docs/public/img/desktop/list-chevrons-down-up.svg new file mode 100644 index 00000000000..39eeef94089 --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/list-chevrons-down-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/list-filter.svg b/packages/kilo-docs/public/img/desktop/list-filter.svg new file mode 100644 index 00000000000..70f0022da1d --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/list-filter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/maximize-2.svg b/packages/kilo-docs/public/img/desktop/maximize-2.svg new file mode 100644 index 00000000000..c1f7585782f --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/maximize-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/notebooks-toolbar-controls.png b/packages/kilo-docs/public/img/desktop/notebooks-toolbar-controls.png new file mode 100644 index 00000000000..3fec48e7332 Binary files /dev/null and b/packages/kilo-docs/public/img/desktop/notebooks-toolbar-controls.png differ diff --git a/packages/kilo-docs/public/img/desktop/notebooks-with-chat.png b/packages/kilo-docs/public/img/desktop/notebooks-with-chat.png new file mode 100644 index 00000000000..66c48992b76 Binary files /dev/null and b/packages/kilo-docs/public/img/desktop/notebooks-with-chat.png differ diff --git a/packages/kilo-docs/public/img/desktop/panel-right-close.svg b/packages/kilo-docs/public/img/desktop/panel-right-close.svg new file mode 100644 index 00000000000..e39325dfbd5 --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/panel-right-close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/panels-top-left.svg b/packages/kilo-docs/public/img/desktop/panels-top-left.svg new file mode 100644 index 00000000000..af7976c65a2 --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/panels-top-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/play.svg b/packages/kilo-docs/public/img/desktop/play.svg new file mode 100644 index 00000000000..b9312312a53 --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/play.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/plus.svg b/packages/kilo-docs/public/img/desktop/plus.svg new file mode 100644 index 00000000000..12b5ca626d6 --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/quickstart-add-tab-group-button.png b/packages/kilo-docs/public/img/desktop/quickstart-add-tab-group-button.png new file mode 100644 index 00000000000..8e583d8a405 Binary files /dev/null and b/packages/kilo-docs/public/img/desktop/quickstart-add-tab-group-button.png differ diff --git a/packages/kilo-docs/public/img/desktop/quickstart-home-screen.png b/packages/kilo-docs/public/img/desktop/quickstart-home-screen.png new file mode 100644 index 00000000000..583416db5b4 Binary files /dev/null and b/packages/kilo-docs/public/img/desktop/quickstart-home-screen.png differ diff --git a/packages/kilo-docs/public/img/desktop/quickstart-panels.png b/packages/kilo-docs/public/img/desktop/quickstart-panels.png new file mode 100644 index 00000000000..d587cbf802a Binary files /dev/null and b/packages/kilo-docs/public/img/desktop/quickstart-panels.png differ diff --git a/packages/kilo-docs/public/img/desktop/rotate-ccw.svg b/packages/kilo-docs/public/img/desktop/rotate-ccw.svg new file mode 100644 index 00000000000..20b65698b9f --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/rotate-ccw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/server.svg b/packages/kilo-docs/public/img/desktop/server.svg new file mode 100644 index 00000000000..38158518b2b --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/settings.svg b/packages/kilo-docs/public/img/desktop/settings.svg new file mode 100644 index 00000000000..811cf8292eb --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/square.svg b/packages/kilo-docs/public/img/desktop/square.svg new file mode 100644 index 00000000000..2ede1c84637 --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/text-initial.svg b/packages/kilo-docs/public/img/desktop/text-initial.svg new file mode 100644 index 00000000000..504d8c3135b --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/text-initial.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/trash.svg b/packages/kilo-docs/public/img/desktop/trash.svg new file mode 100644 index 00000000000..03f7e8ccc0f --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/trash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/desktop/x.svg b/packages/kilo-docs/public/img/desktop/x.svg new file mode 100644 index 00000000000..ce192bc2842 --- /dev/null +++ b/packages/kilo-docs/public/img/desktop/x.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/all-colors-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/all-colors-chromium-linux.png index 1db5243c66d..dfc2ed20eef 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/all-colors-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/all-colors-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:36307f822b0ff600c2a03e7d46cdeb472e05beef03078e192a575471acb35c61 -size 18814 +oid sha256:4cd5c79231513f2825b0225f6535b09a750f8cdc28bf7f374f36e39fc66854a6 +size 20655 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/collapsed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/collapsed-chromium-linux.png index 9213af71911..1ebdad0cc7a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/collapsed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/collapsed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8835aa7e907191c075e7d4ca6f985da35aaaa87867dc5f93e1bdc89b07b63078 -size 3183 +oid sha256:e027d7872e185fe9976aa7054be747fc09c903a231e6a16139d275d0a88024ab +size 3170 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/default-color-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/default-color-chromium-linux.png index cbc1d1ad7e7..4b83387b9e2 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/default-color-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/default-color-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d44d27d74b86ce7c36b00b596b21daacb40326c25ab0ac94de712774f036c8a8 -size 5452 +oid sha256:d7c493709a855de7bf6ca8854cddedf17375bb87adc45f6cca85f11a5cd3f829 +size 5209 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/dense-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/dense-sidebar-chromium-linux.png index d328d29a58a..74165715756 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/dense-sidebar-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/dense-sidebar-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fca0787df7617128a799a3d165fb6eab5866da724057d903fca2a6445e88b270 -size 15802 +oid sha256:88b64d71c18f4f27cc04f4f4ce6effba9904067f419dd01a152555da4078f80c +size 14941 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/empty-chromium-linux.png index e42552c925b..f3da9e0ccfc 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/empty-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/empty-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c3648d26aa6cb3d7ff3f7e0fc64c310eaa06a2f77962130e28b2449c025b564d -size 1621 +oid sha256:ebe993393360739fc21253254259bbcf225d8ab12a9698bc5857894be5de1919 +size 1580 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/expanded-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/expanded-with-items-chromium-linux.png index 474e2358101..a3287c248ed 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/expanded-with-items-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/expanded-with-items-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c4ce051e41718cc42f5f857b1e7fca4e831b2f1035ba81906c70f11bd7a32e71 -size 8351 +oid sha256:9ec9ea955202d9a8d5f1ba333b7520a3f7bb5350fabbd74823796507cb3fec20 +size 8126 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/first-and-last-section-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/first-and-last-section-chromium-linux.png index 8192c5f2936..24876eb32a3 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/first-and-last-section-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/first-and-last-section-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:16f7657b3ff94247e2996c0570afaa32c49a2fbc58fb38f352773e8a6fad3c49 -size 6190 +oid sha256:af7ce5b8720108314c725ef7997f7c29fe69dacab1446604388a6f4d03a1f45c +size 6141 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/long-section-name-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/long-section-name-chromium-linux.png index 30b1128e905..601d844b6ef 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/long-section-name-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/long-section-name-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd173646e91086783e0e709e869f47a7c20974189e1d2dc949c95a63fc30c650 -size 6995 +oid sha256:b937549bb0a40b04d204c0e77c9bb78e08f8ce8653eb3043a4a9f81ef28d5e2c +size 6961 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/multiple-sections-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/multiple-sections-chromium-linux.png index 9e0572896b4..418a9656ef7 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/multiple-sections-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/multiple-sections-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:81e0ceb2df757a8c1e2ecd8dbb8bd47ea4b329e7a083b58b2ff234022995ba66 -size 13490 +oid sha256:61349d0abaa3dfec7f265f32c9e6e634bf7b865ec6daea8be88814a243825a4d +size 12935 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-active-worktree-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-active-worktree-chromium-linux.png index 6f323c9e2a4..ae717733133 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-active-worktree-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-active-worktree-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3425cba75c3f26c4afa354fcf0ecb2e3f881b53b3066e58f7ecf7891d47ff330 -size 8005 +oid sha256:53b51b08720e28e849dd7d4ead13a2e4e465796e3c91165f0ee4ae02d86ea424 +size 7735 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-busy-worktree-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-busy-worktree-chromium-linux.png index 10b0d28a962..b40b1f9d013 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-busy-worktree-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-busy-worktree-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:282f06bcbf858f812e865e1f65fee7085621871b7591f9ed006c40fd5304cd45 -size 6165 +oid sha256:be864b66016d9228c902947ed49a98ac378d4caefa17068c33e54942948f48a3 +size 5903 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png index cd1adc8d22a..0a0f6b1391f 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8dc93140348078b81a7635eabd6c841404270f6ef4c5ba23fdc1c1298b83fc7e -size 27434 +oid sha256:e3ba1a6e54bd592c95017068086b437ea34dff61a5e45550499ca4e4921207ba +size 27227 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-stale-worktree-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-stale-worktree-chromium-linux.png index b6491924a6b..87d4e76db18 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-stale-worktree-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-stale-worktree-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:528b416746cba9632f90fd2838da1903e81dcaac509e4021467934df99dd5170 -size 6335 +oid sha256:0d1e3eed2669e64309c2466df0a277e77789653b5fff7a8c2d70229024706147 +size 6109 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-versions-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-versions-chromium-linux.png index 07ed8dc919f..516034c355a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-versions-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-versions-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7ca64d3d7b866d10d4164d6793b9342824769e589c2393b3545df2f631e64229 -size 11275 +oid sha256:07e12e821538eff60533a1a162a97483c6037994062c21c2642cadc03b1ec6f9 +size 10999 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-scrolled-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-scrolled-chromium-linux.png index 6f79125f05f..a07978cd444 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-scrolled-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-scrolled-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:abe9958c2910bc8498736d5c9e9e15e2249935a8192bb65cc7067f6f99798a81 -size 40427 +oid sha256:9e9dfb75b8706420e3378e5f6ca6b1007e22e86c3fd26f3fbf9f609c16749891 +size 42622 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/projects-avatars-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/projects-avatars-200-chromium-linux.png new file mode 100644 index 00000000000..6d0e68d0294 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/projects-avatars-200-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c1339bbfebeb17d2ee163478fd8134436684a20864b73cd97a92f7a79e8bd52 +size 8123 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/projects-avatars-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/projects-avatars-chromium-linux.png new file mode 100644 index 00000000000..0e02cf9fc53 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/projects-avatars-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:05003e48bb2a5f60e1a097210fff1000c10c50762b2bd70866926131f5d03792 +size 12594 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-1280-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-1280-chromium-linux.png index 069d67aaa49..89aa0f4c1d0 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-1280-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-1280-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:67dd41c461129f4b94a976717852dd3255178a3ed87d2cc6ffd09684bfa70bdf -size 51964 +oid sha256:dacbe2c2d3d7a3d2501884ef433bce84914a2b0e0997fe8defaeb600df319266 +size 51308 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-420-chromium-linux.png index db9f15fe989..d9e04ee816c 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-420-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-420-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b640b57996b7eabe7ec66678263c7c39dd9eaab0f0a6192d25098055df9a25f5 -size 46408 +oid sha256:18b2b6921d0efa1b53d4ff2b8f3b7101aba8dd915b94f0758fc27f90803532cc +size 46483 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/board-closed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/board-closed-chromium-linux.png index 47db20ef4ba..79ae0d655e9 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/board-closed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/board-closed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f640341d4bbe9baa7cb7f4e0726ea7e8cbd9355ea4916b730677acd11dd7cd71 -size 4524 +oid sha256:b4e1df09a8d44a7944f3e479c55eb3babaee59f96871dbe71e4c9315942283e0 +size 3249 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/board-empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/board-empty-chromium-linux.png index 0bd819cf600..9eed02d1ae6 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/board-empty-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/board-empty-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:06ab090fffbf150c7c2b764cb6fba704b182eb39e7da390592ce1a63fba76d86 -size 4618 +oid sha256:05f8f2fa65219399301780aff0efc088f43c7f1fbf2fec8136b6814b81e7d68e +size 2856 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/board-open-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/board-open-chromium-linux.png index be5f8617489..a956b9d0fa5 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/board-open-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/board-open-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:90f6024687145d72a5d61bb927cd1c64c938573f919d8caae3e2d1d420fbfcb1 -size 8785 +oid sha256:f88e6a65295630335fd64493e36aeaadb1ee7253e21837cd3528213b4469340c +size 22607 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-agent-manager-completed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-agent-manager-completed-chromium-linux.png index 34d6d80f9bc..ae2b880d532 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-agent-manager-completed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-agent-manager-completed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:29ee360a78c4b3d1de1d2ab1a4c9f49243121f4397af96bf456ed81171607997 -size 11507 +oid sha256:69120690b449183089defd1e7fb59843c2d644c339170cbe34723551e29a1066 +size 12566 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-1280-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-1280-chromium-linux.png index 973fd8f4047..bade317af99 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-1280-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-1280-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4b51d518444bb8fad973fa1713ae2144cb0a8f7858842a2f95996a36af9e1d4d -size 35581 +oid sha256:924170cff3c6366b087ad40ee2d586b3ed8185c7942fc39e3350189bf4613639 +size 35170 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png index 4449aca1055..4f3007c78c7 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bd90cc1727371759558e0dfa0d8384fa59589ebd7dae949d5e9e6d72fab76800 -size 32327 +oid sha256:a25b68a7f69b656ec152bdf4f5b1f1f92494b51328f9d7b2f09edff656379d42 +size 32069 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-session-dock-todo-states-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-session-dock-todo-states-chromium-linux.png new file mode 100644 index 00000000000..7bf0bf74611 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-session-dock-todo-states-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:74a4403e149d335dc88d7c531f24c37383d6e63ea2dae95ceb2d13ddbcbb7fa2 +size 22019 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-with-messages-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-with-messages-chromium-linux.png index b789e1f35a9..1e47b74b5c1 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-with-messages-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-with-messages-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0e39f92b6c3d27aba029ce1911edb655cc4ba23e9331aaaf9a1878cc8f97196f +oid sha256:fce269de2b26cdddb9ce8c0ce2deb6015cceae4413c7f4be32f187a064a927b5 size 7865 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-layout-correction-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-layout-correction-chromium-linux.png index 187552b50b1..54d931afa29 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-layout-correction-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-layout-correction-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5683d0a49b8c168af4a7829be8094d9687f064e3235c30f377190802dfafae57 -size 27124 +oid sha256:83e22e62c0139d980922db718166f013c81396417060126b8c896a4d8c6e028c +size 27026 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png index 9c7317c4e34..06f1c8037f9 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1e81dc5c2df088319fbe25fcb33b45680d03235f7ab7e8753f5c276eed140d70 -size 27128 +oid sha256:fccfe243d320e400c28a650c8aa2cf0bab65055e07957564b544c45396491ccc +size 27371 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-busy-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-busy-chromium-linux.png new file mode 100644 index 00000000000..16227af778b --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-busy-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dcceffd21542ef8a953d6f08d9941dd24b464b64d71a2cbaa92f9da19be1e103 +size 8192 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-skeleton-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-skeleton-chromium-linux.png index 60aa99a49b1..6ad060e7854 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-skeleton-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-skeleton-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e221d6e0f0b2d050174e589dad0a76edac6b652532a64e1f7b3dc18b37b79a80 -size 5017 +oid sha256:dc228e896b24e355b5b2daeac55601db25fa42fec3668fb3e320a66d3e98ffac +size 4957 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-subagent-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-subagent-chromium-linux.png new file mode 100644 index 00000000000..b23135df37a --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-subagent-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7344b86c1062eb2b30f8a644e93024285145220429b315f15bbefed896fea798 +size 8337 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-all-done-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-all-done-chromium-linux.png deleted file mode 100644 index 350cee0fbf7..00000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-all-done-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4547aec1e18ea0bb3f96a90e948d5d635aacfea1b489fa4fb6d16f08819cd3e6 -size 8292 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png deleted file mode 100644 index 31dcb9991c2..00000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0a5d443fe621c852c474976dd2f0fe6bad425dfc8c32b2d4e91a2ee2d0497266 -size 10102 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/bash-with-permission-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/bash-with-permission-chromium-linux.png index 27805a32613..376d0424834 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/bash-with-permission-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/bash-with-permission-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:130cc835a2177093e3c057298201665a838fd18db724bc04b90e766fbd8fdbf6 -size 12719 +oid sha256:af0f4161db4ed48e5bd19924c602ceac8cdf05c1c6adf585c2fc1c6fac89a4e8 +size 12824 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/glob-with-permission-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/glob-with-permission-chromium-linux.png index 1f0045d3ada..d52f9dfc1ca 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/glob-with-permission-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/glob-with-permission-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0aa0b801ffd749159ab320a0b1ec06ff3ce8eb4dde3f35f351ea1bd940876fc9 -size 12237 +oid sha256:4f82c001f3644cff631557c9ef5144f3344f9bb4e1e0e6daab66167aee9c2cac +size 12343 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png index 83eddee527c..8bb5f2ebbf1 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7f420ed050e3a61ac1c014502eaa7a6c870574a761c9cc802426a5051171eb86 -size 17038 +oid sha256:48a19dc1e971e86711d3bee9d1afda82d8d9753b340cf10aec9559d301a58007 +size 17682 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-bash-many-rules-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-bash-many-rules-chromium-linux.png index 77c5f40d671..36d133c7fd5 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-bash-many-rules-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-bash-many-rules-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ebed4a4b1cb60fde2ea10347a32efac445fce3e0b5016d9145db2cd206048f0 -size 16008 +oid sha256:3b1b0461ebf6d6e0c2426da9ae4ac6e83941d0eb587e90980945e68cc172593d +size 16078 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png index 9a40f01f22e..2d4fb2a998d 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:32ef1801f9bc8cb34965b1c72adea0e68ef92552bf79c982fb8623a4550d17e4 +oid sha256:4fb15ac4970e5bad27203abe6386f7a0fd2106efeb35922573bc1d0ba93e49cf size 12312 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-external-dir-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-external-dir-chromium-linux.png index 5e6515713e6..7b49580aff1 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-external-dir-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-external-dir-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:071e8183c7ad58a87ffe885a8c52e4e6b38d5bb4db59ef42578b0767406015f8 -size 15260 +oid sha256:5d3284619df72053e228d39ac2b9cbf418e85452ef27a814e35a149d91133d05 +size 15365 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png index d21f2426851..ed09836c65c 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:817f34ea29415a6e7e0dd2c0d729eda4160d78fd795cbeb9221ad43b65fd21d0 -size 16345 +oid sha256:b736901c5123027f0b9a270647a8e0c708691347fd1c8f0350b5460c2cbf2437 +size 16464 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-skill-shell-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-skill-shell-chromium-linux.png index 956096c932f..3d5b3de7948 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-skill-shell-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-skill-shell-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cb62cb13c060bec6598942e79842655c104c8f33068a3fb7b64ebb02e370fe8e -size 12778 +oid sha256:cea50b5467dcffd1dd3b97c5215ed4194d29b772f0f6a3fee4bedf480452a47a +size 12885 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-subagent-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-subagent-chromium-linux.png index dea700077a5..0c05dc32375 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-subagent-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-subagent-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:645db2f7e620865fd1c39dc0118dcbd091ccfa9074e12681d5b71ced86008963 -size 12433 +oid sha256:02be2fb0ac293e36f6df65eb3983e5bd84c8de3d247e5e6fe0cdae0c83332e7f +size 12539 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-todo-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-todo-chromium-linux.png index 9ce4ebfdf08..aede9f99475 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-todo-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-todo-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:61b2f070fb90f09dea8b875d99d4033d3cd36969b08c7e1d2ee5ba342cfdc689 -size 12007 +oid sha256:456814bd8863dfc043c0a49bd122ec0a19efb58d465409b4c5b51f70a2f2acb0 +size 12113 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-websearch-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-websearch-chromium-linux.png index eefd4a6fe65..3543db24e0a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-websearch-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-websearch-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:611b5df1c8fb5fd26f72d7f6dd3fd99052099280123269f01da78e3f7819def8 -size 11778 +oid sha256:9268b73db8b6b9380e0536aecacb45c58bc41b0fdef7c682d9e9e3a94405fcdd +size 11883 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-write-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-write-chromium-linux.png index 87412def6c4..6f68410a28d 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-write-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-write-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0779076c695c03ae92177d752a1e57201690da25b8b28563a03c2e3e10d4c586 +oid sha256:fe1739efa3649c9a2d39156272adc25f84e1311c0ee656867f49e696f9d0e605 size 12578 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-above-chatbox-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-above-chatbox-chromium-linux.png index b98a197e2a4..a982056acce 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-above-chatbox-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-above-chatbox-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df77c092fd64adfa59f3cea5e605f798eebd2dbed0c44b6ba8a5e64aea6e0742 +oid sha256:271ca9f28a9acd04c6bbfba5c6ae606fe6b92eb347d300d03d947e96ef6b1769 size 18955 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-with-permission-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-with-permission-chromium-linux.png index 2143304f21e..129d511b24f 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-with-permission-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-with-permission-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0280fcd906bb87b2b0576b0cce3b231e510c720dae5a19504ff80e7c3502f3b3 -size 13516 +oid sha256:6e18d62dc33023b0bbd40cedbd99aea9a3bb6c27ca1bc2bd73bc3b8972578e82 +size 13517 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png index 17136a73276..1ae9d5424d1 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:587ed6444c8df4d303ed4ee2f853f9f6d07fd6706443c4f014d95799d788c1f0 -size 746905 +oid sha256:d1fc81ccc9d7d68e34128d2cd1461323845a46d8732b556ff629a673aa34d624 +size 711806 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-200-chromium-linux.png index 3749ab07b0a..d4d17bd9132 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-200-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-200-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:28a0ba0675cdfb62f6f80d63a3fadcaacf215c2ad65f12f2fa04774bfdb33028 -size 4940 +oid sha256:352dfe36ed8a02365c18313281a048283adcf58593431384910676d2c147b9c5 +size 3109 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-420-chromium-linux.png index 33fdb217470..d9874f298a9 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-420-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-420-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:00746923c55f811fba5e823ff8e35205f6f6c70d1e7ce440514636ce8a4d0791 -size 5364 +oid sha256:94937c48bf1380567e8a1084aa97303584361ad8e42e9bddbe4fd1d4a1ea050f +size 4938 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-code-context-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-code-context-200-chromium-linux.png index 83c5507bd19..1eec4b09893 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-code-context-200-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-code-context-200-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0fa512d7a35f92d135fb7f029936186dd86e8514ac9956ca99f7a8d2852f36d2 -size 13981 +oid sha256:18576213b3aa90acd6114cd92a4b3861240c0c14889e9486921afde5e76f015f +size 12230 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-prompt-training-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-prompt-training-200-chromium-linux.png index cbaeac4ae1c..af515b5e166 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-prompt-training-200-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-prompt-training-200-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:10b8abe679042e3ccd36f7baca0cdcf14ceae61faadd597aa94c7471e17ffd7c -size 5209 +oid sha256:7ad974c5a1795d6d8f199614fefd923c316b169414441fd7d907b5c84b70c563 +size 3165 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-prompt-training-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-prompt-training-420-chromium-linux.png index 47cd701459f..46006da543f 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-prompt-training-420-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-prompt-training-420-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76f28776968833286e0dc6c2610fa7a9cc32652615b1d5dbd0d7fef092b28afc -size 5620 +oid sha256:751e89229eeaf0f30aed769be94509fc66230b38e5e7373e10b5ea5f535b5ef8 +size 5135 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-200-chromium-linux.png index 69ca3e934c3..eb7afa863ce 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-200-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-200-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cdb59fc167c642e9c6a5e8298fe06393cc8b4ce67bfc4c588245b748d2661615 -size 5584 +oid sha256:539e854b91b550275df4748253147091913b3e0240b30a7141b2dd7381b21643 +size 3057 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-420-chromium-linux.png index 91653c96dea..0aac9a4ac7f 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-420-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-420-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da4aac6eededda5642e50d4482a684242ddcd2502dd660140c61bafacdabbea7 -size 6039 +oid sha256:bffc5d14bc63e0ba18c2b4f43f36ac408c1add96f926dfdff13073b835686589 +size 5672 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mcp-edit-view-remote-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mcp-edit-view-remote-chromium-linux.png index 48c5d0a065c..d911b881ce8 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mcp-edit-view-remote-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mcp-edit-view-remote-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9705e24a6f810e24de6204424e30e15efb2dc4e454f2af2512093f3c30cdad9b -size 12028 +oid sha256:d08cf250b147510435f04a67fe5d943fcb9430ce94eecdcf9ff11e859d642bf1 +size 21286 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mcp-edit-view-remote-with-custom-oauth-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mcp-edit-view-remote-with-custom-oauth-chromium-linux.png new file mode 100644 index 00000000000..e78ef973343 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mcp-edit-view-remote-with-custom-oauth-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a1ec541b1ec900d8fe347add4fd48a9b6cd412c5e713e2674c5b843f4432521 +size 37861 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png index 45f8a1d46e3..aca72a8115d 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47359662c01450a529f85c4a0cf48b3f34fe94720d478cf4574fcb697442d9bc -size 46612 +oid sha256:c40b5faa5599dcde050f5ade38097631b582263b872e2d793271da830f75f0e2 +size 38382 diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 112de917a04..ba92173de77 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -201,6 +201,7 @@ - + - - diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 9b75f3d8f2d..fb1dec6a848 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.8.3", + "version": "7.8.8", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index 9503ecb1042..f312b99cf42 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -6,7 +6,7 @@ import { KILO_API_BASE, KILO_OPENROUTER_BASE, MODELS_FETCH_TIMEOUT_MS, PROMPTS, export type KiloModelsResult = { models: Record - error?: { kind: "unauthorized" | "network" | "schema" | "http"; status?: number } + error?: { kind: "unauthorized" | "network" | "schema" | "http"; status?: number; retryAfter?: number } } /** @@ -241,7 +241,11 @@ async function fetchRawKiloModels(options?: { return fetchRawKiloModels({}) } const kind = response.status === 401 || response.status === 403 ? "unauthorized" : "http" - return { error: { kind, status: response.status } } + const header = response.headers.get("retry-after") + const seconds = + header == null ? NaN : /^\d+$/.test(header) ? Number(header) : (Date.parse(header) - Date.now()) / 1000 + const retryAfter = Number.isFinite(seconds) ? Math.max(0, seconds) : undefined + return { error: { kind, status: response.status, ...(retryAfter == null ? {} : { retryAfter }) } } } const json = await response.json().catch(() => null) diff --git a/packages/kilo-gateway/test/api/models.test.ts b/packages/kilo-gateway/test/api/models.test.ts index f028d554db2..c6118432ec0 100644 --- a/packages/kilo-gateway/test/api/models.test.ts +++ b/packages/kilo-gateway/test/api/models.test.ts @@ -84,6 +84,32 @@ function stubFetch(fn: (input: string | URL | Request, init?: RequestInit) => Pr ;(globalThis as any).fetch = fn } +for (const status of [408, 429, 503]) { + for (const [header, expected] of [ + ["90", 90], + ["Thu, 01 Jan 1970 00:02:00 GMT", 120], + ["invalid", undefined], + [undefined, undefined], + ] as const) { + test(`preserves valid Retry-After from HTTP ${status}: ${header}`, async () => { + const clock = spyOn(Date, "now").mockReturnValue(0) + const fetch = spyOn(globalThis, "fetch").mockResolvedValue( + new Response("Unavailable", { status, headers: header == null ? {} : { "retry-after": header } }), + ) + try { + const result = await fetchKiloModels({ kilocodeOrganizationId: "org-a" }) + expect(result.error?.kind).toBe("http") + expect(result.error?.status).toBe(status) + expect(result.error?.retryAfter).toBe(expected) + expect(fetch).toHaveBeenCalledTimes(1) + } finally { + fetch.mockRestore() + clock.mockRestore() + } + }) + } +} + test("returns empty models and error when both auth and public requests return 401", async () => { const orig = globalThis.fetch stubFetch(async () => new Response("Unauthorized", { status: 401, statusText: "Unauthorized" })) diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 61b3b15e9f2..186f8afac5a 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.8.3", + "version": "7.8.8", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-i18n/src/ar.ts b/packages/kilo-i18n/src/ar.ts index 4cc40d8c53e..76355367487 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -47,7 +47,6 @@ export const dict = { "marketplace.install.about.plugin": "تضيف إضافة أدوات وتكاملات مخصصة إلى Kilo. تعمل الإضافات بصلاحيات كاملة.", "marketplace.install.plugin.warning": "تشغّل الإضافات التعليمات البرمجية بصلاحيات كاملة. يمكنها قراءة ملفاتك وتغييرها وتشغيل الأوامر والوصول إلى بيانات اعتمادك وشبكتك. لا تثبّت إلا الإضافات التي تثق بها.", - "marketplace.install.installedAt": "تم التثبيت في {{path}}", "marketplace.intro": "ثبّت وكلاء ومهارات وأدوات MCP وإضافات قابلة لإعادة الاستخدام لمشروع واحد أو لجميع المشاريع.", "marketplace.intro.learnMore": "حول Marketplace", "marketplace.install.prerequisites": "المتطلبات الأساسية", @@ -57,6 +56,13 @@ export const dict = { "marketplace.install.failed": "فشل التثبيت", "marketplace.install.done": "تم", "marketplace.install.close": "إغلاق", + "marketplace.install.mcp.signIn.message": "تم تثبيت {{name}} ولكنه يحتاج إلى تسجيل الدخول قبل أن يمكن استخدام أدواته.", + "marketplace.install.mcp.signIn.button": "تسجيل الدخول", + "marketplace.install.mcp.signIn.waiting": "في انتظار تسجيل الدخول عبر المتصفح…", + "marketplace.install.mcp.signIn.cancel": "إلغاء", + "marketplace.install.mcp.signIn.skip": "لاحقًا", + "marketplace.install.mcp.signIn.success": "تم تسجيل الدخول إلى {{name}}.", + "marketplace.install.mcp.signIn.failed": "فشل تسجيل الدخول إلى {{name}}.", "marketplace.remove.title": "إزالة {{name}}؟", "marketplace.remove.confirm": "هل أنت متأكد أنك تريد إزالة هذا الـ {{type}}؟ سيؤدي هذا إلى إزالته من تكوين {{scope}} الخاص بك.", diff --git a/packages/kilo-i18n/src/br.ts b/packages/kilo-i18n/src/br.ts index c8f67f37c2f..eaa22705938 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -51,7 +51,6 @@ export const dict = { "Um plugin adiciona ferramentas e integrações personalizadas ao Kilo. Os plugins são executados com permissões totais.", "marketplace.install.plugin.warning": "Os plugins executam código com permissões totais. Eles podem ler e alterar seus arquivos, executar comandos e acessar suas credenciais e sua rede. Instale apenas plugins em que você confia.", - "marketplace.install.installedAt": "Instalado em {{path}}", "marketplace.intro": "Instale agentes, habilidades, ferramentas MCP e plugins reutilizáveis em um projeto ou em todos os projetos.", "marketplace.intro.learnMore": "Sobre o Marketplace", @@ -62,6 +61,13 @@ export const dict = { "marketplace.install.failed": "Falha na instalação", "marketplace.install.done": "Concluído", "marketplace.install.close": "Fechar", + "marketplace.install.mcp.signIn.message": "{{name}} está instalado, mas precisa de login antes que suas ferramentas possam ser usadas.", + "marketplace.install.mcp.signIn.button": "Entrar", + "marketplace.install.mcp.signIn.waiting": "Aguardando login pelo navegador…", + "marketplace.install.mcp.signIn.cancel": "Cancelar", + "marketplace.install.mcp.signIn.skip": "Mais tarde", + "marketplace.install.mcp.signIn.success": "Login realizado em {{name}}.", + "marketplace.install.mcp.signIn.failed": "Falha ao fazer login em {{name}}.", "marketplace.remove.title": "Remover {{name}}?", "marketplace.remove.confirm": "Tem certeza que deseja remover este {{type}}? Isso o removerá da sua configuração {{scope}}.", diff --git a/packages/kilo-i18n/src/bs.ts b/packages/kilo-i18n/src/bs.ts index ce896e5b81d..70140a5f61f 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -56,7 +56,6 @@ export const dict = { "Dodatak dodaje prilagođene alate i integracije u Kilo. Dodaci se izvršavaju sa svim dozvolama.", "marketplace.install.plugin.warning": "Dodaci izvršavaju kod sa svim dozvolama. Mogu čitati i mijenjati vaše datoteke, pokretati naredbe i pristupati vašim pristupnim podacima i mreži. Instalirajte samo dodatke kojima vjerujete.", - "marketplace.install.installedAt": "Instalirano u {{path}}", "marketplace.intro": "Instalirajte višekratne agente, vještine, MCP alate i dodatke za jedan ili sve projekte.", "marketplace.intro.learnMore": "O Marketplaceu", "marketplace.install.prerequisites": "Preduslovi", @@ -66,6 +65,13 @@ export const dict = { "marketplace.install.failed": "Instalacija nije uspjela", "marketplace.install.done": "Završeno", "marketplace.install.close": "Zatvori", + "marketplace.install.mcp.signIn.message": "{{name}} je instaliran, ali zahtijeva prijavu prije nego što se njegovi alati mogu koristiti.", + "marketplace.install.mcp.signIn.button": "Prijava", + "marketplace.install.mcp.signIn.waiting": "Čekanje na prijavu putem preglednika…", + "marketplace.install.mcp.signIn.cancel": "Otkaži", + "marketplace.install.mcp.signIn.skip": "Kasnije", + "marketplace.install.mcp.signIn.success": "Prijavljeni ste na {{name}}.", + "marketplace.install.mcp.signIn.failed": "Prijava na {{name}} nije uspjela.", "marketplace.remove.title": "Ukloniti {{name}}?", "marketplace.remove.confirm": "Jeste li sigurni da želite ukloniti ovaj {{type}}? Ovo će ga ukloniti iz vaše {{scope}} konfiguracije.", diff --git a/packages/kilo-i18n/src/da.ts b/packages/kilo-i18n/src/da.ts index 0a9e87b1ced..19bbeaf8b37 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -50,7 +50,6 @@ export const dict = { "Et plugin tilføjer tilpassede værktøjer og integrationer til Kilo. Plugins kører med fulde tilladelser.", "marketplace.install.plugin.warning": "Plugins kører kode med fulde tilladelser. De kan læse og ændre dine filer, køre kommandoer og få adgang til dine legitimationsoplysninger og dit netværk. Installer kun plugins, du har tillid til.", - "marketplace.install.installedAt": "Installeret i {{path}}", "marketplace.intro": "Installer genanvendelige agenter, færdigheder, MCP-værktøjer og plugins til ét eller alle projekter.", "marketplace.intro.learnMore": "Om Marketplace", @@ -61,6 +60,13 @@ export const dict = { "marketplace.install.failed": "Installation mislykkedes", "marketplace.install.done": "Færdig", "marketplace.install.close": "Luk", + "marketplace.install.mcp.signIn.message": "{{name}} er installeret, men kræver login, før dens værktøjer kan bruges.", + "marketplace.install.mcp.signIn.button": "Log ind", + "marketplace.install.mcp.signIn.waiting": "Venter på login via browser…", + "marketplace.install.mcp.signIn.cancel": "Annuller", + "marketplace.install.mcp.signIn.skip": "Senere", + "marketplace.install.mcp.signIn.success": "Logget ind på {{name}}.", + "marketplace.install.mcp.signIn.failed": "Login til {{name}} mislykkedes.", "marketplace.remove.title": "Fjern {{name}}?", "marketplace.remove.confirm": "Er du sikker på, at du vil fjerne denne {{type}}? Dette vil fjerne den fra din {{scope}} konfiguration.", diff --git a/packages/kilo-i18n/src/de.ts b/packages/kilo-i18n/src/de.ts index 3da6ee607dd..ac586940654 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -52,7 +52,6 @@ export const dict = { "Ein Plugin fügt Kilo benutzerdefinierte Werkzeuge und Integrationen hinzu. Plugins werden mit vollständigen Berechtigungen ausgeführt.", "marketplace.install.plugin.warning": "Plugins führen Code mit vollständigen Berechtigungen aus. Sie können Ihre Dateien lesen und ändern, Befehle ausführen und auf Ihre Zugangsdaten und Ihr Netzwerk zugreifen. Installieren Sie nur Plugins, denen Sie vertrauen.", - "marketplace.install.installedAt": "Installiert unter {{path}}", "marketplace.intro": "Installieren Sie wiederverwendbare Agenten, Skills, MCP-Werkzeuge und Plugins für ein Projekt oder für alle Projekte.", "marketplace.intro.learnMore": "Über den Marketplace", @@ -63,6 +62,13 @@ export const dict = { "marketplace.install.failed": "Installation fehlgeschlagen", "marketplace.install.done": "Fertig", "marketplace.install.close": "Schließen", + "marketplace.install.mcp.signIn.message": "{{name}} ist installiert, benötigt aber eine Anmeldung, bevor die Tools verwendet werden können.", + "marketplace.install.mcp.signIn.button": "Anmelden", + "marketplace.install.mcp.signIn.waiting": "Warten auf Anmeldung im Browser…", + "marketplace.install.mcp.signIn.cancel": "Abbrechen", + "marketplace.install.mcp.signIn.skip": "Später", + "marketplace.install.mcp.signIn.success": "Bei {{name}} angemeldet.", + "marketplace.install.mcp.signIn.failed": "Anmeldung bei {{name}} fehlgeschlagen.", "marketplace.remove.title": "{{name}} entfernen?", "marketplace.remove.confirm": "Soll dieser Eintrag ({{type}}) wirklich entfernt werden? Er wird dadurch aus Ihrer {{scope}}-Konfiguration entfernt.", diff --git a/packages/kilo-i18n/src/en.ts b/packages/kilo-i18n/src/en.ts index 2007efabf08..258af093255 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -52,7 +52,6 @@ export const dict = { "Project files may be committed to version control. Do not store secrets here unless the configuration references an environment variable.", "marketplace.install.learnMore": "Learn how Marketplace installs work", "marketplace.install.learnMcp": "Learn more about MCP", - "marketplace.install.installedAt": "Installed to {{path}}", "marketplace.intro": "Install reusable agents, skills, MCP tools, and plugins for one project or every project.", "marketplace.intro.learnMore": "About Marketplace", "marketplace.install.prerequisites": "Prerequisites", @@ -62,6 +61,13 @@ export const dict = { "marketplace.install.failed": "Installation failed", "marketplace.install.done": "Done", "marketplace.install.close": "Close", + "marketplace.install.mcp.signIn.message": "{{name}} is installed but needs sign-in before its tools can be used.", + "marketplace.install.mcp.signIn.button": "Sign In", + "marketplace.install.mcp.signIn.waiting": "Waiting for browser sign-in…", + "marketplace.install.mcp.signIn.cancel": "Cancel", + "marketplace.install.mcp.signIn.skip": "Later", + "marketplace.install.mcp.signIn.success": "Signed in to {{name}}.", + "marketplace.install.mcp.signIn.failed": "Sign-in to {{name}} failed.", "marketplace.remove.title": "Remove {{name}}?", "marketplace.remove.confirm": "Are you sure you want to remove this {{type}}? This will remove it from your {{scope}} configuration.", diff --git a/packages/kilo-i18n/src/es.ts b/packages/kilo-i18n/src/es.ts index e3405319efb..f19d8c61437 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -51,7 +51,6 @@ export const dict = { "Un complemento añade herramientas e integraciones personalizadas a Kilo. Los complementos se ejecutan con todos los permisos.", "marketplace.install.plugin.warning": "Los complementos ejecutan código con todos los permisos. Pueden leer y modificar tus archivos, ejecutar comandos y acceder a tus credenciales y a tu red. Instala solo complementos en los que confíes.", - "marketplace.install.installedAt": "Instalado en {{path}}", "marketplace.intro": "Instala agentes, habilidades, herramientas MCP y complementos reutilizables en uno o todos los proyectos.", "marketplace.intro.learnMore": "Acerca de Marketplace", @@ -62,6 +61,13 @@ export const dict = { "marketplace.install.failed": "La instalación falló", "marketplace.install.done": "Hecho", "marketplace.install.close": "Cerrar", + "marketplace.install.mcp.signIn.message": "{{name}} está instalado, pero necesita iniciar sesión antes de poder usar sus herramientas.", + "marketplace.install.mcp.signIn.button": "Iniciar sesión", + "marketplace.install.mcp.signIn.waiting": "Esperando el inicio de sesión en el navegador…", + "marketplace.install.mcp.signIn.cancel": "Cancelar", + "marketplace.install.mcp.signIn.skip": "Más tarde", + "marketplace.install.mcp.signIn.success": "Sesión iniciada en {{name}}.", + "marketplace.install.mcp.signIn.failed": "No se pudo iniciar sesión en {{name}}.", "marketplace.remove.title": "¿Eliminar {{name}}?", "marketplace.remove.confirm": "¿Estás seguro de que deseas eliminar este {{type}}? Esto lo eliminará de tu configuración {{scope}}.", diff --git a/packages/kilo-i18n/src/fr.ts b/packages/kilo-i18n/src/fr.ts index 55a4fcaa2d0..5faa59871c6 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -52,7 +52,6 @@ export const dict = { "Un plugin ajoute des outils et des intégrations personnalisés à Kilo. Les plugins s'exécutent avec toutes les autorisations.", "marketplace.install.plugin.warning": "Les plugins exécutent du code avec toutes les autorisations. Ils peuvent lire et modifier vos fichiers, exécuter des commandes et accéder à vos identifiants et à votre réseau. Installez uniquement des plugins auxquels vous faites confiance.", - "marketplace.install.installedAt": "Installé dans {{path}}", "marketplace.intro": "Installez des agents, des compétences, des outils MCP et des plugins réutilisables pour un projet ou pour tous vos projets.", "marketplace.intro.learnMore": "À propos du Marketplace", @@ -63,6 +62,13 @@ export const dict = { "marketplace.install.failed": "L'installation a échoué", "marketplace.install.done": "Terminé", "marketplace.install.close": "Fermer", + "marketplace.install.mcp.signIn.message": "{{name}} est installé, mais nécessite une connexion avant que ses outils puissent être utilisés.", + "marketplace.install.mcp.signIn.button": "Se connecter", + "marketplace.install.mcp.signIn.waiting": "En attente de connexion via le navigateur…", + "marketplace.install.mcp.signIn.cancel": "Annuler", + "marketplace.install.mcp.signIn.skip": "Plus tard", + "marketplace.install.mcp.signIn.success": "Connecté à {{name}}.", + "marketplace.install.mcp.signIn.failed": "La connexion à {{name}} a échoué.", "marketplace.remove.title": "Supprimer {{name}} ?", "marketplace.remove.confirm": "Êtes-vous sûr de vouloir supprimer ce {{type}} ? Cela le supprimera de votre configuration {{scope}}.", diff --git a/packages/kilo-i18n/src/it.ts b/packages/kilo-i18n/src/it.ts index 550624f379e..12d2db0299e 100644 --- a/packages/kilo-i18n/src/it.ts +++ b/packages/kilo-i18n/src/it.ts @@ -54,7 +54,6 @@ export const dict = { "Un plugin aggiunge strumenti e integrazioni personalizzati a Kilo. I plugin vengono eseguiti con tutte le autorizzazioni.", "marketplace.install.plugin.warning": "I plugin eseguono codice con tutte le autorizzazioni. Possono leggere e modificare i tuoi file, eseguire comandi e accedere alle tue credenziali e alla tua rete. Installa solo plugin di cui ti fidi.", - "marketplace.install.installedAt": "Installato in {{path}}", "marketplace.intro": "Installa agenti, skill, strumenti MCP e plugin riutilizzabili per uno o tutti i progetti.", "marketplace.intro.learnMore": "Informazioni sul Marketplace", "marketplace.install.prerequisites": "Prerequisiti", @@ -64,6 +63,13 @@ export const dict = { "marketplace.install.failed": "Installazione non riuscita", "marketplace.install.done": "Fatto", "marketplace.install.close": "Chiudi", + "marketplace.install.mcp.signIn.message": "{{name}} è installato, ma richiede l'accesso prima che i suoi strumenti possano essere usati.", + "marketplace.install.mcp.signIn.button": "Accedi", + "marketplace.install.mcp.signIn.waiting": "In attesa dell'accesso tramite browser…", + "marketplace.install.mcp.signIn.cancel": "Annulla", + "marketplace.install.mcp.signIn.skip": "Più tardi", + "marketplace.install.mcp.signIn.success": "Accesso effettuato a {{name}}.", + "marketplace.install.mcp.signIn.failed": "Accesso a {{name}} non riuscito.", "marketplace.remove.title": "Rimuovere {{name}}?", "marketplace.remove.confirm": "Vuoi davvero rimuovere questo {{type}}? Verrà rimosso dalla configurazione {{scope}}.", "marketplace.remove.cancel": "Annulla", diff --git a/packages/kilo-i18n/src/ja.ts b/packages/kilo-i18n/src/ja.ts index 55371499026..3fea807b30e 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -49,7 +49,6 @@ export const dict = { "プラグインは、カスタムツールや連携機能をKiloに追加します。プラグインはすべての権限を持って実行されます。", "marketplace.install.plugin.warning": "プラグインはすべての権限を持ってコードを実行します。ファイルの読み取りや変更、コマンドの実行、認証情報やネットワークへのアクセスが可能です。信頼できるプラグインのみをインストールしてください。", - "marketplace.install.installedAt": "{{path}} にインストール済み", "marketplace.intro": "再利用可能なエージェント、スキル、MCPツール、プラグインを1つのプロジェクトまたはすべてのプロジェクトにインストールできます。", "marketplace.intro.learnMore": "Marketplaceについて", @@ -60,6 +59,13 @@ export const dict = { "marketplace.install.failed": "インストールに失敗しました", "marketplace.install.done": "完了", "marketplace.install.close": "閉じる", + "marketplace.install.mcp.signIn.message": "{{name}} はインストールされていますが、ツールを使用する前にサインインが必要です。", + "marketplace.install.mcp.signIn.button": "サインイン", + "marketplace.install.mcp.signIn.waiting": "ブラウザでのサインインを待っています…", + "marketplace.install.mcp.signIn.cancel": "キャンセル", + "marketplace.install.mcp.signIn.skip": "後で", + "marketplace.install.mcp.signIn.success": "{{name}} にサインインしました。", + "marketplace.install.mcp.signIn.failed": "{{name}} へのサインインに失敗しました。", "marketplace.remove.title": "{{name}} を削除しますか?", "marketplace.remove.confirm": "この{{type}}を削除してもよろしいですか? これにより、{{scope}}設定から削除されます。", "marketplace.remove.cancel": "キャンセル", diff --git a/packages/kilo-i18n/src/ko.ts b/packages/kilo-i18n/src/ko.ts index f2fe5a22148..486fa82723a 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -49,7 +49,6 @@ export const dict = { "플러그인은 Kilo에 사용자 정의 도구와 통합 기능을 추가합니다. 플러그인은 모든 권한으로 실행됩니다.", "marketplace.install.plugin.warning": "플러그인은 모든 권한으로 코드를 실행합니다. 사용자의 파일을 읽고 변경하고, 명령을 실행하며, 사용자의 인증 정보와 네트워크에 접근할 수 있습니다. 신뢰할 수 있는 플러그인만 설치하세요.", - "marketplace.install.installedAt": "{{path}}에 설치됨", "marketplace.intro": "재사용 가능한 에이전트, 스킬, MCP 도구 및 플러그인을 하나의 프로젝트 또는 모든 프로젝트에 설치하세요.", "marketplace.intro.learnMore": "Marketplace 정보", @@ -60,6 +59,13 @@ export const dict = { "marketplace.install.failed": "설치 실패", "marketplace.install.done": "완료", "marketplace.install.close": "닫기", + "marketplace.install.mcp.signIn.message": "{{name}}이(가) 설치되었지만, 도구를 사용하려면 먼저 로그인해야 합니다.", + "marketplace.install.mcp.signIn.button": "로그인", + "marketplace.install.mcp.signIn.waiting": "브라우저 로그인을 기다리는 중…", + "marketplace.install.mcp.signIn.cancel": "취소", + "marketplace.install.mcp.signIn.skip": "나중에", + "marketplace.install.mcp.signIn.success": "{{name}}에 로그인했습니다.", + "marketplace.install.mcp.signIn.failed": "{{name}} 로그인에 실패했습니다.", "marketplace.remove.title": "{{name}}을(를) 제거하시겠습니까?", "marketplace.remove.confirm": "이 {{type}}을(를) 제거하시겠습니까? {{scope}} 구성에서 제거됩니다.", "marketplace.remove.cancel": "취소", diff --git a/packages/kilo-i18n/src/nl.ts b/packages/kilo-i18n/src/nl.ts index 026dd243ad0..083551e0b8b 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -53,7 +53,6 @@ export const dict = { "Een plugin voegt aangepaste hulpmiddelen en integraties toe aan Kilo. Plugins worden uitgevoerd met volledige machtigingen.", "marketplace.install.plugin.warning": "Plugins voeren code uit met volledige machtigingen. Ze kunnen je bestanden lezen en wijzigen, opdrachten uitvoeren en toegang krijgen tot je inloggegevens en netwerk. Installeer alleen plugins die je vertrouwt.", - "marketplace.install.installedAt": "Geïnstalleerd in {{path}}", "marketplace.intro": "Installeer herbruikbare agenten, vaardigheden, MCP-hulpmiddelen en plugins voor één project of voor alle projecten.", "marketplace.intro.learnMore": "Over Marketplace", @@ -64,6 +63,13 @@ export const dict = { "marketplace.install.failed": "Installatie mislukt", "marketplace.install.done": "Klaar", "marketplace.install.close": "Sluiten", + "marketplace.install.mcp.signIn.message": "{{name}} is geïnstalleerd, maar vereist een login voordat de tools gebruikt kunnen worden.", + "marketplace.install.mcp.signIn.button": "Inloggen", + "marketplace.install.mcp.signIn.waiting": "Wachten op inloggen via de browser…", + "marketplace.install.mcp.signIn.cancel": "Annuleren", + "marketplace.install.mcp.signIn.skip": "Later", + "marketplace.install.mcp.signIn.success": "Ingelogd bij {{name}}.", + "marketplace.install.mcp.signIn.failed": "Inloggen bij {{name}} mislukt.", "marketplace.remove.title": "{{name}} verwijderen?", "marketplace.remove.confirm": "Weet je zeker dat je deze {{type}} wilt verwijderen? Dit verwijdert het uit je {{scope}} configuratie.", diff --git a/packages/kilo-i18n/src/no.ts b/packages/kilo-i18n/src/no.ts index c932bb8fb93..e5cf973252c 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -51,7 +51,6 @@ export const dict = { "Et programtillegg legger til tilpassede verktøy og integrasjoner i Kilo. Programtillegg kjører med fulle tillatelser.", "marketplace.install.plugin.warning": "Programtillegg kjører kode med fulle tillatelser. De kan lese og endre filene dine, kjøre kommandoer og få tilgang til påloggingsopplysningene dine og nettverket ditt. Installer bare programtillegg du stoler på.", - "marketplace.install.installedAt": "Installert i {{path}}", "marketplace.intro": "Installer gjenbrukbare agenter, ferdigheter, MCP-verktøy og programtillegg for ett eller alle prosjekter.", "marketplace.intro.learnMore": "Om Marketplace", @@ -62,6 +61,13 @@ export const dict = { "marketplace.install.failed": "Installasjonen mislyktes", "marketplace.install.done": "Ferdig", "marketplace.install.close": "Lukk", + "marketplace.install.mcp.signIn.message": "{{name}} er installert, men krever innlogging før verktøyene kan brukes.", + "marketplace.install.mcp.signIn.button": "Logg inn", + "marketplace.install.mcp.signIn.waiting": "Venter på innlogging via nettleseren…", + "marketplace.install.mcp.signIn.cancel": "Avbryt", + "marketplace.install.mcp.signIn.skip": "Senere", + "marketplace.install.mcp.signIn.success": "Logget inn på {{name}}.", + "marketplace.install.mcp.signIn.failed": "Innlogging til {{name}} mislyktes.", "marketplace.remove.title": "Fjern {{name}}?", "marketplace.remove.confirm": "Er du sikker på at du vil fjerne denne {{type}}? Dette vil fjerne den fra din {{scope}}-konfigurasjon.", diff --git a/packages/kilo-i18n/src/pl.ts b/packages/kilo-i18n/src/pl.ts index cc40942f9d2..83c3b6f7076 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -51,7 +51,6 @@ export const dict = { "Wtyczka dodaje niestandardowe narzędzia i integracje do Kilo. Wtyczki działają z pełnymi uprawnieniami.", "marketplace.install.plugin.warning": "Wtyczki wykonują kod z pełnymi uprawnieniami. Mogą odczytywać i zmieniać Twoje pliki, uruchamiać polecenia oraz uzyskiwać dostęp do Twoich danych uwierzytelniających i sieci. Instaluj tylko wtyczki, którym ufasz.", - "marketplace.install.installedAt": "Zainstalowano w {{path}}", "marketplace.intro": "Instaluj agentów, umiejętności, narzędzia MCP i wtyczki wielokrotnego użytku w jednym lub we wszystkich projektach.", "marketplace.intro.learnMore": "O Marketplace", @@ -62,6 +61,13 @@ export const dict = { "marketplace.install.failed": "Instalacja nie powiodła się", "marketplace.install.done": "Gotowe", "marketplace.install.close": "Zamknij", + "marketplace.install.mcp.signIn.message": "{{name}} jest zainstalowany, ale wymaga zalogowania, zanim będzie można używać jego narzędzi.", + "marketplace.install.mcp.signIn.button": "Zaloguj się", + "marketplace.install.mcp.signIn.waiting": "Czekanie na zalogowanie w przeglądarce…", + "marketplace.install.mcp.signIn.cancel": "Anuluj", + "marketplace.install.mcp.signIn.skip": "Później", + "marketplace.install.mcp.signIn.success": "Zalogowano do {{name}}.", + "marketplace.install.mcp.signIn.failed": "Logowanie do {{name}} nie powiodło się.", "marketplace.remove.title": "Usunąć {{name}}?", "marketplace.remove.confirm": "Czy na pewno chcesz usunąć ten {{type}}? Spowoduje to usunięcie z konfiguracji: {{scope}}.", diff --git a/packages/kilo-i18n/src/ru.ts b/packages/kilo-i18n/src/ru.ts index 5bbf9060bc5..7fd3079a07b 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -52,7 +52,6 @@ export const dict = { "Плагин добавляет в Kilo пользовательские инструменты и интеграции. Плагины выполняются с полными правами доступа.", "marketplace.install.plugin.warning": "Плагины выполняют код с полными правами доступа. Они могут читать и изменять ваши файлы, выполнять команды и получать доступ к вашим учётным данным и сети. Устанавливайте только те плагины, которым доверяете.", - "marketplace.install.installedAt": "Установлено в {{path}}", "marketplace.intro": "Устанавливайте многократно используемых агентов, навыки, инструменты MCP и плагины для одного или всех проектов.", "marketplace.intro.learnMore": "О Marketplace", @@ -63,6 +62,13 @@ export const dict = { "marketplace.install.failed": "Сбой установки", "marketplace.install.done": "Готово", "marketplace.install.close": "Закрыть", + "marketplace.install.mcp.signIn.message": "{{name}} установлен, но требуется вход, прежде чем его инструменты можно будет использовать.", + "marketplace.install.mcp.signIn.button": "Войти", + "marketplace.install.mcp.signIn.waiting": "Ожидание входа через браузер…", + "marketplace.install.mcp.signIn.cancel": "Отмена", + "marketplace.install.mcp.signIn.skip": "Позже", + "marketplace.install.mcp.signIn.success": "Вход в {{name}} выполнен.", + "marketplace.install.mcp.signIn.failed": "Не удалось войти в {{name}}.", "marketplace.remove.title": "Удалить {{name}}?", "marketplace.remove.confirm": "Вы уверены, что хотите удалить этот {{type}}? Это удалит его из вашей конфигурации ({{scope}}).", diff --git a/packages/kilo-i18n/src/th.ts b/packages/kilo-i18n/src/th.ts index 9b88711a274..a118043cfe4 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -49,7 +49,6 @@ export const dict = { "ปลั๊กอินเพิ่มเครื่องมือและการเชื่อมต่อแบบกำหนดเองให้กับ Kilo ปลั๊กอินทำงานโดยมีสิทธิ์เต็มรูปแบบ", "marketplace.install.plugin.warning": "ปลั๊กอินเรียกใช้โค้ดโดยมีสิทธิ์เต็มรูปแบบ สามารถอ่านและแก้ไขไฟล์ของคุณ เรียกใช้คำสั่ง และเข้าถึงข้อมูลรับรองและเครือข่ายของคุณได้ ติดตั้งเฉพาะปลั๊กอินที่คุณเชื่อถือเท่านั้น", - "marketplace.install.installedAt": "ติดตั้งไปยัง {{path}} แล้ว", "marketplace.intro": "ติดตั้งเอเจนต์ ทักษะ เครื่องมือ MCP และปลั๊กอินที่นำกลับมาใช้ใหม่ได้สำหรับหนึ่งโปรเจกต์หรือทุกโปรเจกต์", "marketplace.intro.learnMore": "เกี่ยวกับ Marketplace", @@ -60,6 +59,13 @@ export const dict = { "marketplace.install.failed": "การติดตั้งล้มเหลว", "marketplace.install.done": "เสร็จสิ้น", "marketplace.install.close": "ปิด", + "marketplace.install.mcp.signIn.message": "{{name}} ติดตั้งแล้ว แต่ต้องเข้าสู่ระบบก่อนจึงจะใช้งานเครื่องมือได้", + "marketplace.install.mcp.signIn.button": "เข้าสู่ระบบ", + "marketplace.install.mcp.signIn.waiting": "กำลังรอการเข้าสู่ระบบผ่านเบราว์เซอร์…", + "marketplace.install.mcp.signIn.cancel": "ยกเลิก", + "marketplace.install.mcp.signIn.skip": "ไว้ทีหลัง", + "marketplace.install.mcp.signIn.success": "เข้าสู่ระบบ {{name}} แล้ว", + "marketplace.install.mcp.signIn.failed": "เข้าสู่ระบบ {{name}} ไม่สำเร็จ", "marketplace.remove.title": "ลบ {{name}} หรือไม่?", "marketplace.remove.confirm": "คุณแน่ใจหรือไม่ว่าต้องการลบ {{type}} นี้? สิ่งนี้จะลบออกจากการกำหนดค่า {{scope}} ของคุณ", diff --git a/packages/kilo-i18n/src/tr.ts b/packages/kilo-i18n/src/tr.ts index 24bf5b0a581..796b1e6a4e8 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -51,7 +51,6 @@ export const dict = { "Bir eklenti, Kilo'ya özel araçlar ve entegrasyonlar ekler. Eklentiler tam izinlerle çalışır.", "marketplace.install.plugin.warning": "Eklentiler tam izinlerle kod çalıştırır. Dosyalarınızı okuyup değiştirebilir, komut çalıştırabilir ve kimlik bilgilerinize ve ağınıza erişebilirler. Yalnızca güvendiğiniz eklentileri yükleyin.", - "marketplace.install.installedAt": "{{path}} konumuna yüklendi", "marketplace.intro": "Yeniden kullanılabilir ajanları, yetenekleri, MCP araçlarını ve eklentileri bir proje veya tüm projeler için yükleyin.", "marketplace.intro.learnMore": "Marketplace hakkında", @@ -62,6 +61,13 @@ export const dict = { "marketplace.install.failed": "Yükleme başarısız oldu", "marketplace.install.done": "Bitti", "marketplace.install.close": "Kapat", + "marketplace.install.mcp.signIn.message": "{{name}} yüklendi, ancak araçlarının kullanılabilmesi için önce oturum açılması gerekiyor.", + "marketplace.install.mcp.signIn.button": "Giriş Yap", + "marketplace.install.mcp.signIn.waiting": "Tarayıcıda oturum açma bekleniyor…", + "marketplace.install.mcp.signIn.cancel": "İptal", + "marketplace.install.mcp.signIn.skip": "Daha sonra", + "marketplace.install.mcp.signIn.success": "{{name}} oturumu açıldı.", + "marketplace.install.mcp.signIn.failed": "{{name}} oturumu açılamadı.", "marketplace.remove.title": "{{name}} kaldırılsın mı?", "marketplace.remove.confirm": "Bu {{type}} öğesini kaldırmak istediğinizden emin misiniz? Bu, {{scope}} yapılandırmanızdan kaldırılacaktır.", diff --git a/packages/kilo-i18n/src/uk.ts b/packages/kilo-i18n/src/uk.ts index 36a6d3e2d77..d71bc239253 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -51,7 +51,6 @@ export const dict = { "Плагін додає до Kilo користувацькі інструменти та інтеграції. Плагіни виконуються з повними правами доступу.", "marketplace.install.plugin.warning": "Плагіни виконують код із повними правами доступу. Вони можуть читати й змінювати ваші файли, виконувати команди та отримувати доступ до ваших облікових даних і мережі. Встановлюйте лише плагіни, яким довіряєте.", - "marketplace.install.installedAt": "Встановлено в {{path}}", "marketplace.intro": "Встановлюйте багаторазових агентів, навички, інструменти MCP та плагіни для одного або всіх проєктів.", "marketplace.intro.learnMore": "Про Marketplace", @@ -62,6 +61,13 @@ export const dict = { "marketplace.install.failed": "Встановлення не вдалося", "marketplace.install.done": "Готово", "marketplace.install.close": "Закрити", + "marketplace.install.mcp.signIn.message": "{{name}} встановлено, але потрібно увійти, перш ніж можна буде використовувати його інструменти.", + "marketplace.install.mcp.signIn.button": "Увійти", + "marketplace.install.mcp.signIn.waiting": "Очікування входу через браузер…", + "marketplace.install.mcp.signIn.cancel": "Скасувати", + "marketplace.install.mcp.signIn.skip": "Пізніше", + "marketplace.install.mcp.signIn.success": "Вхід у {{name}} виконано.", + "marketplace.install.mcp.signIn.failed": "Не вдалося увійти в {{name}}.", "marketplace.remove.title": "Видалити {{name}}?", "marketplace.remove.confirm": "Ви впевнені, що хочете видалити цей {{type}}? Це видалить його з вашої конфігурації {{scope}}.", diff --git a/packages/kilo-i18n/src/zh.ts b/packages/kilo-i18n/src/zh.ts index 0f102618015..079839d247d 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -46,7 +46,6 @@ export const dict = { "marketplace.install.about.plugin": "插件可为 Kilo 添加自定义工具和集成功能。插件以完整权限运行。", "marketplace.install.plugin.warning": "插件以完整权限运行代码。它们可以读取和修改你的文件、运行命令,以及访问你的凭据和网络。请仅安装你信任的插件。", - "marketplace.install.installedAt": "已安装到 {{path}}", "marketplace.intro": "为一个项目或所有项目安装可复用的智能体、技能、MCP 工具和插件。", "marketplace.intro.learnMore": "关于 Marketplace", "marketplace.install.prerequisites": "先决条件", @@ -56,6 +55,13 @@ export const dict = { "marketplace.install.failed": "安装失败", "marketplace.install.done": "完成", "marketplace.install.close": "关闭", + "marketplace.install.mcp.signIn.message": "{{name}} 已安装,但需要先登录才能使用其工具。", + "marketplace.install.mcp.signIn.button": "登录", + "marketplace.install.mcp.signIn.waiting": "正在等待浏览器登录…", + "marketplace.install.mcp.signIn.cancel": "取消", + "marketplace.install.mcp.signIn.skip": "以后再说", + "marketplace.install.mcp.signIn.success": "已登录 {{name}}。", + "marketplace.install.mcp.signIn.failed": "登录 {{name}} 失败。", "marketplace.remove.title": "移除 {{name}}?", "marketplace.remove.confirm": "确定要移除此 {{type}} 吗?这会将其从你的 {{scope}} 配置中移除。", "marketplace.remove.cancel": "取消", diff --git a/packages/kilo-i18n/src/zht.ts b/packages/kilo-i18n/src/zht.ts index 744867b7648..e0b799b2c8b 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -45,7 +45,6 @@ export const dict = { "marketplace.install.about.plugin": "外掛可為 Kilo 新增自訂工具和整合功能。外掛以完整權限執行。", "marketplace.install.plugin.warning": "外掛以完整權限執行程式碼。它們可以讀取和修改你的檔案、執行命令,以及存取你的憑證和網路。請僅安裝你信任的外掛。", - "marketplace.install.installedAt": "已安裝至 {{path}}", "marketplace.intro": "為一個專案或所有專案安裝可重複使用的智能體、技能、MCP 工具和外掛。", "marketplace.intro.learnMore": "關於 Marketplace", "marketplace.install.prerequisites": "先決條件", @@ -55,6 +54,13 @@ export const dict = { "marketplace.install.failed": "安裝失敗", "marketplace.install.done": "完成", "marketplace.install.close": "關閉", + "marketplace.install.mcp.signIn.message": "{{name}} 已安裝,但需要先登入才能使用其工具。", + "marketplace.install.mcp.signIn.button": "登入", + "marketplace.install.mcp.signIn.waiting": "正在等待瀏覽器登入…", + "marketplace.install.mcp.signIn.cancel": "取消", + "marketplace.install.mcp.signIn.skip": "稍後再說", + "marketplace.install.mcp.signIn.success": "已登入 {{name}}。", + "marketplace.install.mcp.signIn.failed": "登入 {{name}} 失敗。", "marketplace.remove.title": "移除 {{name}}?", "marketplace.remove.confirm": "確定要移除此 {{type}} 嗎?這會將其從你的 {{scope}} 設定中移除。", "marketplace.remove.cancel": "取消", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 9d116dcde8b..5608676f5db 100644 --- a/packages/kilo-indexing/package.json +++ b/packages/kilo-indexing/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-indexing", - "version": "7.8.3", + "version": "7.8.8", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", diff --git a/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts b/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts index 100d2fe6ff0..93ce4d6184b 100644 --- a/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts +++ b/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts @@ -9,7 +9,12 @@ import { REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, } from "../constants" import { getDefaultModelId, getModelQueryPrefix } from "../model-registry" -import { withValidationErrorHandling, type HttpError, formatEmbeddingError } from "../shared/validation-helpers" +import { + withValidationErrorHandling, + extractStatusCode, + type HttpError, + formatEmbeddingError, +} from "../shared/validation-helpers" import { applyQueryPrefix, embedBatches } from "../shared/embedder-helpers" import { createRateLimitState, @@ -54,6 +59,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { private readonly maxItemTokens: number private readonly headers: Record private readonly dimensions?: number + private readonly omitted = new Set() // Global rate limiting state shared across all instances private static globalRateLimitState = createRateLimitState() @@ -157,12 +163,15 @@ export class OpenAICompatibleEmbedder implements IEmbedder { * @param url The full endpoint URL * @param batchTexts Array of texts to embed * @param model Model identifier to use + * @param dimensions Optional embedding dimensions to request + * @param signal Optional abort signal * @returns Promise resolving to OpenAI-compatible response */ private async makeDirectEmbeddingRequest( url: string, batchTexts: string[], model: string, + dimensions: number | undefined, signal?: AbortSignal, ): Promise { const response = await fetch(url, { @@ -181,7 +190,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { input: batchTexts, model: model, encoding_format: "base64", - ...(this.dimensions !== undefined ? { dimensions: this.dimensions } : {}), + ...(dimensions !== undefined ? { dimensions } : {}), }), signal, }) @@ -213,6 +222,84 @@ export class OpenAICompatibleEmbedder implements IEmbedder { } } + private async request( + texts: string[], + model: string, + options: { signal?: AbortSignal; timeout?: number; maxRetries?: number } = {}, + ): Promise<{ + response: OpenAIEmbeddingResponse + projected?: { embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } } + }> { + const send = async (dimensions: number | undefined): Promise => { + if (this.isFullUrl) { + return this.makeDirectEmbeddingRequest(this.baseUrl, texts, model, dimensions, options.signal) + } + const body = { + input: texts, + model: model, + // OpenAI package (as of v4.78.1) has a parsing issue that truncates embedding dimensions to 256 + // when processing numeric arrays, which breaks compatibility with models using larger dimensions. + // By requesting base64 encoding, we bypass the package's parser and handle decoding ourselves. + encoding_format: "base64" as const, + ...(dimensions !== undefined ? { dimensions } : {}), + } + if (options.timeout === undefined && options.maxRetries === undefined) { + return this.embeddingsClient.embeddings.create(body) + } + return this.embeddingsClient.embeddings.create(body, { + timeout: options.timeout, + maxRetries: options.maxRetries, + }) + } + + const check = (response: OpenAIEmbeddingResponse) => { + // Guard the shape before decoding. A 200 response with an error body has no + // data, so decoding it first would throw a raw TypeError. Do not throw here + // either: validation reads response.error for that case, and the batch caller + // reports the missing data when it needs the vectors. + const hasData = Array.isArray(response?.data) + const projected = hasData ? projectEmbeddingResponse(response) : undefined + if (this.dimensions !== undefined && projected) { + if (response.data.length !== texts.length) { + throw new Error("Invalid response from embedding endpoint") + } + for (const vector of projected.embeddings) { + if (Array.isArray(vector) && vector.length === this.dimensions) continue + throw new Error( + `Embedding endpoint returned ${Array.isArray(vector) ? vector.length : 0} dimensions, but ${this.dimensions} are configured. ` + + "Set the configured dimension to match the model output or use an endpoint that supports the requested dimensions.", + ) + } + } + return { response, projected } + } + const dimensions = this.omitted.has(model) ? undefined : this.dimensions + + try { + return check(await send(dimensions)) + } catch (error) { + const status = extractStatusCode(error) + const param = error instanceof Error && "param" in error && error.param === "dimensions" + if ( + dimensions === undefined || + (status !== 400 && status !== 422) || + !(error instanceof Error) || + (!param && !/\bdimensions\b/i.test(error.message)) || + !/unsupported|not support|not allowed|not permitted|unknown|unrecognized|unexpected|extra|does not|doesn't/i.test( + error.message, + ) + ) { + throw error + } + + log.warn("Embedding endpoint rejected the dimensions parameter, retrying without it") + // Keep the configured store size. Omit dimensions only after a matching response succeeds. + const result = check(await send(undefined)) + if (result.projected) this.omitted.add(model) + return result + } + } + /** * Helper method to handle batch embedding with retries and exponential backoff * @param batchTexts Array of texts to embed in this batch @@ -223,33 +310,15 @@ export class OpenAICompatibleEmbedder implements IEmbedder { batchTexts: string[], model: string, ): Promise<{ embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } }> { - // Use cached value for performance - const isFullUrl = this.isFullUrl - for (let attempts = 0; attempts < MAX_RETRIES; attempts++) { // Check global rate limit before attempting request await this.waitForGlobalRateLimit() try { - let response: OpenAIEmbeddingResponse + const { projected } = await this.request(batchTexts, model) + if (!projected) throw new Error("Invalid response from embedding endpoint") - if (isFullUrl) { - // Use direct HTTP request for full endpoint URLs - response = await this.makeDirectEmbeddingRequest(this.baseUrl, batchTexts, model) - } else { - // Use OpenAI SDK for base URLs - response = (await this.embeddingsClient.embeddings.create({ - input: batchTexts, - model: model, - // OpenAI package (as of v4.78.1) has a parsing issue that truncates embedding dimensions to 256 - // when processing numeric arrays, which breaks compatibility with models using larger dimensions. - // By requesting base64 encoding, we bypass the package's parser and handle decoding ourselves. - encoding_format: "base64", - ...(this.dimensions !== undefined ? { dimensions: this.dimensions } : {}), - })) as OpenAIEmbeddingResponse - } - - return projectEmbeddingResponse(response) + return projected } catch (error) { log.error("OpenAI Compatible embedder batch error", { err: error instanceof Error ? error.message : String(error), @@ -295,32 +364,21 @@ export class OpenAICompatibleEmbedder implements IEmbedder { // Test with a minimal embedding request const testTexts = ["test"] const modelToUse = this.defaultModelId - let response: OpenAIEmbeddingResponse - if (this.isFullUrl) { - // Test direct HTTP request for full endpoint URLs - const ctl = new AbortController() - const timer = setTimeout(() => ctl.abort(), REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS) - try { - response = await this.makeDirectEmbeddingRequest(this.baseUrl, testTexts, modelToUse, ctl.signal) - } finally { - clearTimeout(timer) - } - } else { - // Test using OpenAI SDK for base URLs - response = (await this.embeddingsClient.embeddings.create( - { - input: testTexts, - model: modelToUse, - encoding_format: "base64", - ...(this.dimensions !== undefined ? { dimensions: this.dimensions } : {}), - }, - { - timeout: REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, - maxRetries: REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, - }, - )) as OpenAIEmbeddingResponse + // Abort full-endpoint requests on timeout. Base URLs get an SDK timeout instead. + const ctl = this.isFullUrl ? new AbortController() : undefined + const timer = ctl ? setTimeout(() => ctl.abort(), REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS) : undefined + + try { + const result = await this.request(testTexts, modelToUse, { + signal: ctl?.signal, + timeout: REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, + maxRetries: REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, + }) + response = result.response + } finally { + if (timer) clearTimeout(timer) } const error = (response as { error?: string | { message?: string } }).error diff --git a/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible-http.test.ts b/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible-http.test.ts new file mode 100644 index 00000000000..ee19c1a6600 --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible-http.test.ts @@ -0,0 +1,133 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +for (const endpoint of ["/v1", "/v1/embeddings"]) { + for (const mode of ["supported", "unsupported", "unprocessable", "structured", "mismatch", "invalid"]) { + test(`${endpoint}: preserves configured dimensions with ${mode} endpoint`, async () => { + const root = await mkdtemp(join(tmpdir(), "kilo-dimensions-")) + const requests: Array<{ input: string[]; dimensions?: number }> = [] + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + const body: { input: string[]; dimensions?: number } = await req.json() + requests.push(body) + if (mode === "invalid") { + return Response.json( + { error: { message: "Invalid model", type: "invalid_request_error" } }, + { status: 400 }, + ) + } + if (mode !== "supported" && body.dimensions !== undefined) { + return Response.json( + { + error: { + message: + mode === "structured" + ? "Extra inputs are not permitted" + : "Unrecognized request argument: dimensions", + param: "dimensions", + }, + }, + { status: mode === "unprocessable" ? 422 : 400 }, + ) + } + const size = mode === "supported" ? (body.dimensions ?? 768) : mode === "mismatch" ? 768 : 4096 + const vector = new Float32Array(size).fill(0.25) + return Response.json({ + object: "list", + data: body.input.map((_, index) => ({ + object: "embedding", + index, + embedding: Buffer.from(vector.buffer).toString("base64"), + })), + model: "qwen3-embedding-8b", + usage: { prompt_tokens: body.input.length, total_tokens: body.input.length }, + }) + }, + }) + + try { + // A subprocess keeps the real SDK and LanceDB isolated from module mocks in other test files. + const script = ` + import assert from "node:assert/strict" + import { CodeIndexConfigManager } from "./src/indexing/config-manager.ts" + import { CodeIndexServiceFactory } from "./src/indexing/service-factory.ts" + import { CacheManager } from "./src/indexing/cache-manager.ts" + const root = ${JSON.stringify(root)} + const cfg = new CodeIndexConfigManager({ + enabled: true, + embedderProvider: "openai-compatible", + vectorStoreProvider: "lancedb", + modelId: "qwen3-embedding-8b", + modelDimension: 4096, + openAiCompatibleApiKey: "fixture-key", + openAiCompatibleBaseUrl: ${JSON.stringify(`http://127.0.0.1:${server.port}${endpoint}`)}, + }) + const factory = new CodeIndexServiceFactory(cfg, root, new CacheManager(root, root), root) + const embedder = factory.createEmbedder() + const validation = await factory.validateEmbedder(embedder) + const mode = ${JSON.stringify(mode)} + if (mode === "mismatch" || mode === "invalid") { + assert.equal(validation.valid, false) + if (mode === "mismatch") { + assert.match(validation.error, /768.*4096/) + await assert.rejects(embedder.createEmbeddings(["hello"]), /768.*4096/) + } + process.exit(0) + } + assert.equal(validation.valid, true, validation.error) + const store = factory.createVectorStore() + try { + await store.initialize() + const document = await embedder.createEmbeddings(["hello"]) + assert.equal(document.embeddings[0].length, 4096) + await store.upsertPoints([{ + id: "00000000-0000-4000-8000-000000000001", + vector: document.embeddings[0], + payload: { filePath: "fixture.ts", fileHash: "fixture", codeChunk: "hello", startLine: 1, endLine: 1 }, + }]) + const query = await embedder.createEmbeddings(["hello"]) + assert.equal(query.embeddings[0].length, 4096) + const results = await store.search(query.embeddings[0], undefined, 0, 5) + assert.equal(results[0]?.payload?.filePath, "fixture.ts") + } finally { + await store.close?.() + } + ` + const child = Bun.spawn([process.execPath, "-e", script], { + cwd: join(import.meta.dir, "../../../.."), + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }) + const gate = Promise.withResolvers() + const timer = setTimeout(() => { + child.kill() + gate.reject(new Error("Dimension validation subprocess timed out")) + }, 15_000) + const [exit, , stderr] = await Promise.race([ + Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]), + gate.promise, + ]).finally(() => clearTimeout(timer)) + if (exit !== 0) throw new Error(stderr) + + expect(requests.at(0)?.dimensions).toBe(4096) + if (mode === "supported") { + expect(requests).toHaveLength(3) + expect(requests.every((req) => req.dimensions === 4096)).toBe(true) + } + if (mode === "unsupported" || mode === "unprocessable" || mode === "structured") { + expect(requests).toHaveLength(4) + expect(requests.slice(1).every((req) => req.dimensions === undefined)).toBe(true) + } + if (mode === "invalid") expect(requests).toHaveLength(1) + } finally { + server.stop(true) + await rm(root, { recursive: true, force: true }) + } + }, 20_000) + } +} diff --git a/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible.test.ts b/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible.test.ts index cfc8c7d9a73..4c2c7b3c507 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible.test.ts @@ -840,6 +840,195 @@ describe("OpenAICompatibleEmbedder", () => { }) }) + describe("dimension fallback", () => { + const rejection = () => Object.assign(new Error("Unrecognized request argument: dimensions"), { status: 400 }) + + test("retries without dimensions when the endpoint rejects them", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { + dimensions: 2, + }) + + const testEmbedding = new Float32Array([0.25, 0.5]) + const base64String = Buffer.from(testEmbedding.buffer).toString("base64") + mockEmbeddingsCreate.mockRejectedValueOnce(rejection()).mockResolvedValueOnce({ + data: [{ embedding: base64String }], + usage: { prompt_tokens: 1, total_tokens: 1 }, + }) + + const result = await embedder.createEmbeddings(["hello"]) + + expect(result.embeddings).toEqual([[0.25, 0.5]]) + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(2) + expect(mockEmbeddingsCreate.mock.calls.at(0)?.at(0)).toEqual({ + input: ["hello"], + model: "custom-embed", + encoding_format: "base64", + dimensions: 2, + }) + expect(mockEmbeddingsCreate.mock.calls.at(1)?.at(0)).toEqual({ + input: ["hello"], + model: "custom-embed", + encoding_format: "base64", + }) + }) + + test("stops sending dimensions after the first rejection", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { + dimensions: 2, + }) + + const testEmbedding = new Float32Array([0.25, 0.5]) + const base64String = Buffer.from(testEmbedding.buffer).toString("base64") + const success = { data: [{ embedding: base64String }], usage: { prompt_tokens: 1, total_tokens: 1 } } + mockEmbeddingsCreate + .mockRejectedValueOnce(rejection()) + .mockResolvedValueOnce(success) + .mockResolvedValueOnce(success) + + await embedder.createEmbeddings(["hello"]) + await embedder.createEmbeddings(["hello"]) + + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(3) + expect(mockEmbeddingsCreate.mock.calls.at(2)?.at(0)).toEqual({ + input: ["hello"], + model: "custom-embed", + encoding_format: "base64", + }) + }) + + test("validates successfully when dimensions are rejected", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { + dimensions: 3, + }) + + mockEmbeddingsCreate.mockRejectedValueOnce(rejection()).mockResolvedValueOnce({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + }) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(true) + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(2) + }) + + test("keeps the server error for a 200 response without data", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + + mockEmbeddingsCreate.mockResolvedValue({ error: { message: "model not found" } } as never) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("model not found") + }) + + test("keeps the server error for a 200 response without data when dimensions are configured", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { + dimensions: 2, + }) + + mockEmbeddingsCreate.mockResolvedValue({ error: { message: "model not found" } } as never) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("model not found") + }) + + test("reports a clear error when a batch response has no data", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { + dimensions: 2, + }) + + mockEmbeddingsCreate.mockResolvedValue({ error: { message: "model not found" } } as never) + + await expect(embedder.createEmbeddings(["hello"])).rejects.toThrow(/Invalid response from embedding endpoint/) + }) + + test("does not retry a 400 when no dimensions are configured", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + + mockEmbeddingsCreate.mockRejectedValue(rejection()) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Configuration error. Please verify your embedder settings.") + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1) + }) + + test.each([401, 403, 429, 500])("does not retry a %s error without dimensions", async (status) => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { dimensions: 2 }) + mockEmbeddingsCreate.mockRejectedValue(Object.assign(new Error("Unsupported dimensions"), { status })) + + expect((await embedder.validateConfiguration()).valid).toBe(false) + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1) + }) + + test("does not cache omission when the fallback fails", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { dimensions: 2 }) + mockEmbeddingsCreate + .mockRejectedValueOnce(rejection()) + .mockRejectedValueOnce(Object.assign(new Error("Server error"), { status: 500 })) + .mockResolvedValueOnce({ data: [{ embedding: [0.25, 0.5] }] }) + + expect((await embedder.validateConfiguration()).valid).toBe(false) + expect((await embedder.validateConfiguration()).valid).toBe(true) + expect(mockEmbeddingsCreate.mock.calls.at(2)?.at(0).dimensions).toBe(2) + }) + + test("does not cache omission when the fallback returns no usable data", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { dimensions: 2 }) + mockEmbeddingsCreate + .mockRejectedValueOnce(rejection()) + .mockResolvedValueOnce({ error: { message: "model not found" } } as never) + .mockResolvedValueOnce({ data: [{ embedding: [0.25, 0.5] }] }) + + expect((await embedder.validateConfiguration()).valid).toBe(false) + expect((await embedder.validateConfiguration()).valid).toBe(true) + expect(mockEmbeddingsCreate.mock.calls.at(2)?.at(0).dimensions).toBe(2) + }) + + test("does not apply one model's omission to another model", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { dimensions: 2 }) + mockEmbeddingsCreate + .mockRejectedValueOnce(rejection()) + .mockResolvedValueOnce({ data: [{ embedding: [0.25, 0.5] }] }) + .mockResolvedValueOnce({ data: [{ embedding: [0.25, 0.5] }] }) + + await embedder.createEmbeddings(["hello"]) + await embedder.createEmbeddings(["hello"], "another-model") + expect(mockEmbeddingsCreate.mock.calls.at(2)?.at(0)).toEqual({ + input: ["hello"], + model: "another-model", + encoding_format: "base64", + dimensions: 2, + }) + }) + + test.each(["numeric", "base64"])("rejects a mismatched %s fallback without caching omission", async (format) => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { dimensions: 2 }) + const vector = new Float32Array([0.25, 0.5, 0.75]) + const embedding = format === "numeric" ? Array.from(vector) : Buffer.from(vector.buffer).toString("base64") + mockEmbeddingsCreate + .mockRejectedValueOnce(rejection()) + .mockResolvedValueOnce({ data: [{ embedding }] }) + .mockResolvedValueOnce({ data: [{ embedding: [0.25, 0.5] }] }) + + expect((await embedder.validateConfiguration()).error).toContain("3 dimensions, but 2 are configured") + expect((await embedder.validateConfiguration()).valid).toBe(true) + expect(mockEmbeddingsCreate.mock.calls.at(2)?.at(0).dimensions).toBe(2) + }) + + test("checks every vector in a batch", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { dimensions: 2 }) + mockEmbeddingsCreate.mockResolvedValue({ data: [{ embedding: [0.25, 0.5] }, { embedding: [0.25] }] }) + + await expect(embedder.createEmbeddings(["hello", "world"])).rejects.toThrow("1 dimensions, but 2 are configured") + }) + }) + describe("validateConfiguration", () => { let embedder: OpenAICompatibleEmbedder diff --git a/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts b/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts index 7b7d5f1539b..953f967f0f0 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts @@ -68,7 +68,7 @@ describe("CodeIndexServiceFactory", () => { modelDimension: 4096, }) - const testEmbedding = new Float32Array([0.25, 0.5]) + const testEmbedding = new Float32Array(4096).fill(0.25) const base64String = Buffer.from(testEmbedding.buffer).toString("base64") mockEmbeddingsCreate.mockResolvedValue({ @@ -330,7 +330,7 @@ describe("CodeIndexServiceFactory", () => { }) mockEmbeddingsCreate.mockResolvedValue({ - data: [{ embedding: [0.1, 0.2] }], + data: [{ embedding: Array.from({ length: 1024 }, () => 0.25) }], usage: { prompt_tokens: 1, total_tokens: 1 }, }) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 43c2e65790e..ad5013acc11 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -562,6 +562,48 @@ ## [Unreleased] +## [7.1.9] - 2026-10-07 + +### Added +- Added a prompt navigator to the chat transcript for jumping between your sent prompts. +- Added MCP OAuth sign-in support, including recovery flows for expired or failed authorizations. +- Combined MCP and skill badges in the chat UI. +- Added a core settings reload action. +- Rendered agent board messages as markdown. + +### Fixed +- Fixed hidden session questions not surfacing in the chat UI. +- Made chat dialogs submit with Cmd/Ctrl+Enter. +- Fixed spreadsheet date/time handling when reading xlsx and ods files. +- Finalized reasoning output before stream retries to avoid lost context. +- Kept storage usable after an interrupted first access. +- Prevented a rejected attachment from discarding the in-progress prompt. +- Gated prompt cache breakpoints to first-party OpenAI providers. + +### Changed +- Renamed JetBrains workflows to commands. +- Split agent guidance documentation into skills. +- Bumped the bundled Kilo CLI pin to v7.8.8 (via v7.8.3 and v7.8.7 intermediate bumps). + +## [7.1.9-rc.2] - 2026-10-07 + +### Added + +- Add a prompt navigator to the chat transcript for jumping between your own prompts. +- Add MCP OAuth sign-in support, including recognizing recoverable auth failures and refreshing auth after a reset. +- Show a combined MCP and Skill badge for marketplace servers that include companion skills. + +### Fixed + +- Submit chat dialogs with Command+Enter from any focused dialog control. +- Refresh a worktree's pull request status when its agent stops. +- Prevent a rejected attachment from discarding the prompt you were composing. + +### Changed + +- Bump the bundled Kilo CLI pin from 7.8.1 to 7.8.8. +- Pin the Bun runtime and types to 1.4.2 for reproducible builds. + ## [7.1.9-rc.1] - 2026-09-28 ### Added diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index ef289ffca77..093275e0d0f 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -33,7 +33,10 @@ import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.MessageSummaryDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.McpAuthEventDto +import ai.kilocode.rpc.dto.McpAuthResultDto import ai.kilocode.rpc.dto.McpConfigDto +import ai.kilocode.rpc.dto.McpOAuthDto import ai.kilocode.rpc.dto.McpStatusDto import ai.kilocode.rpc.dto.ModelAutoRoutingDto import ai.kilocode.rpc.dto.ModelCacheCostDto @@ -692,10 +695,25 @@ object KiloCliDataParser { headers = item.map("headers").takeIf { it.isNotEmpty() }, enabled = item.flagOrNull("enabled"), timeout = item.long("timeout"), + oauth = parseMcpOAuth(item["oauth"]), ) }.toMap() } + private fun parseMcpOAuth(elem: JsonElement?): McpOAuthDto? { + if (elem == null || elem is JsonNull) return null + runCatching { elem.jsonPrimitive.booleanOrNull }.getOrNull()?.let { return McpOAuthDto(enabled = it) } + val obj = elem.obj() ?: return null + return McpOAuthDto( + enabled = true, + clientId = obj.str("clientId"), + clientSecret = obj.str("clientSecret"), + scope = obj.str("scope"), + callbackPort = obj.long("callbackPort")?.toInt(), + redirectUri = obj.str("redirectUri"), + ) + } + private fun parseAgentConfig(obj: JsonObject?): Map { if (obj == null) return emptyMap() return obj.entries.mapNotNull { (name, elem) -> @@ -819,6 +837,33 @@ object KiloCliDataParser { } } + fun parseMcpAuthResult(code: Int, raw: String): McpAuthResultDto { + val obj = tryParseObject(raw) + if (code in 200..299) { + return McpAuthResultDto(status = obj?.str("status") ?: "failed", error = obj?.str("error")) + } + val status = when (code) { + 400 -> "unsupported" + 404 -> "not_found" + else -> "failed" + } + return McpAuthResultDto(status = status, error = obj?.str("error") ?: obj?.str("message") ?: "HTTP $code") + } + + /** + * Parses the `{ mcpName, url }` payload shared by `mcp.auth.url` and `mcp.browser.open.failed`. + * + * [external] marks the `mcp.auth.url` variant, where opening the URL is the client's job. + */ + fun parseMcpAuthEvent(raw: String, external: Boolean = false): McpAuthEventDto? { + val obj = tryParseObject(raw) ?: return null + val payload = obj["payload"]?.jsonObject ?: obj + val props = payload["properties"]?.jsonObject ?: obj + val name = props.str("mcpName") ?: return null + val url = props.str("url") ?: return null + return McpAuthEventDto(name = name, url = url, external = external) + } + private fun String.array(): JsonArray { val root = runCatching { json.parseToJsonElement(this) }.getOrNull() return when (root) { @@ -828,6 +873,15 @@ object KiloCliDataParser { } } + /** + * The status is taken verbatim from the CLI. + * + * Classifying a transport failure as "sign-in required" belongs to the CLI, which does it once + * in `packages/opencode/src/kilocode/mcp/auth-failure.ts` and reports `needs_auth` directly — + * including for a rejected browser flow, a failed token exchange, and HTTP 401/403 rejections. + * Re-deriving that here from the human-readable `error` text would duplicate the rules and drift + * from them, so the plugin only reads the structured field. + */ private fun mcpStatus(item: JsonElement, fallback: String? = null): McpStatusDto? { val obj = item.obj() ?: return null val name = obj.str("name") ?: fallback ?: return null @@ -1065,6 +1119,23 @@ object KiloCliDataParser { } if (it.enabled != null) put("enabled", it.enabled) if (it.timeout != null) put("timeout", it.timeout) + val oauth = it.oauth + when { + oauth == null -> Unit + oauth.clear -> put("oauth", JsonNull) + oauth.enabled == false -> put("oauth", JsonPrimitive(false)) + // The config schema deep-merges on PATCH, so an omitted key keeps + // its old value on disk. A field the user cleared therefore has to + // be written as an explicit null sentinel or the previous client + // id/secret/scope survives and is reused after a restart. + else -> put("oauth", buildJsonObject { + put("clientId", oauth.clientId?.let(::JsonPrimitive) ?: JsonNull) + put("clientSecret", oauth.clientSecret?.let(::JsonPrimitive) ?: JsonNull) + put("scope", oauth.scope?.let(::JsonPrimitive) ?: JsonNull) + put("callbackPort", oauth.callbackPort?.let(::JsonPrimitive) ?: JsonNull) + put("redirectUri", oauth.redirectUri?.let(::JsonPrimitive) ?: JsonNull) + }) + } } } ?: JsonNull) }) @@ -1141,6 +1212,20 @@ object KiloCliDataParser { return json.encodeToString(JsonObject.serializer(), obj) } + fun buildMcpOverlayPatch(name: String, scope: String, config: McpConfigDto?): String = buildJsonObject { + put("scope", if (scope == "workspace") "project" else "global") + if (config == null) { + put("unset", buildJsonArray { + add(buildJsonArray { + add(JsonPrimitive("mcp")) + add(JsonPrimitive(name)) + }) + }) + return@buildJsonObject + } + put("set", json.parseToJsonElement(buildConfigPatch(ConfigPatchDto(mcp = mapOf(name to config))))) + }.toString() + fun buildDisabledProviderPatch(ids: List): String { val arr = JsonArray(ids.distinct().sorted().map { JsonPrimitive(it) }) return json.encodeToString(JsonObject.serializer(), JsonObject(mapOf("disabled_providers" to arr))) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/marketplace/KiloBackendMarketplaceManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/marketplace/KiloBackendMarketplaceManager.kt index c4289c5b9d2..8eed69a7856 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/marketplace/KiloBackendMarketplaceManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/marketplace/KiloBackendMarketplaceManager.kt @@ -1,7 +1,9 @@ package ai.kilocode.backend.marketplace import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.MarketplaceBundleDto import ai.kilocode.rpc.dto.MarketplaceItemDto import ai.kilocode.rpc.dto.MarketplaceListDto import ai.kilocode.rpc.dto.MarketplaceMethodDto @@ -30,13 +32,16 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.net.URLEncoder +import java.net.URI import java.nio.charset.StandardCharsets import java.nio.file.FileSystems import java.nio.file.FileVisitResult import java.nio.file.Files +import java.nio.file.LinkOption import java.nio.file.Path import java.nio.file.SimpleFileVisitor import java.nio.file.attribute.BasicFileAttributes +import java.util.UUID /** * Talks to the CLI's marketplace routes (`/kilocode/marketplace*`), which fetch the remote @@ -65,6 +70,29 @@ class KiloBackendMarketplaceManager(private val backend: KiloBackendAppService? return MarketplaceListDto(items = dtos, errors = errors) } + suspend fun bundles(directory: String): List { + val skills = KiloCliDataParser.parseAgentBehaviorSkills(request(directory, PATH_SKILLS, null)) + return withContext(Dispatchers.IO) { + skills.mapNotNull { skill -> + val path = skillPath(skill.location) ?: return@mapNotNull null + val file = path.parent?.resolve(OWNER_FILE) ?: return@mapNotNull null + val owner = runCatching { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.size(file) > MAX_OWNER_BYTES) { + return@runCatching null + } + JSON.decodeFromString(Files.readString(file)) + }.onFailure { LOG.warn("marketplace owner marker read failed: $file", it) }.getOrNull() + ?: return@mapNotNull null + if (owner.version != 1 || !safe(owner.id) || runCatching { UUID.fromString(owner.token) }.isFailure) { + return@mapNotNull null + } + Owned(owner.id, scope(directory, path), skill.location) + }.groupBy { it.id to it.scope } + .map { (key, owned) -> MarketplaceBundleDto(key.first, key.second, owned.map { it.location }.sorted()) } + .sortedWith(compareBy(MarketplaceBundleDto::scope, MarketplaceBundleDto::id)) + } + } + suspend fun install( directory: String, item: MarketplaceItemDto, @@ -135,6 +163,26 @@ class KiloBackendMarketplaceManager(private val backend: KiloBackendAppService? @Serializable private data class WireSkill(val id: String, val content: String) + @Serializable + private data class WireOwner(val version: Int, val id: String, val token: String) + + private data class Owned(val id: String, val scope: String, val location: String) + + private fun skillPath(location: String): Path? = runCatching { + if (location.startsWith("file:")) Path.of(URI(location)) else Path.of(location) + }.getOrNull()?.toAbsolutePath()?.normalize() + + private fun scope(directory: String, path: Path): String { + val root = runCatching { Path.of(directory).toAbsolutePath().normalize() }.getOrNull() + return if (root != null && path.startsWith(root)) "project" else "global" + } + + private fun safe(id: String): Boolean { + if (id.isBlank() || id == "." || ".." in id || "/" in id || "\\" in id || id.endsWith(".")) return false + if (RESERVED.matches(id)) return false + return OWNER_ID.matches(id) + } + private fun installedKeys(element: JsonElement?): Set = element?.jsonObject?.keys ?: emptySet() /** [item] paired with its raw `suggest_for.filename` glob patterns, used only to compute [relevantKeys]. */ @@ -301,7 +349,12 @@ class KiloBackendMarketplaceManager(private val backend: KiloBackendAppService? val EXCLUDED_DIRS = setOf("node_modules", ".git", "dist", "build", "out", ".kilo", ".opencode", ".kilocode") const val MAX_RELEVANCE_FILES = 20_000 const val PATH_LIST = "/kilocode/marketplace" + const val PATH_SKILLS = "/skill" const val PATH_INSTALL = "/kilocode/marketplace/install" const val PATH_REMOVE = "/kilocode/marketplace/remove" + const val OWNER_FILE = ".kilo-marketplace.json" + const val MAX_OWNER_BYTES = 4096L + val OWNER_ID = Regex("^[A-Za-z0-9_@.\\-]+$") + val RESERVED = Regex("^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\\.|$)", RegexOption.IGNORE_CASE) } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt index 244e5b2f2da..2bea052f1d5 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt @@ -12,12 +12,17 @@ import ai.kilocode.rpc.dto.AgentDetailDto import ai.kilocode.jetbrains.api.model.AgentBuilderSaveRequest import ai.kilocode.rpc.dto.CommandFileDto import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.McpAuthEventDto +import ai.kilocode.rpc.dto.McpAuthResultDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.McpServerConfigDto import ai.kilocode.rpc.dto.PermissionRuleItemDto import ai.kilocode.rpc.dto.SkillDto import com.intellij.openapi.components.service import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -40,6 +45,15 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = private val saved = ConcurrentHashMap() private val port = AtomicInteger(-1) private val extensions = setOf("md", "markdown", "txt", "text", "html", "htm") + private const val BROWSER_OPEN_FAILED = "mcp.browser.open.failed" + private const val AUTH_URL = "mcp.auth.url" + + /** + * Asks the CLI to hand the authorization URL back over SSE instead of opening a browser + * itself. In split mode the CLI runs on the host while the user sits at the client, so only + * the client can open a usable browser. + */ + private val AUTH_EXTERNAL = JsonObject(mapOf("external" to JsonPrimitive(true))) } private val app: KiloBackendAppService get() = backend ?: service() @@ -203,12 +217,8 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = override suspend fun saveMcp(directory: String, name: String, scope: String, config: McpConfigDto?): Boolean { app.requireReady() - val patch = ConfigPatchDto(mcp = mapOf(name to config)) - if (scope == "workspace") { - patchConfig("/config?directory=${encode(directory)}", KiloCliDataParser.buildConfigPatch(patch)) - } else { - app.updateConfig(patch) - } + val patch = KiloCliDataParser.buildMcpOverlayPatch(name, scope, config) + patchConfig("/config/overlay?directory=${encode(directory)}", patch) saveMcpOverride(directory, name, scope, config) return true } @@ -217,8 +227,38 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = override suspend fun mcpDisconnect(directory: String, name: String): Boolean = post(directory, "/mcp/${encodePath(name)}/disconnect") - override suspend fun mcpAuthenticate(directory: String, name: String): Boolean = - post(directory, "/mcp/${encodePath(name)}/auth/authenticate") + override suspend fun mcpAuthenticate(directory: String, name: String): McpAuthResultDto { + val reply = send(directory, "/mcp/${encodePath(name)}/auth/authenticate", "POST", AUTH_EXTERNAL) + val result = KiloCliDataParser.parseMcpAuthResult(reply.code, reply.body) + LOG.info("MCP auth dir=$directory name=$name http=${reply.code} status=${result.status}") + return result + } + + override suspend fun mcpAuthCancel(directory: String, name: String): Boolean { + val reply = send(directory, "/mcp/${encodePath(name)}/auth/cancel", "POST") + LOG.info("MCP auth cancel dir=$directory name=$name http=${reply.code}") + return reply.code in 200..299 + } + + override suspend fun mcpAuthRemove(directory: String, name: String): Boolean { + val reply = send(directory, "/mcp/${encodePath(name)}/auth", "DELETE") + LOG.info("MCP auth remove dir=$directory name=$name http=${reply.code}") + return reply.code in 200..299 + } + + override suspend fun mcpAuthEvents(): Flow = channelFlow { + app.events.collect { event -> + val type = if (event.type == AUTH_URL || event.type == BROWSER_OPEN_FAILED) { + event.type + } else { + KiloCliDataParser.extractEventType(event.data) + } + if (type != AUTH_URL && type != BROWSER_OPEN_FAILED) return@collect + val dto = KiloCliDataParser.parseMcpAuthEvent(event.data, external = type == AUTH_URL) ?: return@collect + LOG.info("MCP auth url event name=${dto.name} type=$type") + trySendBlocking(dto) + } + } override suspend fun claudeCodeCompat(): Boolean = KiloClaudeCompatSettings.get() @@ -417,6 +457,26 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = } } + private data class Reply(val code: Int, val body: String) + + private suspend fun send( + directory: String, + path: String, + method: String, + body: JsonObject = JsonObject(emptyMap()), + ): Reply = withContext(Dispatchers.IO) { + val http = app.http ?: throw IllegalStateException("Kilo HTTP client is unavailable") + val url = "http://127.0.0.1:${app.port}$path?directory=${encode(directory)}" + val builder = Request.Builder().url(url) + when (method) { + "DELETE" -> builder.delete() + else -> builder.post(body.toString().toRequestBody(JSON)) + } + http.newCall(builder.build()).execute().use { response -> + Reply(response.code, response.body?.string().orEmpty()) + } + } + private suspend fun request(directory: String, path: String, body: JsonObject?): String = withContext(Dispatchers.IO) { val http = app.http ?: throw IllegalStateException("Kilo HTTP client is unavailable") val url = "http://127.0.0.1:${app.port}$path?directory=${encode(directory)}" @@ -443,13 +503,27 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = } } + /** + * Overlays the just-saved MCP entries on top of what the CLI currently reports, so a save is + * visible before the CLI reload lands. + * + * The overlay never carries a client secret (see [saveMcpOverride]); the secret is taken back + * from the authoritative config in [items] so the edit dialog still prefills it. + */ private fun withSavedMcp(directory: String, items: Map): Map = buildMap { syncSaved() putAll(items) for (item in saved.values) { if (item.scope == "workspace" && item.directory != directory) continue val cfg = item.config ?: continue - put(item.name, McpServerConfigDto(cfg, item.scope)) + val secret = items[item.name]?.config?.oauth?.clientSecret + val oauth = cfg.oauth + val merged = if (oauth == null || oauth.clientSecret != null || secret == null) { + cfg + } else { + cfg.copy(oauth = oauth.copy(clientSecret = secret)) + } + put(item.name, McpServerConfigDto(merged, item.scope)) } } @@ -462,11 +536,15 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = saved.remove(key) return } + // The overlay only has to make the save visible until the CLI reload lands, so the client + // secret is dropped rather than held in a long-lived map. The config the CLI reports back is + // the one source for it, and `withSavedMcp` reads it from there. + val oauth = config.oauth saved[key] = SavedMcp( directory = if (scope == "workspace") directory else "", name = name, scope = scope, - config = config, + config = if (oauth?.clientSecret == null) config else config.copy(oauth = oauth.copy(clientSecret = null)), ) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMarketplaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMarketplaceRpcApiImpl.kt index c6f68be6ea2..28b516834f4 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMarketplaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMarketplaceRpcApiImpl.kt @@ -5,6 +5,7 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.backend.marketplace.KiloBackendMarketplaceManager import ai.kilocode.rpc.KiloMarketplaceRpcApi +import ai.kilocode.rpc.dto.MarketplaceBundleDto import ai.kilocode.rpc.dto.MarketplaceItemDto import ai.kilocode.rpc.dto.MarketplaceListDto import ai.kilocode.rpc.dto.MarketplaceResultDto @@ -15,6 +16,8 @@ class KiloMarketplaceRpcApiImpl( ) : KiloMarketplaceRpcApi { override suspend fun list(directory: String): MarketplaceListDto = manager.list(directory) + override suspend fun bundles(directory: String): List = manager.bundles(directory) + override suspend fun install( directory: String, item: MarketplaceItemDto, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt index 3f0a15878ab..07a1c5809e1 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt @@ -393,9 +393,16 @@ class KiloWorktreeRpcApiImpl( probeGh(Path.of(directory).normalize(), "rpc", github, maxAge) } - override suspend fun prStatus(directory: String, maxAge: Long?): WorktreePrListDto = withContext(Dispatchers.IO) { + override suspend fun prStatus(directory: String, maxAge: Long?, fresh: List): WorktreePrListDto = withContext(Dispatchers.IO) { val now = System.currentTimeMillis() - prs[directory]?.takeIf { usable(it.time, now, prTtl(it.value.availability), maxAge) }?.let { return@withContext it.value } + val named = prPaths(fresh) + // A caller naming paths knows something about them this cache cannot: it was written before + // whatever prompted the call. Only the shared list is refused, though — every path it did + // not name still answers from the resolver's own caches below, which is what makes this + // cheaper than the `maxAge = 0` it replaces. + if (named.isEmpty()) { + prs[directory]?.takeIf { usable(it.time, now, prTtl(it.value.availability), maxAge) }?.let { return@withContext it.value } + } val root = Path.of(directory).normalize() // A gone directory reports nothing and is not cached, so a real availability problem found // from a live directory still reaches the UI. @@ -417,7 +424,7 @@ class KiloWorktreeRpcApiImpl( var status = GhAvailability.OK val data = parallel(items) { item -> if (status != GhAvailability.OK) return@parallel null - val lookup = runCatching { resolver.resolve(item.path, item.branch, base, maxAge) }.getOrElse { err -> + val lookup = runCatching { resolver.resolve(item.path, item.branch, base, prAge(item.path, named, maxAge)) }.getOrElse { err -> if (err is CancellationException) throw err LOG.warn("worktree poll failed: op=pr path=${item.path} message=${err.message}", err) return@parallel null @@ -1812,6 +1819,24 @@ internal fun prTargets(items: List): List { return items.filter { !it.prunable && it.branch != "(detached)" } } +/** + * The paths a `prStatus` caller named as no longer covered by any cache, in the shape the worktree + * list reports them, so a trailing separator or a `..` segment on the way in still matches a row. + */ +internal fun prPaths(fresh: List): Set = + fresh.mapTo(HashSet()) { Path.of(it).normalize().toString() } + +/** + * The freshness ceiling one worktree's lookup runs under: nothing cached at all for a path the + * caller named, and whatever the caller allowed for the rest. Keeping this per path is the whole + * point of naming them — a repository with a dozen worktrees would otherwise re-run every + * PR-less checkout's full `gh` ladder to notice a pull request on one of them. + */ +internal fun prAge(path: String, fresh: Set, maxAge: Long?): Long? { + if (fresh.isEmpty()) return maxAge + return if (Path.of(path).normalize().toString() in fresh) 0 else maxAge +} + /** Branch checked out in the main working tree, or null when it is missing or detached. */ internal fun baseBranch(items: List): String? { return items.firstOrNull { it.main }?.branch?.takeIf { it.isNotBlank() && it != "(detached)" } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index f225e55e405..afb2a838296 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -2,6 +2,7 @@ package ai.kilocode.backend.cli import ai.kilocode.backend.workspace.CommandInfo import ai.kilocode.backend.workspace.ProviderData +import kotlinx.serialization.json.JsonPrimitive import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.AgentConfigPatchDto import ai.kilocode.rpc.dto.CompactionPatchDto @@ -12,6 +13,7 @@ import ai.kilocode.rpc.dto.CustomModelDto import ai.kilocode.rpc.dto.CustomProviderSaveDto import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.McpConfigDto +import ai.kilocode.rpc.dto.McpOAuthDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRuleDto @@ -27,6 +29,7 @@ import ai.kilocode.rpc.dto.SkillsPatchDto import ai.kilocode.rpc.dto.WatcherPatchDto import org.junit.jupiter.api.Nested import kotlin.test.Test +import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse @@ -1295,6 +1298,43 @@ class KiloCliDataParserTest { assertEquals(false, cfg.mcp["off"]?.enabled) } + @Test + fun `parseConfig - mcp oauth absent`() { + val cfg = KiloCliDataParser.parseConfig( + """{"mcp":{"remote":{"type":"remote","url":"https://mcp.example.test"}}}""" + ) + + assertNull(cfg.mcp["remote"]?.oauth) + } + + @Test + fun `parseConfig - mcp oauth disabled`() { + val cfg = KiloCliDataParser.parseConfig( + """{"mcp":{"remote":{"type":"remote","url":"https://mcp.example.test","oauth":false}}}""" + ) + val oauth = cfg.mcp["remote"]?.oauth + + assertNotNull(oauth) + assertEquals(false, oauth.enabled) + assertNull(oauth.clientId) + } + + @Test + fun `parseConfig - mcp oauth custom client object`() { + val cfg = KiloCliDataParser.parseConfig( + """{"mcp":{"remote":{"type":"remote","url":"https://mcp.example.test","oauth":{"clientId":"abc","clientSecret":"shh","scope":"read","callbackPort":19999,"redirectUri":"http://127.0.0.1:19999/cb"}}}}""" + ) + val oauth = cfg.mcp["remote"]?.oauth + + assertNotNull(oauth) + assertEquals(true, oauth.enabled) + assertEquals("abc", oauth.clientId) + assertEquals("shh", oauth.clientSecret) + assertEquals("read", oauth.scope) + assertEquals(19999, oauth.callbackPort) + assertEquals("http://127.0.0.1:19999/cb", oauth.redirectUri) + } + @Test fun `parseConfig - scalars instructions and skills`() { val cfg = KiloCliDataParser.parseConfig( @@ -2193,6 +2233,163 @@ class KiloCliDataParserTest { } } + @Nested + inner class McpAuth { + + // ---- parseMcpAuthResult ---- + + @Test + fun `parseMcpAuthResult - 200 with connected status`() { + val result = KiloCliDataParser.parseMcpAuthResult(200, """{"status":"connected"}""") + + assertEquals("connected", result.status) + assertNull(result.error) + } + + @Test + fun `parseMcpAuthResult - 200 with failed status and error`() { + val result = KiloCliDataParser.parseMcpAuthResult(200, """{"status":"failed","error":"denied"}""") + + assertEquals("failed", result.status) + assertEquals("denied", result.error) + } + + @Test + fun `parseMcpAuthResult - 400 maps to unsupported`() { + val result = KiloCliDataParser.parseMcpAuthResult(400, """{"error":"UnsupportedOAuthError"}""") + + assertEquals("unsupported", result.status) + assertEquals("UnsupportedOAuthError", result.error) + } + + @Test + fun `parseMcpAuthResult - 404 maps to not_found`() { + val result = KiloCliDataParser.parseMcpAuthResult(404, """{"message":"no such server"}""") + + assertEquals("not_found", result.status) + assertEquals("no such server", result.error) + } + + @Test + fun `parseMcpAuthResult - non-JSON body falls back to HTTP status`() { + val result = KiloCliDataParser.parseMcpAuthResult(500, "not json") + + assertEquals("failed", result.status) + assertEquals("HTTP 500", result.error) + } + + /** + * The CLI owns the "is this really a sign-in problem" decision and reports `needs_auth` + * itself, so the parser must pass the status through untouched rather than re-deriving it + * from the human-readable error text. + */ + @Test + fun `parseMcpStatus - needs_auth is taken from the CLI status`() { + val result = KiloCliDataParser.parseMcpStatus( + """{"anaconda":{"status":"needs_auth","error":"Unauthorized: authentication required"}}""", + ).single() + + assertEquals("needs_auth", result.status) + assertEquals("Unauthorized: authentication required", result.error) + } + + @Test + fun `parseMcpStatus - needs_auth without an error is preserved`() { + val result = KiloCliDataParser.parseMcpStatus("""{"anaconda":{"status":"needs_auth"}}""").single() + + assertEquals("needs_auth", result.status) + assertNull(result.error) + } + + @Test + fun `parseMcpStatus - needs_client_registration is preserved`() { + val result = KiloCliDataParser.parseMcpStatus( + """{"anaconda":{"status":"needs_client_registration","error":"no dynamic registration"}}""", + ).single() + + assertEquals("needs_client_registration", result.status) + assertEquals("no dynamic registration", result.error) + } + + /** + * Auth-shaped error text on a `failed` status must NOT be upgraded to `needs_auth` here. + * The CLI already classifies those; re-doing it client-side would duplicate the rules and + * drift from them. + */ + @Test + fun `parseMcpStatus - failed is never reclassified from its error text`() { + val reasons = listOf( + "Unauthorized: authentication required", + "Browser authorization failed: Authorization cancelled", + "Token exchange failed: invalid client", + "Error POSTing to endpoint (HTTP 401): missing bearer token", + "SSE error: Non-200 status code (403)", + "OAuth discovery failed", + "Server rejected the request: invalid_grant", + "Connection closed", + "spawn npx ENOENT", + ) + + for (reason in reasons) { + val json = """{"anaconda":{"status":"failed","error":${JsonPrimitive(reason)}}}""" + val result = KiloCliDataParser.parseMcpStatus(json).single() + assertEquals("failed", result.status, "must stay failed for: $reason") + assertEquals(reason, result.error, "the reason must survive parsing") + } + } + + // ---- parseMcpAuthEvent ---- + + @Test + fun `parseMcpAuthEvent - wrapped payload shape`() { + val event = KiloCliDataParser.parseMcpAuthEvent( + """{"directory":"/test","payload":{"type":"mcp.browser.open.failed","properties":{"mcpName":"linear","url":"https://auth.example.test/authorize"}}}""" + ) + + assertNotNull(event) + assertEquals("linear", event.name) + assertEquals("https://auth.example.test/authorize", event.url) + assertFalse(event.external) + } + + @Test + fun `parseMcpAuthEvent - flat payload shape`() { + val event = KiloCliDataParser.parseMcpAuthEvent( + """{"properties":{"mcpName":"linear","url":"https://auth.example.test/authorize"}}""" + ) + + assertNotNull(event) + assertEquals("linear", event.name) + assertEquals("https://auth.example.test/authorize", event.url) + } + + /** `mcp.auth.url` means the client owns opening the URL, which the DTO has to carry. */ + @Test + fun `parseMcpAuthEvent - external marks the client-owned variant`() { + val event = KiloCliDataParser.parseMcpAuthEvent( + """{"payload":{"type":"mcp.auth.url","properties":{"mcpName":"linear","url":"https://auth.example.test/authorize"}}}""", + external = true, + ) + + assertNotNull(event) + assertTrue(event.external) + } + + @Test + fun `parseMcpAuthEvent - missing url returns null`() { + val event = KiloCliDataParser.parseMcpAuthEvent( + """{"properties":{"mcpName":"linear"}}""" + ) + + assertNull(event) + } + + @Test + fun `parseMcpAuthEvent - malformed JSON returns null`() { + assertNull(KiloCliDataParser.parseMcpAuthEvent("not json")) + } + } + // ================================================================ // Group 3 — Request / body builders and local model state // ================================================================ @@ -2702,6 +2899,118 @@ class KiloCliDataParserTest { ) } + @Test + fun `buildConfigPatch - mcp oauth disabled writes false`() { + val patch = ConfigPatchDto(mcp = linkedMapOf( + "remote" to McpConfigDto( + type = "remote", + url = "https://mcp.example.test", + oauth = McpOAuthDto(enabled = false), + ), + )) + + assertEquals( + "{\"mcp\":{\"remote\":{\"type\":\"remote\",\"url\":\"https://mcp.example.test\",\"oauth\":false}}}", + KiloCliDataParser.buildConfigPatch(patch), + ) + } + + @Test + /** + * Unset fields are emitted as explicit nulls, not omitted: the config schema deep-merges on + * PATCH, so an omitted key would keep whatever the user just cleared. + */ + fun `buildConfigPatch - mcp oauth custom client writes object`() { + val patch = ConfigPatchDto(mcp = linkedMapOf( + "remote" to McpConfigDto( + type = "remote", + url = "https://mcp.example.test", + oauth = McpOAuthDto(enabled = true, clientId = "abc", scope = "read"), + ), + )) + + assertEquals( + "{\"mcp\":{\"remote\":{\"type\":\"remote\",\"url\":\"https://mcp.example.test\"," + + "\"oauth\":{\"clientId\":\"abc\",\"clientSecret\":null,\"scope\":\"read\"," + + "\"callbackPort\":null,\"redirectUri\":null}}}}", + KiloCliDataParser.buildConfigPatch(patch), + ) + } + + @Test + fun `buildConfigPatch - mcp oauth clear writes null`() { + val patch = ConfigPatchDto(mcp = linkedMapOf( + "remote" to McpConfigDto( + type = "remote", + url = "https://mcp.example.test", + oauth = McpOAuthDto(clear = true), + ), + )) + + assertEquals( + "{\"mcp\":{\"remote\":{\"type\":\"remote\",\"url\":\"https://mcp.example.test\",\"oauth\":null}}}", + KiloCliDataParser.buildConfigPatch(patch), + ) + } + + @Test + fun `buildConfigPatch - mcp without oauth omits the key`() { + val patch = ConfigPatchDto(mcp = linkedMapOf( + "remote" to McpConfigDto(type = "remote", url = "https://mcp.example.test"), + )) + + assertEquals( + "{\"mcp\":{\"remote\":{\"type\":\"remote\",\"url\":\"https://mcp.example.test\"}}}", + KiloCliDataParser.buildConfigPatch(patch), + ) + } + + @Test + fun `buildMcpOverlayPatch - sets a project MCP`() { + val result = KiloCliDataParser.buildMcpOverlayPatch( + "anaconda", + "workspace", + McpConfigDto(type = "remote", url = "https://anaconda.com/api/mcp"), + ) + + assertEquals( + "{\"scope\":\"project\",\"set\":{\"mcp\":{\"anaconda\":{\"type\":\"remote\",\"url\":\"https://anaconda.com/api/mcp\"}}}}", + result, + ) + } + + @Test + fun `buildMcpOverlayPatch - unsets a project MCP`() { + assertEquals( + "{\"scope\":\"project\",\"unset\":[[\"mcp\",\"anaconda\"]]}", + KiloCliDataParser.buildMcpOverlayPatch("anaconda", "workspace", null), + ) + } + + /** + * The config schema deep-merges on PATCH, so an omitted key keeps its old value on disk. A + * field the user cleared has to go out as an explicit null or the previous client + * id/secret/scope survives and gets reused after a restart. + */ + @Test + fun `buildMcpOverlayPatch - cleared oauth fields are written as explicit nulls`() { + val result = KiloCliDataParser.buildMcpOverlayPatch( + "anaconda", + "workspace", + McpConfigDto( + type = "remote", + url = "https://anaconda.com/api/mcp", + oauth = McpOAuthDto(enabled = true, clientId = "abc"), + ), + ) + + assertContains(result, "\"clientId\":\"abc\"") + assertContains(result, "\"clientSecret\":null") + assertContains(result, "\"scope\":null") + assertContains(result, "\"callbackPort\":null") + assertContains(result, "\"redirectUri\":null") + } + @Test fun `buildConfigPatch - full agent permission object`() { val patch = ConfigPatchDto(agents = linkedMapOf("custom" to AgentConfigPatchDto( diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt index 6daa8b0096f..4a9f49466e3 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt @@ -316,6 +316,8 @@ class KiloAgentBehaviorRpcApiImplTest { command = listOf("node", "server.js"), environment = mapOf("TOKEN" to "x"), ))) + assertContains(mock.lastConfigPatchBody.orEmpty(), "\"scope\":\"global\"") + assertContains(mock.lastConfigPatchBody.orEmpty(), "\"set\":{\"mcp\"") assertContains(mock.lastConfigPatchBody.orEmpty(), "\"global-added\"") assertContains(mock.lastConfigPatchBody.orEmpty(), "\"environment\":{\"TOKEN\":\"x\"}") assertEquals("local", rpc.mcpConfig("/test dir")["global"]?.config?.type) @@ -326,7 +328,8 @@ class KiloAgentBehaviorRpcApiImplTest { url = "https://mcp.example.test", headers = mapOf("Authorization" to "Bearer t"), ))) - assertEquals("/config?directory=%2Ftest+dir", mock.lastWorkspaceConfigPatchPath) + assertEquals("/config/overlay?directory=%2Ftest+dir", mock.lastWorkspaceConfigPatchPath) + assertContains(mock.lastWorkspaceConfigPatchBody.orEmpty(), "\"scope\":\"project\"") assertContains(mock.lastWorkspaceConfigPatchBody.orEmpty(), "\"workspace-added\"") assertEquals("workspace", rpc.mcpConfig("/test dir")["workspace-added"]?.scope) @@ -336,7 +339,7 @@ class KiloAgentBehaviorRpcApiImplTest { assertEquals("global", rpc.mcpConfig("/test dir")["shared"]?.scope) assertTrue(rpc.saveMcp("/test dir", "workspace-added", "workspace", null)) - assertContains(mock.lastWorkspaceConfigPatchBody.orEmpty(), "\"workspace-added\":null") + assertContains(mock.lastWorkspaceConfigPatchBody.orEmpty(), "\"unset\":[[\"mcp\",\"workspace-added\"]]") assertFalse(rpc.mcpConfig("/test dir").containsKey("workspace-added")) } @@ -353,10 +356,99 @@ class KiloAgentBehaviorRpcApiImplTest { assertContains(mock.lastMcpActionPath.orEmpty(), "/mcp/local%20server/connect") assertTrue(rpc.mcpDisconnect("/test", "local server")) assertContains(mock.lastMcpActionPath.orEmpty(), "/mcp/local%20server/disconnect") - assertTrue(rpc.mcpAuthenticate("/test", "local server")) + val authResult = rpc.mcpAuthenticate("/test", "local server") + assertEquals("connected", authResult.status) assertContains(mock.lastMcpActionPath.orEmpty(), "/mcp/local%20server/auth/authenticate") } + /** + * The CLI classifies recoverable auth failures itself and reports `needs_auth`, so the wire path + * only has to carry that status through for Settings, Marketplace, and the session prompt. + */ + @Test + fun `mcp status carries needs sign in through the wire path`() = runBlocking { + mock.mcp = """{"anaconda":{"status":"needs_auth","error":"Unauthorized: authentication required"}}""" + val rpc = rpc() + + val status = rpc.mcpStatus("/test").single() + + assertEquals("needs_auth", status.status) + assertEquals("Unauthorized: authentication required", status.error) + } + + /** + * Auth-shaped error text on a `failed` status must not be upgraded by the plugin. Doing so would + * duplicate the CLI's classification rules and drift from them. + */ + @Test + fun `mcp status does not reclassify failed from its error text`() = runBlocking { + mock.mcp = """{"anaconda":{"status":"failed","error":"SSE error: Non-200 status code (403)"}}""" + val rpc = rpc() + + val status = rpc.mcpStatus("/test").single() + + assertEquals("failed", status.status) + assertEquals("SSE error: Non-200 status code (403)", status.error) + } + + @Test + fun `mcp status keeps unrelated failures failed`() = runBlocking { + mock.mcp = """{"anaconda":{"status":"failed","error":"Connection closed"}}""" + val rpc = rpc() + + assertEquals("failed", rpc.mcpStatus("/test").single().status) + } + + /** + * In split mode the CLI runs on the host and the user sits at the client, so the plugin must ask + * for the authorization URL instead of letting the CLI open a browser the user cannot see. + */ + @Test + fun `mcp authenticate asks the CLI for external browser handling`() = runBlocking { + val rpc = rpc() + + rpc.mcpAuthenticate("/test", "linear") + + assertContains(mock.lastMcpAuthenticateBody.orEmpty(), "\"external\":true") + } + + @Test + fun `mcp auth cancel posts to the cancel endpoint`() = runBlocking { + val rpc = rpc() + + mock.mcpAuthCancelStatus = 200 + assertTrue(rpc.mcpAuthCancel("/test", "linear")) + assertEquals("/mcp/linear/auth/cancel", mock.lastMcpAuthCancelPath) + + mock.mcpAuthCancelStatus = 404 + assertFalse(rpc.mcpAuthCancel("/test", "missing")) + } + + @Test + fun `mcp auth remove and browser open failed event`() = runBlocking { + val rpc = rpc() + + mock.mcpAuthRemoveStatus = 200 + assertTrue(rpc.mcpAuthRemove("/test", "linear")) + assertContains(mock.lastMcpAuthDeletePath.orEmpty(), "/mcp/linear/auth") + + mock.mcpAuthRemoveStatus = 404 + assertFalse(rpc.mcpAuthRemove("/test", "missing")) + } + + @Test + fun `mcp authenticate maps failure statuses`() = runBlocking { + val rpc = rpc() + + mock.mcpActionStatus = 400 + mock.mcpAuthenticateResponse = """{"error":"unsupported"}""" + assertEquals("unsupported", rpc.mcpAuthenticate("/test", "linear").status) + + mock.mcpActionStatus = 404 + mock.mcpAuthenticateResponse = """{"error":"not found"}""" + assertEquals("not_found", rpc.mcpAuthenticate("/test", "linear").status) + } + private suspend fun rpc(): KiloAgentBehaviorRpcApiImpl = KiloAgentBehaviorRpcApiImpl(app()) private suspend fun app(): KiloBackendAppService { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloMarketplaceRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloMarketplaceRpcApiImplTest.kt index 61180432446..307e5f542ee 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloMarketplaceRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloMarketplaceRpcApiImplTest.kt @@ -5,6 +5,7 @@ import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.backend.testing.FakeCliServer import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog +import ai.kilocode.rpc.dto.MarketplaceBundleDto import ai.kilocode.rpc.dto.MarketplaceSkillDto import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -17,6 +18,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import java.nio.file.Files +import java.util.UUID import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -117,6 +119,41 @@ class KiloMarketplaceRpcApiImplTest { assertEquals("directory=%2Ftest+project", mock.lastMarketplaceListPath?.substringAfter("?")) } + @Test + fun `bundles reads catalog independent companion ownership markers`() = runBlocking { + val root = Files.createTempDirectory("kilo-marketplace-bundles") + val project = root.resolve("project") + val local = project.resolve(".kilo/skills/docs/SKILL.md") + val global = root.resolve("global/skills/review/SKILL.md") + try { + Files.createDirectories(local.parent) + Files.createDirectories(global.parent) + Files.writeString(local, "# Docs") + Files.writeString(global, "# Review") + val token = UUID.randomUUID().toString() + Files.writeString(local.parent.resolve(".kilo-marketplace.json"), """{"version":1,"id":"context7","token":"$token"}""") + Files.writeString(global.parent.resolve(".kilo-marketplace.json"), """{"version":1,"id":"context7","token":"$token"}""") + mock.skills = """ + [ + {"name":"docs","location":"$local"}, + {"name":"review","location":"$global"} + ] + """.trimIndent() + + val result = rpc().bundles(project.toString()) + + assertEquals( + listOf( + MarketplaceBundleDto("context7", "global", listOf(global.toString())), + MarketplaceBundleDto("context7", "project", listOf(local.toString())), + ), + result, + ) + } finally { + root.toFile().deleteRecursively() + } + } + @Test fun `list marks items relevant when a suggest_for filename pattern matches the workspace`() = runBlocking { val dir = Files.createTempDirectory("kilo-marketplace-relevance") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt index 1a57e9cb49f..9d81c05ec7f 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt @@ -377,6 +377,24 @@ class KiloWorktreeRpcApiImplTest { assertFalse(usable(time = 0, now = 0, ttl = 90_000, maxAge = -1)) } + @Test + fun `a named worktree resolves its pull request against nothing cached`() { + val fresh = prPaths(listOf("/repo/.kilo/worktrees/feature-x/")) + + // The caller named this one because an agent just stopped in it, so no cached answer about it + // can have accounted for whatever that agent did. + assertEquals(0, prAge("/repo/.kilo/worktrees/feature-x", fresh, maxAge = null)) + // A trailing separator, a `.` segment, or a `..` on the way in still names the same checkout. + assertEquals(0, prAge("/repo/.kilo/worktrees/./feature-x", fresh, maxAge = null)) + assertEquals(0, prAge("/repo/.kilo/worktrees/other/../feature-x", fresh, maxAge = null)) + + // Every other row keeps the caller's own ceiling. Spending the fan-out on all of them is what + // naming paths exists to avoid: one row's news is not news about the rest of the repository. + assertEquals(90_000, prAge("/repo/.kilo/worktrees/other", fresh, maxAge = 90_000)) + assertNull(prAge("/repo/.kilo/worktrees/other", fresh, maxAge = null)) + assertNull(prAge("/repo/.kilo/worktrees/feature-x", emptySet(), maxAge = null)) + } + @Test fun `reason reports a timeout instead of falling through to the fallback text`() { assertEquals("timed out", api.reason(CmdOut(-1, "", "", timeout = true), "git worktree remove failed")) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/run/WorktreeRunDelegateTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/run/WorktreeRunDelegateTest.kt index 65097ea161f..f82f76fea2d 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/run/WorktreeRunDelegateTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/run/WorktreeRunDelegateTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.backend.run import ai.kilocode.backend.testing.PlainApplicationConfig +import ai.kilocode.backend.testing.ConfigTypes import ai.kilocode.backend.testing.PlainApplicationType import com.intellij.execution.BeforeRunTask import com.intellij.execution.CommonProgramRunConfigurationParameters @@ -27,6 +28,13 @@ import org.jdom.Element import java.nio.file.Path class WorktreeRunDelegateTest : BasePlatformTestCase() { + private lateinit var types: ConfigTypes + + override fun setUp() { + super.setUp() + types = ConfigTypes(testRootDisposable).also { it.mask() } + } + fun testSupportNamesTheConfigTypeWhenItIsNotModuleBased() { val settings = add(register(paramsType("kilo.test.delegate.plain")), "dev") assertEquals( @@ -160,8 +168,7 @@ class WorktreeRunDelegateTest : BasePlatformTestCase() { fun testPlainIsUnavailableWithoutTheApplicationConfigurationType() { // The non-JVM IDE case: no "Application" type is registered, so there is nothing to copy into - // and the fallback declines instead of failing. This is also the default test platform, which - // ships without the Java plugin. + // and the fallback declines instead of failing. setUp hides any real one the test platform ships. val settings = add(register(frameworkType("kilo.test.delegate.plain.nojava")), "app") val source = settings.configuration as FrameworkConfig source.setModule(module) @@ -178,8 +185,7 @@ class WorktreeRunDelegateTest : BasePlatformTestCase() { // ------ fixtures ------ private fun register(type: T): T { - ConfigurationType.CONFIGURATION_TYPE_EP.point.registerExtension(type, testRootDisposable) - return type + return types.add(type) } /** diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/run/WorktreeRunManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/run/WorktreeRunManagerTest.kt index 7a22b7a20a2..b1153a32945 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/run/WorktreeRunManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/run/WorktreeRunManagerTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.backend.run import ai.kilocode.backend.testing.PlainApplicationConfig +import ai.kilocode.backend.testing.ConfigTypes import ai.kilocode.backend.testing.PlainApplicationType import ai.kilocode.backend.testing.StubbornJvm import ai.kilocode.rpc.dto.RunProcessState @@ -70,8 +71,11 @@ class WorktreeRunManagerTest : BasePlatformTestCase() { private val launched = mutableListOf() private val added = mutableListOf() + private lateinit var types: ConfigTypes + override fun setUp() { super.setUp() + types = ConfigTypes(testRootDisposable).also { it.mask() } cs = CoroutineScope(SupervisorJob() + Dispatchers.Default) launched.clear() } @@ -842,8 +846,7 @@ class WorktreeRunManagerTest : BasePlatformTestCase() { private fun start(clone: RunnerAndConfigurationSettings) = start(clone, NopProcessHandler()) private fun register(type: T): T { - ConfigurationType.CONFIGURATION_TYPE_EP.point.registerExtension(type, testRootDisposable) - return type + return types.add(type) } /** Marks the test module as imported by Gradle, which is what makes a config delegable. */ diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 1c00514df7b..7238ab85812 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -67,11 +67,17 @@ class MockCliServer : AutoCloseable { @Volatile var mcp = "[]" @Volatile var mcpStatus = 200 @Volatile var mcpActionStatus = 200 + @Volatile var mcpAuthenticateResponse = """{"status":"connected"}""" + @Volatile var mcpAuthRemoveStatus = 200 + @Volatile var mcpAuthCancelStatus = 200 @Volatile var agentRemoveStatus = 200 @Volatile var commandRemoveStatus = 200 @Volatile var skillRemoveStatus = 200 @Volatile var agentBuilderStatus = 200 @Volatile var lastMcpActionPath: String? = null + @Volatile var lastMcpAuthDeletePath: String? = null + @Volatile var lastMcpAuthCancelPath: String? = null + @Volatile var lastMcpAuthenticateBody: String? = null @Volatile var lastAgentRemoveBody: String? = null @Volatile var sessionBoard = """{"ownerSessionID":"ses_root","revision":1,"hasMore":false,"messages":[]}""" @Volatile var sessionBoardStatus = 200 @@ -388,6 +394,15 @@ class MockCliServer : AutoCloseable { } bare == "/global/dispose" && method == "POST" -> respond(output, disposeStatus, "true") path.startsWith("/config/warnings") -> respond(output, warningsStatus, warnings) + bare == "/config/overlay" && method == "PATCH" -> { + if (body.contains("\"scope\":\"global\"")) { + lastConfigPatchBody = body + } else { + lastWorkspaceConfigPatchPath = path + lastWorkspaceConfigPatchBody = body + } + respond(output, workspaceConfigStatus, "{}") + } bare == "/config" && method == "PATCH" -> { lastWorkspaceConfigPatchPath = path lastWorkspaceConfigPatchBody = body @@ -506,7 +521,16 @@ class MockCliServer : AutoCloseable { } bare.matches(Regex("/mcp/[^/]+/auth/authenticate")) && method == "POST" -> { lastMcpActionPath = path - respond(output, mcpActionStatus, "true") + lastMcpAuthenticateBody = body + respond(output, mcpActionStatus, mcpAuthenticateResponse) + } + bare.matches(Regex("/mcp/[^/]+/auth/cancel")) && method == "POST" -> { + lastMcpAuthCancelPath = bare + respond(output, mcpAuthCancelStatus, """{"success":true}""") + } + bare.matches(Regex("/mcp/[^/]+/auth")) && method == "DELETE" -> { + lastMcpAuthDeletePath = bare + respond(output, mcpAuthRemoveStatus, "true") } bare == "/experimental/session" -> { lastExperimentalSessionPath = path diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/PlainApplicationType.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/PlainApplicationType.kt index 9f2e6041c06..c9d505d8ed1 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/PlainApplicationType.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/PlainApplicationType.kt @@ -2,6 +2,7 @@ package ai.kilocode.backend.testing import com.intellij.execution.CommonProgramRunConfigurationParameters import com.intellij.execution.Executor +import com.intellij.execution.configurations.ConfigurationType import com.intellij.execution.configurations.ConfigurationFactory import com.intellij.execution.configurations.ConfigurationTypeBase import com.intellij.execution.configurations.ModuleBasedConfiguration @@ -9,14 +10,18 @@ import com.intellij.execution.configurations.RunConfiguration import com.intellij.execution.configurations.RunConfigurationModule import com.intellij.execution.configurations.RunProfileState import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.openapi.module.Module import com.intellij.openapi.options.SettingsEditor import com.intellij.openapi.project.Project +import com.intellij.testFramework.ExtensionTestUtil import org.jdom.Element /** - * Stand-in for the Java plugin's plain application run configuration type, which the platform test - * fixture does not ship. `WorktreeRunDelegate.plain` looks the real one up by this id, so any test of + * Stand-in for the Java plugin's plain application run configuration type. Whether the platform test + * fixture ships the real one depends on the platform plugin version, so tests hide it with [ConfigTypes] + * and register this instead. `WorktreeRunDelegate.plain` looks the type up by this id, so any test of * the plain-application fallback must register a type under it. * * Shared rather than duplicated per test class on purpose: the lookup takes the first type with this @@ -38,6 +43,35 @@ class PlainApplicationType : ConfigurationTypeBase(ID, "Plain Application", null } } +/** + * Run configuration types for one test. Hides any real "Application" type the test platform ships: + * older platform plugin versions left the Java plugin out of the test runtime, newer ones put it in, + * and tests must not depend on which. + * + * Types are added through the mask rather than `registerExtension`, because a masked extension point + * is read-only. [mask] runs once from `setUp`, and each [add] runs it again so earlier types stay + * registered. + */ +class ConfigTypes(private val parent: Disposable) { + private val types = mutableListOf() + private var scope: Disposable? = null + + /** Hides the real "Application" type and registers the types added so far. Replaces the previous mask. */ + fun mask() { + scope?.let { Disposer.dispose(it) } + val next = Disposer.newDisposable(parent, "ConfigTypes") + scope = next + val point = ConfigurationType.CONFIGURATION_TYPE_EP + ExtensionTestUtil.maskExtensions(point, point.extensionList.filter { it.id != PlainApplicationType.ID } + types, next) + } + + fun add(type: T): T { + types += type + mask() + return type + } +} + /** * Reads back only the JVM option tags a plain application configuration understands — the reason a * framework-only option shows up as dropped when a configuration is copied into this type. diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index 9b4f07d70fe..3f16eec1ba3 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -108,7 +108,8 @@ val worktreeRoot = providers.gradleProperty("kilo.dev.worktree.root").orElse( val ides = file(".intellijPlatform/ides") val corrupt = ides.listFiles() ?.filter { ide -> - ide.isDirectory && ( + // layoutIndex is the platform plugin's own layout cache (stored here since 2.19.0), not an IDE. + ide.isDirectory && ide.name != "layoutIndex" && ( ide.walkTopDown().none { it.name == "product-info.json" } || ide.resolve("lib").listFiles()?.any { jar -> jar.isFile && jar.extension == "jar" } != true ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt index 694b34cff5a..4b12f261750 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt @@ -114,8 +114,8 @@ class KiloWorktreeService internal constructor( suspend fun ghStatus(directory: String, github: Boolean = true, maxAge: Long? = null): GhAvailability = call { ghStatus(directory, github, maxAge) } - suspend fun prStatus(directory: String, maxAge: Long? = null): WorktreePrListDto = try { - call { prStatus(directory, maxAge) } + suspend fun prStatus(directory: String, maxAge: Long? = null, fresh: List = emptyList()): WorktreePrListDto = try { + call { prStatus(directory, maxAge, fresh) } } catch (e: Exception) { LOG.warn("worktree PR status failed for $directory", e) WorktreePrListDto() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt index a54d214c805..f6b4e5e1c6e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt @@ -1,13 +1,16 @@ package ai.kilocode.client.agentManager.worktree +import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.kiloRoot import ai.kilocode.client.plugin.KiloPluginSettings +import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.client.util.UiTimer import ai.kilocode.client.util.UiTimerSource import ai.kilocode.client.util.UiTimers import ai.kilocode.client.util.edt import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.GhAvailability +import ai.kilocode.rpc.dto.SessionActivityDto import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto @@ -29,8 +32,14 @@ class WorktreeStatusService internal constructor( private val project: Project, private val cs: CoroutineScope, private val timers: UiTimerSource = UiTimers, + // Live session activity, as a lambda so the platform constructor below does not reach into + // another project service while this one is still being built — it is called from a coroutine + // instead. Defaults to a flow that never emits, so a test only wires this up when it is about + // turn endings. + activity: () -> StateFlow> = { MutableStateFlow(emptyMap()) }, ) { - constructor(project: Project, cs: CoroutineScope) : this(project, cs, UiTimers) + constructor(project: Project, cs: CoroutineScope) : + this(project, cs, UiTimers, { project.service().activity }) companion object { private val LOG = KiloLog.create(WorktreeStatusService::class.java) @@ -51,10 +60,12 @@ class WorktreeStatusService internal constructor( /** In-flight stats/dirty polls, so a slow repository cannot stack fan-outs. See [loadStats]. */ private var statsJob: Job? = null private var dirtyJob: Job? = null - /** Trailing lookup for a return held back by the spend floor. See [hold]. */ + /** Trailing lookup for a request held back by the spend floor. See [hold]. */ private var trail: UiTimer? = null - /** Freshness ceiling the held return is waiting to spend, or null when none is held. */ - private var pending: Long? = null + /** The request waiting to be spent, or null when none is held. */ + private var want: Want? = null + /** Worktree activity as of the previous snapshot, so an agent that stopped can be spotted. */ + private var kinds = emptyMap() private var refs = 0 private var lastPr = 0L private var github = KiloPluginSettings.getGithub() @@ -85,8 +96,17 @@ class WorktreeStatusService internal constructor( override fun applicationDeactivated(ideFrame: IdeFrame) = away.left() }) + cs.launch { activity().collect { snap -> edt { turned(aggregateWorktreeActivity(snap)) } } } } + /** A refresh waiting for the spend floor, or for the lookup already running, to clear. */ + private data class Want( + /** The strictest freshness ceiling held, or null to accept whatever the backend's TTL allows. */ + val age: Long?, + /** Worktree paths whose cached answers are refused however loose [age] is. */ + val paths: Set, + ) + fun attach(): AutoCloseable { refs++ if (refs == 1) start() @@ -109,8 +129,11 @@ class WorktreeStatusService internal constructor( * * The two are separate because they guard different costs: the throttle guards the RPC round * trip, [maxAge] guards the per-worktree `gh` fan-out behind it. + * + * [fresh] names worktrees whose cached answers the backend must refuse whatever [maxAge] says, + * for a caller that learned something about those rows in particular. See [stopped]. */ - fun refreshPr(force: Boolean = false, maxAge: Long? = null) { + fun refreshPr(force: Boolean = false, maxAge: Long? = null, fresh: List = emptyList()) { if (project.isDisposed || refs == 0 || !github) return // One lookup at a time, whatever the caller asked for. [force] bypasses the throttle, so // without this a caller returning to the IDE every few seconds could stack lookups faster than @@ -119,7 +142,10 @@ class WorktreeStatusService internal constructor( // spawned; the poll and the next focus correct whatever the running lookup began too early to // observe. if (prJob?.isActive == true) { - LOG.info("worktree PR refresh skipped, lookup in flight force=$force maxAge=${maxAge ?: "default"}") + LOG.info( + "worktree PR refresh skipped, lookup in flight force=$force " + + "maxAge=${maxAge ?: "default"} fresh=${fresh.size}", + ) return } val now = timers.now() @@ -128,7 +154,55 @@ class WorktreeStatusService internal constructor( return } lastPr = now - loadPr(maxAge) + loadPr(maxAge, fresh) + } + + /** + * Records that the agent working in [path] has stopped, so the next lookup resolves that + * worktree from `gh` rather than from anything cached for it. + * + * This is the signal nothing else carries. A pull request opened from inside a worktree — the + * usual way one appears in Agent Manager — changes neither the branch nor the head commit, which + * is exactly what the backend keys its "this checkout has no pull request" answer by, so the + * badge stayed hidden for as long as that answer was allowed to live. Nothing local moved, so + * neither the poll nor a return to the IDE could tell the difference. + * + * Held rather than spent, so a repository full of busy agents cannot cost a lookup per turn + * ending: the floor collapses a burst of them onto one lookup carrying every path they named. + */ + @RequiresEdt(generateAssertion = false) + private fun stopped(path: String) { + if (project.isDisposed || refs == 0 || !github) return + // Same reasoning as the focus path: the fan-out this would pay for is the one GitHub is + // refusing, and the poll stays the single probe that notices the budget reset. + if (ghFlow.value == GhAvailability.RATE_LIMITED) { + LOG.info("worktree PR lookup skipped, github budget spent path=$path") + return + } + LOG.info("worktree PR lookup owed, agent stopped path=$path") + // No ceiling of its own: naming the path is what makes this lookup fresh, and the other rows + // have had nothing happen to them worth re-running their `gh` ladder for. + hold(null, setOf(path)) + } + + /** + * Spots the worktrees whose agents stopped between [kinds] and [next], then adopts [next]. + * + * Leaving [SessionActivityKind.RUNNING] is the transition that matters, whichever way it goes: to + * no activity at all when the turn simply ended, or to a question, a permission or an error when + * it stopped on something. Every one of those is a point where an agent has finished doing work + * and may have opened a pull request on the way there. Entering RUNNING is not — nothing has + * happened yet — and a row that was already not running has no new work to account for. + */ + @RequiresEdt(generateAssertion = false) + private fun turned(next: Map) { + val was = kinds + kinds = next + for ((path, kind) in was) { + if (kind != SessionActivityKind.RUNNING) continue + if (next[path] == SessionActivityKind.RUNNING) continue + stopped(path) + } } /** @@ -162,57 +236,68 @@ class WorktreeStatusService internal constructor( } /** - * Records a return that deserves fresh data, then tries to spend it. The record is what makes a - * return that cannot run right now survive: the strictest ceiling wins, and the newest return can - * never make an earlier one cheaper. + * Records a request that deserves fresh data, then tries to spend it. The record is what makes a + * request that cannot run right now survive: the strictest ceiling wins, every named path + * accumulates, and a newer request can never make an earlier one cheaper. */ @RequiresEdt(generateAssertion = false) - private fun hold(max: Long) { - pending = pending?.let { minOf(it, max) } ?: max + private fun hold(age: Long?, paths: Set = emptySet()) { + val held = want + want = if (held == null) Want(age, paths) else Want(strict(held.age, age), held.paths + paths) spend() } + /** The stricter of two freshness ceilings, where null is the loosest there is. */ + private fun strict(a: Long?, b: Long?): Long? { + if (a == null) return b + if (b == null) return a + return minOf(a, b) + } + /** - * Spends the held return when nothing stands in the way, and otherwise leaves it held for whichever + * Spends the held request when nothing stands in the way, and otherwise leaves it held for whichever * trigger clears first — the floor timer, or the completion of the lookup already running. * * Both blockers must hold rather than drop. The spend floor is the cheap case: the request only has * to wait out the rest of the window. The in-flight lookup is the dangerous one, because it may have - * started *before* the departure, so its answer can predate the very change the return came back to - * see; dropping the request there would leave that stale answer standing until the next [PR_POLL]. + * started *before* the departure or the turn ending, so its answer can predate the very change the + * request came back to see; dropping it there would leave that stale answer standing until the next + * [PR_POLL]. */ @RequiresEdt(generateAssertion = false) private fun spend() { - val max = pending ?: return + val held = want ?: return if (project.isDisposed || refs == 0 || !github) { - pending = null + want = null return } - // Re-checked here and not only at focus time: a lookup that landed while the return was held can - // report the budget spent, and the fan-out it would pay for is the one GitHub is refusing. + // Re-checked here and not only when the request was recorded: a lookup that landed while it was + // held can report the budget spent, and the fan-out it would pay for is the one GitHub is refusing. if (ghFlow.value == GhAvailability.RATE_LIMITED) { - LOG.info("worktree PR focus dropped, github budget spent maxAge=$max") - pending = null + LOG.info("worktree PR request dropped, github budget spent ${describe(held)}") + want = null return } - // Its completion calls back here, so the return stays held instead of stacking a second fan-out. + // Its completion calls back here, so the request stays held instead of stacking a second fan-out. if (prJob?.isActive == true) { - LOG.info("worktree PR focus held, lookup in flight maxAge=$max") + LOG.info("worktree PR request held, lookup in flight ${describe(held)}") return } val since = timers.now() - lastPr if (since < PR_THROTTLE) { - LOG.info("worktree PR focus deferred maxAge=$max sinceMs=$since") + LOG.info("worktree PR request deferred ${describe(held)} sinceMs=$since") arm(PR_THROTTLE - since) return } - pending = null + want = null trail?.stop() trail = null - LOG.info("worktree PR focus resumed maxAge=$max") - refreshPr(force = true, maxAge = max) + LOG.info("worktree PR request resumed ${describe(held)}") + refreshPr(force = true, maxAge = held.age, fresh = held.paths.toList()) } + private fun describe(held: Want): String = "maxAge=${held.age ?: "default"} fresh=${held.paths.size}" + /** * Arms the trailing lookup at the end of the current floor window. Deliberately not a sliding * debounce: a continuing burst must not keep pushing the deadline out, so an already-armed timer is @@ -253,7 +338,7 @@ class WorktreeStatusService internal constructor( statsTimer = null prTimer = null trail = null - pending = null + want = null prJob = null statsJob = null dirtyJob = null @@ -274,11 +359,11 @@ class WorktreeStatusService internal constructor( prJob = null generation++ lastPr = 0 - // A return held from before the toggle has nothing left to ask about, and must not survive + // A request held from before the toggle has nothing left to ask about, and must not survive // to spend a fan-out once the integration is switched back on. trail?.stop() trail = null - pending = null + want = null prFlow.value = emptyMap() ghFlow.value = GhAvailability.OK return @@ -328,11 +413,11 @@ class WorktreeStatusService internal constructor( } } - private fun loadPr(maxAge: Long? = null) { + private fun loadPr(maxAge: Long? = null, fresh: List = emptyList()) { val gen = ++generation val job = cs.launch { val dir = project.kiloRoot() ?: return@launch - runCatching { service().prStatus(dir, maxAge) } + runCatching { service().prStatus(dir, maxAge, fresh) } .onSuccess { dto -> // KiloWorktreeService.prStatus swallows the cancellation and answers with an // empty DTO, so a lookup cancelled by a disable still lands here — and after a @@ -340,7 +425,10 @@ class WorktreeStatusService internal constructor( // publish, or a stale empty result would wipe fresh badges and report a false OK // over a real UNAUTH. if (gen != generation) return@onSuccess - LOG.info("worktree PR refresh done items=${dto.items.size} value=${dto.availability} maxAge=${maxAge ?: "default"}") + LOG.info( + "worktree PR refresh done items=${dto.items.size} value=${dto.availability} " + + "maxAge=${maxAge ?: "default"} fresh=${fresh.size}", + ) // A spent GitHub budget carries no pull request data and says nothing about the // pull requests themselves, so the rows keep what they had and the banner explains // why it stopped moving. Publishing the empty list would instead blank every badge diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAgentBehaviorService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAgentBehaviorService.kt index b168d900c61..15ac33973b3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAgentBehaviorService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAgentBehaviorService.kt @@ -8,13 +8,17 @@ import ai.kilocode.rpc.dto.AgentDetailDto import ai.kilocode.rpc.dto.AgentCreateDto import ai.kilocode.rpc.dto.CommandDto import ai.kilocode.rpc.dto.CommandFileDto +import ai.kilocode.rpc.dto.McpAuthEventDto +import ai.kilocode.rpc.dto.McpAuthResultDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.McpServerConfigDto import ai.kilocode.rpc.dto.McpStatusDto import ai.kilocode.rpc.dto.SkillDto import com.intellij.openapi.components.Service import fleet.rpc.client.durable +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow @Service(Service.Level.APP) class KiloAgentBehaviorService internal constructor( @@ -80,7 +84,16 @@ class KiloAgentBehaviorService internal constructor( suspend fun mcpDisconnect(directory: String, name: String): Boolean = safe(false) { call { mcpDisconnect(directory, name) } } - suspend fun mcpAuthenticate(directory: String, name: String): Boolean = safe(false) { call { mcpAuthenticate(directory, name) } } + suspend fun mcpAuthenticate(directory: String, name: String): McpAuthResultDto = + safe(McpAuthResultDto("failed", "RPC failed")) { call { mcpAuthenticate(directory, name) } } + + suspend fun mcpAuthCancel(directory: String, name: String): Boolean = + safe(false) { call { mcpAuthCancel(directory, name) } } + + suspend fun mcpAuthRemove(directory: String, name: String): Boolean = + safe(false) { call { mcpAuthRemove(directory, name) } } + + suspend fun mcpAuthEvents(): Flow = call { mcpAuthEvents() } suspend fun claudeCodeCompat(): Boolean = safe(false) { call { claudeCodeCompat() } } @@ -88,6 +101,8 @@ class KiloAgentBehaviorService internal constructor( private suspend fun safe(fallback: T, block: suspend () -> T): T = try { block() + } catch (err: CancellationException) { + throw err } catch (e: Exception) { LOG.warn("agent behavior RPC failed", e) fallback diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMarketplaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMarketplaceService.kt index ce416d2cfd5..7251ed1e497 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMarketplaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMarketplaceService.kt @@ -4,6 +4,7 @@ package ai.kilocode.client.app import ai.kilocode.log.KiloLog import ai.kilocode.rpc.KiloMarketplaceRpcApi +import ai.kilocode.rpc.dto.MarketplaceBundleDto import ai.kilocode.rpc.dto.MarketplaceItemDto import ai.kilocode.rpc.dto.MarketplaceListDto import ai.kilocode.rpc.dto.MarketplaceResultDto @@ -48,6 +49,10 @@ class KiloMarketplaceService internal constructor( suspend fun list(directory: String): MarketplaceListDto = call("list dir=$directory", LIST_TIMEOUT_MS) { list(directory) } + suspend fun bundles(directory: String): List = call("bundles dir=$directory", LIST_TIMEOUT_MS) { + bundles(directory) + } + suspend fun install( directory: String, item: MarketplaceItemDto, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt new file mode 100644 index 00000000000..19c780ff0cc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloMcpAuthService.kt @@ -0,0 +1,248 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.client.app + +import ai.kilocode.client.KiloNotifications +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.agents.McpAuthUrlDialog +import ai.kilocode.client.util.webUrl +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.McpAuthEventDto +import ai.kilocode.rpc.dto.McpAuthResultDto +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.application.EDT +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.util.concurrency.annotations.RequiresEdt +import fleet.rpc.client.durable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference + +/** + * App-level service that centralizes remote MCP OAuth sign-in for Settings, Marketplace, and the + * session prompt toolbar. + */ +@Service(Service.Level.APP) +class KiloMcpAuthService internal constructor( + private val cs: CoroutineScope, + private val behavior: KiloAgentBehaviorService?, + private val authTimeoutMs: Long = DEFAULT_AUTH_TIMEOUT_MS, + private val dedupeWindowMs: Long = DEFAULT_DEDUPE_WINDOW_MS, + private val showAuthUrl: (String, String) -> Unit = { name, url -> McpAuthUrlDialog(name, url).show() }, + private val openUrl: (String) -> Unit = { url -> BrowserUtil.browse(url) }, +) { + constructor(cs: CoroutineScope) : this(cs, null) + + companion object { + private val LOG = KiloLog.create(KiloMcpAuthService::class.java) + + // Above the CLI's 5-minute OAuth callback timeout (packages/opencode/src/mcp/oauth-callback.ts), + // so the CLI names the failure first. + private const val DEFAULT_AUTH_TIMEOUT_MS = 6 * 60 * 1000L + private const val DEFAULT_DEDUPE_WINDOW_MS = 4000L + private const val MAX_DIRECTORIES = 64 + } + + private fun svc(): KiloAgentBehaviorService = behavior ?: service() + + private val _needsAuth = MutableStateFlow>>(emptyMap()) + val needsAuth: StateFlow>> = _needsAuth.asStateFlow() + + private val _busy = MutableStateFlow>(emptySet()) + val busy: StateFlow> = _busy.asStateFlow() + private val active = ConcurrentHashMap() + private val cancelled = ConcurrentHashMap.newKeySet() + + private val started = AtomicBoolean(false) + private val lastEventUrl = AtomicReference(null) + private val lastEventAt = AtomicLong(0) + + /** Refreshes the needs-auth set for [dir] and returns it. Returns an empty set for a blank directory. */ + suspend fun refresh(dir: String): Set { + start() + if (dir.isBlank()) return emptySet() + val names = attempt("mcp auth refresh failed dir=$dir", emptyList()) { svc().mcpStatus(dir) } + .filter { it.status == "needs_auth" } + .map { it.name } + .toSet() + updateNeedsAuth(dir, names) + return names + } + + /** Starts (or resumes) sign-in for [name] in [dir]. Single-flight per directory/name pair. */ + suspend fun signIn(dir: String, name: String): McpAuthResultDto { + val key = busyKey(dir, name) + val token = Any() + if (active.putIfAbsent(key, token) != null) return McpAuthResultDto("failed", null) + _busy.update { it + key } + return try { + val result = withTimeoutOrNull(authTimeoutMs) { + attempt("mcp auth signIn failed dir=$dir name=$name", McpAuthResultDto("failed", null)) { + svc().mcpAuthenticate(dir, name) + } + } + if (result != null) { + if (cancelled.contains(token)) return McpAuthResultDto("cancelled", null) + return result + } + // Cancel rather than remove: a timeout should abandon this attempt, not discard + // credentials the user may already have from an earlier successful sign-in. + attempt("mcp auth timeout cleanup failed dir=$dir name=$name", false) { + svc().mcpAuthCancel(dir, name) + } + McpAuthResultDto("timeout", null) + } finally { + active.remove(key, token) + cancelled.remove(token) + _busy.update { it - key } + refresh(dir) + } + } + + /** Cancels a pending sign-in for [name], keeping any stored credentials. */ + suspend fun cancel(dir: String, name: String): Boolean { + val key = busyKey(dir, name) + val token = active[key] + if (token != null) cancelled.add(token) + val stopped = attempt("mcp auth cancel failed dir=$dir name=$name", false) { svc().mcpAuthCancel(dir, name) } + if (!stopped && token != null) cancelled.remove(token) + return stopped + } + + /** Clears stored credentials and reconnects so the runtime immediately reports [needsAuth]. */ + suspend fun reset(dir: String, name: String): Boolean { + val key = busyKey(dir, name) + val auth = active[key] + if (auth != null) cancelled.add(auth) + val removed = attempt("mcp auth reset failed dir=$dir name=$name", false) { svc().mcpAuthRemove(dir, name) } + if (!removed) { + if (auth != null) cancelled.remove(auth) + return false + } + var reconnected = false + try { + val disconnected = attempt("mcp disconnect after auth reset failed dir=$dir name=$name", false) { + svc().mcpDisconnect(dir, name) + } + val connected = attempt("mcp connect after auth reset failed dir=$dir name=$name", false) { + svc().mcpConnect(dir, name) + } + reconnected = disconnected && connected + return reconnected + } finally { + refresh(dir) + if (!reconnected) markNeedsAuth(dir, name) + } + } + + /** Reports a sign-in [result] for [name] via a Kilo notification. Must run on EDT. */ + @RequiresEdt + fun report(name: String, result: McpAuthResultDto) { + when (result.status) { + "cancelled" -> Unit + "connected" -> KiloNotifications.info( + KiloBundle.message("settings.agentBehavior.mcp.signIn.success", name), + ) + "timeout" -> KiloNotifications.error( + null, + KiloBundle.message("settings.agentBehavior.mcp.signIn.timeout", name), + ) + "unsupported" -> KiloNotifications.error( + null, + KiloBundle.message("settings.agentBehavior.mcp.signIn.unsupported", name), + ) + "not_found" -> KiloNotifications.error( + null, + KiloBundle.message("settings.agentBehavior.mcp.signIn.notFound", name), + ) + else -> KiloNotifications.error( + null, + KiloBundle.message("settings.agentBehavior.mcp.signIn.failed", name), + result.error, + ) + } + } + + private fun busyKey(dir: String, name: String) = "$dir\u0000$name" + + private fun markNeedsAuth(dir: String, name: String) { + updateNeedsAuth(dir, _needsAuth.value[dir].orEmpty() + name) + } + + private fun updateNeedsAuth(dir: String, names: Set) { + _needsAuth.update { current -> + val next = current + (dir to names) + if (next.size <= MAX_DIRECTORIES) return@update next + next.entries.drop(next.size - MAX_DIRECTORIES).associate { it.toPair() } + } + } + + private suspend fun attempt(message: String, fallback: T, block: suspend () -> T): T = try { + block() + } catch (err: CancellationException) { + throw err + } catch (err: Exception) { + LOG.warn(message, err) + fallback + } + + private fun start() { + if (!started.compareAndSet(false, true)) return + cs.launch { + durable { + svc().mcpAuthEvents().collect { event -> onAuthUrl(event) } + } + } + } + + /** + * Puts an authorization URL in front of the user. + * + * For [McpAuthEventDto.external] the CLI deliberately did not open a browser, so the client + * does it here and only falls back to the dialog when that fails. Otherwise the CLI already + * tried and failed, and the dialog is the remaining option. + * + * The URL comes from the remote server's `authorization_endpoint`, so a non-web scheme is + * dropped outright rather than opened or offered in the dialog. + */ + private suspend fun onAuthUrl(event: McpAuthEventDto) { + if (!webUrl(event.url)) { + LOG.warn("mcp auth url rejected name=${event.name}: not an http(s) URL") + return + } + val now = System.currentTimeMillis() + val prevUrl = lastEventUrl.get() + val prevAt = lastEventAt.get() + if (event.url == prevUrl && now - prevAt < dedupeWindowMs) return + lastEventUrl.set(event.url) + lastEventAt.set(now) + if (event.external && browse(event.url)) return + withContext(Dispatchers.EDT) { + showAuthUrl(event.name, event.url) + } + } + + // The authorization URL carries the OAuth `state` and other single-use parameters, so it is + // never logged; the server name is enough to identify which sign-in failed to open. + private fun browse(url: String): Boolean = try { + openUrl(url) + true + } catch (err: CancellationException) { + throw err + } catch (err: Exception) { + LOG.warn("mcp auth browser open failed", err) + false + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index f079e2b4d44..39c452cee61 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session import ai.kilocode.client.KiloNotifications import ai.kilocode.client.actions.reloadCoreSettings import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloMcpAuthService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace @@ -39,8 +40,11 @@ import ai.kilocode.client.session.ui.prompt.KiloPromptCompletionProvider import ai.kilocode.client.session.ui.prompt.MentionAction import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.ui.prompt.SessionIssue +import ai.kilocode.client.session.ui.prompt.SessionIssueAction import ai.kilocode.client.session.ui.prompt.SlashAction import ai.kilocode.client.session.ui.prompt.mentionParts as promptMentionParts +import ai.kilocode.client.session.ui.rail.PromptRailController import ai.kilocode.client.session.settings.ApprovalReasonVisibilityListener import ai.kilocode.client.session.ui.account.SessionAccountOverlay import ai.kilocode.client.session.ui.popup.HeaderPopupController @@ -77,6 +81,7 @@ import ai.kilocode.client.session.views.SessionOutcomeView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.settings.KiloSettingsConfigurable +import ai.kilocode.client.settings.agents.McpConfigurable import ai.kilocode.client.settings.checkpoints.CheckpointsConfigurable import ai.kilocode.client.settings.profile.UserProfileConfigurable import ai.kilocode.client.telemetry.Telemetry @@ -104,6 +109,7 @@ import com.intellij.ide.ui.LafManagerListener import com.intellij.openapi.Disposable import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.EDT import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service import com.intellij.openapi.editor.colors.EditorColorsListener @@ -121,6 +127,8 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.BorderLayout @@ -167,6 +175,12 @@ class SessionUi( private var revertPrompt: String? = null private var pendingRollback: String? = null private var pendingRedo: String? = null + // See sendPrompt()/onStateChanged(): the number of user messages present when a prompt was + // dispatched, or null when nothing is in flight. Comparing against a baseline rather than + // using a plain boolean is what makes the restore reliable while a turn is streaming: the + // prompt box stays enabled during a busy turn, so a queued send must not treat the *previous* + // turn's message as confirmation of its own. + private var pendingSend: Int? = null private val flushMs = Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt()) .takeIf { it > 0 } @@ -198,6 +212,7 @@ class SessionUi( private lateinit var account: SessionAccountOverlay private lateinit var drop: SessionDropOverlay private lateinit var overlay: SessionHoverCopyOverlay + internal lateinit var promptRail: PromptRailController private val hide = timers.timer(HIDE_MS, repeats = false) { if (disposed || !this::drop.isInitialized) return@timer drop.setActive(false) @@ -253,6 +268,7 @@ class SessionUi( private var refreshJob: Job? = null private var branchJob: Job? = null private var boardJob: Job? = null + private var issuesJob: Job? = null private var boardHasMessages = false private var disposed = false @@ -532,6 +548,7 @@ class SessionUi( focus = focus, retry = if (readonly) null else controller::retry, retryable = controller::canRetry, + dismiss = if (readonly) null else controller::dismissError, ) messageBody = SessionMessageListPanel( controller.model, @@ -574,6 +591,7 @@ class SessionUi( boardVisible = { board }, onShowBoard = ::showBoard, onOpenSubagent = ::openSubagent, + onNavigate = { item -> scroll.scrollPart(item.id.substringBefore('/'), item.part.id) }, ) if (!readonly && showBranchDock()) { val owner = manager @@ -656,6 +674,14 @@ class SessionUi( java.awt.Rectangle(0, 0, pane.width, pane.height) } root.overlay.setComponentZOrder(drop, 0) + promptRail = PromptRailController( + root = root, + model = controller.model, + messages = messageBody, + scroll = scroll, + parent = this, + timers = timers, + ) if (!readonly) { prompt.onFileDrag = ::syncDrop prompt.installFileDrop(root, "session-root") @@ -712,6 +738,7 @@ class SessionUi( prompt.onChange = { scroll.refresh() } prompt.onAutoApproveToggle = ::setAuto prompt.setAutoApprove(controller.autoApprove) + startMcpAuthTracking() prompt.model.favorites = { app.favorites.value } prompt.model.onFavoriteToggle = { item -> Telemetry.send( @@ -769,6 +796,7 @@ class SessionUi( prompt.setResetVisible(m.modelOverride) prompt.setReady(m.isReady()) prompt.refreshHighlights() + cs.launch { service().refresh(workspace.directory) } } is SessionControllerEvent.ViewChanged.ShowProgress -> { @@ -815,10 +843,19 @@ class SessionUi( is SessionModelEvent.RevertChanged -> onRevertChanged(event.revert) - is SessionModelEvent.MessageAdded, + is SessionModelEvent.MessageAdded -> { + confirmPendingSend() + syncDock() + refreshBoardIfEmpty() + } + is SessionModelEvent.MessageRemoved, is SessionModelEvent.HistoryLoaded, is SessionModelEvent.Cleared -> { + // A reload can surface the pending send's own message without a MessageAdded, + // so re-check here too; otherwise the retained draft would linger until the + // next submit. + confirmPendingSend() syncDock() // Covers opening a session with an existing board, and this session's own first // board post; a subagent's post is caught by the busy->idle probe instead. @@ -904,6 +941,7 @@ class SessionUi( ApplicationManager.getApplication().invokeLater { surfaceActivePrompt() } } else { popup.hideAll() + promptRail.hideAll() } } @@ -1003,10 +1041,32 @@ class SessionUi( val model = controller.model.model ?: "none" "${ChatLogSummary.prompt(PromptDto(parts = parts, editorContext = editor.context))} agent=$agent model=$model ready=${controller.ready}" } + // Baseline the user-message count for this send. onStateChanged uses it to tell a prompt + // that failed before the server ever created its message (restore the draft) apart from a + // turn that failed after the message was already persisted (nothing to restore -- it's in + // the transcript already). A queued send baselines above the in-flight turn's message, so + // that turn cannot be mistaken for confirmation of this one. + pendingSend = userMessages() controller.prompt(text, allFiles, editor.context, select) scroll.followBottom(follow) } + @RequiresEdt + private fun userMessages() = controller.model.messages().count { it.info.role == "user" } + + /** + * Marks the in-flight send as accepted once its own user message is persisted, which releases + * the retained draft. Scoped by the dispatch-time baseline so a message belonging to an earlier, + * still-streaming turn cannot confirm a queued send that has not landed yet. + */ + @RequiresEdt + private fun confirmPendingSend() { + val baseline = pendingSend ?: return + if (userMessages() <= baseline) return + pendingSend = null + prompt.clearLastSubmission() + } + @RequiresEdt private fun revert(id: String) { pendingRollback = id @@ -1398,6 +1458,21 @@ class SessionUi( pendingRollback = null pendingRedo = null } + // Restore the typed prompt when the send never produced a persisted message (rejected + // synchronously, or prompt_async's 204-then-session.error case). A turn that fails after its + // user message was already persisted has nothing to restore -- it's in the transcript -- so + // this only fires while a send is still pending. + // + // TurnEnded deliberately does not participate. It is session-scoped with no turn identity, + // and clearing on it is never correct: if the pending send's own message is persisted, + // confirmPendingSend() has already released it, and if it is not, the turn that just ended + // belongs to an earlier send (a turn only starts once its user message exists). Acting here + // would destroy the draft of a send queued behind a still-streaming turn, since the prompt + // box stays enabled while busy. + if (pendingSend != null && state is SessionState.Error) { + pendingSend = null + prompt.restoreLastSubmission() + } prompt.setBusy(busy) dock?.setBusy(busy) load.setState(state) @@ -1448,6 +1523,7 @@ class SessionUi( prompt.applyStyle(style) connection.applyStyle(style) scroll.applyStyle(style) + promptRail.applyStyle(style) empty?.applyStyle(style) refresh() } @@ -1472,6 +1548,67 @@ class SessionUi( ) } + /** + * Collects [KiloMcpAuthService] state for this session's directory and reflects it on the prompt + * issue actions. Refresh is event-driven (workspace ready, after a sign-in attempt) rather + * than polled. + */ + private fun startMcpAuthTracking() { + val auth = service() + issuesJob?.cancel() + issuesJob = cs.launch { + auth.needsAuth.combine(auth.busy) { needs, busy -> + val servers = needs[workspace.directory].orEmpty() + val prefix = "${workspace.directory}\u0000" + val active = busy.mapNotNull { key -> if (key.startsWith(prefix)) key.removePrefix(prefix) else null }.toSet() + servers to active + } + .distinctUntilChanged() + .collect { (servers, active) -> + withContext(Dispatchers.EDT) { + prompt.setIssues(servers.sorted().map { name -> mcpAuthIssue(name, name in active) }) + } + } + } + } + + @RequiresEdt + private fun mcpAuthIssue(name: String, busy: Boolean): SessionIssue { + val display = name.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + return SessionIssue( + id = "mcp-auth:$name", + title = KiloBundle.message("prompt.mcp.provider", display), + actions = listOf( + SessionIssueAction( + title = KiloBundle.message(if (busy) "prompt.mcp.needsAuth.busy" else "settings.agentBehavior.mcp.signIn"), + enabled = !busy, + action = { signInFromPrompt(name) }, + ), + SessionIssueAction( + title = KiloBundle.message("prompt.mcp.openSettings"), + action = { openMcpSettings(name) }, + ), + ), + ) + } + + @RequiresEdt + private fun openMcpSettings(name: String) { + ShowSettingsUtil.getInstance().showSettingsDialog( + project, + Predicate { cfg: Configurable -> cfg is ConfigurableWithId && cfg.getId() == McpConfigurable.ID }, + { cfg: Configurable -> (cfg as? McpConfigurable)?.filter(name) }, + ) + } + + private fun signInFromPrompt(name: String) { + val auth = service() + cs.launch { + val result = auth.signIn(workspace.directory, name) + withContext(Dispatchers.EDT) { auth.report(name, result) } + } + } + private fun openKiloSettings() { ShowSettingsUtil.getInstance().showSettingsDialog( project, @@ -1486,8 +1623,10 @@ class SessionUi( disposed = true refreshJob?.cancel() branchJob?.cancel() + issuesJob?.cancel() hide.stop() popup.hideAll() + if (this::promptRail.isInitialized) promptRail.hideAll() modalFocus = null empty = null if (this::root.isInitialized) root.setModalContent(null) 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 020421d8751..5270838b4dc 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 @@ -2634,6 +2634,18 @@ class SessionController( } } + /** + * Clears a standalone error card (one with no transcript tail to Retry -- see [canRetry] / + * [retryTarget]) back to idle. Without this, a send that failed before any message was + * created (a rejected attachment, or a prompt_async send that failed asynchronously) has no + * action that ever dismisses it: it just sits there until the next unrelated state change. + */ + fun dismissError() { + assertEdt() + if (model.state !is SessionState.Error) return + updateModel { model.setState(SessionState.Idle) } + } + private fun accountSnapshot(): SessionControllerEvent.AccountOverlaySnapshot { val state = model.app val prof = state.profile diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptAttachment.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptAttachment.kt index 214df0ab6a4..3c13f59fd45 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptAttachment.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptAttachment.kt @@ -66,7 +66,14 @@ object PromptAttachmentExtractor { ) } - fun image(mime: String): Boolean = mime.startsWith("image/") + // Only bitmap formats the server can actually decode and resize (Photon) and that + // model providers accept as an inline image. SVG and other non-raster "image/*" mimes + // are not in this set -- they're treated as source text instead (see mime() below) so + // an unsupported format cannot kill the whole prompt after the message is sent + // (ImageDecodeError used to die the request before the user's text was persisted). + private val RASTER_IMAGE_MIMES = setOf("image/png", "image/jpeg", "image/gif", "image/webp") + + fun image(mime: String): Boolean = mime in RASTER_IMAGE_MIMES fun image(raw: Any): PromptAttachment? { val image = when (raw) { @@ -94,10 +101,10 @@ object PromptAttachmentExtractor { "jpg", "jpeg" -> "image/jpeg" "gif" -> "image/gif" "webp" -> "image/webp" - "bmp" -> "image/bmp" - "svg" -> "image/svg+xml" "pdf" -> "application/pdf" - "txt", "md", "kt", "kts", "java", "js", "jsx", "ts", "tsx", "json", "xml", "html", "css", "scss", "yml", "yaml", "toml", "sh", "py", "rb", "go", "rs", "c", "cc", "cpp", "h", "hpp" -> "text/plain" + // bmp and svg are not in RASTER_IMAGE_MIMES (bmp: the server's decoder can't open it and no + // provider accepts it as an image mime; svg: it's markup, not a raster), so attach their source as text + "txt", "md", "kt", "kts", "java", "js", "jsx", "ts", "tsx", "json", "xml", "html", "css", "scss", "yml", "yaml", "toml", "sh", "py", "rb", "go", "rs", "c", "cc", "cpp", "h", "hpp", "svg", "bmp" -> "text/plain" else -> "application/octet-stream" } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt index 6279bdd6e00..dd21d159eba 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt @@ -180,6 +180,42 @@ internal class SessionScroll( return true } + /** + * Scrolls so [id]'s turn sits at the top of the viewport, like [scrollMessageBottom] but anchored to + * the top of the turn rather than the bottom of a message — used by the prompt navigator, whose + * ticks jump to where a prompt starts rather than where the transcript ends. + */ + @RequiresEdt + fun scrollMessageTop(id: String): Boolean = scrollTop { messages.findTurn(id) } + + @RequiresEdt + fun scrollPart(messageId: String, partId: String): Boolean = scrollTop { + val message = messages.findMessage(messageId)?.takeIf { it.isVisible } ?: return@scrollTop null + // Step-finish entries have no part view; navigate to their containing message instead. + message.part(partId)?.takeIf { it.isVisible } ?: message + } + + @RequiresEdt + private fun scrollTop(find: () -> JComponent?): Boolean { + val target = find() ?: return false + if (!target.isVisible) return false + user = false + pause = false + stable = -1 + auto = true + show(messages) + auto = false + val gen = ++seq + if (SwingUtilities.isEventDispatchThread()) { + topPass(gen, find, FOLLOW_PASSES) + return true + } + ApplicationManager.getApplication().invokeLater { + topPass(gen, find, FOLLOW_PASSES) + } + return true + } + @RequiresEdt fun following(): Boolean { return component.viewport.view === messages && tail @@ -372,6 +408,43 @@ internal class SessionScroll( } } + @RequiresEdt + private fun topPass(id: Int, find: () -> JComponent?, remaining: Int) { + if (id != seq) return + val target = find() + if (target == null || !target.isVisible) { + stable = -1 + updateJump() + return + } + auto = true + try { + layoutScroll() + val y = messageTop(target) + component.viewport.viewPosition = Point(0, y) + bar.value = y + // Not near(): a top-anchored jump to a short latest turn lands within the follow threshold + // of the bottom, and re-arming follow there would let the next streamed delta pull the view + // back down and undo the jump. Only a transcript that cannot scroll stays followed, where + // there is nothing for follow to move. + tail = bar.maximum <= bar.visibleAmount + updateJump() + } finally { + auto = false + } + syncValue() + if (remaining <= 0) { + stable = -1 + return + } + val next = messageTop(target) + val left = if (next == stable) remaining - 1 else FOLLOW_PASSES + stable = next + ApplicationManager.getApplication().invokeLater { + topPass(id, find, left) + } + } + @RequiresEdt private fun layoutScroll() { component.validate() @@ -394,6 +467,12 @@ internal class SessionScroll( return (point.y - extent).coerceIn(0, bottom()) } + @RequiresEdt + private fun messageTop(target: JComponent): Int { + val point = SwingUtilities.convertPoint(target, Point(0, 0), messages) + return point.y.coerceIn(0, bottom()) + } + @RequiresEdt private fun bottom(): Int { val bar = component.verticalScrollBar diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 207ed99773f..9ffd1fefd68 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -78,7 +78,8 @@ class SessionMessageListPanel( SessionUiStyle.SessionLayout.INNER_TOP, SessionUiStyle.SessionLayout.INNER_HORIZONTAL, SessionUiStyle.SessionLayout.INNER_BOTTOM, - SessionUiStyle.SessionLayout.INNER_HORIZONTAL, + // Wider than the left: the prompt rail is drawn over this edge and must clear the content. + SessionUiStyle.SessionLayout.INNER_RIGHT, ), ), Disposable, SessionEditorStyleTarget { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index 6a41d199f83..ce91681f364 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui.header import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.SessionHeaderSnapshot import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.TimelineItem import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.controller.SessionController @@ -47,6 +48,7 @@ class SessionHeaderPanel( private val onShowBoard: () -> Unit = {}, /** Opens a background agent's read-only transcript. See [ai.kilocode.client.session.SessionUi.openSubagent]. */ private val onOpenSubagent: (String, String) -> Unit = { _, _ -> }, + onNavigate: (TimelineItem) -> Unit = {}, ) : BorderLayoutPanel(), SessionEditorStyleTarget { companion object { @@ -106,7 +108,7 @@ class SessionHeaderPanel( } }) } - private val timeline = TimelinePanel() + private val timeline = TimelinePanel(onNavigate) private val viewport = JViewport().apply { isOpaque = false view = timeline diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/TimelinePanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/TimelinePanel.kt index b0e1cf49fc3..db6fd911d42 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/TimelinePanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/TimelinePanel.kt @@ -12,6 +12,7 @@ import ai.kilocode.client.session.model.ToolKind import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.util.ui.JBUI import java.awt.Color +import java.awt.Cursor import java.awt.Dimension import java.awt.Graphics import java.awt.Graphics2D @@ -20,8 +21,9 @@ import java.awt.event.MouseEvent import java.awt.event.MouseMotionAdapter import kotlin.math.roundToInt import javax.swing.JPanel +import javax.swing.SwingUtilities -internal class TimelinePanel : JPanel() { +internal class TimelinePanel(private val onNavigate: (TimelineItem) -> Unit) : JPanel() { companion object { private const val WIDTH = 12 private const val MIN = 8 @@ -32,6 +34,7 @@ internal class TimelinePanel : JPanel() { private var items: List = emptyList() private var heights: List = emptyList() private var hover = -1 + private var dragged = false init { isOpaque = false @@ -39,14 +42,29 @@ internal class TimelinePanel : JPanel() { override fun mouseMoved(event: MouseEvent) { val idx = index(event) toolTipText = items.getOrNull(idx)?.title + cursor = Cursor.getPredefinedCursor(if (idx >= 0) Cursor.HAND_CURSOR else Cursor.DEFAULT_CURSOR) if (hover == idx) return hover = idx repaint() } + + override fun mouseDragged(event: MouseEvent) { + dragged = true + } }) addMouseListener(object : MouseAdapter() { + override fun mousePressed(event: MouseEvent) { + dragged = false + } + + override fun mouseClicked(event: MouseEvent) { + if (dragged || !SwingUtilities.isLeftMouseButton(event)) return + items.getOrNull(index(event))?.let(onNavigate) + } + override fun mouseExited(event: MouseEvent) { toolTipText = null + cursor = Cursor.getDefaultCursor() if (hover == -1) return hover = -1 repaint() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index 5ba849fd631..883e1eb8aac 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -23,6 +23,9 @@ import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.editor.EditorFolds import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.iconButton +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.PromptPartDto @@ -45,6 +48,7 @@ import com.intellij.openapi.actionSystem.ActionUiKind import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.actionSystem.ex.ActionUtil import com.intellij.openapi.actionSystem.IdeActions @@ -167,6 +171,12 @@ class PromptPanel( ) } private val attachments = mutableListOf() + // The most recently submitted text + attachments, kept around so a send that the server + // rejects or that fails after the client already got a 2xx (prompt_async's async + // session.error) can be put back in the editor. Without this, clear() on submit followed + // by a failure permanently loses whatever the user typed -- there is no draft store or + // prompt history to fall back on. + private var lastSubmission: Pair>? = null private val highlighters = mutableListOf() private val folds: EditorFolds = EditorFolds(live = { editor.getEditor(false) }, resize = ::syncEditorHeight) private val strip = PromptAttachmentStrip(project) { removeAttachment(it) } @@ -259,6 +269,16 @@ class PromptPanel( addActionListener { onAutoApproveToggle(!autoApprove) } } + private var issues = emptyList() + private val issuesButton = HoverIcon().apply { + icon = AllIcons.General.Warning + toolTipText = KiloBundle.message("prompt.issues.title") + accessibleContext.accessibleName = toolTipText + isVisible = false + addActionListener { showIssues() } + } + private val issuesSlot = issuesButton.align(HAlign.RIGHT, VAlign.TOP).apply { isVisible = false } + /** * Opens the Kilo.Session.PromptMenu popup (auto-approve + sharing). Resolves its context from * DataManager, so it reads live SessionActionsKeys.ACTIONS from the session ancestor chain rather @@ -340,7 +360,12 @@ class PromptPanel( } }) shell.add(strip, BorderLayout.NORTH) - shell.add(editor, BorderLayout.CENTER) + val input = BorderLayoutPanel().apply { + isOpaque = false + add(editor, BorderLayout.CENTER) + add(issuesSlot, BorderLayout.EAST) + } + shell.add(input, BorderLayout.CENTER) val bar = BorderLayoutPanel().apply { layout = BoxLayout(this, BoxLayout.X_AXIS) @@ -522,6 +547,16 @@ class PromptPanel( repaint() } + @RequiresEdt + internal fun setIssues(value: List) { + issues = value + val visible = value.isNotEmpty() + issuesButton.isVisible = visible + issuesSlot.isVisible = visible + revalidate() + repaint() + } + @RequiresEdt fun text(): String = editor.text.trim() @@ -555,6 +590,10 @@ class PromptPanel( internal fun resetForTest(): JComponent = reset + internal fun issuesForTest(): List = issues + + internal fun issuesButtonForTest(): JComponent = issuesButton + internal fun shellForTest(): JComponent = shell internal fun buttonForTest(): JButton = button @@ -587,6 +626,26 @@ class PromptPanel( syncHighlights() } + // Puts the most recently submitted prompt back in the editor after the server rejected it + // or the send failed, so the user doesn't lose what they typed. A no-op once consumed, or + // if the user already started a new draft in the meantime (we must not clobber that). + @RequiresEdt + fun restoreLastSubmission() { + val (text, items) = lastSubmission ?: return + lastSubmission = null + if (hasDraft()) return + setText(text) + items.forEach(::addAttachment) + } + + // Drops the retained submission once the send is confirmed. A pasted image is held as a full + // base64 data URL, so keeping it past the point where it could still be restored would pin + // megabytes per send for the lifetime of the panel. + @RequiresEdt + fun clearLastSubmission() { + lastSubmission = null + } + @RequiresEdt fun clear() { editor.text = "" @@ -690,6 +749,37 @@ class PromptPanel( popup.show(PopupShowOptions.aboveComponent(menu)) } + @RequiresEdt + private fun showIssues() { + if (issues.isEmpty()) return + val group = DefaultActionGroup() + issueActions().forEach(group::add) + JBPopupFactory.getInstance().createActionGroupPopup( + null, + group, + DataManager.getInstance().getDataContext(issuesButton), + JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, + true, + ).show(PopupShowOptions.aboveComponent(issuesButton)) + } + + @RequiresEdt + internal fun issueActions(): List = issues.map { issue -> + DefaultActionGroup(issue.title, true).apply { + for (item in issue.actions) { + add(object : DumbAwareAction(item.title, item.description, null) { + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = item.enabled + } + + override fun actionPerformed(e: AnActionEvent) { + item.action() + } + }) + } + } + } + @RequiresEdt private fun enhance() { if (!enhance.isEnabled) return @@ -763,6 +853,7 @@ class PromptPanel( if (project.isDisposed) return@withContext val parts = files + mentioned LOG.debug { "${ChatLogSummary.prompt(promptDto(txt, parts))} src=$src busy=$busy" } + lastSubmission = txt to items onSend(txt, parts) } } catch (e: CancellationException) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/SessionIssue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/SessionIssue.kt new file mode 100644 index 00000000000..e267bf60982 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/SessionIssue.kt @@ -0,0 +1,16 @@ +package ai.kilocode.client.session.ui.prompt + +/** An actionable problem scoped to one chat session. */ +internal data class SessionIssue( + val id: String, + val title: String, + val actions: List, +) + +/** One recovery path shown under a [SessionIssue] provider group. */ +internal data class SessionIssueAction( + val title: String, + val description: String? = null, + val enabled: Boolean = true, + val action: () -> Unit, +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRail.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRail.kt new file mode 100644 index 00000000000..4e920e8f8af --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRail.kt @@ -0,0 +1,259 @@ +package ai.kilocode.client.session.ui.rail + +import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.util.ui.JBUI +import java.awt.AlphaComposite +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseMotionAdapter +import java.awt.event.MouseWheelEvent +import java.awt.event.MouseWheelListener +import javax.swing.JComponent +import javax.swing.SwingUtilities +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * Paints the prompt navigator ticks and dispatches hover/click/wheel input for them. Lives on the + * session overlay layer (see [ai.kilocode.client.ui.LayeredOverlayPanel]); [PromptRailController] owns + * its bounds, model and popup wiring. + * + * [contains] only reports true within the vertical span the ticks actually occupy — above and below + * that band, in narrow layouts where the rail sits over the scrollbar column, events fall through to + * the scrollbar underneath. + */ +internal class PromptRail : JComponent() { + private var entries: List = emptyList() + private var items: List = emptyList() + private var active: Int? = null + private var hover = -1 + private var open = -1 + private var available = true + + var centered = false + + var onHover: ((PromptRailEntry?) -> Unit)? = null + var onSelect: ((PromptRailEntry) -> Unit)? = null + + var onWheel: (() -> Unit)? = null + + /** + * Where a mouse wheel event over the rail is redirected. The rail sits on the overlay layer, not + * inside the scroll pane, so a wheel event delivered to it would otherwise never reach the + * transcript — [PromptRailController] points this at the scroll pane once it is built. + */ + var wheelTarget: JComponent? = null + + /** + * Scrollbar the rail is drawn over, or null when it sits in its own gutter. + * + * In the narrow layout the rail covers the scrollbar. It claims the whole band for hover, so without + * this the bar would never see a press and its thumb could not be dragged. A press that misses every + * tick is handed over instead; Swing then routes the rest of that drag to the bar on its own, so one + * reachable row between ticks is enough to start a drag. + */ + var barTarget: JComponent? = null + + init { + isOpaque = false + addMouseListener(object : MouseAdapter() { + override fun mouseExited(e: MouseEvent) = setHover(-1) + override fun mousePressed(e: MouseEvent) = press(e) + override fun mouseReleased(e: MouseEvent) = press(e) + override fun mouseClicked(e: MouseEvent) { + val idx = indexAt(e.y) + if (idx < 0) return press(e) + onSelect?.invoke(entries[idx]) + } + }) + addMouseMotionListener(object : MouseMotionAdapter() { + override fun mouseMoved(e: MouseEvent) { + if (e.modifiersEx and BUTTON_MASK != 0) return + setHover(indexAt(e.y)) + } + + // Mid-drag the bar owns the gesture, so keep handing it the movement. + override fun mouseDragged(e: MouseEvent) = press(e) + }) + addMouseWheelListener(MouseWheelListener { e -> forward(e) }) + } + + /** True while a popup for this rail's tick at [index] is showing, so that tick stays full size. */ + fun setOpen(index: Int) { + if (open == index) return + open = index + repaint() + } + + fun setAvailable(value: Boolean) { + if (available == value) return + available = value + syncVisible() + } + + /** Replaces the rail's data. Repaints only when something visible actually changed. */ + fun update(items: List, entries: List, active: Int?) { + val changed = this.items != items || this.entries != entries || this.active != active + this.items = items + this.entries = entries + this.active = active + syncVisible() + if (!changed) return + revalidate() + repaint() + } + + fun entries(): List = entries + + fun items(): List = items + + fun active(): Int? = active + + fun available(): Boolean = available + + /** Center y, in this component's own coordinates, of tick [index] — used to place the popup. */ + fun tickCenterY(index: Int): Int { + val geo = geometry() ?: return height / 2 + return geo.top + index * geo.step + geo.step / 2 + } + + /** + * Claims the whole tick band, so hovering anywhere along the rail opens the navigator. + * + * Presses are split finer than this. The band grows with the prompt count — a hundred prompts covers + * a 600px viewport outright — and in the narrow layout the rail is drawn over the scrollbar, so + * claiming every press here left the thumb undraggable. [press] forwards presses that did not land + * on a tick to the scrollbar instead; see [barTarget]. + */ + override fun contains(x: Int, y: Int): Boolean { + val geo = geometry() ?: return false + if (y < geo.top || y >= geo.top + geo.step * entries.size) return false + return x in 0 until width + } + + override fun paintComponent(g: Graphics) { + val geo = geometry() ?: return + val g2 = g.create() as Graphics2D + try { + if (hover < 0 && open < 0) g2.composite = AlphaComposite.SrcOver.derive(0.5f) + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + for (i in entries.indices) { + paintEntry(g2, geo, i) + } + } finally { + g2.dispose() + } + } + + override fun getPreferredSize() = java.awt.Dimension(JBUI.scale(SessionUiStyle.PromptRail.TICK_HOVER), 0) + + private fun paintEntry(g2: Graphics2D, geo: Geometry, index: Int) { + val entry = entries[index] + val y = geo.top + index * geo.step + geo.step / 2 + val over = index == hover || index == open + val isActive = entry is PromptRailEntry.Prompt && entry.index == active + val queued = entry is PromptRailEntry.Prompt && items.getOrNull(entry.index)?.queued == true + val length = when { + over -> JBUI.scale(SessionUiStyle.PromptRail.TICK_HOVER) + isActive -> JBUI.scale(SessionUiStyle.PromptRail.TICK_ACTIVE) + else -> JBUI.scale(SessionUiStyle.PromptRail.TICK_REST) + } + val thickness = JBUI.scale(SessionUiStyle.PromptRail.TICK_THICKNESS) + val x = if (centered) (width - length) / 2 else width - length + g2.color = when { + queued -> SessionUiStyle.PromptRail.queuedColor() + over || isActive -> SessionUiStyle.PromptRail.activeColor() + else -> SessionUiStyle.PromptRail.restColor() + } + if (entry is PromptRailEntry.Overflow) { + paintDashed(g2, x, y - thickness / 2, length, thickness) + return + } + g2.fillRect(x, y - thickness / 2, length, thickness) + } + + private fun paintDashed(g2: Graphics2D, x: Int, y: Int, length: Int, thickness: Int) { + val dash = JBUI.scale(2) + var pos = x + val end = x + length + while (pos < end) { + val w = min(dash, end - pos) + g2.fillRect(pos, y, w, thickness) + pos += dash * 2 + } + } + + private fun setHover(index: Int) { + if (hover == index) return + hover = index + onHover?.invoke(entries.getOrNull(index)) + repaint() + } + + /** + * Tick whose own rows cover [y], or -1 for the gap between two ticks. + * + * The grab band around a tick is widened past its painted thickness so a tick stays easy to hit, but + * it deliberately stops short of the full step: the remainder is what leaves the scrollbar beneath + * the rail reachable. See [contains]. + */ + private fun indexAt(y: Int): Int { + val geo = geometry() ?: return -1 + if (entries.isEmpty()) return -1 + val idx = (y - geo.top) / geo.step + if (idx !in entries.indices) return -1 + val center = geo.top + idx * geo.step + geo.step / 2 + val grab = max(JBUI.scale(SessionUiStyle.PromptRail.TICK_THICKNESS), geo.step / 2 - JBUI.scale(2)) + if (abs(y - center) > grab) return -1 + return idx + } + + /** + * Hands a press, drag or release that did not land on a tick to the scrollbar under the rail. + * + * Only meaningful in the narrow layout, where [barTarget] is set because the rail covers the bar. A + * press on a tick is the rail's own and is left alone. + */ + private fun press(e: MouseEvent) { + // barTarget is only set while the bar is visible and sized under the rail, so no further + // liveness check is needed here. + val target = barTarget ?: return + if (e.id == MouseEvent.MOUSE_PRESSED && indexAt(e.y) >= 0) return + target.dispatchEvent(SwingUtilities.convertMouseEvent(this, e, target)) + } + + private fun forward(e: MouseWheelEvent) { + onWheel?.invoke() + val target = wheelTarget ?: return + target.dispatchEvent(SwingUtilities.convertMouseEvent(this, e, target)) + } + + private fun syncVisible() { + val show = available && entries.size >= 2 + if (isVisible != show) isVisible = show + } + + private fun geometry(): Geometry? { + if (entries.isEmpty() || height <= 0) return null + val pad = JBUI.scale(SessionUiStyle.PromptRail.RAIL_INSET) / 2 + val usable = max(0, height - 2 * pad) + val minStep = JBUI.scale(SessionUiStyle.PromptRail.STEP_MIN) + val maxStep = JBUI.scale(SessionUiStyle.PromptRail.STEP_MAX) + val ideal = if (entries.isEmpty()) minStep else usable / entries.size + val step = ideal.coerceIn(minStep, maxStep) + val total = step * entries.size + val top = (height - total) / 2 + return Geometry(top, step) + } + + private data class Geometry(val top: Int, val step: Int) + + private companion object { + const val BUTTON_MASK = MouseEvent.BUTTON1_DOWN_MASK or + MouseEvent.BUTTON2_DOWN_MASK or MouseEvent.BUTTON3_DOWN_MASK + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt new file mode 100644 index 00000000000..576ee44bb7d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailController.kt @@ -0,0 +1,432 @@ +package ai.kilocode.client.session.ui.rail + +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.scroll.SessionScroll +import ai.kilocode.client.session.ui.SessionMessageListPanel +import ai.kilocode.client.session.ui.SessionRootPanel +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.popup.SidePopupController +import ai.kilocode.client.ui.popup.SidePopupContent +import ai.kilocode.client.ui.popup.SidePopupRequest +import ai.kilocode.client.ui.popup.SidePopupSpot +import ai.kilocode.client.util.UiTimerSource +import ai.kilocode.client.util.UiTimers +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import java.awt.Point +import java.awt.Rectangle +import java.awt.event.AdjustmentListener +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import javax.swing.JPanel +import javax.swing.SwingUtilities +import javax.swing.event.ChangeListener + +/** + * Owns the prompt navigator rail: its bounds on the session overlay, its data (from [SessionModel] + * events), the active tick (from scroll position), and the balloon that lists every prompt. + * + * Placement mirrors the jump-to-bottom button's readable-lane math (see [SessionScroll]): when the + * viewport has room right of the lane, the rail sits at the standard large inset from the panel edge; + * otherwise it covers the scrollbar column so it stays reachable in a narrow sidebar. + */ +internal class PromptRailController( + private val root: SessionRootPanel, + private val model: SessionModel, + private val messages: SessionMessageListPanel, + private val scroll: SessionScroll, + parent: Disposable, + timers: UiTimerSource = UiTimers, +) : Disposable { + val rail = PromptRail() + private val popup = SidePopupController(timers, SessionUiStyle.PromptRail.OPEN_MS) + private var style = SessionEditorStyle.current() + private var dirty = false + private var dead = false + + /** The live balloon body, so a move between ticks can re-select in place instead of reopening it. */ + private var body: PromptRailPopup? = null + + /** + * Item the navigator should highlight, captured when the tick is hovered. + * + * The body is built when the dwell elapses, not when the hover happens, so reading the rail's live + * hover state at build time raced the pointer: by then it could sit on another tick, or have left the + * band entirely and report -1. Recording the intent up front makes the opened balloon show the tick + * that actually asked for it. + */ + private var pending = -1 + + /** Per-turn preview cache, so a streamed delta only re-previews the turn it changed. */ + private val cache = PromptRailItems.Cache() + private val adjustment = AdjustmentListener { recomputeActive() } + private val change = ChangeListener { recomputeActive() } + private val geometry = object : ComponentAdapter() { + override fun componentResized(e: ComponentEvent) = scheduleRebuild() + override fun componentShown(e: ComponentEvent) = scheduleRebuild() + override fun componentHidden(e: ComponentEvent) = scheduleRebuild() + } + + init { + rail.wheelTarget = scroll.component + rail.onSelect = { entry -> select(entry) } + rail.onHover = { entry -> hover(entry) } + rail.onWheel = { + popup.hideAll() + rail.setOpen(-1) + } + root.addOverlay(rail) { pane, _ -> bounds(pane) } + + scroll.bar.addAdjustmentListener(adjustment) + scroll.component.viewport.addChangeListener(change) + scroll.component.viewport.addComponentListener(geometry) + scroll.bar.addComponentListener(geometry) + + model.addListener(parent) { event -> + when (event) { + is SessionModelEvent.TurnAdded, + is SessionModelEvent.TurnUpdated, + is SessionModelEvent.TurnRemoved, + is SessionModelEvent.HistoryLoaded, + is SessionModelEvent.Cleared, + is SessionModelEvent.RevertChanged, + is SessionModelEvent.QueueChanged, + is SessionModelEvent.MessageAdded, + is SessionModelEvent.MessageRemoved, + -> scheduleRebuild() + + is SessionModelEvent.ContentAdded, + is SessionModelEvent.ContentUpdated, + is SessionModelEvent.ContentRemoved, + is SessionModelEvent.ContentDelta, + -> scheduleRebuild() + + is SessionModelEvent.MessageUpdated, + is SessionModelEvent.StateChanged, + is SessionModelEvent.SessionUpdated, + is SessionModelEvent.DiffUpdated, + is SessionModelEvent.TodosUpdated, + is SessionModelEvent.BackgroundAgentsUpdated, + is SessionModelEvent.HeaderUpdated, + is SessionModelEvent.Compacted, + -> Unit + } + } + + Disposer.register(parent, this) + rebuild() + } + + @RequiresEdt + fun applyStyle(style: SessionEditorStyle) { + this.style = style + root.overlay.revalidate() + root.overlay.repaint() + } + + @RequiresEdt + fun refresh() { + rebuild() + } + + @RequiresEdt + fun hideAll() { + // Disposing the body clears `body` and the open tick through the hook registered in `request`. + popup.hideAll() + rail.setOpen(-1) + } + + @RequiresEdt + override fun dispose() { + dead = true + dirty = false + scroll.bar.removeAdjustmentListener(adjustment) + scroll.component.viewport.removeChangeListener(change) + scroll.component.viewport.removeComponentListener(geometry) + scroll.bar.removeComponentListener(geometry) + hideAll() + } + + @RequiresEdt + private fun scheduleRebuild() { + if (dead || dirty) return + dirty = true + ApplicationManager.getApplication().invokeLater { + if (dead || !dirty) return@invokeLater + dirty = false + rebuild() + } + } + + @RequiresEdt + private fun rebuild() { + val items = PromptRailItems.items(model, cache) + val capacity = PromptRailItems.capacity( + scroll.component.viewport.height, + JBUI.scale(SessionUiStyle.PromptRail.STEP_MIN), + JBUI.scale(SessionUiStyle.PromptRail.RAIL_INSET) / 2, + ) + val entries = PromptRailItems.entries(items, capacity) + val stale = body != null && rail.items() != items + rail.update(items, entries, activeIndex(items)) + rail.setAvailable(scroll.view === messages) + // The open card captured the list it was built from, so once the transcript moves on it is + // showing rows that no longer match the rail. Close it rather than leave it misleading; the next + // hover rebuilds it from the current prompts. + if (stale) hideAll() + relayout() + } + + @RequiresEdt + private fun recomputeActive() { + rail.setAvailable(scroll.view === messages) + if (rail.items().isEmpty()) return + rail.update(rail.items(), rail.entries(), activeIndex(rail.items())) + } + + private fun activeIndex(items: List): Int? { + if (items.isEmpty()) return null + val tops = items.map { top(it.id) } + val vp = scroll.component.viewport + val bar = scroll.bar + val scrollable = bar.maximum > bar.visibleAmount + val atTop = vp.viewPosition.y <= 0 + return PromptRailItems.active(tops, vp.viewPosition.y, scrollable, atTop) + } + + private fun top(id: String): Int { + val turn = messages.findTurn(id) ?: return 0 + return SwingUtilities.convertPoint(turn, Point(0, 0), messages).y + } + + @RequiresEdt + private fun relayout() { + root.overlay.revalidate() + root.overlay.repaint() + } + + private fun select(entry: PromptRailEntry) { + when (entry) { + is PromptRailEntry.Prompt -> jump(rail.items().getOrNull(entry.index)?.id) + // The overflow tick stands in for prompts with no tick of their own, so its only action is + // revealing them in the list — without waiting out the hover dwell. + is PromptRailEntry.Overflow -> { + rail.setOpen(rail.entries().indexOf(entry)) + pending = item(entry) + if (popup.showing()) { + body?.select(pending) + return + } + popup.showNow(rail, this) { request() } + } + } + } + + private fun jump(id: String?) { + if (id == null) return + scroll.scrollMessageTop(id) + } + + /** + * Navigates to the prompt at [index]: scrolls the transcript to it, highlights its row, and moves + * the rail's tick emphasis to it. [scroll] additionally brings the row into view in the list. + * + * The first/latest buttons and a row click all route through here. Scrolling the transcript alone + * would leave the open card still pointing at whichever row the pointer last hovered, so the + * navigator would disagree with what the transcript is showing. + */ + @RequiresEdt + private fun go(index: Int, scroll: Boolean = true) { + val item = rail.items().getOrNull(index) ?: return + pending = index + rail.setOpen(tick(index)) + body?.select(index, scroll) + jump(item.id) + } + + /** Tick showing the prompt at [item], or -1 when that prompt sits behind the overflow tick. */ + private fun tick(item: Int): Int = + rail.entries().indexOfFirst { it is PromptRailEntry.Prompt && it.index == item } + + /** + * The balloon is keyed on the rail rather than on the hovered tick, so travelling down the ticks + * neither restarts the dwell nor rebuilds it somewhere else — only the highlighted row follows the + * pointer. The open tick is left alone on exit so the rail stays lit while the pointer is inside the + * balloon; it is cleared when the balloon actually goes away. + */ + @RequiresEdt + private fun hover(entry: PromptRailEntry?) { + if (entry == null) { + popup.notifyExit(rail) + // The reset that clears the open tick is registered on the balloon body, so it only runs + // if the dwell actually produced one. Leaving the band before that has to drop the hover + // emphasis here, or the tick stays painted at hover size until the next interaction. + if (!popup.showing()) rail.setOpen(-1) + return + } + rail.setOpen(rail.entries().indexOf(entry)) + pending = item(entry) + popup.show(rail, this) { request() } + body?.select(pending) + } + + /** Item the list should highlight for [entry]; an overflow tick points at the first prompt it hides. */ + private fun item(entry: PromptRailEntry): Int = when (entry) { + is PromptRailEntry.Prompt -> entry.index + is PromptRailEntry.Overflow -> entry.hidden.first + } + + private fun request(): SidePopupRequest = SidePopupRequest( + build = { + PromptRailPopup( + items = rail.items(), + hovered = pending, + // A clicked row is already under the pointer, so it is navigated to without moving the + // list; only the buttons and tick hovers, which the user cannot aim, scroll it. + // + // A double click (or Enter) arrives with the list's focus flag set. That reads as a + // committed pick rather than browsing, so the card closes and leaves the transcript on + // the chosen prompt. A single click keeps it open for scanning further rows. + onSelect = { item, commit -> + go(rail.items().indexOfFirst { it.id == item.id }, scroll = false) + if (commit) hideAll() + }, + onFirst = { go(0) }, + onLatest = { go(rail.items().lastIndex) }, + ).also { built -> + body = built + Disposer.register(built.disposable) { + if (body === built) body = null + rail.setOpen(-1) + } + } + }, + place = { built -> place(built) }, + ) + + /** + * Puts the balloon entirely left of the ticks, so the body opens into the transcript instead of + * covering the rail it was opened from. The body is capped to the room between the rail and the + * window edge, and its height to the visible session. + * + * The point handed back is the balloon's intended center, not an edge: with the callout off the + * platform ignores the position and the pointer distance and centers the box on the target (see + * [PromptRailPlacement]). `SidePopupGeometry` is deliberately not used here — its `aim` result drives + * `cornerToPointerDistance`, which only applies to balloons that draw a pointer. + */ + private fun place(built: SidePopupContent): SidePopupSpot? { + val pane = SwingUtilities.getRootPane(rail)?.layeredPane ?: return null + if (!rail.isShowing) return null + val area = SwingUtilities.convertRectangle(root, root.visibleRect, pane) + if (area.isEmpty) return null + val rect = SwingUtilities.convertRectangle(rail.parent, rail.bounds, pane) + val margin = UiStyle.Gap.pad() + val insets = UiStyle.Balloon.insets() + // No callout and no shadow on this balloon, so the only chrome is the border inset. The shadow + // is switched off because it is reserved outside the border box and is hit-testable: against a + // flush card it would cover the ticks with an invisible margin and swallow their clicks. + val chromeWidth = insets.left + insets.right + val chromeHeight = insets.top + insets.bottom + // Room is measured to the window edge rather than the chat panel: the rail hugs the right side of + // a narrow sidebar, where the panel alone would leave almost nothing to open into. + val maxWidth = PromptRailPlacement.maxWidth( + railX = rect.x, + inset = FLUSH, + margin = margin, + chrome = chromeWidth, + cap = JBUI.scale((SessionUiStyle.View.Popup.MAX_WIDTH * SessionUiStyle.PromptRail.WIDTH_SCALE).toInt()), + ) + val maxHeight = PromptRailPlacement.maxHeight( + height = area.height, + margin = margin, + chrome = chromeHeight, + cap = JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT), + ) + if (maxWidth <= 0 || maxHeight <= 0) return null + built.fitWithin(maxWidth, maxHeight) + val body = built.component.preferredSize + val content = Dimension( + body.width + insets.left + insets.right, + body.height + insets.top + insets.bottom, + ) + val center = PromptRailPlacement.center( + railX = rect.x, + area = area, + inset = FLUSH, + margin = margin, + content = content, + // Centred on the rail, not the hovered tick, so the balloon does not drift while the pointer + // moves between ticks. + anchorY = rect.y + rect.height / 2, + ) + return SidePopupSpot( + pane = pane, + point = center, + // Ignored while the callout is off, but kept correct so enabling it would still open left. + position = Balloon.Position.atLeft, + distance = 0, + callout = false, + shadow = false, + ) + } + + /** + * Rail bounds, in overlay coordinates: standard inset right of the readable lane when there is + * room, or centered over the scrollbar column when the transcript is squeezed horizontally. + */ + private fun bounds(pane: JPanel): Rectangle { + val vp = scroll.component.viewport + if (vp.parent == null || vp.width <= 0 || vp.height <= 0) return Rectangle() + val vpBounds = SwingUtilities.convertRectangle(vp, Rectangle(vp.size), pane) + val lane = minOf(vp.width, SessionUiStyle.SessionLayout.readableWidth(messages, style.transcriptFont)) + val laneRight = vp.x + (vp.width + lane) / 2 + val railW = JBUI.scale(SessionUiStyle.PromptRail.TICK_HOVER) + val pad = UiStyle.Gap.pad() + val vpRightInPane = vpBounds.x + vpBounds.width + val bar = scroll.bar + val barBounds = if (bar.isVisible && bar.width > 0) { + SwingUtilities.convertRectangle(bar, Rectangle(bar.size), pane) + } else { + null + } + val contentRight = barBounds?.x ?: vpRightInPane + val laneRightInPane = SwingUtilities.convertPoint(vp, Point(laneRight - vp.x, 0), pane).x + val wide = contentRight - laneRightInPane >= railW + 2 * pad + val top = vpBounds.y + val height = vpBounds.height + if (wide) { + rail.centered = false + // Its own gutter: the bar is clear of the rail, so presses stay the rail's own. + rail.barTarget = null + val right = contentRight - pad + return Rectangle(right - railW, top, railW, height) + } + rail.centered = true + // Drawn over the bar, so a press that misses a tick has to reach it. + rail.barTarget = scroll.bar.takeIf { barBounds != null } + if (barBounds != null) { + val width = barBounds.width.coerceAtMost(railW).coerceAtLeast(JBUI.scale(2)) + val x = barBounds.x + (barBounds.width - width) / 2 + return Rectangle(x, top, width, height) + } + val padding = JBUI.scale(SessionUiStyle.SessionLayout.TRANSCRIPT_SCROLLBAR_PADDING) + val width = padding.coerceAtMost(railW).coerceAtLeast(JBUI.scale(2)) + return Rectangle(vpRightInPane - padding + (padding - width) / 2, top, width, height) + } + + private companion object { + /** + * Horizontal distance kept between the card and the rail: none. Any gap is dead space where the + * pointer is over neither surface, which starts the hide timer mid-traverse. + */ + const val FLUSH = 0 + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailItems.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailItems.kt new file mode 100644 index 00000000000..060363c66d5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailItems.kt @@ -0,0 +1,335 @@ +package ai.kilocode.client.session.ui.rail + +import ai.kilocode.client.session.model.Compaction +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.FileAttachment +import ai.kilocode.client.session.model.Message +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.Text +import ai.kilocode.client.session.model.Turn +import kotlin.math.floor + +/** One navigable prompt: a user turn, its preview text, and the start of its response, if any. */ +data class PromptRailItem( + val id: String, + val queued: Boolean, + val prompt: String, + val answer: String, +) + +/** One rail tick: a navigable prompt, or a placeholder standing in for a run of hidden prompts. */ +sealed class PromptRailEntry { + data class Prompt(val index: Int) : PromptRailEntry() + data class Overflow(val hidden: IntRange) : PromptRailEntry() +} + +/** + * Pure logic for the prompt navigator: which turns are navigable, their preview text, how many ticks + * fit a given rail height, and which ticks are kept when there are more prompts than fit. + * + * Mirrors `packages/kilo-vscode/webview-ui/src/components/chat/prompt-rail.ts` for JetBrains, without + * lazy history paging — the JetBrains transcript always loads full history up front. + */ +object PromptRailItems { + const val PROMPT_LIMIT = 160 + const val ANSWER_LIMIT = 220 + + /** Prose characters read per preview before stripping. Comfortably above [ANSWER_LIMIT]. */ + private const val BUDGET = 1200 + + /** Length of a fence marker. */ + private const val MARK = 3 + + /** + * Characters scanned looking for a fence's closing marker. Sized past any realistic code block, so + * a block is normally stepped over in full; beyond it the preview keeps the prose before the fence + * and stops, which bounds the work without ever spilling code into the output. + */ + private const val SCAN = BUDGET * 16 + + /** Leading characters folded into a turn's cache stamp. */ + private const val PROBE = 256 + + /** + * Per-turn preview cache. Rebuilds are driven by streamed content, so without this every delta + * re-previews every turn in the transcript; with it only the turn that changed is recomputed. + * + * Keyed by turn id, holding the [stamp] the entry was built from. EDT-only, like the model it reads. + */ + class Cache { + internal val entries = HashMap>() + + internal fun take(id: String, mark: Long): PromptRailItem? = + entries[id]?.takeIf { it.first == mark }?.second + + internal fun put(id: String, mark: Long, item: PromptRailItem) { + entries[id] = mark to item + } + + /** Drops turns that are no longer in the transcript, so a cleared session does not linger. */ + internal fun retain(ids: Set) { + if (entries.size != ids.size) entries.keys.retainAll(ids) + } + + fun size(): Int = entries.size + } + + /** One item per navigable user turn, in transcript order. */ + fun items(model: SessionModel, cache: Cache? = null): List { + val out = mutableListOf() + val seen = mutableSetOf() + for (turn in model.turns()) { + val anchor = model.message(turn.id) ?: continue + if (anchor.info.role != "user") continue + if (anchor.parts.values.any { it is Compaction }) continue + if (model.isRevertedMessage(turn.id)) continue + if (!prompted(anchor)) continue + seen.add(turn.id) + val mark = if (cache == null) 0L else stamp(model, turn) + cache?.take(turn.id, mark)?.let { + out.add(it) + continue + } + val prompt = truncate(preview(text(anchor.parts.values)), PROMPT_LIMIT) + val answer = truncate(preview(answerText(model, turn.messageIds)), ANSWER_LIMIT) + val item = if (prompt.isEmpty()) { + // Promoted to the title, so it takes the title's limit rather than keeping the longer + // answer one. + PromptRailItem(turn.id, model.isQueued(turn.id), truncate(answer, PROMPT_LIMIT), "") + } else { + PromptRailItem(turn.id, model.isQueued(turn.id), prompt, answer) + } + cache?.put(turn.id, mark, item) + out.add(item) + } + cache?.retain(seen) + return out + } + + /** + * Whether [anchor] carries something the user actually supplied: text they typed, or an attachment. + * + * The CLI injects user-role messages of its own — compaction replays earlier assistant content this + * way, and task summaries and resumed tool results do the same — marking the text `synthetic`. + * [SessionModel] strips those parts, so such a turn reaches here with no user content at all. It has + * to be skipped rather than treated as a prompt with empty text: the attachment-only branch would + * otherwise promote the assistant's reply into the title and list a response as if it were typed. + */ + private fun prompted(anchor: Message): Boolean = anchor.parts.values.any { + (it is Text && it.content.isNotBlank()) || it is FileAttachment + } + + /** Joined, non-blank [Text] parts of the first assistant message in [messageIds] that has any. */ + private fun answerText(model: SessionModel, messageIds: List): String { + for (id in messageIds.drop(1)) { + val msg = model.message(id) ?: continue + if (msg.info.role != "assistant") continue + val joined = text(msg.parts.values) + if (joined.isNotBlank()) return joined + } + return "" + } + + /** + * Joined [Text] parts, cut to [BUDGET] characters before any markdown stripping runs. + * + * The previews are at most a couple of hundred characters, but an assistant answer can be tens of + * kilobytes and this is reached from every streamed delta. Copying and running the preview regexes + * over the whole answer was the cost; the budget leaves ample slack for stripping to shorten the + * text and still reach the limit. + */ + private fun text(parts: Collection): String { + val out = StringBuilder() + for (part in parts) { + if (part !is Text) continue + if (part.content.isBlank()) continue + if (out.length >= BUDGET) break + if (out.isNotEmpty()) out.append('\n') + prose(out, part.content) + } + return out.toString() + } + + /** + * Appends the non-code text of [src] until the budget is reached, stepping over fenced blocks. + * + * The budget counts prose rather than raw characters precisely so a fence is never cut in half: + * [preview] only strips a *closed* fence, so a budget landing inside one would leave the opening + * marker and a slice of code in the preview, where reading the whole text dropped the block. Fences + * are found by scanning for the marker instead of by regex, so stepping over a large code block + * stays a cheap linear walk that allocates nothing. + * + * An unclosed fence is kept as text, which is what [preview]'s closed-pair regex does with it too. + */ + internal fun prose(out: StringBuilder, src: CharSequence) { + var at = 0 + while (at < src.length && out.length < BUDGET) { + val room = BUDGET - out.length + // The opening search stops at the remaining budget. A fence that opens beyond it cannot + // affect the output, because the prose in front of it already fills the preview, so a + // fence-free answer costs the budget rather than its own length. That matters because a + // streamed delta re-previews its turn, which would otherwise make the stream quadratic. + val open = mark(src, at, at + room) + if (open < 0) return take(out, src, at, at + room) + // The closing search is bounded too, but by its own cap rather than the budget: the block + // has to be measured to be stepped over, so this cannot stop at the budget the way the + // opening search does. [SCAN] is sized past any realistic code block, which keeps a fenced + // answer bounded per call instead of rescanning the block on every streamed delta. + val reach = open + MARK + SCAN + val close = mark(src, open + MARK, minOf(src.length, reach)) + if (close < 0) { + // Whole part scanned, so the fence really is unclosed: keep it as text, which is what + // [preview]'s closed-pair regex does with it. + if (reach >= src.length) return take(out, src, at, src.length) + // Cap reached first, so the block may well close further on. Emit the prose in front of + // it and stop; keeping the remainder as text here is what spills code into the preview. + return take(out, src, at, open) + } + take(out, src, at, open) + if (out.length < BUDGET) out.append(' ') + at = close + MARK + } + } + + /** Index of the first fence marker starting in `[from, until)`, or -1. Reads [src] in place. */ + private fun mark(src: CharSequence, from: Int, until: Int): Int { + var i = from.coerceAtLeast(0) + val last = minOf(until, src.length - MARK + 1) + while (i < last) { + if (src[i] == '`' && src[i + 1] == '`' && src[i + 2] == '`') return i + i++ + } + return -1 + } + + private fun take(out: StringBuilder, src: CharSequence, from: Int, to: Int) { + val room = BUDGET - out.length + if (room <= 0) return + val end = minOf(to, from + room, src.length) + if (end > from) out.append(src, from, end) + } + + /** + * Cheap stand-in for a turn's preview inputs, so an unchanged turn is not previewed again. + * + * Bounded on purpose: this runs for every turn on every streamed delta, so it folds in the role + * [answerText] selects on, each part's id and length, and a hash of a [PROBE]-character prefix + * rather than hashing the whole text. Length alone would miss an in-place edit that replaces text + * with a different string of the same length; the prefix catches those, and the remaining blind + * spot is an equal-length edit entirely beyond the probe, which streaming never produces because it + * only appends. + */ + private fun stamp(model: SessionModel, turn: Turn): Long { + var hash = if (model.isQueued(turn.id)) 1L else 0L + for (id in turn.messageIds) { + val msg = model.message(id) ?: continue + hash = hash * 31 + id.hashCode() + hash = hash * 31 + msg.info.role.hashCode() + for (part in msg.parts.values) { + hash = hash * 31 + part.id.hashCode() + if (part !is Text) continue + hash = hash * 31 + part.content.length + hash = hash * 31 + head(part.content) + } + } + return hash + } + + /** Hash of at most [PROBE] leading characters. Reads [src] in place, copying nothing. */ + private fun head(src: CharSequence): Int { + var hash = 0 + val end = minOf(src.length, PROBE) + for (i in 0 until end) hash = hash * 31 + src[i].code + return hash + } + + /** + * Plain-text preview of markdown [text]: fenced code blocks, inline code and images are dropped, + * links keep only their label, leading heading/list/quote markers are stripped, and whitespace is + * collapsed to single spaces. + */ + fun preview(text: String): String { + var out = text + out = out.replace(FENCE, " ") + out = out.replace(IMAGE, " ") + out = out.replace(LINK) { it.groupValues[1] } + out = out.replace(INLINE_CODE, " ") + out = out.lineSequence() + .map { it.replace(LEADING_MARKER, "") } + .joinToString("\n") + return out.replace(WHITESPACE, " ").trim() + } + + /** [text] cut to [limit] characters, with a trailing ellipsis when it was cut. */ + fun truncate(text: String, limit: Int): String { + if (text.length <= limit) return text + return text.take((limit - 1).coerceAtLeast(0)).trimEnd() + "…" + } + + /** Ticks that fit a rail of [height] px, spaced no closer than [stepMin] and no wider than [stepMax]. */ + fun capacity(height: Int, stepMin: Int, pad: Int): Int { + val usable = height - 2 * pad + if (usable <= 0) return 0 + return floor(usable.toDouble() / stepMin.toDouble()).toInt() + } + + /** + * Which [items] to show as ticks when only [capacity] fit: the first prompt, one overflow tick for + * everything hidden in between, and the most recent prompts. Below capacity 2 there is no overflow + * tick — see the branches below. + */ + fun entries(items: List, capacity: Int): List { + if (items.isEmpty() || capacity <= 0) return emptyList() + if (capacity == 1 || items.size <= capacity) { + return if (capacity == 1) { + listOf(PromptRailEntry.Prompt(items.lastIndex)) + } else { + items.indices.map { PromptRailEntry.Prompt(it) } + } + } + if (capacity == 2) { + return listOf(PromptRailEntry.Prompt(0), PromptRailEntry.Prompt(items.lastIndex)) + } + val tailCount = capacity - 2 + val tailStart = items.size - tailCount + return buildList { + add(PromptRailEntry.Prompt(0)) + add(PromptRailEntry.Overflow(1 until tailStart)) + for (i in tailStart until items.size) add(PromptRailEntry.Prompt(i)) + } + } + + /** + * Index into [items] that should read as active: the last prompt when the transcript cannot scroll, + * the first prompt when the viewport sits at the very top, otherwise the last prompt whose top is at + * or above [viewTop]. + */ + fun active(tops: List, viewTop: Int, scrollable: Boolean, atTop: Boolean): Int? { + if (tops.isEmpty()) return null + if (!scrollable) return tops.lastIndex + if (atTop) return 0 + var lo = 0 + var hi = tops.lastIndex + var found = -1 + while (lo <= hi) { + val mid = (lo + hi) / 2 + if (tops[mid] <= viewTop) { + found = mid + lo = mid + 1 + } else { + hi = mid - 1 + } + } + return if (found < 0) 0 else found + } + + private val FENCE = Regex("```[\\s\\S]*?```") + private val IMAGE = Regex("!\\[[^\\]]*]\\([^)]*\\)") + private val LINK = Regex("\\[([^\\]]*)]\\([^)]*\\)") + // Bounded to a single line, like the fenced form above handles multi-line spans. A class that + // also matched newlines would let two unrelated backticks on different lines swallow everything + // between them, which can empty a prompt outright. + private val INLINE_CODE = Regex("`[^`\\n]*`") + private val LEADING_MARKER = Regex("^\\s*(#{1,6}\\s+|[-*+>]\\s+)") + private val WHITESPACE = Regex("\\s+") +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacement.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacement.kt new file mode 100644 index 00000000000..0fc6b992e3f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacement.kt @@ -0,0 +1,62 @@ +package ai.kilocode.client.session.ui.rail + +import java.awt.Dimension +import java.awt.Point +import java.awt.Rectangle + +/** + * Geometry for the navigator balloon, which is shown without a callout. + * + * A pointerless balloon is placed differently from a pointed one. `BalloonImpl.getUpdatedBounds` uses + * the requested [com.intellij.openapi.ui.popup.Balloon.Position] and `cornerToPointerDistance` only + * when the pointer is shown; with the callout off it centers the content box on the target point and + * ignores both. So the caller cannot ask for "left of the rail" — it has to hand the platform the + * center that puts the box there, which is what [center] computes. + * + * All values are already-scaled device px, in the layered pane's coordinate space, whose left edge is 0. + */ +internal object PromptRailPlacement { + /** + * Largest body that fits between the window's left [margin] and the rail, whose left edge is [railX] + * and against which the card sits [inset] short. + */ + fun maxWidth(railX: Int, inset: Int, margin: Int, chrome: Int, cap: Int): Int = + (railX - inset - margin - chrome).coerceIn(0, cap) + + /** Largest body that fits in [height] of visible session, keeping [margin] above and below. */ + fun maxHeight(height: Int, margin: Int, chrome: Int, cap: Int): Int = + (height - margin * 2 - chrome).coerceIn(0, cap) + + /** + * Center to hand the platform so the [content] box ends [inset] left of [railX]. + * + * The navigator passes `inset = 0`, putting the card hard against the rail. Any gap there is dead + * space: the pointer crossing it is over neither the card nor the ticks, which drops the controller's + * on-subject flag and starts the hide timer, so the card closes on the way to a tick. Flush also + * requires the balloon's shadow to be off, since that shadow is reserved outside the border box and + * is hit-testable, and would otherwise cover the ticks with an invisible margin. + * + * [anchorY] is the center the balloon should sit on, clamped by [margin] to keep the box inside + * [area], the visible session. It is the center of the rail rather than of the hovered tick, so the + * balloon holds still while the pointer travels down the ticks. + */ + fun center(railX: Int, area: Rectangle, inset: Int, margin: Int, content: Dimension, anchorY: Int): Point { + val x = railX - inset - content.width / 2 + val min = area.y + margin + content.height / 2 + val max = area.y + area.height - margin - content.height / 2 + // A body taller than the room it was budgeted for can only be centered. + val y = if (max < min) area.y + area.height / 2 else anchorY.coerceIn(min, max) + return Point(x, y) + } + + /** + * The box the platform lays out around [center] for a [content]-sized pointerless balloon, mirroring + * `BalloonImpl.getUpdatedBounds`. Excludes the shadow, which is added outside this rect. + */ + fun box(center: Point, content: Dimension): Rectangle = Rectangle( + center.x - content.width / 2, + center.y - content.height / 2, + content.width, + content.height, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopup.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopup.kt new file mode 100644 index 00000000000..c197b8f2b91 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopup.kt @@ -0,0 +1,210 @@ +package ai.kilocode.client.session.ui.rail + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.list.ActiveList +import ai.kilocode.client.ui.list.ActiveListConfig +import ai.kilocode.client.ui.list.ActiveListItem +import ai.kilocode.client.ui.list.ActiveListRowHeight +import ai.kilocode.client.ui.popup.SidePopupContent +import com.intellij.icons.AllIcons +import com.intellij.openapi.util.Disposer +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.BorderLayout +import java.awt.Cursor +import java.awt.Dimension +import javax.swing.JComponent + +/** + * Balloon body for the prompt navigator: a header with first/latest jump buttons, and every navigable + * prompt as a row in the shared [ActiveList], so the navigator reads and behaves like the worktree, + * history, and settings lists. + * + * This implements [SidePopupContent] directly rather than wrapping + * [ai.kilocode.client.session.ui.popup.HeaderPopupBody]. That wrapper supplies its own scroll pane, and + * nesting [ActiveList] inside it left two scroll panes with neither owning the scrolling: the wrapper + * measures wrapped content by sizing it to `Short.MAX_VALUE`, which propagated down and gave the inner + * viewport a ~32k extent, so every row counted as visible and revealing one scrolled nowhere. Here the + * list's own scroll pane is the only one, and [capped] clamps the body so that pane actually clips. + */ +internal class PromptRailPopup( + private val items: List, + hovered: Int, + /** + * Navigate to a row. The flag is the list's `focus` signal: false for a single click, true for a + * double click or Enter, which the navigator treats as a committed pick and so also closes on. + */ + onSelect: (PromptRailItem, Boolean) -> Unit, + onFirst: () -> Unit, + onLatest: () -> Unit, +) : SidePopupContent { + /** One navigable prompt as a standard list row: the prompt is the title, its answer the description. */ + private data class Row( + override val key: String, + override val title: String, + override val description: String?, + ) : ActiveListItem + + private val disposer = Disposer.newDisposable("PromptRailPopup") + + /** Row to reveal once the list has been laid out, or -1. See [reveal]. */ + private var pending = -1 + private var cap = Dimension( + JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH), + JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT), + ) + + private val rows = ActiveList( + emptyText = "", + cfg = ActiveListConfig( + // A prompt and the start of its answer both need their own line, and the answer wraps. + height = ActiveListRowHeight.PREFERRED, + wrapDescription = true, + // The row already shows the answer preview it would repeat, and a tooltip inside a balloon + // that itself opened on hover reads as a surface stacked on a surface. + tooltip = false, + ), + showSearch = false, + // Left at open-on-click so a single click reaches onOpen. With it off, ActiveListView's click + // path falls through to the (absent) onClick handler and returns, so only a double click would + // jump — the navigator is a one-click list. + // + // Enter commits unconditionally. The default provider reports the global + // `edit.source.on.enter.key.request.focus.in.editor` advanced setting, which would make Enter + // close the card on some installs and leave it open on others. + enter = { true }, + onCell = { _, _ -> }, + onOpen = { row, focus -> + items.firstOrNull { it.id == row.key }?.let { onSelect(it, focus) } + }, + ).apply { + setListCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)) + update(items.map(::row)) + } + + // Labelled for screen readers but deliberately without tooltips: the navigator opens on hover, so a + // tooltip here would pop a second floating surface over the card the pointer is already inside. + private val first = HoverIcon().apply { + icon = AllIcons.Actions.MoveUp + accessibleContext.accessibleName = KiloBundle.message("session.prompts.first") + addActionListener { onFirst() } + } + + private val latest = HoverIcon().apply { + icon = AllIcons.Actions.MoveDown + accessibleContext.accessibleName = KiloBundle.message("session.prompts.latest") + addActionListener { onLatest() } + } + + /** + * Clamps the body to the room the balloon was given. Without the clamp the list's preferred height + * is every row stacked, the balloon would be sized to that, and the list would never need to scroll. + */ + private val capped = object : BorderLayoutPanel() { + override fun getPreferredSize(): Dimension { + val pref = super.getPreferredSize() + // Width takes the room the balloon was given rather than the rows' natural width: a wrapping + // row has no meaningful preferred width, and the navigator should read as a steady column. + return Dimension(cap.width, pref.height.coerceAtMost(cap.height)) + } + + /** + * Replays a reveal the body could not serve before it had bounds. + * + * Hooked here rather than on a resize listener: component events are posted to the event queue, + * so a listener would reveal on a later turn of the EDT — the asynchrony this is meant to remove. + * Laying out is the synchronous point at which the list first has a viewport to scroll in. + */ + override fun doLayout() { + super.doLayout() + reveal() + } + }.apply { + isOpaque = false + addToTop(header()) + addToCenter(rows) + } + + override val component: JComponent get() = capped + override val disposable get() = disposer + override val background get() = UiStyle.Balloon.bg() + + override fun fitWithin(width: Int, height: Int) { + cap = Dimension(width, height) + capped.invalidate() + } + + /** The row list, for tests that need to drive layout or inspect selection. */ + internal val list get() = rows + + init { + select(hovered) + } + + /** + * Highlights the prompt at [index], and brings its row into view when [scroll] is set. Safe before + * the balloon has been laid out and safe to repeat with the same row, so it can be called on every + * tick hover. + * + * Clicking a row passes `scroll = false`. The row was reachable under the pointer, so moving the + * list to re-centre it would shift the content out from under a deliberate click; the list is only + * scrolled for navigation the user cannot aim, which is a tick hover or a first/latest jump. + */ + @RequiresEdt + fun select(index: Int, scroll: Boolean = true) { + val key = items.getOrNull(index)?.id ?: return + if (!scroll) { + // Also drops any deferred reveal, so an earlier request cannot fire on the next layout and + // move the list after the click. + pending = -1 + rows.select(key, scroll = false) + return + } + pending = index + reveal() + } + + /** + * Applies [pending], or leaves it pending while the list has no bounds to scroll within. + * + * `ScrollingUtil.ensureIndexIsVisible` measures the viewport extent, so running it against an + * unsized list resolves to no movement and the request would be silently dropped. + */ + @RequiresEdt + private fun reveal() { + val index = pending + if (index < 0) return + if (rows.width <= 0 || rows.height <= 0) return + val key = items.getOrNull(index)?.id ?: return + // Cleared before scrolling: selecting re-enters layout, and that pass must not scroll again. + pending = -1 + // The list lays out its own scroll pane, and the extent has to be settled before it is measured. + rows.validate() + rows.select(key) + } + + private fun row(item: PromptRailItem) = Row( + key = item.id, + title = if (item.queued) "${KiloBundle.message("session.queued")} · ${item.prompt}" else item.prompt, + description = item.answer.ifBlank { KiloBundle.message("session.prompts.noAnswer") }, + ) + + private fun header() = BorderLayoutPanel().apply { + isOpaque = false + border = JBUI.Borders.empty(4, 5, 4, 11) + add( + JBLabel(KiloBundle.message("session.prompts.navLabel")).apply { + foreground = UIUtil.getContextHelpForeground() + }, + BorderLayout.WEST, + ) + add(Stack.horizontal(UiStyle.Gap.xs()).next(first).next(latest), BorderLayout.EAST) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index 0960dc8dcce..7c7df1f940f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui.style import ai.kilocode.client.ui.UiStyle import com.intellij.ui.JBColor import com.intellij.util.ui.JBUI +import com.intellij.util.ui.NamedColorUtil import com.intellij.util.ui.UIUtil import java.awt.Color import java.awt.Component @@ -92,6 +93,16 @@ object SessionUiStyle { const val INNER_BOTTOM = 4 const val INNER_HORIZONTAL = 4 + TRANSCRIPT_SCROLLBAR_PADDING + /** + * Right transcript inset. Wider than [INNER_HORIZONTAL] because the prompt rail is drawn over + * this edge: it reserves the rail's full tick width plus the smallest standard gap, so a tick at + * hover size still clears the content instead of sitting on top of it. + * + * Reserved whether or not the rail is currently showing, so the transcript does not reflow when + * a second prompt makes it appear. + */ + const val INNER_RIGHT = PromptRail.TICK_HOVER + UiStyle.Gap.XS + const val USER_PROMPT_INDENT = 100 const val SCROLL_INCREMENT = 48 @@ -101,6 +112,49 @@ object SessionUiStyle { } } + /** Geometry and colors for the prompt navigator rail and its ticks. */ + object PromptRail { + /** Tick length across the rail, at rest. */ + const val TICK_REST = 12 + + /** Tick length across the rail when active. */ + const val TICK_ACTIVE = 16 + + /** + * Tick length across the rail at full size (hover or open), and so the rail's own width. + * [SessionLayout.INNER_RIGHT] reserves this much beside the transcript. + */ + const val TICK_HOVER = 20 + + /** Tick thickness on the scroll axis. */ + const val TICK_THICKNESS = 2 + + /** Closest allowed spacing between tick centers. */ + const val STEP_MIN = 7 + + /** Widest allowed spacing between tick centers. */ + const val STEP_MAX = 14 + + /** Total vertical padding reserved across the top and bottom of the rail. */ + const val RAIL_INSET = 24 + + /** Hover dwell, in ms, before the navigator balloon opens. */ + const val OPEN_MS = 350 + + /** + * Widest the navigator card may get, as a multiple of the standard session popup width. Prompts + * and their answer previews are prose, so the card reads better wider than a menu-sized popup. + * The room left of the rail still caps it, so a narrow sidebar gets a narrower card. + */ + const val WIDTH_SCALE = 1.5 + + fun restColor(): Color = UIUtil.getContextHelpForeground() + + fun activeColor(): Color = UIUtil.getLabelForeground() + + fun queuedColor(): Color = NamedColorUtil.getInactiveTextColor() + } + /** Shared tokens for individual transcript views and session views. */ object View { /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionOutcomeView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionOutcomeView.kt index 5e816a6c838..fcb7f05ab03 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionOutcomeView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionOutcomeView.kt @@ -22,6 +22,7 @@ class SessionOutcomeView( focus: (() -> Unit)? = null, private val retry: (() -> Unit)? = null, private val retryable: (() -> Boolean)? = null, + private val dismiss: (() -> Unit)? = null, ) : DialogView(selection, focus), SessionView { override val sessionViewKind = SessionView.Kind.Default @@ -42,7 +43,7 @@ class SessionOutcomeView( error.text = message setContentPadding(left = false, right = false) setContent(error.scroll) - syncRetry(true) + syncRetry(true, dismissible = true) isVisible = true refresh() } @@ -116,24 +117,26 @@ class SessionOutcomeView( * * [retryable] is asked on every show because the answer depends on the transcript tail, not on the * outcome alone: a session-level error that arrived after a completed turn has nothing to replay. + * + * [dismissible] is only true for [showError]: a send that failed before any message was created + * (see [ai.kilocode.client.session.SessionUi.restoreLastSubmission]) has no transcript entry for + * Retry to continue, so without a dismiss action the card would stay up until the next unrelated + * state change. The other two card shapes ([showRetry], [showOutcome]) describe a transcript tail + * that already exists, so Retry (or nothing, for an interrupted/incomplete note) is enough. */ @RequiresEdt - private fun syncRetry(show: Boolean) { - val run = retry - if (run == null || !show || retryable?.invoke() == false) { - setActions(emptyList()) - return + private fun syncRetry(show: Boolean, dismissible: Boolean = false) { + val actions = buildList { + val run = retry + if (run != null && show && retryable?.invoke() != false) { + add(Action(id = RETRY_ACTION, text = KiloBundle.message("session.outcome.retry"), primary = true, handler = run)) + } + val clear = dismiss + if (clear != null && dismissible) { + add(Action(id = DISMISS_ACTION, text = KiloBundle.message("session.outcome.dismiss"), primary = isEmpty(), handler = clear)) + } } - setActions( - listOf( - Action( - id = RETRY_ACTION, - text = KiloBundle.message("session.outcome.retry"), - primary = true, - handler = run, - ), - ), - ) + setActions(actions) } @RequiresEdt @@ -151,6 +154,7 @@ class SessionOutcomeView( private companion object { const val RETRY_ACTION = "retry" + const val DISMISS_ACTION = "dismiss" } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpAuthUrlDialog.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpAuthUrlDialog.kt new file mode 100644 index 00000000000..7890573a181 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpAuthUrlDialog.kt @@ -0,0 +1,81 @@ +package ai.kilocode.client.settings.agents + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.auth.copyToClipboard +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.util.webUrl +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextField +import com.intellij.util.ui.JBUI +import java.awt.event.ActionEvent +import java.awt.event.FocusAdapter +import java.awt.event.FocusEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.AbstractAction +import javax.swing.Action +import javax.swing.JComponent + +/** + * Shown when the CLI could not open a browser to finish an MCP OAuth sign-in. Lets the user copy or + * open the authorization URL manually. + */ +internal class McpAuthUrlDialog( + private val name: String, + private val url: String, +) : DialogWrapper(false) { + private val urlField = JBTextField(url).apply { + isEditable = false + columns = 40 + addFocusListener(object : FocusAdapter() { + override fun focusGained(e: FocusEvent) = selectAll() + }) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) = selectAll() + }) + } + private var center: JComponent? = null + + // The URL originates from the remote MCP server, so the scheme is re-checked here instead of + // trusting the caller: this dialog is also reachable from the CLI's browser-open-failed event. + private val openAction: Action = object : AbstractAction(KiloBundle.message("settings.agentBehavior.mcp.authUrl.open")) { + override fun actionPerformed(e: ActionEvent) { + if (!webUrl(url)) return + BrowserUtil.browse(url) + } + } + + private val copyAction: Action = object : AbstractAction(KiloBundle.message("settings.agentBehavior.mcp.authUrl.copy")) { + override fun actionPerformed(e: ActionEvent) { + copyToClipboard(url, KiloBundle.message("settings.agentBehavior.mcp.authUrl.copy"), urlField) + } + } + + init { + title = KiloBundle.message("settings.agentBehavior.mcp.authUrl.title") + isModal = false + // Copying a non-web URL is harmless, opening it is not, so only Open is withheld. + openAction.isEnabled = webUrl(url) + init() + } + + internal fun contentForTest(): JComponent = center ?: error("center panel not built") + + internal fun openActionForTest(): Action = openAction + + override fun createCenterPanel(): JComponent { + val panel = Stack.vertical(UiStyle.Gap.sm()) + .next(JBLabel(KiloBundle.message("settings.agentBehavior.mcp.authUrl.message", name))) + .next(urlField) + panel.border = JBUI.Borders.empty(UiStyle.Gap.pad()) + center = panel + return panel + } + + override fun createActions(): Array = arrayOf(openAction, copyAction, okAction) + + override fun getPreferredFocusedComponent(): JComponent = urlField +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpBundles.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpBundles.kt new file mode 100644 index 00000000000..292908f08cd --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpBundles.kt @@ -0,0 +1,30 @@ +package ai.kilocode.client.settings.agents + +import ai.kilocode.client.app.KiloMarketplaceService +import ai.kilocode.log.KiloLog +import com.intellij.openapi.components.service +import kotlinx.coroutines.CancellationException + +private val LOG = KiloLog.create(KiloMarketplaceService::class.java) + +/** + * Marketplace bundles owning MCP servers or skills in [dir], or an empty list when the Marketplace + * cannot be reached. + * + * Bundle ownership only decides whether deleting one half of a Marketplace install should offer to + * remove the whole bundle. It is an enrichment, so a Marketplace outage or timeout must not take the + * MCP and Skills pages down with it — without it those pages still list and manage everything, they + * just fall back to the plain single-item delete confirmation. + */ +internal suspend fun ownedBundles(dir: String) = if (dir.isBlank()) { + emptyList() +} else { + try { + service().bundles(dir) + } catch (err: CancellationException) { + throw err + } catch (err: Exception) { + LOG.warn("marketplace bundles unavailable dir=$dir; continuing without bundle ownership", err) + emptyList() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt index e67abf569b1..df75e880eff 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpConfigurable.kt @@ -1,6 +1,8 @@ package ai.kilocode.client.settings.agents import ai.kilocode.client.app.KiloAgentBehaviorService +import ai.kilocode.client.app.KiloMcpAuthService +import ai.kilocode.client.app.KiloMarketplaceService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.plugin.KiloDocs import ai.kilocode.client.settings.base.DirectoryReadyConfigurable @@ -18,6 +20,7 @@ import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.McpServerConfigDto import ai.kilocode.rpc.dto.McpStatusDto +import ai.kilocode.rpc.dto.MarketplaceBundleDto import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.application.EDT @@ -26,34 +29,57 @@ import com.intellij.openapi.application.asContextElement import com.intellij.openapi.components.service import com.intellij.openapi.ui.Messages import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.UIUtil import javax.swing.JComponent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private val edt = Dispatchers.EDT + ModalityState.any().asContextElement() class McpConfigurable : DirectoryReadyConfigurable() { + private var ui: McpSettingsUi? = null + private var query: String? = null + override fun getId(): String = ID override fun getDisplayName(): String = KiloBundle.message("settings.agentBehavior.mcp.displayName") - override fun create(cs: CoroutineScope, dir: String): JComponent = McpSettingsUi(cs, dir) + override fun create(cs: CoroutineScope, dir: String): JComponent = McpSettingsUi(cs, dir).also { panel -> + ui = panel + query?.let(panel::filter) + } override fun update(ui: JComponent, dir: String) { (ui as? McpSettingsUi)?.setDirectory(dir) } override fun scrollReadyShell() = false + @RequiresEdt + internal fun filter(query: String) { + this.query = query + ui?.filter(query) + } + + override fun disposeReadyComponent(component: JComponent) { + if (ui === component) ui = null + query = null + // DraftReadyConfigurableBase clears its retained panel and disposes McpSettingsUi, which is a + // Disposable SettingsListPanel. Skipping super would leak the page and its in-flight reload. + super.disposeReadyComponent(component) + } + companion object { const val ID = "ai.kilocode.jetbrains.settings.agentBehavior.mcp" } } internal class McpSettingsUi( - cs: CoroutineScope, + private val cs: CoroutineScope, dir: String, private val create: (String, McpConfigDto) -> McpEditDialogHandle = ::McpEditDialog, -) : SettingsListPanel(cs, ActiveListConfig.Equal.copy(description = false)) { +) : SettingsListPanel(cs, ActiveListConfig.Equal.copy(description = false, keepActions = true)) { private var dir = dir private var servers: Map = emptyMap() + private var bundles: List = emptyList() init { start() @@ -65,10 +91,17 @@ internal class McpSettingsUi( reload() } + /** A Marketplace install or removal can add or drop servers while this page sits open. */ + override fun refreshOnFocus(): Boolean = true + override suspend fun fetch(): List { val behavior = service() val cfg = behavior.mcpConfig(dir) - withContext(edt) { servers = cfg } + val owned = ownedBundles(dir) + withContext(edt) { + servers = cfg + bundles = owned + } val statuses = if (dir.isBlank()) { LOG.warn("mcp settings fetch skipped runtime status: missing project directory config=${cfg.size}") emptyMap() @@ -80,6 +113,13 @@ internal class McpSettingsUi( if (names.isEmpty()) { LOG.warn("mcp settings fetch returned no servers dir=$dir") } + // A row only has room for the status pill, so the reason a server is unhealthy would otherwise + // live solely in its tooltip. Log it too, so a failure is diagnosable from the IDE log alone. + for (name in names) { + val status = statuses[name] ?: continue + if (status.status == CONNECTED || status.status == DISABLED) continue + LOG.warn("mcp server unhealthy dir=$dir name=$name status=${status.status} reason=${status.error ?: "unreported"}") + } return names.map { name -> item(name, cfg[name]?.config, statuses[name]) } } @@ -87,7 +127,8 @@ internal class McpSettingsUi( when (cellId) { CONNECT_CELL -> mutate(key) { service().mcpConnect(dir, key) } DISCONNECT_CELL -> mutate(key) { service().mcpDisconnect(dir, key) } - AUTH_CELL -> mutate(key) { service().mcpAuthenticate(dir, key) } + AUTH_CELL -> signIn(key) + RESET_AUTH_CELL -> resetAuth(key) EDIT_CELL -> edit(key) REMOVE_CELL -> remove(key) } @@ -125,15 +166,26 @@ internal class McpSettingsUi( } private fun badges(cfg: McpConfigDto?, status: McpStatusDto?): List = listOfNotNull( - ActiveListBadge(statusLabel(status), statusStyle(status)).takeIf { status != null }, + // The row hides its description line, which suppresses the row-level tooltip, so the status + // pill carries the reason itself. An id is what opts a badge into list hit-testing. + ActiveListBadge( + statusLabel(status), + statusStyle(status), + id = STATUS_BADGE, + icon = AllIcons.General.Warning.takeIf { status?.status == NEEDS_AUTH }, + tooltip = statusTooltip(status), + ).takeIf { status != null }, ActiveListBadge(cfg?.type ?: KiloBundle.message("settings.agentBehavior.mcp.configured")).takeIf { cfg != null }, ) private fun cells(cfg: McpConfigDto?, status: McpStatusDto?): List = listOfNotNull( - connect(status?.status == CONNECTED), + connect(status?.status == CONNECTED).takeUnless { status?.status == NEEDS_AUTH }, ActiveListCell(AUTH_CELL, KiloBundle.message("settings.agentBehavior.mcp.signIn")).takeIf { status?.status == NEEDS_AUTH }, + ActiveListCell(RESET_AUTH_CELL, KiloBundle.message("settings.agentBehavior.mcp.resetAuth")).takeIf { + cfg?.type == "remote" && status?.status == CONNECTED + }, ActiveListCell( EDIT_CELL, KiloBundle.message("settings.agentBehavior.edit"), @@ -166,17 +218,72 @@ internal class McpSettingsUi( } } - private fun remove(name: String) { + @RequiresEdt + private fun signIn(name: String) { + checkEdt() + val auth = service() + if (!launch("mcp sign in name=$name") { id -> + val result = auth.signIn(dir, name) + if (!withContext(edt) { active(id) }) return@launch + withContext(edt) { auth.report(name, result) } + if (result.status != "connected" && result.status != "cancelled") { + throw SettingsMessageException( + result.error ?: KiloBundle.message("settings.agentBehavior.mcp.signIn.failed", name), + ) + } + val items = fetch() + withContext(edt) { + if (!active(id)) return@withContext + setBusy(false) + view.update(items, ActiveListSelection.Key(name)) + clearProgress() + } + }) return + showProgress( + KiloBundle.message("settings.agentBehavior.mcp.signIn.progress", name), + KiloBundle.message("settings.agentBehavior.mcp.signIn.cancel"), + ) { cs.launch { auth.cancel(dir, name) } } + } + + private fun resetAuth(name: String) { val result = Messages.showYesNoDialog( - KiloBundle.message("settings.agentBehavior.mcp.delete.message", name), + KiloBundle.message("settings.agentBehavior.mcp.resetAuth.message", name), + KiloBundle.message("settings.agentBehavior.mcp.resetAuth.title"), + KiloBundle.message("settings.agentBehavior.mcp.resetAuth"), + Messages.getCancelButton(), + Messages.getQuestionIcon(), + ) + if (result != Messages.YES) return + mutate(name) { service().reset(dir, name) } + } + + private fun remove(name: String) { + val scope = servers[name]?.scope ?: return + val target = if (scope == "workspace") "project" else scope + val bundle = bundles.singleOrNull { it.id == name && it.scope == target } + val result = Messages.showYesNoDialog( + KiloBundle.message( + if (bundle == null) "settings.agentBehavior.mcp.delete.message" + else "settings.agentBehavior.mcp.delete.bundle.message", + name, + ), KiloBundle.message("settings.agentBehavior.mcp.delete.title"), KiloBundle.message("common.delete"), Messages.getCancelButton(), Messages.getQuestionIcon(), ) if (result != Messages.YES) return - val scope = servers[name]?.scope ?: return mutateAndReload(ActiveListSelection.Slide) { + if (bundle != null) { + val removed = service().remove(dir, name, "mcp", target) + if (!removed.success) { + throw SettingsMessageException( + removed.error ?: KiloBundle.message("settings.marketplace.remove.failed"), + ) + } + service().reloadSkills(dir) + return@mutateAndReload true + } if (!service().saveMcp(dir, name, scope, null)) { throw SettingsMessageException(KiloBundle.message("settings.agentBehavior.save.failed")) } @@ -197,9 +304,11 @@ internal class McpSettingsUi( const val NEEDS_AUTH = "needs_auth" const val NEEDS_REGISTRATION = "needs_client_registration" const val DISABLED = "disabled" + const val STATUS_BADGE = "status" const val CONNECT_CELL = "connect" const val DISCONNECT_CELL = "disconnect" const val AUTH_CELL = "auth" + const val RESET_AUTH_CELL = "resetAuth" const val EDIT_CELL = "edit" const val REMOVE_CELL = "remove" val LOG = KiloLog.create(McpSettingsUi::class.java) @@ -216,6 +325,15 @@ internal class McpSettingsUi( } } + /** + * Why the server is in [status], wrapped for a multi-line tooltip. Null when the pill text + * already says everything, so a healthy row does not gain a tooltip that repeats it. + */ + fun statusTooltip(status: McpStatusDto?): String? { + val reason = status?.error?.takeIf { it.isNotBlank() } ?: return null + return UiStyle.Text.tipLines(listOf(statusLabel(status)) + reason.lines()) + } + fun statusStyle(status: McpStatusDto?): UiStyle.Badge.Style { return when (status?.status) { CONNECTED -> UiStyle.Badge.Highlight diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpEditDialog.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpEditDialog.kt index 8ba8ab8e89e..d8e92746a13 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpEditDialog.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/McpEditDialog.kt @@ -10,13 +10,18 @@ import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.StackAxis import ai.kilocode.client.ui.list.ActiveListActionCell import ai.kilocode.client.ui.list.ActiveListCell +import ai.kilocode.client.util.webUrl import ai.kilocode.rpc.dto.McpConfigDto +import ai.kilocode.rpc.dto.McpOAuthDto import com.intellij.icons.AllIcons +import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo import com.intellij.ui.CollectionListModel import com.intellij.ui.ScrollingUtil import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBPasswordField import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBTextArea import com.intellij.ui.components.JBTextField @@ -63,6 +68,31 @@ internal class McpEditDialog( columns = FIELD_COLUMNS emptyText.text = KiloBundle.message("settings.agentBehavior.mcp.edit.url.placeholder") } + private val oauthMode = ComboBox(arrayOf(OAUTH_AUTOMATIC, OAUTH_DISABLED, OAUTH_CUSTOM)).apply { + selectedItem = oauthModeFor(cfg.oauth) + addActionListener { syncOauthFields() } + } + private val oauthClientId = JBTextField(cfg.oauth?.clientId.orEmpty()).apply { + columns = FIELD_COLUMNS + emptyText.text = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.clientId") + } + private val oauthClientSecret = JBPasswordField().apply { + columns = FIELD_COLUMNS + text = cfg.oauth?.clientSecret.orEmpty() + } + private val oauthScope = JBTextField(cfg.oauth?.scope.orEmpty()).apply { + columns = FIELD_COLUMNS + emptyText.text = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.scope") + } + private val oauthCallbackPort = JBTextField(cfg.oauth?.callbackPort?.toString().orEmpty()).apply { + columns = ENV_COLUMNS + emptyText.text = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.callbackPort") + } + private val oauthRedirectUri = JBTextField(cfg.oauth?.redirectUri.orEmpty()).apply { + columns = FIELD_COLUMNS + emptyText.text = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.redirectUri") + } + private var oauthCustomRows: SettingsRows? = null private val env = linkedMapOf().apply { putAll(cfg.environment.orEmpty()) } private val key = JBTextField().apply { columns = ENV_COLUMNS @@ -82,8 +112,10 @@ internal class McpEditDialog( internal fun centerComponent(): JComponent = center ?: error("center panel not built") + internal fun validateForTest(): ValidationInfo? = doValidate() + override fun result(): McpConfigDto { - if (type == REMOTE) return cfg.copy(url = text(url.text)) + if (type == REMOTE) return cfg.copy(url = text(url.text), oauth = oauthResult()) return cfg.copy( type = LOCAL, command = listOf(command.text.trim()) + args.text.lines().map { it.trim() }.filter { it.isNotEmpty() }, @@ -91,6 +123,19 @@ internal class McpEditDialog( ) } + private fun oauthResult(): McpOAuthDto? = when (oauthMode.selectedItem) { + OAUTH_DISABLED -> McpOAuthDto(enabled = false) + OAUTH_CUSTOM -> McpOAuthDto( + enabled = true, + clientId = text(oauthClientId.text), + clientSecret = String(oauthClientSecret.password).takeIf { it.isNotBlank() }, + scope = text(oauthScope.text), + callbackPort = oauthCallbackPort.text.trim().toIntOrNull(), + redirectUri = text(oauthRedirectUri.text), + ) + else -> if (cfg.oauth != null) McpOAuthDto(clear = true) else null + } + override fun createCenterPanel(): JComponent { val panel = BaseContentPanel().apply { border = JBUI.Borders.empty(UiStyle.Gap.pad()) @@ -112,6 +157,25 @@ internal class McpEditDialog( )) panel.next(this) } + panel.section( + KiloBundle.message("settings.agentBehavior.mcp.edit.oauth"), + KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.help"), + ).apply { + row(SettingsRow(KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.mode"), null, oauthMode)) + oauthCustomRows = SettingsRows().apply { + row(SettingsStackedRow(KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.clientId"), null, oauthClientId)) + row(SettingsStackedRow(KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.clientSecret"), null, oauthClientSecret)) + row(SettingsStackedRow(KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.scope"), null, oauthScope)) + row(SettingsStackedRow(KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.callbackPort"), null, oauthCallbackPort)) + row(SettingsStackedRow( + KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.redirectUri"), + KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.redirectUri.help"), + oauthRedirectUri, + )) + } + row(oauthCustomRows!!) + } + syncOauthFields() } else { SettingsRows().apply { row(SettingsStackedRow( @@ -143,6 +207,29 @@ internal class McpEditDialog( override fun getDimensionServiceKey(): String = "Kilo.McpEditDialog" + override fun doValidate(): ValidationInfo? { + if (type != REMOTE || oauthMode.selectedItem != OAUTH_CUSTOM) return null + val port = oauthCallbackPort.text.trim() + if (port.isNotEmpty() && (port.toIntOrNull() == null || port.toInt() !in 1..65535)) { + return ValidationInfo(KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.port.invalid"), oauthCallbackPort) + } + if (String(oauthClientSecret.password).isNotBlank() && text(oauthClientId.text) == null) { + return ValidationInfo(KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.secret.invalid"), oauthClientId) + } + // Absolute is not enough: `javascript:x` and `file:///x` are both absolute URIs. The + // redirect target is a loopback HTTP listener, so only http(s) with a host is accepted. + val redirect = oauthRedirectUri.text.trim() + if (redirect.isNotEmpty() && !webUrl(redirect)) { + return ValidationInfo(KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid"), oauthRedirectUri) + } + return null + } + + private fun syncOauthFields() { + val custom = oauthMode.selectedItem == OAUTH_CUSTOM + oauthCustomRows?.isVisible = custom + } + private fun envEditor(): JComponent = Stack.vertical(UiStyle.Gap.sm()) .next(Stack.horizontal(UiStyle.Gap.sm()) .next(key) @@ -304,6 +391,15 @@ internal class McpEditDialog( const val FIELD_COLUMNS = 36 const val ENV_COLUMNS = 14 const val ENV_ROWS = 4 + val OAUTH_AUTOMATIC get() = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.mode.automatic") + val OAUTH_DISABLED get() = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.mode.disabled") + val OAUTH_CUSTOM get() = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.mode.custom") + + fun oauthModeFor(oauth: McpOAuthDto?): String = when { + oauth == null -> OAUTH_AUTOMATIC + oauth.enabled == false -> OAUTH_DISABLED + else -> OAUTH_CUSTOM + } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt index bd9a563a8f0..2f48cbd7620 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.settings.agents import ai.kilocode.client.KiloNotifications import ai.kilocode.client.app.KiloAgentBehaviorService import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloMarketplaceService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.plugin.KiloDocs @@ -29,6 +30,7 @@ import ai.kilocode.client.ui.list.ActiveListSelection import ai.kilocode.client.ui.list.ActiveListView import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.MarketplaceBundleDto import ai.kilocode.rpc.dto.SkillDto import ai.kilocode.rpc.dto.SkillsConfigDto import ai.kilocode.rpc.dto.SkillsPatchDto @@ -87,10 +89,11 @@ internal class SkillsSettingsUi( SettingsPathDialog(sourceDialogTitle(adding, path), value, if (path) choose else null) }, private val edit: (SkillDto, Boolean) -> SkillEditDialogHandle = ::SkillEditDialog, -) : SettingsListPanel(scope, ActiveListConfig.Equal.copy(tooltip = false)), SettingsDraftPage { +) : SettingsListPanel(scope, ActiveListConfig.Equal.copy(tooltip = false, keepActions = true)), SettingsDraftPage { private val cs = scope private var dir = dir private var skills = emptyMap() + private var bundles = emptyList() private val app get() = service() private val state = SettingsDraftState(skillsDraft(app.state.value.config?.skills ?: SkillsConfigDto()), ::saved) private var draft: SkillsDraft @@ -112,16 +115,27 @@ internal class SkillsSettingsUi( reload() } + /** A Marketplace install or removal can add or drop skills while this page sits open. */ + override fun refreshOnFocus(): Boolean = true + override suspend fun fetch(): List { val items = withTimeoutOrNull(SKILL_LOAD_TIMEOUT_MS) { service().loadSkills(dir) } ?: throw SettingsMessageException(KiloBundle.message("settings.agentBehavior.skills.load.timeout")) + val owned = ownedBundles(dir) withContext(edt) { val dirty = state.modified() val edit = draft state.accept(skillsDraft(config())) - if (dirty) draft = state.draft.copy(edited = edit.edited, deleted = edit.deleted) + if (dirty) { + draft = state.draft.copy( + edited = edit.edited, + deleted = edit.deleted, + uninstalls = edit.uninstalls, + ) + } skills = items.associateBy { key(it) } + bundles = owned sources.refresh(draft.sources) } LOG.info("skills settings fetch dir=$dir total=${items.size}") @@ -178,6 +192,15 @@ internal class SkillsSettingsUi( if (target.edited.isNotEmpty() && !behavior.saveSkills(dir, target.edited)) { failed = KiloBundle.message("settings.agentBehavior.save.failed") } + if (failed == null) { + for (bundle in target.uninstalls) { + val result = service().remove(dir, bundle.id, "mcp", bundle.scope) + if (!result.success) { + failed = result.error ?: KiloBundle.message("settings.marketplace.remove.failed") + break + } + } + } if (failed == null) { for (location in target.deleted) { if (!behavior.removeSkill(dir, location)) { @@ -226,12 +249,12 @@ internal class SkillsSettingsUi( } private fun rows(items: List = skills.values.toList()): List = items.mapNotNull { skill -> - if (skill.location in draft.deleted) return@mapNotNull null + if (skill.location in draft.deleted || draft.uninstalls.any { skill.location in it.skills }) return@mapNotNull null item(skill) } private fun skillFallback(target: SkillsDraft): List = skills.values.mapNotNull { skill -> - if (skill.location in target.deleted) return@mapNotNull null + if (skill.location in target.deleted || target.uninstalls.any { skill.location in it.skills }) return@mapNotNull null target.edited[skill.location]?.let { skill.copy(content = it) } ?: skill } @@ -287,14 +310,30 @@ internal class SkillsSettingsUi( } private fun remove(skill: SkillDto) { + val bundle = bundles.singleOrNull { skill.location in it.skills } val result = Messages.showYesNoDialog( - KiloBundle.message("settings.agentBehavior.skills.delete.message", skill.name), + if (bundle == null) { + KiloBundle.message("settings.agentBehavior.skills.delete.message", skill.name) + } else { + KiloBundle.message("settings.agentBehavior.skills.delete.bundle.message", skill.name, bundle.id) + }, KiloBundle.message("settings.agentBehavior.skills.delete.title"), KiloBundle.message("common.delete"), Messages.getCancelButton(), Messages.getQuestionIcon(), ) if (result != Messages.YES) return + if (bundle != null) { + state.update { + copy( + deleted = deleted - bundle.skills.toSet(), + edited = edited - bundle.skills.toSet(), + uninstalls = uninstalls + bundle, + ) + } + view.update(rows(), ActiveListSelection.Slide) + return + } state.update { copy(deleted = deleted + skill.location, edited = edited - skill.location) } view.update(rows(), ActiveListSelection.Slide) } @@ -325,6 +364,7 @@ private data class SkillsDraft( val sources: SkillsConfigDto, val edited: Map = emptyMap(), val deleted: Set = emptySet(), + val uninstalls: Set = emptySet(), ) private fun skillsDraft(sources: SkillsConfigDto) = SkillsDraft(sources) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListPanel.kt index 3e9b8c8fc69..88996c20ce2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListPanel.kt @@ -30,6 +30,7 @@ import com.intellij.ui.DocumentAdapter import com.intellij.ui.SearchTextField import com.intellij.util.concurrency.annotations.RequiresEdt import java.awt.BorderLayout +import java.awt.event.HierarchyEvent import java.awt.event.KeyEvent import javax.swing.Icon import javax.swing.JComponent @@ -61,6 +62,7 @@ internal abstract class SettingsListPanel( private var request = 0 private var disposed = false private var pending = false + private var seenShowing = false protected var busy = false private set @@ -71,6 +73,16 @@ internal abstract class SettingsListPanel( view.setEmptyText(emptyText()) setHeader(header()) setContent(view) + seenShowing = isShowing + addHierarchyListener { e -> + if (e.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@addHierarchyListener + if (!isShowing) return@addHierarchyListener + if (!seenShowing) { + seenShowing = true + return@addHierarchyListener + } + if (!disposed && refreshOnFocus()) reload() + } } @RequiresEdt @@ -93,6 +105,15 @@ internal abstract class SettingsListPanel( return pending } + /** Pre-populate the list search when another UI opens this settings page for a specific item. */ + @RequiresEdt + internal fun filter(query: String) { + checkEdt() + search.text = query + view.filter(query) + search.textEditor.requestFocusInWindow() + } + @RequiresEdt protected fun mutateAndReload( selection: ActiveListSelection = ActiveListSelection.Preserve, @@ -152,6 +173,16 @@ internal abstract class SettingsListPanel( protected open fun showRefresh(): Boolean = true + /** + * Reload the list automatically whenever this panel becomes visible again, e.g. re-navigating + * into it. + * + * Off by default so an unrelated settings list keeps its existing load-once behavior. Turn it on + * for a page whose contents another page can change behind its back, such as MCP and Skills + * after a Marketplace install or removal. + */ + protected open fun refreshOnFocus(): Boolean = false + protected open fun afterApply() = Unit private fun header(): JComponent { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUi.kt index 99792740ea9..5820cd1d615 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUi.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.settings.marketplace import ai.kilocode.client.app.KiloAgentBehaviorService import ai.kilocode.client.app.KiloMarketplaceService +import ai.kilocode.client.app.KiloMcpAuthService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.base.SettingsBannerKind import ai.kilocode.client.settings.base.SettingsListPanel @@ -26,6 +27,7 @@ import com.intellij.openapi.actionSystem.ActionToolbar import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.ex.ActionUtil +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.EDT import com.intellij.openapi.application.ModalityState import com.intellij.openapi.application.asContextElement @@ -43,12 +45,13 @@ import javax.swing.JSeparator import javax.swing.SwingConstants import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private val edt = Dispatchers.EDT + ModalityState.any().asContextElement() internal class MarketplaceSettingsUi( - cs: CoroutineScope, + private val cs: CoroutineScope, dir: String, private val create: (MarketplaceItemDto, Boolean) -> MarketplaceInstallDialogHandle = ::MarketplaceInstallDialog, private val browse: (String) -> Unit = { BrowserUtil.browse(it) }, @@ -65,6 +68,8 @@ internal class MarketplaceSettingsUi( /** Set while a row action is in flight, so the finished reload hands focus back to the list. */ private var refocus = false + /** An MCP item that just installed needing sign-in. Drained by [afterApply] once the reload lands. */ + private var authPrompt: MarketplaceItemDto? = null private val allButton = textAction(this, allLabel()) { selectAllTypes() } private val agentChip = FilterChip(agentLabel(), UiStyle.Badge::typeAgent) { toggleType("agent") } @@ -91,6 +96,9 @@ internal class MarketplaceSettingsUi( reload() } + /** Installed state changes when MCP or Skills Settings removes a bundle this page listed. */ + override fun refreshOnFocus(): Boolean = true + override suspend fun fetch(): List { val result = service().list(dir) items = result.items @@ -270,6 +278,10 @@ internal class MarketplaceSettingsUi( if (request.remove) "Marketplace Item Removed" else "Marketplace Item Installed", mapOf("type" to item.type, "id" to item.id, "target" to request.target), ) + if (!request.remove && item.type == "mcp") { + val needs = service().refresh(dir) + if (item.id in needs) withContext(edt) { authPrompt = item } + } ok = true true } finally { @@ -310,10 +322,47 @@ internal class MarketplaceSettingsUi( override fun afterApply() { syncFilters() - if (!refocus) return - refocus = false - // Puts the row's action overlay back after a mutation, without stealing focus on a plain reload. - view.focusList() + if (refocus) { + refocus = false + // Puts the row's action overlay back after a mutation, without stealing focus on a plain reload. + view.focusList() + } + val item = authPrompt ?: return + authPrompt = null + ApplicationManager.getApplication().invokeLater({ promptSignIn(item) }, ModalityState.any()) + } + + @RequiresEdt + private fun promptSignIn(item: MarketplaceItemDto) { + val answer = Messages.showYesNoDialog( + KiloBundle.message("settings.marketplace.mcp.signIn.message", item.name), + KiloBundle.message("settings.marketplace.mcp.signIn.title"), + KiloBundle.message("settings.marketplace.mcp.signIn.confirm"), + Messages.getCancelButton(), + Messages.getQuestionIcon(), + ) + if (answer != Messages.YES) return + val name = item.id + val auth = service() + if (!launch("marketplace mcp sign in name=$name") { id -> + val result = auth.signIn(dir, name) + if (!withContext(edt) { active(id) }) return@launch + withContext(edt) { auth.report(name, result) } + if (result.status != "connected" && result.status != "cancelled") { + throw SettingsMessageException( + result.error ?: KiloBundle.message("settings.agentBehavior.mcp.signIn.failed", name), + ) + } + withContext(edt) { + if (!active(id)) return@withContext + setBusy(false) + clearProgress() + } + }) return + showProgress( + KiloBundle.message("settings.agentBehavior.mcp.signIn.progress", name), + KiloBundle.message("settings.agentBehavior.mcp.signIn.cancel"), + ) { cs.launch { auth.cancel(dir, name) } } } /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt index 42b4a4b63fc..a59bc1ed72c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt @@ -189,7 +189,16 @@ open class LayeredOverlayPanel( override fun doLayout() { items.forEach { (child, bounds) -> - child.bounds = bounds(this, child) + val next = bounds(this, child) + val prev = child.bounds + if (prev != next) { + child.bounds = next + // Swing repaints a moved component's new bounds, but the region it vacated belongs to + // this panel. Overlay children do move — the prompt rail switches between the lane + // gutter and the scrollbar column, the jump button follows the lane — and without this + // the old position stays painted over the content beneath, showing the child twice. + repaint(prev.union(next)) + } child.doLayout() } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt index 4547cd16394..0e5f1262f48 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt @@ -84,6 +84,14 @@ internal data class ActiveListConfig( val iconAlignment: ActiveListIconAlignment = ActiveListIconAlignment.CENTER, val selection: Int = ListSelectionModel.SINGLE_SELECTION, val hoverActions: Boolean = false, + /** + * Keep a selected row's in-place action cells visible while focus sits outside the list. + * + * Off by default, so a list only shows actions on the focused selection. Turn it on for a list + * whose actions open a dialog or move focus elsewhere (MCP sign-in, skill editing): the actions + * would otherwise disappear from under the pointer as soon as the click moved focus away. + */ + val keepActions: Boolean = false, /** Weight used for the primary row title. */ val title: ActiveListWeight = ActiveListWeight.BOLD, /** Weight used for section headers. */ @@ -149,8 +157,8 @@ internal interface ActiveListHitCell { * list, and the session history stack: a leading icon, a title whose weight follows * [ActiveListConfig.title] with an inline [note], a secondary [description] line, [leading] badges * before the title, inline [badges] after it, optional right-aligned [trailing] text, and action - * [cells]. Action cells are shown only for the active focused selection unless - * [ActiveListCell.alwaysVisible] is true. + * [cells]. Action cells are shown for the selected row, including while the list is unfocused. + * [ActiveListCell.alwaysVisible] also exposes an action on unselected rows. */ internal interface ActiveListItem { val key: String @@ -259,9 +267,8 @@ internal fun activeListHits( @Suppress("UNCHECKED_CAST") val renderer = list.cellRenderer as? ListCellRenderer ?: return emptyList() val cell = list.getCellBounds(index, index) ?: return emptyList() - // Render as focused so the region geometry is available for hit-testing even when the list is - // not the focus owner. Painting still hides the cells on an unfocused list; this only resolves - // hit targets and keeps them stable regardless of focus. + // Render as focused so the region geometry and focused/unfocused selection state never alter + // hit targets. val comp = renderer.getListCellRendererComponent(list, model.getElementAt(index), index, selected, true) comp.setBounds(0, 0, cell.width, cell.height) activeListLayout(comp) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt index 316b62539c2..ebb4fd4cda4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt @@ -358,7 +358,11 @@ internal class ActiveListRenderer( trail.foreground = weak val hovered = (list as? ActiveListActive)?.hoveredIndex() == index - val show = if (cfg.hoverActions) list.isEnabled && selected && hovered else active && list.isEnabled + val show = when { + cfg.hoverActions -> list.isEnabled && selected && hovered + cfg.keepActions -> selected && list.isEnabled + else -> active && list.isEnabled + } syncCells(value, show) cellPane.isVisible = cells.isVisible pill.isVisible = cells.isVisible diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt index b335718db91..8f0c699ef01 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt @@ -881,10 +881,17 @@ internal class ActiveListView( val id = if (enabled) { activeListCellAt(list, idx, e.point, selected, menu?.takeIf { it.available(item) } != null) } else { - activeListCellBounds(list, idx, selected) - .entries - .firstOrNull { it.value.contains(e.point) } - ?.key + // A hit-tested region without an action — a status pill with only an id for tooltip + // hit-testing — must not swallow a click/double-click that falls through to it. `action` + // is only the per-cell handler and is null for every cell routed through the panel-level + // `onCell` callback (Sign In, Connect, Delete, rename, the row menu glyph, ...), so + // blocking must key off whether the hit id is a real cell/region, not that field. + val ids = activeListVisibleCells(item, selected, menu?.takeIf { it.available(item) } != null) + .map { it.id } + .toSet() + activeListRegions(item).keys + activeListHits(list, idx, selected) + .firstOrNull { it.id in ids && it.bounds.contains(e.point) } + ?.id } return Hit(item, id) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/popup/SidePopup.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/popup/SidePopup.kt index 9691e0207f5..45db931c2ad 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/popup/SidePopup.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/popup/SidePopup.kt @@ -28,6 +28,15 @@ internal class SidePopupSpot( val point: Point, val position: Balloon.Position, val distance: Int, + /** Whether the balloon points at [point] with the usual triangular callout. */ + val callout: Boolean = true, + /** + * Whether the balloon paints its drop shadow. + * + * The shadow is reserved outside the border box and is hit-testable, so a balloon placed hard against + * its subject covers the subject with an invisible margin. Turn it off for those placements. + */ + val shadow: Boolean = true, ) /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/popup/SidePopupController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/popup/SidePopupController.kt index 5ed1003bcd7..7a52304b6b3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/popup/SidePopupController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/popup/SidePopupController.kt @@ -71,6 +71,14 @@ internal class SidePopupController( showTimer.restart() } + /** Shows [request] immediately, without the normal hover dwell. */ + @RequiresEdt + fun showNow(key: Any, owner: Disposable, request: () -> SidePopupRequest?) { + show(key, owner, request) + showTimer.stop() + display() + } + @RequiresEdt fun notifyExit(key: Any) { if (target != key) return @@ -88,17 +96,30 @@ internal class SidePopupController( hideTimer.stop() onSubject = false onPopup = false - val popup = balloon - val item = body val hook = guard // Cleared before disposing: Disposer.dispose can re-enter through the balloon's own onClosed, and // that reentry has to find clean state and no-op rather than tear down a newer popup. target = null source = null - balloon = null - body = null guard = null hook?.let(Disposer::dispose) + close() + } + + /** + * Takes down the balloon that is on screen, leaving the pending [target] and [source] alone. + * + * Separate from [hideAll] so opening can guarantee at most one balloon per controller without + * discarding the request it is in the middle of serving. + */ + @RequiresEdt + private fun close() { + val popup = balloon + val item = body + // Cleared before disposing, for the same re-entrancy reason as [hideAll]: the balloon's own + // onClosed runs during hide and must find state that no longer points at this body. + balloon = null + body = null popup?.hide() item?.let(Disposer::dispose) } @@ -148,6 +169,9 @@ internal class SidePopupController( @RequiresEdt private fun open(req: SidePopupRequest, built: SidePopupContent, spot: SidePopupSpot) { + // At most one balloon per controller. [showNow] can reach here while one is already on screen, + // and overwriting the field without this would strand the previous balloon visible. + close() val popup = JBPopupFactory.getInstance() .createBalloonBuilder(built.component) .setFillColor(built.background) @@ -156,6 +180,8 @@ internal class SidePopupController( .setPointerSize(UiStyle.Balloon.pointer()) .setCornerToPointerDistance(spot.distance) .setCornerRadius(UiStyle.Balloon.arc()) + .setShowCallout(spot.callout) + .setShadow(spot.shadow) .setHideOnClickOutside(true) .setHideOnKeyOutside(true) .setHideOnFrameResize(true) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/util/Url.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/util/Url.kt new file mode 100644 index 00000000000..807be6801e7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/util/Url.kt @@ -0,0 +1,26 @@ +package ai.kilocode.client.util + +import java.net.URI +import java.net.URISyntaxException + +/** + * Whether [value] is an absolute `http`/`https` URL with a host. + * + * MCP authorization URLs and OAuth redirect URIs are supplied by the remote server, so they must be + * checked before they reach a browser or the config file. Handing an arbitrary scheme to + * `BrowserUtil.browse` lets a server open `file:///…`, `smb://…`, or `javascript:…` on the user's + * machine, so only the two schemes an OAuth endpoint can legitimately use are accepted. + * + * Parsed rather than prefix-matched so that case, embedded credentials, and a missing host are all + * handled by the URI parser instead of by hand. + */ +internal fun webUrl(value: String): Boolean { + val uri = try { + URI(value.trim()) + } catch (_: URISyntaxException) { + return false + } + val scheme = uri.scheme?.lowercase() ?: return false + if (scheme != "http" && scheme != "https") return false + return !uri.host.isNullOrBlank() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index c0014424360..202ea2517d9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -80,6 +80,10 @@ feedback.dialog.discord=Join our Discord community feedback.dialog.support=Customer Support session.scroll.bottom=Scroll to bottom session.scroll.question=Scroll to question +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet session.copy.hover=Copy session.copy.prompt=Copy prompt session.copy.response=Copy response @@ -274,6 +278,7 @@ session.outcome.incomplete.title=Response may be incomplete session.outcome.incomplete.description=The provider ended this response without signalling that it finished. session.outcome.incomplete.reason=Technical finish reason: {0} session.outcome.retry=Retry +session.outcome.dismiss=Dismiss session.outcome.interrupted.note=Stopped session.cancelled.title=Kilo stopped a running task session.cancelled.reload=Kilo reloaded its configuration and stopped this task before it finished. Nothing was lost from the conversation \u2014 use Retry to continue. @@ -885,11 +890,53 @@ settings.agentBehavior.mcp.edit.env.add=Add settings.agentBehavior.mcp.edit.env.remove=Remove {0} settings.agentBehavior.mcp.delete.title=Delete MCP Server settings.agentBehavior.mcp.delete.message=Delete MCP server {0}? This cannot be undone. +settings.agentBehavior.mcp.delete.bundle.message=Delete MCP server {0} and its companion skills? This removes both the server and every skill owned by this Marketplace installation. settings.agentBehavior.mcp.status.connected=connected settings.agentBehavior.mcp.status.failed=failed settings.agentBehavior.mcp.status.needsAuth=needs auth settings.agentBehavior.mcp.status.needsRegistration=needs registration settings.agentBehavior.mcp.status.disabled=disabled +settings.agentBehavior.mcp.signIn.progress=Waiting for browser sign-in to {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancel +settings.agentBehavior.mcp.signIn.success=Signed in to {0}. +settings.agentBehavior.mcp.signIn.failed=Sign-in to {0} failed. +settings.agentBehavior.mcp.signIn.timeout=Sign-in to {0} timed out. +settings.agentBehavior.mcp.signIn.unsupported={0} does not support OAuth sign-in. +settings.agentBehavior.mcp.signIn.notFound=MCP server {0} was not found. +settings.agentBehavior.mcp.resetAuth=Reset sign-in +settings.agentBehavior.mcp.resetAuth.title=Reset MCP sign-in +settings.agentBehavior.mcp.resetAuth.message=Clear the stored sign-in for {0}? You will need to sign in again. +settings.agentBehavior.mcp.resetAuth.failed=Could not clear the stored sign-in. +settings.agentBehavior.mcp.authUrl.title=Finish MCP sign-in +settings.agentBehavior.mcp.authUrl.message=Kilo could not open a browser for {0}. Open this URL on the machine running Kilo to finish signing in. +settings.agentBehavior.mcp.authUrl.open=Open in Browser +settings.agentBehavior.mcp.authUrl.copy=Copy URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Leave on Automatic unless the server requires a pre-registered client. A client secret is stored in your Kilo config file. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatic +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Disabled +settings.agentBehavior.mcp.edit.oauth.mode.custom=Custom client +settings.agentBehavior.mcp.edit.oauth.clientId=Client ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client secret +settings.agentBehavior.mcp.edit.oauth.scope=Scope +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Redirect URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Defaults to http://127.0.0.1:19876/mcp/oauth/callback and overrides the callback port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Enter a port between 1 and 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=A client secret requires a client ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Enter a valid redirect URI. +settings.marketplace.mcp.signIn.title=Sign in to MCP server +settings.marketplace.mcp.signIn.message={0} is installed but needs sign-in before its tools can be used. Sign in now? +settings.marketplace.mcp.signIn.confirm=Sign In +prompt.mcp.needsAuth.one={0} needs sign-in +prompt.mcp.needsAuth.many={0} MCP servers need sign-in +prompt.mcp.needsAuth.busy=Signing in\u2026 +prompt.mcp.openSettings=Open in Settings +prompt.mcp.provider={0} MCP +prompt.issues.title=Session issues +prompt.mcp.signIn=Sign in to {0} +prompt.mcp.signingIn=Signing in to {0}\u2026 settings.agentBehavior.skills.displayName=Skills settings.agentBehavior.skills.info=Skills are instruction packs the agent loads only when the task at hand matches one. settings.agentBehavior.skills.info.more=Each skill is a SKILL.md file with a short description. Kilo keeps only that description in context and reads the full body when the agent decides the skill applies, so many skills stay cheap. Skills come from this project, your global Kilo config, and any extra folders or URLs listed under Additional Skill Sources. New and changed skills apply after the core reloads, which is deferred while a session is running. @@ -929,6 +976,7 @@ settings.agentBehavior.skills.reload.blocked=Skills settings saved, but active s settings.agentBehavior.skills.saved.notification=Skills settings saved settings.agentBehavior.skills.delete.title=Delete Skill settings.agentBehavior.skills.delete.message=Delete skill {0}? This removes the skill file and cannot be undone. +settings.agentBehavior.skills.delete.bundle.message=Delete skill {0}? This also uninstalls the {1} MCP server and every companion skill owned by the same Marketplace installation. settings.agentBehavior.skills.delete.failed=Could not delete the skill. settings.agentBehavior.skills.openInEditor=Open in Editor settings.agentBehavior.skills.openInEditor.pending=The skill file will open after you close Settings. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index cd8684c6f0a..6b135fa2d0b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=لإضافة خادم MCP، اطلب من الوكيل إضافته. +settings.agentBehavior.mcp.signIn.progress=بانتظار تسجيل الدخول عبر المتصفح إلى {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=إلغاء +settings.agentBehavior.mcp.signIn.success=تم تسجيل الدخول إلى {0}. +settings.agentBehavior.mcp.signIn.failed=فشل تسجيل الدخول إلى {0}. +settings.agentBehavior.mcp.signIn.timeout=انتهت مهلة تسجيل الدخول إلى {0}. +settings.agentBehavior.mcp.signIn.unsupported={0} لا يدعم تسجيل الدخول عبر OAuth. +settings.agentBehavior.mcp.signIn.notFound=لم يتم العثور على خادم MCP ‏{0}. +settings.agentBehavior.mcp.resetAuth=إعادة تعيين تسجيل الدخول +settings.agentBehavior.mcp.resetAuth.title=إعادة تعيين تسجيل دخول MCP +settings.agentBehavior.mcp.resetAuth.message=هل تريد مسح تسجيل الدخول المخزن لـ {0}؟ ستحتاج إلى تسجيل الدخول مرة أخرى. +settings.agentBehavior.mcp.resetAuth.failed=تعذر مسح تسجيل الدخول المخزن. +settings.agentBehavior.mcp.authUrl.title=إكمال تسجيل دخول MCP +settings.agentBehavior.mcp.authUrl.message=تعذر على Kilo فتح متصفح لـ {0}. افتح عنوان URL هذا على الجهاز الذي يشغل Kilo لإكمال تسجيل الدخول. +settings.agentBehavior.mcp.authUrl.open=فتح في المتصفح +settings.agentBehavior.mcp.authUrl.copy=نسخ عنوان URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=اتركه على تلقائي ما لم يتطلب الخادم عميلا مسجلا مسبقا. يتم تخزين سر العميل في ملف إعدادات Kilo لديك. +settings.agentBehavior.mcp.edit.oauth.mode=الوضع +settings.agentBehavior.mcp.edit.oauth.mode.automatic=تلقائي +settings.agentBehavior.mcp.edit.oauth.mode.disabled=معطل +settings.agentBehavior.mcp.edit.oauth.mode.custom=عميل مخصص +settings.agentBehavior.mcp.edit.oauth.clientId=معرف العميل +settings.agentBehavior.mcp.edit.oauth.clientSecret=سر العميل +settings.agentBehavior.mcp.edit.oauth.scope=النطاق +settings.agentBehavior.mcp.edit.oauth.callbackPort=منفذ رد الاتصال +settings.agentBehavior.mcp.edit.oauth.redirectUri=معرف URI لإعادة التوجيه +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=القيمة الافتراضية هي http://127.0.0.1:19876/mcp/oauth/callback وتتجاوز منفذ رد الاتصال. +settings.agentBehavior.mcp.edit.oauth.port.invalid=أدخل منفذا بين 1 و 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=يتطلب سر العميل معرف عميل. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=أدخل معرف URI صالحا لإعادة التوجيه. +settings.marketplace.mcp.signIn.title=تسجيل الدخول إلى خادم MCP +settings.marketplace.mcp.signIn.message={0} مثبت ولكنه يحتاج إلى تسجيل الدخول قبل استخدام أدواته. هل تريد تسجيل الدخول الآن؟ +settings.marketplace.mcp.signIn.confirm=تسجيل الدخول +prompt.mcp.needsAuth.one={0} يحتاج إلى تسجيل الدخول +prompt.mcp.needsAuth.many={0} من خوادم MCP تحتاج إلى تسجيل الدخول +prompt.mcp.needsAuth.busy=جار تسجيل الدخول\u2026 +prompt.mcp.openSettings=فتح في الإعدادات +prompt.mcp.provider={0} MCP +prompt.issues.title=مشكلات الجلسة +prompt.mcp.signIn=تسجيل الدخول إلى {0} +prompt.mcp.signingIn=جار تسجيل الدخول إلى {0}\u2026 settings.agentBehavior.skills.displayName=المهارات settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=تم حفظ إعدادات المه settings.agentBehavior.skills.saved.notification=تم حفظ إعدادات المهارات settings.agentBehavior.skills.delete.title=حذف المهارة settings.agentBehavior.skills.delete.message=هل تريد حذف المهارة {0}؟ سيؤدي ذلك إلى إزالة ملف المهارة ولا يمكن التراجع عنه. +settings.agentBehavior.skills.delete.bundle.message=هل تريد حذف المهارة {0}؟ يؤدي هذا أيضا إلى إلغاء تثبيت خادم MCP ‏{1} وكل مهارة مرافقة تملكها عملية تثبيت Marketplace نفسها. +settings.agentBehavior.mcp.delete.bundle.message=هل تريد حذف خادم MCP ‏{0} ومهاراته المرافقة؟ يؤدي هذا إلى إزالة الخادم وكل مهارة تملكها عملية تثبيت Marketplace هذه. settings.agentBehavior.skills.delete.failed=تعذر حذف المهارة. settings.agentBehavior.skills.openInEditor=فتح في المحرر settings.agentBehavior.skills.openInEditor.pending=سيتم فتح ملف المهارة بعد إغلاق الإعدادات. @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=تعذّر فتح ملف ا settings.rules.info=القواعد هي ملفات تعليمات يضيفها Kilo إلى موجّه النظام في كل طلب، لذا تنطبق على جميع الجلسات. settings.rules.info.more=تُلتقط ملفات AGENTS.md تلقائيًا؛ هذه الصفحة مخصصة للمسارات الإضافية وأنماط المطابقة والروابط التي تريد إدراجها إلى جانبها. استخدم القواعد للقيود الدائمة مثل أسلوب الكتابة البرمجية أو متطلبات المراجعة: الأمر هو موجّه تشغّله، والمهارة تُحمَّل فقط عند ملاءمتها، أما القاعدة فهي فعّالة دائمًا. يؤدي توافق Claude Code أيضًا إلى تحميل تعليمات ومهارات CLAUDE.md من دليل إعدادات Claude Code الخاص بك ويتطلب إعادة التشغيل. settings.agentBehavior.description=تهيئة العملاء وخوادم MCP والقواعد والأوامر والمهارات. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index 8e97236d576..bc4074373cd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Da dodate MCP server, zamolite agenta da ga doda. +settings.agentBehavior.mcp.signIn.progress=Čeka se prijava u {0} preko preglednika\u2026 +settings.agentBehavior.mcp.signIn.cancel=Otkaži +settings.agentBehavior.mcp.signIn.success=Prijavljeni ste u {0}. +settings.agentBehavior.mcp.signIn.failed=Prijava u {0} nije uspjela. +settings.agentBehavior.mcp.signIn.timeout=Vrijeme za prijavu u {0} je isteklo. +settings.agentBehavior.mcp.signIn.unsupported={0} ne podržava OAuth prijavu. +settings.agentBehavior.mcp.signIn.notFound=MCP server {0} nije pronađen. +settings.agentBehavior.mcp.resetAuth=Resetuj prijavu +settings.agentBehavior.mcp.resetAuth.title=Resetuj MCP prijavu +settings.agentBehavior.mcp.resetAuth.message=Obrisati sačuvanu prijavu za {0}? Morat ćete se ponovo prijaviti. +settings.agentBehavior.mcp.resetAuth.failed=Nije moguće obrisati sačuvanu prijavu. +settings.agentBehavior.mcp.authUrl.title=Dovrši MCP prijavu +settings.agentBehavior.mcp.authUrl.message=Kilo nije mogao otvoriti preglednik za {0}. Otvorite ovaj URL na računaru na kojem radi Kilo da dovršite prijavu. +settings.agentBehavior.mcp.authUrl.open=Otvori u pregledniku +settings.agentBehavior.mcp.authUrl.copy=Kopiraj URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Ostavite na Automatski osim ako server zahtijeva unaprijed registrovanog klijenta. Tajna klijenta se čuva u vašoj Kilo konfiguracijskoj datoteci. +settings.agentBehavior.mcp.edit.oauth.mode=Način +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatski +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Onemogućeno +settings.agentBehavior.mcp.edit.oauth.mode.custom=Prilagođeni klijent +settings.agentBehavior.mcp.edit.oauth.clientId=ID klijenta +settings.agentBehavior.mcp.edit.oauth.clientSecret=Tajna klijenta +settings.agentBehavior.mcp.edit.oauth.scope=Opseg +settings.agentBehavior.mcp.edit.oauth.callbackPort=Port za povratni poziv +settings.agentBehavior.mcp.edit.oauth.redirectUri=URI za preusmjeravanje +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Podrazumijevano je http://127.0.0.1:19876/mcp/oauth/callback i nadjačava port za povratni poziv. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Unesite port između 1 i 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=Tajna klijenta zahtijeva ID klijenta. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Unesite važeći URI za preusmjeravanje. +settings.marketplace.mcp.signIn.title=Prijava na MCP server +settings.marketplace.mcp.signIn.message={0} je instaliran, ali zahtijeva prijavu prije korištenja alata. Prijaviti se sada? +settings.marketplace.mcp.signIn.confirm=Prijavi se +prompt.mcp.needsAuth.one={0} zahtijeva prijavu +prompt.mcp.needsAuth.many={0} MCP servera zahtijeva prijavu +prompt.mcp.needsAuth.busy=Prijavljivanje\u2026 +prompt.mcp.openSettings=Otvori u postavkama +prompt.mcp.provider={0} MCP +prompt.issues.title=Problemi sesije +prompt.mcp.signIn=Prijavi se u {0} +prompt.mcp.signingIn=Prijavljivanje u {0}\u2026 settings.agentBehavior.skills.displayName=Vještine settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=Postavke vještina su sačuvane, al settings.agentBehavior.skills.saved.notification=Postavke vještina su sačuvane settings.agentBehavior.skills.delete.title=Izbriši vještinu settings.agentBehavior.skills.delete.message=Izbrisati vještinu {0}? Ovo uklanja datoteku vještine i ne može se poništiti. +settings.agentBehavior.skills.delete.bundle.message=Izbrisati vještinu {0}? Ovo također deinstalira MCP server {1} i svaku prateću vještinu koja pripada istoj Marketplace instalaciji. +settings.agentBehavior.mcp.delete.bundle.message=Izbrisati MCP server {0} i njegove prateće vještine? Ovo uklanja i server i svaku vještinu koja pripada ovoj Marketplace instalaciji. settings.agentBehavior.skills.delete.failed=Nije moguće izbrisati vještinu. settings.agentBehavior.skills.openInEditor=Otvori u editoru settings.agentBehavior.skills.openInEditor.pending=Datoteka vještine će se otvoriti nakon što zatvorite Postavke. @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=Fajl komande nije bilo mogu settings.rules.info=Pravila su fajlovi s uputstvima koje Kilo dodaje u sistemski prompt svakog zahtjeva, tako da se primjenjuju na sve sesije. settings.rules.info.more=AGENTS.md fajlovi se automatski prepoznaju; ova stranica je namijenjena dodatnim putanjama, globovima i URL-ovima koje želite uključiti uz njih. Koristite pravila za trajna ograničenja poput stila kodiranja ili zahtjeva za pregled: komanda je prompt koji pokrećete, vještina se učitava samo kada je relevantna, a pravilo je uvijek aktivno. Claude Code kompatibilnost dodatno učitava CLAUDE.md uputstva i vještine iz vašeg direktorija Claude Code konfiguracije i zahtijeva ponovno pokretanje. settings.agentBehavior.description=Konfigurišite agente, MCP servere, pravila, komande i vještine. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index efec7c356d5..60d04df3b19 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=For at tilføje en MCP-server skal du bede agenten om at gøre det. +settings.agentBehavior.mcp.signIn.progress=Venter på browserlogin til {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Annuller +settings.agentBehavior.mcp.signIn.success=Logget ind på {0}. +settings.agentBehavior.mcp.signIn.failed=Login til {0} mislykkedes. +settings.agentBehavior.mcp.signIn.timeout=Login til {0} fik timeout. +settings.agentBehavior.mcp.signIn.unsupported={0} understøtter ikke OAuth-login. +settings.agentBehavior.mcp.signIn.notFound=MCP-serveren {0} blev ikke fundet. +settings.agentBehavior.mcp.resetAuth=Nulstil login +settings.agentBehavior.mcp.resetAuth.title=Nulstil MCP-login +settings.agentBehavior.mcp.resetAuth.message=Ryd det gemte login for {0}? Du skal logge ind igen. +settings.agentBehavior.mcp.resetAuth.failed=Kunne ikke rydde det gemte login. +settings.agentBehavior.mcp.authUrl.title=Afslut MCP-login +settings.agentBehavior.mcp.authUrl.message=Kilo kunne ikke åbne en browser for {0}. Åbn denne URL på den maskine, der kører Kilo, for at afslutte login. +settings.agentBehavior.mcp.authUrl.open=Åbn i browser +settings.agentBehavior.mcp.authUrl.copy=Kopiér URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Lad stå på Automatisk, medmindre serveren kræver en forhåndsregistreret klient. En klienthemmelighed gemmes i din Kilo-konfigurationsfil. +settings.agentBehavior.mcp.edit.oauth.mode=Tilstand +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatisk +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Deaktiveret +settings.agentBehavior.mcp.edit.oauth.mode.custom=Brugerdefineret klient +settings.agentBehavior.mcp.edit.oauth.clientId=Klient-id +settings.agentBehavior.mcp.edit.oauth.clientSecret=Klienthemmelighed +settings.agentBehavior.mcp.edit.oauth.scope=Omfang +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback-port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Omdirigerings-URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Standard er http://127.0.0.1:19876/mcp/oauth/callback og tilsidesætter callback-porten. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Indtast en port mellem 1 og 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=En klienthemmelighed kræver et klient-id. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Indtast en gyldig omdirigerings-URI. +settings.marketplace.mcp.signIn.title=Log ind på MCP-server +settings.marketplace.mcp.signIn.message={0} er installeret, men kræver login, før værktøjerne kan bruges. Log ind nu? +settings.marketplace.mcp.signIn.confirm=Log ind +prompt.mcp.needsAuth.one={0} kræver login +prompt.mcp.needsAuth.many={0} MCP-servere kræver login +prompt.mcp.needsAuth.busy=Logger ind\u2026 +prompt.mcp.openSettings=Åbn i Indstillinger +prompt.mcp.provider={0} MCP +prompt.issues.title=Sessionsproblemer +prompt.mcp.signIn=Log ind på {0} +prompt.mcp.signingIn=Logger ind på {0}\u2026 settings.agentBehavior.skills.displayName=Færdigheder settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=Færdighedsindstillingerne blev gem settings.agentBehavior.skills.saved.notification=Færdighedsindstillinger gemt settings.agentBehavior.skills.delete.title=Slet færdighed settings.agentBehavior.skills.delete.message=Slet færdigheden {0}? Dette fjerner færdighedsfilen og kan ikke fortrydes. +settings.agentBehavior.skills.delete.bundle.message=Slet færdigheden {0}? Dette afinstallerer også MCP-serveren {1} og alle ledsagende færdigheder, der ejes af samme Marketplace-installation. +settings.agentBehavior.mcp.delete.bundle.message=Slet MCP-serveren {0} og dens ledsagende færdigheder? Dette fjerner både serveren og alle færdigheder, der ejes af denne Marketplace-installation. settings.agentBehavior.skills.delete.failed=Kunne ikke slette færdigheden. settings.agentBehavior.skills.openInEditor=Åbn i editor settings.agentBehavior.skills.openInEditor.pending=Færdighedsfilen åbnes, når du lukker Indstillinger. @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=Kommandofilen kunne ikke åb settings.rules.info=Regler er instruktionsfiler, som Kilo tilføjer til systempromptet for hver anmodning, så de gælder for alle sessioner. settings.rules.info.more=AGENTS.md-filer hentes automatisk; denne side er til de ekstra stier, globs og URL'er, du ønsker inkluderet ud over dem. Brug regler til faste krav, såsom kodestil eller review-krav: en kommando er en prompt, du kører, en skill indlæses kun, når den er relevant, og en regel er altid aktiv. Claude Code-kompatibilitet indlæser derudover CLAUDE.md-instruktioner og skills fra din Claude Code-konfigurationsmappe og kræver en genstart. settings.agentBehavior.description=Konfigurer agenter, MCP-servere, regler, kommandoer og skills. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 1e787b0a679..60fe232207c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Um einen MCP-Server hinzuzufügen, bitten Sie den Agenten darum. +settings.agentBehavior.mcp.signIn.progress=Warte auf Browser-Anmeldung bei {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Abbrechen +settings.agentBehavior.mcp.signIn.success=Bei {0} angemeldet. +settings.agentBehavior.mcp.signIn.failed=Anmeldung bei {0} fehlgeschlagen. +settings.agentBehavior.mcp.signIn.timeout=Zeitüberschreitung bei der Anmeldung bei {0}. +settings.agentBehavior.mcp.signIn.unsupported={0} unterstützt keine OAuth-Anmeldung. +settings.agentBehavior.mcp.signIn.notFound=MCP-Server {0} wurde nicht gefunden. +settings.agentBehavior.mcp.resetAuth=Anmeldung zurücksetzen +settings.agentBehavior.mcp.resetAuth.title=MCP-Anmeldung zurücksetzen +settings.agentBehavior.mcp.resetAuth.message=Gespeicherte Anmeldung für {0} löschen? Sie müssen sich erneut anmelden. +settings.agentBehavior.mcp.resetAuth.failed=Gespeicherte Anmeldung konnte nicht gelöscht werden. +settings.agentBehavior.mcp.authUrl.title=MCP-Anmeldung abschließen +settings.agentBehavior.mcp.authUrl.message=Kilo konnte keinen Browser für {0} öffnen. Öffnen Sie diese URL auf dem Computer, auf dem Kilo ausgeführt wird, um die Anmeldung abzuschließen. +settings.agentBehavior.mcp.authUrl.open=Im Browser öffnen +settings.agentBehavior.mcp.authUrl.copy=URL kopieren +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Auf Automatisch belassen, sofern der Server keinen vorab registrierten Client erfordert. Ein Client-Geheimnis wird in Ihrer Kilo-Konfigurationsdatei gespeichert. +settings.agentBehavior.mcp.edit.oauth.mode=Modus +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatisch +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Deaktiviert +settings.agentBehavior.mcp.edit.oauth.mode.custom=Benutzerdefinierter Client +settings.agentBehavior.mcp.edit.oauth.clientId=Client-ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Client-Geheimnis +settings.agentBehavior.mcp.edit.oauth.scope=Umfang +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callback-Port +settings.agentBehavior.mcp.edit.oauth.redirectUri=Weiterleitungs-URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Standard ist http://127.0.0.1:19876/mcp/oauth/callback; überschreibt den Callback-Port. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Geben Sie einen Port zwischen 1 und 65535 ein. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=Ein Client-Geheimnis erfordert eine Client-ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Geben Sie eine gültige Weiterleitungs-URI ein. +settings.marketplace.mcp.signIn.title=Bei MCP-Server anmelden +settings.marketplace.mcp.signIn.message={0} ist installiert, erfordert aber eine Anmeldung, bevor seine Tools verwendet werden können. Jetzt anmelden? +settings.marketplace.mcp.signIn.confirm=Anmelden +prompt.mcp.needsAuth.one={0} erfordert eine Anmeldung +prompt.mcp.needsAuth.many={0} MCP-Server erfordern eine Anmeldung +prompt.mcp.needsAuth.busy=Anmeldung läuft\u2026 +prompt.mcp.openSettings=In Einstellungen öffnen +prompt.mcp.provider={0} MCP +prompt.issues.title=Sitzungsprobleme +prompt.mcp.signIn=Bei {0} anmelden +prompt.mcp.signingIn=Anmeldung bei {0} läuft\u2026 settings.agentBehavior.skills.displayName=Skills settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=Skill-Einstellungen gespeichert, ab settings.agentBehavior.skills.saved.notification=Skill-Einstellungen gespeichert settings.agentBehavior.skills.delete.title=Skill löschen settings.agentBehavior.skills.delete.message=Skill {0} löschen? Dadurch wird die Skill-Datei entfernt und kann nicht rückgängig gemacht werden. +settings.agentBehavior.skills.delete.bundle.message=Skill {0} löschen? Dadurch wird auch der MCP-Server {1} mit allen begleitenden Skills derselben Marketplace-Installation deinstalliert. +settings.agentBehavior.mcp.delete.bundle.message=MCP-Server {0} und seine begleitenden Skills löschen? Dadurch werden sowohl der Server als auch alle Skills entfernt, die dieser Marketplace-Installation gehören. settings.agentBehavior.skills.delete.failed=Der Skill konnte nicht gelöscht werden. settings.agentBehavior.skills.openInEditor=Im Editor öffnen settings.agentBehavior.skills.openInEditor.pending=Die Skill-Datei wird geöffnet, nachdem Sie die Einstellungen geschlossen haben. @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=Die Befehlsdatei konnte nich settings.rules.info=Regeln sind Anleitungsdateien, die Kilo dem Systemprompt jeder Anfrage hinzufügt, sodass sie für alle Sitzungen gelten. settings.rules.info.more=AGENTS.md-Dateien werden automatisch erkannt; diese Seite dient den zusätzlichen Pfaden, Globs und URLs, die Sie zusätzlich einbeziehen möchten. Verwenden Sie Regeln für dauerhafte Vorgaben wie Coding-Style oder Review-Anforderungen: Ein Befehl ist ein Prompt, den Sie ausführen, ein Skill wird nur geladen, wenn er relevant ist, und eine Regel ist immer aktiv. Die Claude-Code-Kompatibilität lädt zusätzlich CLAUDE.md-Anleitungen und Skills aus Ihrem Claude-Code-Konfigurationsverzeichnis und erfordert einen Neustart. settings.agentBehavior.description=Konfigurieren Sie Agenten, MCP-Server, Regeln, Befehle und Skills. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index 01473c40b83..e57001c3c5f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Para agregar un servidor MCP, pídele al agente que lo haga. +settings.agentBehavior.mcp.signIn.progress=Esperando el inicio de sesión en {0} desde el navegador\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancelar +settings.agentBehavior.mcp.signIn.success=Sesión iniciada en {0}. +settings.agentBehavior.mcp.signIn.failed=No se pudo iniciar sesión en {0}. +settings.agentBehavior.mcp.signIn.timeout=Se agotó el tiempo para iniciar sesión en {0}. +settings.agentBehavior.mcp.signIn.unsupported={0} no admite inicio de sesión con OAuth. +settings.agentBehavior.mcp.signIn.notFound=No se encontró el servidor MCP {0}. +settings.agentBehavior.mcp.resetAuth=Restablecer inicio de sesión +settings.agentBehavior.mcp.resetAuth.title=Restablecer inicio de sesión de MCP +settings.agentBehavior.mcp.resetAuth.message=¿Borrar el inicio de sesión guardado para {0}? Tendrá que iniciar sesión de nuevo. +settings.agentBehavior.mcp.resetAuth.failed=No se pudo borrar el inicio de sesión guardado. +settings.agentBehavior.mcp.authUrl.title=Finalizar inicio de sesión de MCP +settings.agentBehavior.mcp.authUrl.message=Kilo no pudo abrir un navegador para {0}. Abra esta URL en el equipo donde se ejecuta Kilo para terminar de iniciar sesión. +settings.agentBehavior.mcp.authUrl.open=Abrir en navegador +settings.agentBehavior.mcp.authUrl.copy=Copiar URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Déjelo en Automático salvo que el servidor requiera un cliente registrado previamente. Un secreto de cliente se guarda en su archivo de configuración de Kilo. +settings.agentBehavior.mcp.edit.oauth.mode=Modo +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automático +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Deshabilitado +settings.agentBehavior.mcp.edit.oauth.mode.custom=Cliente personalizado +settings.agentBehavior.mcp.edit.oauth.clientId=ID de cliente +settings.agentBehavior.mcp.edit.oauth.clientSecret=Secreto de cliente +settings.agentBehavior.mcp.edit.oauth.scope=Ámbito +settings.agentBehavior.mcp.edit.oauth.callbackPort=Puerto de devolución de llamada +settings.agentBehavior.mcp.edit.oauth.redirectUri=URI de redirección +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=El valor predeterminado es http://127.0.0.1:19876/mcp/oauth/callback y reemplaza el puerto de devolución de llamada. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Ingrese un puerto entre 1 y 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=Un secreto de cliente requiere un ID de cliente. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Ingrese un URI de redirección válido. +settings.marketplace.mcp.signIn.title=Iniciar sesión en el servidor MCP +settings.marketplace.mcp.signIn.message={0} está instalado, pero requiere iniciar sesión antes de usar sus herramientas. ¿Iniciar sesión ahora? +settings.marketplace.mcp.signIn.confirm=Iniciar sesión +prompt.mcp.needsAuth.one={0} requiere iniciar sesión +prompt.mcp.needsAuth.many={0} servidores MCP requieren iniciar sesión +prompt.mcp.needsAuth.busy=Iniciando sesión\u2026 +prompt.mcp.openSettings=Abrir en Configuración +prompt.mcp.provider=MCP de {0} +prompt.issues.title=Problemas de sesión +prompt.mcp.signIn=Iniciar sesión en {0} +prompt.mcp.signingIn=Iniciando sesión en {0}\u2026 settings.agentBehavior.skills.displayName=Habilidades settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=Configuración de habilidades guard settings.agentBehavior.skills.saved.notification=Configuración de habilidades guardada settings.agentBehavior.skills.delete.title=Eliminar habilidad settings.agentBehavior.skills.delete.message=¿Eliminar la habilidad {0}? Esto elimina el archivo de la habilidad y no se puede deshacer. +settings.agentBehavior.skills.delete.bundle.message=¿Eliminar la habilidad {0}? Esto también desinstala el servidor MCP {1} y todas las habilidades complementarias de la misma instalación de Marketplace. +settings.agentBehavior.mcp.delete.bundle.message=¿Eliminar el servidor MCP {0} y sus habilidades complementarias? Esto elimina el servidor y todas las habilidades de esta instalación de Marketplace. settings.agentBehavior.skills.delete.failed=No se pudo eliminar la habilidad. settings.agentBehavior.skills.openInEditor=Abrir en el editor settings.agentBehavior.skills.openInEditor.pending=El archivo de la habilidad se abrirá después de cerrar Configuración. @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=No se pudo abrir el archivo settings.rules.info=Las reglas son archivos de instrucciones que Kilo añade al prompt del sistema de cada solicitud, por lo que se aplican a todas las sesiones. settings.rules.info.more=Los archivos AGENTS.md se detectan automáticamente; esta página es para las rutas, patrones glob y URL adicionales que quieras incluir además de ellos. Usa reglas para restricciones permanentes, como el estilo de codificación o los requisitos de revisión: un comando es un prompt que ejecutas, una habilidad se carga solo cuando es relevante, y una regla siempre está activa. La compatibilidad con Claude Code además carga instrucciones CLAUDE.md y habilidades desde tu directorio de configuración de Claude Code, y requiere un reinicio. settings.agentBehavior.description=Configura agentes, servidores MCP, reglas, comandos y habilidades. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index bded99975d8..0ac92156604 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Pour ajouter un serveur MCP, demandez à l’agent de le faire. +settings.agentBehavior.mcp.signIn.progress=En attente de la connexion navigateur à {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Annuler +settings.agentBehavior.mcp.signIn.success=Connexion à {0} réussie. +settings.agentBehavior.mcp.signIn.failed=La connexion à {0} a échoué. +settings.agentBehavior.mcp.signIn.timeout=La connexion à {0} a expiré. +settings.agentBehavior.mcp.signIn.unsupported={0} ne prend pas en charge la connexion OAuth. +settings.agentBehavior.mcp.signIn.notFound=Le serveur MCP {0} est introuvable. +settings.agentBehavior.mcp.resetAuth=Réinitialiser la connexion +settings.agentBehavior.mcp.resetAuth.title=Réinitialiser la connexion MCP +settings.agentBehavior.mcp.resetAuth.message=Effacer la connexion enregistrée pour {0} ? Vous devrez vous reconnecter. +settings.agentBehavior.mcp.resetAuth.failed=Impossible d’effacer la connexion enregistrée. +settings.agentBehavior.mcp.authUrl.title=Terminer la connexion MCP +settings.agentBehavior.mcp.authUrl.message=Kilo n’a pas pu ouvrir de navigateur pour {0}. Ouvrez cette URL sur la machine exécutant Kilo pour terminer la connexion. +settings.agentBehavior.mcp.authUrl.open=Ouvrir dans le navigateur +settings.agentBehavior.mcp.authUrl.copy=Copier URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Laissez sur Automatique sauf si le serveur nécessite un client préenregistré. Un secret client est stocké dans votre fichier de configuration Kilo. +settings.agentBehavior.mcp.edit.oauth.mode=Mode +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatique +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Désactivé +settings.agentBehavior.mcp.edit.oauth.mode.custom=Client personnalisé +settings.agentBehavior.mcp.edit.oauth.clientId=ID client +settings.agentBehavior.mcp.edit.oauth.clientSecret=Secret client +settings.agentBehavior.mcp.edit.oauth.scope=Portée +settings.agentBehavior.mcp.edit.oauth.callbackPort=Port de rappel +settings.agentBehavior.mcp.edit.oauth.redirectUri=URI de redirection +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=La valeur par défaut est http://127.0.0.1:19876/mcp/oauth/callback et remplace le port de rappel. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Saisissez un port entre 1 et 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=Un secret client nécessite un ID client. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Saisissez un URI de redirection valide. +settings.marketplace.mcp.signIn.title=Se connecter au serveur MCP +settings.marketplace.mcp.signIn.message={0} est installé, mais nécessite une connexion avant de pouvoir utiliser ses outils. Se connecter maintenant ? +settings.marketplace.mcp.signIn.confirm=Se connecter +prompt.mcp.needsAuth.one={0} nécessite une connexion +prompt.mcp.needsAuth.many={0} serveurs MCP nécessitent une connexion +prompt.mcp.needsAuth.busy=Connexion en cours\u2026 +prompt.mcp.openSettings=Ouvrir dans Paramètres +prompt.mcp.provider=MCP {0} +prompt.issues.title=Problèmes de session +prompt.mcp.signIn=Se connecter à {0} +prompt.mcp.signingIn=Connexion à {0} en cours\u2026 settings.agentBehavior.skills.displayName=Compétences settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=Paramètres des compétences enregi settings.agentBehavior.skills.saved.notification=Paramètres des compétences enregistrés settings.agentBehavior.skills.delete.title=Supprimer la compétence settings.agentBehavior.skills.delete.message=Supprimer la compétence {0} ? Cela supprime le fichier de compétence et ne peut pas être annulé. +settings.agentBehavior.skills.delete.bundle.message=Supprimer la compétence {0} ? Cela désinstalle aussi le serveur MCP {1} et toutes les compétences associées appartenant à la même installation Marketplace. +settings.agentBehavior.mcp.delete.bundle.message=Supprimer le serveur MCP {0} et ses compétences associées ? Cela supprime le serveur et toutes les compétences appartenant à cette installation Marketplace. settings.agentBehavior.skills.delete.failed=Impossible de supprimer la compétence. settings.agentBehavior.skills.openInEditor=Ouvrir dans l’éditeur settings.agentBehavior.skills.openInEditor.pending=Le fichier de compétence s’ouvrira après la fermeture des paramètres. @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=Impossible d'ouvrir le fichi settings.rules.info=Les règles sont des fichiers d'instructions que Kilo ajoute au prompt système de chaque requête, elles s'appliquent donc à toutes les sessions. settings.rules.info.more=Les fichiers AGENTS.md sont pris en compte automatiquement ; cette page concerne les chemins, motifs glob et URL supplémentaires que vous souhaitez inclure en plus. Utilisez les règles pour des contraintes permanentes telles que le style de code ou les exigences de revue : une commande est un prompt que vous exécutez, une compétence n'est chargée que lorsqu'elle est pertinente, et une règle est toujours active. La compatibilité Claude Code charge en plus les instructions CLAUDE.md et les compétences depuis le répertoire de configuration Claude Code, et nécessite un redémarrage. settings.agentBehavior.description=Configurez les agents, serveurs MCP, règles, commandes et compétences. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index f30a4dde76d..2e4ec146d5c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=MCPサーバーを追加するには、エージェントに依頼してください。 +settings.agentBehavior.mcp.signIn.progress={0} へのブラウザーでのサインインを待機しています\u2026 +settings.agentBehavior.mcp.signIn.cancel=キャンセル +settings.agentBehavior.mcp.signIn.success={0} にサインインしました。 +settings.agentBehavior.mcp.signIn.failed={0} へのサインインに失敗しました。 +settings.agentBehavior.mcp.signIn.timeout={0} へのサインインがタイムアウトしました。 +settings.agentBehavior.mcp.signIn.unsupported={0} は OAuth サインインをサポートしていません。 +settings.agentBehavior.mcp.signIn.notFound=MCP サーバー {0} が見つかりませんでした。 +settings.agentBehavior.mcp.resetAuth=サインインをリセット +settings.agentBehavior.mcp.resetAuth.title=MCP サインインをリセット +settings.agentBehavior.mcp.resetAuth.message={0} の保存済みサインインを消去しますか?もう一度サインインする必要があります。 +settings.agentBehavior.mcp.resetAuth.failed=保存済みサインインを消去できませんでした。 +settings.agentBehavior.mcp.authUrl.title=MCP サインインを完了 +settings.agentBehavior.mcp.authUrl.message=Kilo は {0} 用のブラウザーを開けませんでした。サインインを完了するには、Kilo を実行しているマシンでこの URL を開いてください。 +settings.agentBehavior.mcp.authUrl.open=ブラウザーで開く +settings.agentBehavior.mcp.authUrl.copy=URL をコピー +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=サーバーが事前登録済みクライアントを必要としない限り、自動のままにしてください。クライアントシークレットは Kilo 設定ファイルに保存されます。 +settings.agentBehavior.mcp.edit.oauth.mode=モード +settings.agentBehavior.mcp.edit.oauth.mode.automatic=自動 +settings.agentBehavior.mcp.edit.oauth.mode.disabled=無効 +settings.agentBehavior.mcp.edit.oauth.mode.custom=カスタムクライアント +settings.agentBehavior.mcp.edit.oauth.clientId=クライアント ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=クライアントシークレット +settings.agentBehavior.mcp.edit.oauth.scope=スコープ +settings.agentBehavior.mcp.edit.oauth.callbackPort=コールバックポート +settings.agentBehavior.mcp.edit.oauth.redirectUri=リダイレクト URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=既定値は http://127.0.0.1:19876/mcp/oauth/callback で、コールバックポートを上書きします。 +settings.agentBehavior.mcp.edit.oauth.port.invalid=1 から 65535 までのポートを入力してください。 +settings.agentBehavior.mcp.edit.oauth.secret.invalid=クライアントシークレットにはクライアント ID が必要です。 +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=有効なリダイレクト URI を入力してください。 +settings.marketplace.mcp.signIn.title=MCP サーバーにサインイン +settings.marketplace.mcp.signIn.message={0} はインストール済みですが、ツールを使用する前にサインインが必要です。今すぐサインインしますか? +settings.marketplace.mcp.signIn.confirm=サインイン +prompt.mcp.needsAuth.one={0} にはサインインが必要です +prompt.mcp.needsAuth.many={0} 個の MCP サーバーにはサインインが必要です +prompt.mcp.needsAuth.busy=サインイン中\u2026 +prompt.mcp.openSettings=設定で開く +prompt.mcp.provider={0} MCP +prompt.issues.title=セッションの問題 +prompt.mcp.signIn={0} にサインイン +prompt.mcp.signingIn={0} にサインイン中\u2026 settings.agentBehavior.skills.displayName=スキル settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=スキル設定を保存しまし settings.agentBehavior.skills.saved.notification=スキル設定を保存しました settings.agentBehavior.skills.delete.title=スキルを削除 settings.agentBehavior.skills.delete.message=スキル {0} を削除しますか?スキルファイルが削除され、この操作は元に戻せません。 +settings.agentBehavior.skills.delete.bundle.message=スキル {0} を削除しますか?これにより、同じ Marketplace インストールが所有する MCP サーバー {1} とすべての関連スキルもアンインストールされます。 +settings.agentBehavior.mcp.delete.bundle.message=MCP サーバー {0} とその関連スキルを削除しますか?これにより、この Marketplace インストールが所有するサーバーとすべてのスキルが削除されます。 settings.agentBehavior.skills.delete.failed=スキルを削除できませんでした。 settings.agentBehavior.skills.openInEditor=エディターで開く settings.agentBehavior.skills.openInEditor.pending=設定を閉じるとスキルファイルが開きます。 @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=コマンドファイルを settings.rules.info=ルールとは、Kilo がすべてのリクエストのシステムプロンプトに追加する指示ファイルであり、すべてのセッションに適用されます。 settings.rules.info.more=AGENTS.md ファイルは自動的に取得されます。このページは、それらに加えて含めたい追加のパス、グロブ、URL のためのものです。コーディングスタイルやレビュー要件といった常時適用される制約にはルールを使用してください。コマンドは実行するプロンプト、スキルは関連性がある場合のみ読み込まれるもの、そしてルールは常に有効です。Claude Code 互換機能では、さらに Claude Code の設定ディレクトリから CLAUDE.md の指示とスキルを読み込み、再起動が必要です。 settings.agentBehavior.description=エージェント、MCP サーバー、ルール、コマンド、スキルを設定します。 + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index 195033ee58f..1c4eb978b10 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=MCP 서버를 추가하려면 에이전트에게 요청하세요. +settings.agentBehavior.mcp.signIn.progress={0}에 대한 브라우저 로그인을 기다리는 중\u2026 +settings.agentBehavior.mcp.signIn.cancel=취소 +settings.agentBehavior.mcp.signIn.success={0}에 로그인했습니다. +settings.agentBehavior.mcp.signIn.failed={0} 로그인에 실패했습니다. +settings.agentBehavior.mcp.signIn.timeout={0} 로그인 시간이 초과되었습니다. +settings.agentBehavior.mcp.signIn.unsupported={0}은 OAuth 로그인을 지원하지 않습니다. +settings.agentBehavior.mcp.signIn.notFound=MCP 서버 {0}을 찾을 수 없습니다. +settings.agentBehavior.mcp.resetAuth=로그인 재설정 +settings.agentBehavior.mcp.resetAuth.title=MCP 로그인 재설정 +settings.agentBehavior.mcp.resetAuth.message={0}의 저장된 로그인을 지울까요? 다시 로그인해야 합니다. +settings.agentBehavior.mcp.resetAuth.failed=저장된 로그인을 지울 수 없습니다. +settings.agentBehavior.mcp.authUrl.title=MCP 로그인 완료 +settings.agentBehavior.mcp.authUrl.message=Kilo가 {0}에 대한 브라우저를 열 수 없습니다. 로그인을 완료하려면 Kilo가 실행 중인 컴퓨터에서 이 URL을 여세요. +settings.agentBehavior.mcp.authUrl.open=브라우저에서 열기 +settings.agentBehavior.mcp.authUrl.copy=URL 복사 +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=서버가 사전 등록된 클라이언트를 요구하지 않는 한 자동으로 두세요. 클라이언트 암호는 Kilo 구성 파일에 저장됩니다. +settings.agentBehavior.mcp.edit.oauth.mode=모드 +settings.agentBehavior.mcp.edit.oauth.mode.automatic=자동 +settings.agentBehavior.mcp.edit.oauth.mode.disabled=비활성화됨 +settings.agentBehavior.mcp.edit.oauth.mode.custom=사용자 지정 클라이언트 +settings.agentBehavior.mcp.edit.oauth.clientId=클라이언트 ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=클라이언트 암호 +settings.agentBehavior.mcp.edit.oauth.scope=범위 +settings.agentBehavior.mcp.edit.oauth.callbackPort=콜백 포트 +settings.agentBehavior.mcp.edit.oauth.redirectUri=리디렉션 URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=기본값은 http://127.0.0.1:19876/mcp/oauth/callback이며 콜백 포트를 재정의합니다. +settings.agentBehavior.mcp.edit.oauth.port.invalid=1에서 65535 사이의 포트를 입력하세요. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=클라이언트 암호에는 클라이언트 ID가 필요합니다. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=유효한 리디렉션 URI를 입력하세요. +settings.marketplace.mcp.signIn.title=MCP 서버에 로그인 +settings.marketplace.mcp.signIn.message={0}이 설치되어 있지만 도구를 사용하려면 먼저 로그인해야 합니다. 지금 로그인할까요? +settings.marketplace.mcp.signIn.confirm=로그인 +prompt.mcp.needsAuth.one={0}에 로그인이 필요합니다 +prompt.mcp.needsAuth.many=MCP 서버 {0}개에 로그인이 필요합니다 +prompt.mcp.needsAuth.busy=로그인 중\u2026 +prompt.mcp.openSettings=설정에서 열기 +prompt.mcp.provider={0} MCP +prompt.issues.title=세션 문제 +prompt.mcp.signIn={0}에 로그인 +prompt.mcp.signingIn={0}에 로그인 중\u2026 settings.agentBehavior.skills.displayName=스킬 settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=스킬 설정이 저장되었지만 settings.agentBehavior.skills.saved.notification=스킬 설정이 저장되었습니다 settings.agentBehavior.skills.delete.title=스킬 삭제 settings.agentBehavior.skills.delete.message=스킬 {0}을 삭제할까요? 스킬 파일이 제거되며 되돌릴 수 없습니다. +settings.agentBehavior.skills.delete.bundle.message=스킬 {0}을 삭제할까요? 그러면 같은 Marketplace 설치가 소유한 MCP 서버 {1} 및 모든 관련 스킬도 제거됩니다. +settings.agentBehavior.mcp.delete.bundle.message=MCP 서버 {0} 및 관련 스킬을 삭제할까요? 그러면 이 Marketplace 설치가 소유한 서버와 모든 스킬이 제거됩니다. settings.agentBehavior.skills.delete.failed=스킬을 삭제할 수 없습니다. settings.agentBehavior.skills.openInEditor=에디터에서 열기 settings.agentBehavior.skills.openInEditor.pending=설정을 닫으면 스킬 파일이 열립니다. @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=편집기에서 명령 파 settings.rules.info=규칙은 Kilo가 모든 요청의 시스템 프롬프트에 추가하는 지침 파일로, 모든 세션에 적용됩니다. settings.rules.info.more=AGENTS.md 파일은 자동으로 인식됩니다. 이 페이지는 그 외에 포함하고 싶은 추가 경로, 글롭 패턴, URL을 위한 곳입니다. 코딩 스타일이나 리뷰 요구 사항과 같은 상시 제약에는 규칙을 사용하세요. 명령은 실행하는 프롬프트이고, 스킬은 관련이 있을 때만 로드되며, 규칙은 항상 적용됩니다. Claude Code 호환성을 사용하면 Claude Code 설정 디렉터리에서 CLAUDE.md 지침과 스킬도 추가로 로드되며 재시작이 필요합니다. settings.agentBehavior.description=에이전트, MCP 서버, 규칙, 명령, 스킬을 구성합니다. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index fd462b1e452..13b22b80e0a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Vraag de agent om een MCP-server toe te voegen. +settings.agentBehavior.mcp.signIn.progress=Wachten op browseraanmelding bij {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Annuleren +settings.agentBehavior.mcp.signIn.success=Aangemeld bij {0}. +settings.agentBehavior.mcp.signIn.failed=Aanmelden bij {0} is mislukt. +settings.agentBehavior.mcp.signIn.timeout=Aanmelden bij {0} is verlopen. +settings.agentBehavior.mcp.signIn.unsupported={0} ondersteunt geen OAuth-aanmelding. +settings.agentBehavior.mcp.signIn.notFound=MCP-server {0} is niet gevonden. +settings.agentBehavior.mcp.resetAuth=Aanmelding resetten +settings.agentBehavior.mcp.resetAuth.title=MCP-aanmelding resetten +settings.agentBehavior.mcp.resetAuth.message=Opgeslagen aanmelding voor {0} wissen? U moet zich opnieuw aanmelden. +settings.agentBehavior.mcp.resetAuth.failed=Kon de opgeslagen aanmelding niet wissen. +settings.agentBehavior.mcp.authUrl.title=MCP-aanmelding voltooien +settings.agentBehavior.mcp.authUrl.message=Kilo kon geen browser openen voor {0}. Open deze URL op de machine waarop Kilo draait om de aanmelding te voltooien. +settings.agentBehavior.mcp.authUrl.open=Openen in browser +settings.agentBehavior.mcp.authUrl.copy=URL kopiëren +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Laat op Automatisch staan, tenzij de server een vooraf geregistreerde client vereist. Een clientgeheim wordt opgeslagen in uw Kilo-configuratiebestand. +settings.agentBehavior.mcp.edit.oauth.mode=Modus +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatisch +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Uitgeschakeld +settings.agentBehavior.mcp.edit.oauth.mode.custom=Aangepaste client +settings.agentBehavior.mcp.edit.oauth.clientId=Client-ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Clientgeheim +settings.agentBehavior.mcp.edit.oauth.scope=Bereik +settings.agentBehavior.mcp.edit.oauth.callbackPort=Callbackpoort +settings.agentBehavior.mcp.edit.oauth.redirectUri=Omleidings-URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Standaard is http://127.0.0.1:19876/mcp/oauth/callback en overschrijft de callbackpoort. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Voer een poort tussen 1 en 65535 in. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=Voor een clientgeheim is een client-ID vereist. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Voer een geldige omleidings-URI in. +settings.marketplace.mcp.signIn.title=Aanmelden bij MCP-server +settings.marketplace.mcp.signIn.message={0} is geïnstalleerd, maar vereist aanmelding voordat de tools kunnen worden gebruikt. Nu aanmelden? +settings.marketplace.mcp.signIn.confirm=Aanmelden +prompt.mcp.needsAuth.one={0} vereist aanmelding +prompt.mcp.needsAuth.many={0} MCP-servers vereisen aanmelding +prompt.mcp.needsAuth.busy=Aanmelden\u2026 +prompt.mcp.openSettings=Openen in Instellingen +prompt.mcp.provider={0} MCP +prompt.issues.title=Sessieproblemen +prompt.mcp.signIn=Aanmelden bij {0} +prompt.mcp.signingIn=Aanmelden bij {0}\u2026 settings.agentBehavior.skills.displayName=Vaardigheden settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=Vaardigheidsinstellingen opgeslagen settings.agentBehavior.skills.saved.notification=Vaardigheidsinstellingen opgeslagen settings.agentBehavior.skills.delete.title=Vaardigheid verwijderen settings.agentBehavior.skills.delete.message=Vaardigheid {0} verwijderen? Dit verwijdert het vaardigheidsbestand en kan niet ongedaan worden gemaakt. +settings.agentBehavior.skills.delete.bundle.message=Vaardigheid {0} verwijderen? Hiermee wordt ook de MCP-server {1} verwijderd, plus elke bijbehorende vaardigheid van dezelfde Marketplace-installatie. +settings.agentBehavior.mcp.delete.bundle.message=MCP-server {0} en de bijbehorende vaardigheden verwijderen? Hiermee worden de server en alle vaardigheden van deze Marketplace-installatie verwijderd. settings.agentBehavior.skills.delete.failed=Kon de vaardigheid niet verwijderen. settings.agentBehavior.skills.openInEditor=Openen in editor settings.agentBehavior.skills.openInEditor.pending=Het vaardigheidsbestand wordt geopend nadat u Instellingen sluit. @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=Het opdrachtbestand kon niet settings.rules.info=Regels zijn instructiebestanden die Kilo toevoegt aan de systeemprompt van elk verzoek, zodat ze voor alle sessies gelden. settings.rules.info.more=AGENTS.md-bestanden worden automatisch opgepikt; deze pagina is voor de extra paden, globs en URL's die je daar bovenop wilt toevoegen. Gebruik regels voor permanente beperkingen zoals codeerstijl of reviewvereisten: een opdracht is een prompt die je uitvoert, een skill wordt alleen geladen wanneer deze relevant is, en een regel is altijd actief. Claude Code-compatibiliteit laadt daarnaast CLAUDE.md-instructies en skills uit je Claude Code-configuratiemap en vereist een herstart. settings.agentBehavior.description=Configureer agents, MCP-servers, regels, opdrachten en skills. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index 7e3f65877d4..c2e0048477c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Be agenten om å legge til en MCP-server. +settings.agentBehavior.mcp.signIn.progress=Venter på nettleserpålogging til {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Avbryt +settings.agentBehavior.mcp.signIn.success=Logget på {0}. +settings.agentBehavior.mcp.signIn.failed=Pålogging til {0} mislyktes. +settings.agentBehavior.mcp.signIn.timeout=Pålogging til {0} ble tidsavbrutt. +settings.agentBehavior.mcp.signIn.unsupported={0} støtter ikke OAuth-pålogging. +settings.agentBehavior.mcp.signIn.notFound=MCP-serveren {0} ble ikke funnet. +settings.agentBehavior.mcp.resetAuth=Tilbakestill pålogging +settings.agentBehavior.mcp.resetAuth.title=Tilbakestill MCP-pålogging +settings.agentBehavior.mcp.resetAuth.message=Fjerne den lagrede påloggingen for {0}? Du må logge på igjen. +settings.agentBehavior.mcp.resetAuth.failed=Kunne ikke fjerne den lagrede påloggingen. +settings.agentBehavior.mcp.authUrl.title=Fullfør MCP-pålogging +settings.agentBehavior.mcp.authUrl.message=Kilo kunne ikke åpne en nettleser for {0}. Åpne denne URL-en på maskinen som kjører Kilo for å fullføre påloggingen. +settings.agentBehavior.mcp.authUrl.open=Åpne i nettleser +settings.agentBehavior.mcp.authUrl.copy=Kopier URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=La stå på Automatisk med mindre serveren krever en forhåndsregistrert klient. En klienthemmelighet lagres i Kilo-konfigurasjonsfilen din. +settings.agentBehavior.mcp.edit.oauth.mode=Modus +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatisk +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Deaktivert +settings.agentBehavior.mcp.edit.oauth.mode.custom=Egendefinert klient +settings.agentBehavior.mcp.edit.oauth.clientId=Klient-ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=Klienthemmelighet +settings.agentBehavior.mcp.edit.oauth.scope=Omfang +settings.agentBehavior.mcp.edit.oauth.callbackPort=Tilbakekallingsport +settings.agentBehavior.mcp.edit.oauth.redirectUri=Omdirigerings-URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Standard er http://127.0.0.1:19876/mcp/oauth/callback og overstyrer tilbakekallingsporten. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Skriv inn en port mellom 1 og 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=En klienthemmelighet krever en klient-ID. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Skriv inn en gyldig omdirigerings-URI. +settings.marketplace.mcp.signIn.title=Logg på MCP-server +settings.marketplace.mcp.signIn.message={0} er installert, men krever pålogging før verktøyene kan brukes. Logge på nå? +settings.marketplace.mcp.signIn.confirm=Logg på +prompt.mcp.needsAuth.one={0} krever pålogging +prompt.mcp.needsAuth.many={0} MCP-servere krever pålogging +prompt.mcp.needsAuth.busy=Logger på\u2026 +prompt.mcp.openSettings=Åpne i Innstillinger +prompt.mcp.provider={0} MCP +prompt.issues.title=Øktproblemer +prompt.mcp.signIn=Logg på {0} +prompt.mcp.signingIn=Logger på {0}\u2026 settings.agentBehavior.skills.displayName=Ferdigheter settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=Ferdighetsinnstillinger ble lagret, settings.agentBehavior.skills.saved.notification=Ferdighetsinnstillinger lagret settings.agentBehavior.skills.delete.title=Slett ferdighet settings.agentBehavior.skills.delete.message=Slette ferdigheten {0}? Dette fjerner ferdighetsfilen og kan ikke angres. +settings.agentBehavior.skills.delete.bundle.message=Slette ferdigheten {0}? Dette avinstallerer også MCP-serveren {1} og alle tilhørende ferdigheter som eies av samme Marketplace-installasjon. +settings.agentBehavior.mcp.delete.bundle.message=Slette MCP-serveren {0} og de tilhørende ferdighetene? Dette fjerner både serveren og alle ferdigheter som eies av denne Marketplace-installasjonen. settings.agentBehavior.skills.delete.failed=Kunne ikke slette ferdigheten. settings.agentBehavior.skills.openInEditor=Åpne i editor settings.agentBehavior.skills.openInEditor.pending=Ferdighetsfilen åpnes etter at du lukker Innstillinger. @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=Kunne ikke åpne kommandofil settings.rules.info=Regler er instruksjonsfiler Kilo legger til i systempromptet for hver forespørsel, så de gjelder for alle økter. settings.rules.info.more=AGENTS.md-filer plukkes opp automatisk; denne siden er for de ekstra banene, mønstrene og URL-ene du vil inkludere i tillegg til dem. Bruk regler for faste begrensninger som kodestil eller krav til gjennomgang: en kommando er en prompt du kjører, en ferdighet lastes kun når den er relevant, og en regel er alltid aktiv. Claude Code-kompatibilitet laster i tillegg CLAUDE.md-instruksjoner og ferdigheter fra Claude Code-konfigurasjonsmappen din, og krever en omstart. settings.agentBehavior.description=Konfigurer agenter, MCP-servere, regler, kommandoer og ferdigheter. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index 67448aedc35..2ed5f54e46d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Aby dodać serwer MCP, poproś agenta, aby to zrobił. +settings.agentBehavior.mcp.signIn.progress=Oczekiwanie na logowanie w przeglądarce do {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Anuluj +settings.agentBehavior.mcp.signIn.success=Zalogowano do {0}. +settings.agentBehavior.mcp.signIn.failed=Logowanie do {0} nie powiodło się. +settings.agentBehavior.mcp.signIn.timeout=Upłynął limit czasu logowania do {0}. +settings.agentBehavior.mcp.signIn.unsupported={0} nie obsługuje logowania OAuth. +settings.agentBehavior.mcp.signIn.notFound=Nie znaleziono serwera MCP {0}. +settings.agentBehavior.mcp.resetAuth=Resetuj logowanie +settings.agentBehavior.mcp.resetAuth.title=Resetuj logowanie MCP +settings.agentBehavior.mcp.resetAuth.message=Wyczyścić zapisane logowanie dla {0}? Trzeba będzie zalogować się ponownie. +settings.agentBehavior.mcp.resetAuth.failed=Nie można wyczyścić zapisanego logowania. +settings.agentBehavior.mcp.authUrl.title=Dokończ logowanie MCP +settings.agentBehavior.mcp.authUrl.message=Kilo nie mogło otworzyć przeglądarki dla {0}. Otwórz ten adres URL na komputerze, na którym działa Kilo, aby dokończyć logowanie. +settings.agentBehavior.mcp.authUrl.open=Otwórz w przeglądarce +settings.agentBehavior.mcp.authUrl.copy=Kopiuj URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Pozostaw tryb Automatyczny, chyba że serwer wymaga wstępnie zarejestrowanego klienta. Sekret klienta jest przechowywany w pliku konfiguracji Kilo. +settings.agentBehavior.mcp.edit.oauth.mode=Tryb +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automatyczny +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Wyłączone +settings.agentBehavior.mcp.edit.oauth.mode.custom=Niestandardowy klient +settings.agentBehavior.mcp.edit.oauth.clientId=ID klienta +settings.agentBehavior.mcp.edit.oauth.clientSecret=Sekret klienta +settings.agentBehavior.mcp.edit.oauth.scope=Zakres +settings.agentBehavior.mcp.edit.oauth.callbackPort=Port wywołania zwrotnego +settings.agentBehavior.mcp.edit.oauth.redirectUri=URI przekierowania +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Domyślnie http://127.0.0.1:19876/mcp/oauth/callback; zastępuje port wywołania zwrotnego. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Wpisz port od 1 do 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=Sekret klienta wymaga ID klienta. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Wpisz prawidłowy URI przekierowania. +settings.marketplace.mcp.signIn.title=Zaloguj do serwera MCP +settings.marketplace.mcp.signIn.message={0} jest zainstalowany, ale wymaga logowania przed użyciem jego narzędzi. Zalogować teraz? +settings.marketplace.mcp.signIn.confirm=Zaloguj +prompt.mcp.needsAuth.one={0} wymaga logowania +prompt.mcp.needsAuth.many={0} serwerów MCP wymaga logowania +prompt.mcp.needsAuth.busy=Logowanie\u2026 +prompt.mcp.openSettings=Otwórz w ustawieniach +prompt.mcp.provider={0} MCP +prompt.issues.title=Problemy sesji +prompt.mcp.signIn=Zaloguj do {0} +prompt.mcp.signingIn=Logowanie do {0}\u2026 settings.agentBehavior.skills.displayName=Umiejętności settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=Ustawienia umiejętności zapisane, settings.agentBehavior.skills.saved.notification=Ustawienia umiejętności zapisane settings.agentBehavior.skills.delete.title=Usuń umiejętność settings.agentBehavior.skills.delete.message=Usunąć umiejętność {0}? Spowoduje to usunięcie pliku umiejętności i nie można tego cofnąć. +settings.agentBehavior.skills.delete.bundle.message=Usunąć umiejętność {0}? Spowoduje to także odinstalowanie serwera MCP {1} oraz wszystkich powiązanych umiejętności należących do tej samej instalacji Marketplace. +settings.agentBehavior.mcp.delete.bundle.message=Usunąć serwer MCP {0} i jego powiązane umiejętności? Spowoduje to usunięcie serwera oraz wszystkich umiejętności należących do tej instalacji Marketplace. settings.agentBehavior.skills.delete.failed=Nie można usunąć umiejętności. settings.agentBehavior.skills.openInEditor=Otwórz w edytorze settings.agentBehavior.skills.openInEditor.pending=Plik umiejętności otworzy się po zamknięciu Ustawień. @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=Nie udało się otworzyć pl settings.rules.info=Reguły to pliki instrukcji, które Kilo dodaje do systemowego promptu każdego żądania, więc obowiązują we wszystkich sesjach. settings.rules.info.more=Pliki AGENTS.md są wykrywane automatycznie; ta strona służy do dodania dodatkowych ścieżek, wzorców glob i adresów URL, które mają być uwzględnione oprócz nich. Reguł używaj do stałych ograniczeń, takich jak styl kodowania czy wymagania dotyczące przeglądu: polecenie to prompt, który uruchamiasz, umiejętność jest wczytywana tylko wtedy, gdy jest istotna, a reguła obowiązuje zawsze. Zgodność z Claude Code dodatkowo wczytuje instrukcje z plików CLAUDE.md oraz umiejętności z katalogu konfiguracyjnego Claude Code i wymaga ponownego uruchomienia. settings.agentBehavior.description=Skonfiguruj agentów, serwery MCP, reguły, polecenia i umiejętności. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 33ea6f1c85f..9a98d248787 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Para adicionar um servidor MCP, peça ao agente para fazer isso. +settings.agentBehavior.mcp.signIn.progress=Aguardando login no navegador em {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=Cancelar +settings.agentBehavior.mcp.signIn.success=Conectado a {0}. +settings.agentBehavior.mcp.signIn.failed=Falha ao entrar em {0}. +settings.agentBehavior.mcp.signIn.timeout=Tempo esgotado ao entrar em {0}. +settings.agentBehavior.mcp.signIn.unsupported={0} não oferece suporte a login OAuth. +settings.agentBehavior.mcp.signIn.notFound=O servidor MCP {0} não foi encontrado. +settings.agentBehavior.mcp.resetAuth=Redefinir login +settings.agentBehavior.mcp.resetAuth.title=Redefinir login MCP +settings.agentBehavior.mcp.resetAuth.message=Limpar o login armazenado de {0}? Você precisará entrar novamente. +settings.agentBehavior.mcp.resetAuth.failed=Não foi possível limpar o login armazenado. +settings.agentBehavior.mcp.authUrl.title=Concluir login MCP +settings.agentBehavior.mcp.authUrl.message=O Kilo não conseguiu abrir um navegador para {0}. Abra esta URL na máquina que executa o Kilo para concluir o login. +settings.agentBehavior.mcp.authUrl.open=Abrir no navegador +settings.agentBehavior.mcp.authUrl.copy=Copiar URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Deixe em Automático, a menos que o servidor exija um cliente pré-registrado. Um segredo do cliente é armazenado no seu arquivo de configuração do Kilo. +settings.agentBehavior.mcp.edit.oauth.mode=Modo +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Automático +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Desativado +settings.agentBehavior.mcp.edit.oauth.mode.custom=Cliente personalizado +settings.agentBehavior.mcp.edit.oauth.clientId=ID do cliente +settings.agentBehavior.mcp.edit.oauth.clientSecret=Segredo do cliente +settings.agentBehavior.mcp.edit.oauth.scope=Escopo +settings.agentBehavior.mcp.edit.oauth.callbackPort=Porta de callback +settings.agentBehavior.mcp.edit.oauth.redirectUri=URI de redirecionamento +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=O padrão é http://127.0.0.1:19876/mcp/oauth/callback e substitui a porta de callback. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Digite uma porta entre 1 e 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=Um segredo do cliente exige um ID do cliente. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Digite um URI de redirecionamento válido. +settings.marketplace.mcp.signIn.title=Entrar no servidor MCP +settings.marketplace.mcp.signIn.message={0} está instalado, mas precisa de login antes que suas ferramentas possam ser usadas. Entrar agora? +settings.marketplace.mcp.signIn.confirm=Entrar +prompt.mcp.needsAuth.one={0} precisa de login +prompt.mcp.needsAuth.many={0} servidores MCP precisam de login +prompt.mcp.needsAuth.busy=Entrando\u2026 +prompt.mcp.openSettings=Abrir em Configurações +prompt.mcp.provider={0} MCP +prompt.issues.title=Problemas da sessão +prompt.mcp.signIn=Entrar em {0} +prompt.mcp.signingIn=Entrando em {0}\u2026 settings.agentBehavior.skills.displayName=Habilidades settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=Configurações de habilidades salv settings.agentBehavior.skills.saved.notification=Configurações de habilidades salvas settings.agentBehavior.skills.delete.title=Excluir habilidade settings.agentBehavior.skills.delete.message=Excluir a habilidade {0}? Isso remove o arquivo da habilidade e não pode ser desfeito. +settings.agentBehavior.skills.delete.bundle.message=Excluir a habilidade {0}? Isso também desinstala o servidor MCP {1} e todas as habilidades complementares pertencentes à mesma instalação do Marketplace. +settings.agentBehavior.mcp.delete.bundle.message=Excluir o servidor MCP {0} e suas habilidades complementares? Isso remove o servidor e todas as habilidades pertencentes a esta instalação do Marketplace. settings.agentBehavior.skills.delete.failed=Não foi possível excluir a habilidade. settings.agentBehavior.skills.openInEditor=Abrir no editor settings.agentBehavior.skills.openInEditor.pending=O arquivo da habilidade será aberto depois que você fechar as Configurações. @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=Não foi possível abrir o a settings.rules.info=Rules são arquivos de instrução que o Kilo adiciona ao prompt do sistema de cada requisição, então se aplicam a todas as sessões. settings.rules.info.more=Arquivos AGENTS.md são reconhecidos automaticamente; esta página é para os caminhos, globs e URLs extras que você deseja incluir além deles. Use rules para restrições permanentes, como estilo de código ou requisitos de revisão: um comando é um prompt que você executa, uma skill é carregada apenas quando é relevante, e uma rule está sempre ativa. A compatibilidade com Claude Code também carrega instruções do CLAUDE.md e skills do diretório de configuração do Claude Code e requer uma reinicialização. settings.agentBehavior.description=Configure agentes, servidores MCP, rules, comandos e skills. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index 822bbc13443..e09f98ad886 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Чтобы добавить MCP-сервер, попросите агента сделать это. +settings.agentBehavior.mcp.signIn.progress=Ожидание входа в {0} через браузер\u2026 +settings.agentBehavior.mcp.signIn.cancel=Отмена +settings.agentBehavior.mcp.signIn.success=Выполнен вход в {0}. +settings.agentBehavior.mcp.signIn.failed=Не удалось войти в {0}. +settings.agentBehavior.mcp.signIn.timeout=Время ожидания входа в {0} истекло. +settings.agentBehavior.mcp.signIn.unsupported={0} не поддерживает вход через OAuth. +settings.agentBehavior.mcp.signIn.notFound=MCP-сервер {0} не найден. +settings.agentBehavior.mcp.resetAuth=Сбросить вход +settings.agentBehavior.mcp.resetAuth.title=Сбросить вход MCP +settings.agentBehavior.mcp.resetAuth.message=Очистить сохранённый вход для {0}? Вам потребуется войти снова. +settings.agentBehavior.mcp.resetAuth.failed=Не удалось очистить сохранённый вход. +settings.agentBehavior.mcp.authUrl.title=Завершить вход MCP +settings.agentBehavior.mcp.authUrl.message=Kilo не удалось открыть браузер для {0}. Откройте этот URL на компьютере, где запущен Kilo, чтобы завершить вход. +settings.agentBehavior.mcp.authUrl.open=Открыть в браузере +settings.agentBehavior.mcp.authUrl.copy=Копировать URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Оставьте Автоматически, если серверу не требуется заранее зарегистрированный клиент. Секрет клиента хранится в вашем файле конфигурации Kilo. +settings.agentBehavior.mcp.edit.oauth.mode=Режим +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Автоматически +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Отключено +settings.agentBehavior.mcp.edit.oauth.mode.custom=Пользовательский клиент +settings.agentBehavior.mcp.edit.oauth.clientId=ID клиента +settings.agentBehavior.mcp.edit.oauth.clientSecret=Секрет клиента +settings.agentBehavior.mcp.edit.oauth.scope=Область +settings.agentBehavior.mcp.edit.oauth.callbackPort=Порт обратного вызова +settings.agentBehavior.mcp.edit.oauth.redirectUri=URI перенаправления +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=По умолчанию используется http://127.0.0.1:19876/mcp/oauth/callback; переопределяет порт обратного вызова. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Введите порт от 1 до 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=Для секрета клиента требуется ID клиента. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Введите допустимый URI перенаправления. +settings.marketplace.mcp.signIn.title=Войти на MCP-сервер +settings.marketplace.mcp.signIn.message={0} установлен, но перед использованием его инструментов требуется вход. Войти сейчас? +settings.marketplace.mcp.signIn.confirm=Войти +prompt.mcp.needsAuth.one={0} требует входа +prompt.mcp.needsAuth.many={0} MCP-серверов требуют входа +prompt.mcp.needsAuth.busy=Выполняется вход\u2026 +prompt.mcp.openSettings=Открыть в настройках +prompt.mcp.provider={0} MCP +prompt.issues.title=Проблемы сеанса +prompt.mcp.signIn=Войти в {0} +prompt.mcp.signingIn=Выполняется вход в {0}\u2026 settings.agentBehavior.skills.displayName=Навыки settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=Настройки навыков settings.agentBehavior.skills.saved.notification=Настройки навыков сохранены settings.agentBehavior.skills.delete.title=Удалить навык settings.agentBehavior.skills.delete.message=Удалить навык {0}? Это удалит файл навыка, и действие нельзя будет отменить. +settings.agentBehavior.skills.delete.bundle.message=Удалить навык {0}? Это также удалит MCP-сервер {1} и все сопутствующие навыки из той же установки Marketplace. +settings.agentBehavior.mcp.delete.bundle.message=Удалить MCP-сервер {0} и его сопутствующие навыки? Это удалит сервер и все навыки из этой установки Marketplace. settings.agentBehavior.skills.delete.failed=Не удалось удалить навык. settings.agentBehavior.skills.openInEditor=Открыть в редакторе settings.agentBehavior.skills.openInEditor.pending=Файл навыка откроется после закрытия настроек. @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=Не удалось откр settings.rules.info=Правила — это файлы инструкций, которые Kilo добавляет в системный промпт каждого запроса, поэтому они применяются ко всем сессиям. settings.rules.info.more=Файлы AGENTS.md подхватываются автоматически; эта страница предназначена для дополнительных путей, шаблонов и URL-адресов, которые вы хотите включить в дополнение к ним. Используйте правила для постоянных ограничений, таких как стиль кода или требования к проверке: команда — это промпт, который вы запускаете, навык загружается только когда он актуален, а правило действует всегда. Совместимость с Claude Code дополнительно загружает инструкции CLAUDE.md и навыки из каталога конфигурации Claude Code и требует перезапуска. settings.agentBehavior.description=Настройка агентов, серверов MCP, правил, команд и навыков. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 4ab76da096d..4fc855cbfae 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=หากต้องการเพิ่มเซิร์ฟเวอร์ MCP ให้ขอให้เอเจนต์เพิ่มให้ +settings.agentBehavior.mcp.signIn.progress=กำลังรอการลงชื่อเข้าใช้ {0} ผ่านเบราว์เซอร์\u2026 +settings.agentBehavior.mcp.signIn.cancel=ยกเลิก +settings.agentBehavior.mcp.signIn.success=ลงชื่อเข้าใช้ {0} แล้ว +settings.agentBehavior.mcp.signIn.failed=ลงชื่อเข้าใช้ {0} ไม่สำเร็จ +settings.agentBehavior.mcp.signIn.timeout=การลงชื่อเข้าใช้ {0} หมดเวลา +settings.agentBehavior.mcp.signIn.unsupported={0} ไม่รองรับการลงชื่อเข้าใช้ OAuth +settings.agentBehavior.mcp.signIn.notFound=ไม่พบเซิร์ฟเวอร์ MCP {0} +settings.agentBehavior.mcp.resetAuth=รีเซ็ตการลงชื่อเข้าใช้ +settings.agentBehavior.mcp.resetAuth.title=รีเซ็ตการลงชื่อเข้าใช้ MCP +settings.agentBehavior.mcp.resetAuth.message=ล้างการลงชื่อเข้าใช้ที่บันทึกไว้สำหรับ {0} หรือไม่ คุณจะต้องลงชื่อเข้าใช้อีกครั้ง +settings.agentBehavior.mcp.resetAuth.failed=ไม่สามารถล้างการลงชื่อเข้าใช้ที่บันทึกไว้ได้ +settings.agentBehavior.mcp.authUrl.title=ลงชื่อเข้าใช้ MCP ให้เสร็จสิ้น +settings.agentBehavior.mcp.authUrl.message=Kilo ไม่สามารถเปิดเบราว์เซอร์สำหรับ {0} ได้ เปิด URL นี้บนเครื่องที่เรียกใช้ Kilo เพื่อเสร็จสิ้นการลงชื่อเข้าใช้ +settings.agentBehavior.mcp.authUrl.open=เปิดในเบราว์เซอร์ +settings.agentBehavior.mcp.authUrl.copy=คัดลอก URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=ปล่อยไว้ที่อัตโนมัติ เว้นแต่เซิร์ฟเวอร์ต้องใช้ไคลเอนต์ที่ลงทะเบียนไว้ล่วงหน้า ความลับของไคลเอนต์จะถูกเก็บไว้ในไฟล์การกำหนดค่า Kilo ของคุณ +settings.agentBehavior.mcp.edit.oauth.mode=โหมด +settings.agentBehavior.mcp.edit.oauth.mode.automatic=อัตโนมัติ +settings.agentBehavior.mcp.edit.oauth.mode.disabled=ปิดใช้งาน +settings.agentBehavior.mcp.edit.oauth.mode.custom=ไคลเอนต์กำหนดเอง +settings.agentBehavior.mcp.edit.oauth.clientId=ID ไคลเอนต์ +settings.agentBehavior.mcp.edit.oauth.clientSecret=ความลับของไคลเอนต์ +settings.agentBehavior.mcp.edit.oauth.scope=ขอบเขต +settings.agentBehavior.mcp.edit.oauth.callbackPort=พอร์ต callback +settings.agentBehavior.mcp.edit.oauth.redirectUri=URI เปลี่ยนเส้นทาง +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=ค่าเริ่มต้นคือ http://127.0.0.1:19876/mcp/oauth/callback และจะแทนที่พอร์ต callback +settings.agentBehavior.mcp.edit.oauth.port.invalid=ป้อนพอร์ตระหว่าง 1 ถึง 65535 +settings.agentBehavior.mcp.edit.oauth.secret.invalid=ความลับของไคลเอนต์ต้องมี ID ไคลเอนต์ +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=ป้อน URI เปลี่ยนเส้นทางที่ถูกต้อง +settings.marketplace.mcp.signIn.title=ลงชื่อเข้าใช้เซิร์ฟเวอร์ MCP +settings.marketplace.mcp.signIn.message=ติดตั้ง {0} แล้ว แต่ต้องลงชื่อเข้าใช้ก่อนจึงจะใช้เครื่องมือได้ ลงชื่อเข้าใช้ตอนนี้หรือไม่ +settings.marketplace.mcp.signIn.confirm=ลงชื่อเข้าใช้ +prompt.mcp.needsAuth.one={0} ต้องลงชื่อเข้าใช้ +prompt.mcp.needsAuth.many=เซิร์ฟเวอร์ MCP {0} รายการต้องลงชื่อเข้าใช้ +prompt.mcp.needsAuth.busy=กำลังลงชื่อเข้าใช้\u2026 +prompt.mcp.openSettings=เปิดในการตั้งค่า +prompt.mcp.provider={0} MCP +prompt.issues.title=ปัญหาเซสชัน +prompt.mcp.signIn=ลงชื่อเข้าใช้ {0} +prompt.mcp.signingIn=กำลังลงชื่อเข้าใช้ {0}\u2026 settings.agentBehavior.skills.displayName=ทักษะ settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=บันทึกการตั settings.agentBehavior.skills.saved.notification=บันทึกการตั้งค่าทักษะแล้ว settings.agentBehavior.skills.delete.title=ลบทักษะ settings.agentBehavior.skills.delete.message=ลบทักษะ {0} หรือไม่ การดำเนินการนี้จะลบไฟล์ทักษะและไม่สามารถย้อนกลับได้ +settings.agentBehavior.skills.delete.bundle.message=ลบทักษะ {0} หรือไม่ การดำเนินการนี้จะถอนการติดตั้งเซิร์ฟเวอร์ MCP {1} และทักษะคู่กันทั้งหมดที่เป็นของการติดตั้ง Marketplace เดียวกันด้วย +settings.agentBehavior.mcp.delete.bundle.message=ลบเซิร์ฟเวอร์ MCP {0} และทักษะคู่กันหรือไม่ การดำเนินการนี้จะลบทั้งเซิร์ฟเวอร์และทุกทักษะที่เป็นของการติดตั้ง Marketplace นี้ settings.agentBehavior.skills.delete.failed=ไม่สามารถลบทักษะได้ settings.agentBehavior.skills.openInEditor=เปิดในตัวแก้ไข settings.agentBehavior.skills.openInEditor.pending=ไฟล์ทักษะจะเปิดหลังจากคุณปิดการตั้งค่า @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=ไม่สามารถ settings.rules.info=กฎคือไฟล์คำสั่งที่ Kilo เพิ่มลงในพรอมต์ระบบของทุกคำขอ จึงมีผลกับทุกเซสชัน settings.rules.info.more=ไฟล์ AGENTS.md จะถูกอ่านโดยอัตโนมัติ หน้านี้ใช้สำหรับพาธ, glob และ URL เพิ่มเติมที่คุณต้องการรวมเข้าไปนอกเหนือจากนั้น ใช้กฎสำหรับข้อกำหนดที่ใช้ตลอด เช่น รูปแบบการเขียนโค้ดหรือข้อกำหนดในการตรวจสอบ โดยคำสั่งคือพรอมต์ที่คุณเรียกใช้ สกิลจะถูกโหลดก็ต่อเมื่อเกี่ยวข้อง ส่วนกฎจะทำงานอยู่เสมอ ความเข้ากันได้กับ Claude Code ยังโหลดคำสั่ง CLAUDE.md และสกิลจากไดเรกทอรีการตั้งค่า Claude Code ของคุณเพิ่มเติม และต้องรีสตาร์ท settings.agentBehavior.description=กำหนดค่าเอเจนต์ เซิร์ฟเวอร์ MCP กฎ คำสั่ง และสกิล + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index 8a6258ec14c..bd1297da520 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=MCP sunucusu eklemek için ajandan bunu yapmasını isteyin. +settings.agentBehavior.mcp.signIn.progress={0} için tarayıcı oturumu bekleniyor\u2026 +settings.agentBehavior.mcp.signIn.cancel=İptal +settings.agentBehavior.mcp.signIn.success={0} oturumu açıldı. +settings.agentBehavior.mcp.signIn.failed={0} oturumu açılamadı. +settings.agentBehavior.mcp.signIn.timeout={0} oturum açma süresi doldu. +settings.agentBehavior.mcp.signIn.unsupported={0} OAuth oturum açmayı desteklemiyor. +settings.agentBehavior.mcp.signIn.notFound={0} MCP sunucusu bulunamadı. +settings.agentBehavior.mcp.resetAuth=Oturumu sıfırla +settings.agentBehavior.mcp.resetAuth.title=MCP oturumunu sıfırla +settings.agentBehavior.mcp.resetAuth.message={0} için saklanan oturum temizlensin mi? Yeniden oturum açmanız gerekecek. +settings.agentBehavior.mcp.resetAuth.failed=Saklanan oturum temizlenemedi. +settings.agentBehavior.mcp.authUrl.title=MCP oturumunu tamamla +settings.agentBehavior.mcp.authUrl.message=Kilo, {0} için bir tarayıcı açamadı. Oturum açmayı tamamlamak için bu URL’yi Kilo çalışan makinede açın. +settings.agentBehavior.mcp.authUrl.open=Tarayıcıda aç +settings.agentBehavior.mcp.authUrl.copy=URL’yi kopyala +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Sunucu önceden kaydedilmiş bir istemci gerektirmiyorsa Otomatik olarak bırakın. İstemci sırrı Kilo yapılandırma dosyanızda saklanır. +settings.agentBehavior.mcp.edit.oauth.mode=Mod +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Otomatik +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Devre dışı +settings.agentBehavior.mcp.edit.oauth.mode.custom=Özel istemci +settings.agentBehavior.mcp.edit.oauth.clientId=İstemci ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=İstemci sırrı +settings.agentBehavior.mcp.edit.oauth.scope=Kapsam +settings.agentBehavior.mcp.edit.oauth.callbackPort=Geri çağırma bağlantı noktası +settings.agentBehavior.mcp.edit.oauth.redirectUri=Yönlendirme URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=Varsayılan değer http://127.0.0.1:19876/mcp/oauth/callback olup geri çağırma bağlantı noktasını geçersiz kılar. +settings.agentBehavior.mcp.edit.oauth.port.invalid=1 ile 65535 arasında bir bağlantı noktası girin. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=İstemci sırrı için istemci ID gerekir. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Geçerli bir yönlendirme URI girin. +settings.marketplace.mcp.signIn.title=MCP sunucusunda oturum aç +settings.marketplace.mcp.signIn.message={0} yüklü, ancak araçları kullanılmadan önce oturum açılması gerekiyor. Şimdi oturum açılsın mı? +settings.marketplace.mcp.signIn.confirm=Oturum Aç +prompt.mcp.needsAuth.one={0} oturum açmayı gerektiriyor +prompt.mcp.needsAuth.many={0} MCP sunucusu oturum açmayı gerektiriyor +prompt.mcp.needsAuth.busy=Oturum açılıyor\u2026 +prompt.mcp.openSettings=Ayarlarda aç +prompt.mcp.provider={0} MCP +prompt.issues.title=Oturum sorunları +prompt.mcp.signIn={0} için oturum aç +prompt.mcp.signingIn={0} için oturum açılıyor\u2026 settings.agentBehavior.skills.displayName=Beceriler settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=Beceri ayarları kaydedildi, ancak settings.agentBehavior.skills.saved.notification=Beceri ayarları kaydedildi settings.agentBehavior.skills.delete.title=Beceriyi Sil settings.agentBehavior.skills.delete.message={0} becerisi silinsin mi? Bu işlem beceri dosyasını kaldırır ve geri alınamaz. +settings.agentBehavior.skills.delete.bundle.message={0} becerisi silinsin mi? Bu, {1} MCP sunucusunu ve aynı Marketplace kurulumuna ait tüm yardımcı becerileri de kaldırır. +settings.agentBehavior.mcp.delete.bundle.message={0} MCP sunucusu ve yardımcı becerileri silinsin mi? Bu, sunucuyu ve bu Marketplace kurulumuna ait tüm becerileri kaldırır. settings.agentBehavior.skills.delete.failed=Beceri silinemedi. settings.agentBehavior.skills.openInEditor=Düzenleyicide Aç settings.agentBehavior.skills.openInEditor.pending=Beceri dosyası Ayarlar kapatıldıktan sonra açılacak. @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=Komut dosyası düzenleyicid settings.rules.info=Kurallar, Kilo'nun her isteğin sistem promptuna eklediği talimat dosyalarıdır, bu nedenle tüm oturumlara uygulanır. settings.rules.info.more=AGENTS.md dosyaları otomatik olarak algılanır; bu sayfa, bunlara ek olarak dahil etmek istediğiniz ekstra yollar, joker karakter desenleri ve URL'ler içindir. Kodlama stili veya inceleme gereksinimleri gibi kalıcı kısıtlamalar için kuralları kullanın: bir komut çalıştırdığınız bir promptdur, bir yetenek yalnızca ilgili olduğunda yüklenir ve bir kural her zaman etkindir. Claude Code uyumluluğu, ayrıca Claude Code yapılandırma dizininizden CLAUDE.md talimatlarını ve yeteneklerini yükler ve yeniden başlatma gerektirir. settings.agentBehavior.description=Ajanları, MCP sunucularını, kuralları, komutları ve yetenekleri yapılandırın. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 9c1211f03cd..cffab083ddf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=Щоб додати сервер MCP, попросіть агента зробити це. +settings.agentBehavior.mcp.signIn.progress=Очікування входу в {0} через браузер\u2026 +settings.agentBehavior.mcp.signIn.cancel=Скасувати +settings.agentBehavior.mcp.signIn.success=Вхід у {0} виконано. +settings.agentBehavior.mcp.signIn.failed=Не вдалося ввійти в {0}. +settings.agentBehavior.mcp.signIn.timeout=Час очікування входу в {0} минув. +settings.agentBehavior.mcp.signIn.unsupported={0} не підтримує вхід через OAuth. +settings.agentBehavior.mcp.signIn.notFound=MCP-сервер {0} не знайдено. +settings.agentBehavior.mcp.resetAuth=Скинути вхід +settings.agentBehavior.mcp.resetAuth.title=Скинути вхід MCP +settings.agentBehavior.mcp.resetAuth.message=Очистити збережений вхід для {0}? Вам потрібно буде ввійти знову. +settings.agentBehavior.mcp.resetAuth.failed=Не вдалося очистити збережений вхід. +settings.agentBehavior.mcp.authUrl.title=Завершити вхід MCP +settings.agentBehavior.mcp.authUrl.message=Kilo не вдалося відкрити браузер для {0}. Відкрийте цю URL-адресу на компʼютері, де запущено Kilo, щоб завершити вхід. +settings.agentBehavior.mcp.authUrl.open=Відкрити у браузері +settings.agentBehavior.mcp.authUrl.copy=Копіювати URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=Залиште Автоматично, якщо серверу не потрібен заздалегідь зареєстрований клієнт. Секрет клієнта зберігається у вашому файлі конфігурації Kilo. +settings.agentBehavior.mcp.edit.oauth.mode=Режим +settings.agentBehavior.mcp.edit.oauth.mode.automatic=Автоматично +settings.agentBehavior.mcp.edit.oauth.mode.disabled=Вимкнено +settings.agentBehavior.mcp.edit.oauth.mode.custom=Користувацький клієнт +settings.agentBehavior.mcp.edit.oauth.clientId=ID клієнта +settings.agentBehavior.mcp.edit.oauth.clientSecret=Секрет клієнта +settings.agentBehavior.mcp.edit.oauth.scope=Область +settings.agentBehavior.mcp.edit.oauth.callbackPort=Порт зворотного виклику +settings.agentBehavior.mcp.edit.oauth.redirectUri=URI перенаправлення +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=За замовчуванням http://127.0.0.1:19876/mcp/oauth/callback; перевизначає порт зворотного виклику. +settings.agentBehavior.mcp.edit.oauth.port.invalid=Введіть порт від 1 до 65535. +settings.agentBehavior.mcp.edit.oauth.secret.invalid=Для секрету клієнта потрібен ID клієнта. +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=Введіть дійсний URI перенаправлення. +settings.marketplace.mcp.signIn.title=Увійти на MCP-сервер +settings.marketplace.mcp.signIn.message={0} встановлено, але перед використанням його інструментів потрібен вхід. Увійти зараз? +settings.marketplace.mcp.signIn.confirm=Увійти +prompt.mcp.needsAuth.one={0} потребує входу +prompt.mcp.needsAuth.many={0} MCP-серверів потребують входу +prompt.mcp.needsAuth.busy=Виконується вхід\u2026 +prompt.mcp.openSettings=Відкрити в налаштуваннях +prompt.mcp.provider={0} MCP +prompt.issues.title=Проблеми сеансу +prompt.mcp.signIn=Увійти в {0} +prompt.mcp.signingIn=Виконується вхід у {0}\u2026 settings.agentBehavior.skills.displayName=Навички settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=Налаштування навич settings.agentBehavior.skills.saved.notification=Налаштування навичок збережено settings.agentBehavior.skills.delete.title=Видалити навичку settings.agentBehavior.skills.delete.message=Видалити навичку {0}? Це видалить файл навички, і дію не можна буде скасувати. +settings.agentBehavior.skills.delete.bundle.message=Видалити навичку {0}? Це також видалить MCP-сервер {1} і всі супутні навички з тієї самої установки Marketplace. +settings.agentBehavior.mcp.delete.bundle.message=Видалити MCP-сервер {0} і його супутні навички? Це видалить сервер і всі навички з цієї установки Marketplace. settings.agentBehavior.skills.delete.failed=Не вдалося видалити навичку. settings.agentBehavior.skills.openInEditor=Відкрити в редакторі settings.agentBehavior.skills.openInEditor.pending=Файл навички відкриється після закриття Налаштувань. @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=Не вдалося відк settings.rules.info=Правила — це файли інструкцій, які Kilo додає до системного промпту кожного запиту, тож вони застосовуються до всіх сесій. settings.rules.info.more=Файли AGENTS.md підхоплюються автоматично; ця сторінка призначена для додаткових шляхів, шаблонів (glob) та URL-адрес, які ви хочете включити на додачу до них. Використовуйте правила для постійних обмежень, таких як стиль коду або вимоги до перевірки: команда — це промпт, який ви запускаєте, навичка завантажується лише тоді, коли вона релевантна, а правило діє завжди. Сумісність із Claude Code додатково завантажує інструкції CLAUDE.md та навички з каталогу конфігурації Claude Code і потребує перезапуску. settings.agentBehavior.description=Налаштування агентів, серверів MCP, правил, команд і навичок. + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index df841314386..7d91e82276f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=要添加 MCP 服务器,请让代理为你添加。 +settings.agentBehavior.mcp.signIn.progress=正在等待通过浏览器登录 {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=取消 +settings.agentBehavior.mcp.signIn.success=已登录 {0}。 +settings.agentBehavior.mcp.signIn.failed=登录 {0} 失败。 +settings.agentBehavior.mcp.signIn.timeout=登录 {0} 超时。 +settings.agentBehavior.mcp.signIn.unsupported={0} 不支持 OAuth 登录。 +settings.agentBehavior.mcp.signIn.notFound=未找到 MCP 服务器 {0}。 +settings.agentBehavior.mcp.resetAuth=重置登录 +settings.agentBehavior.mcp.resetAuth.title=重置 MCP 登录 +settings.agentBehavior.mcp.resetAuth.message=要清除 {0} 的已存储登录信息吗?你需要重新登录。 +settings.agentBehavior.mcp.resetAuth.failed=无法清除已存储的登录信息。 +settings.agentBehavior.mcp.authUrl.title=完成 MCP 登录 +settings.agentBehavior.mcp.authUrl.message=Kilo 无法为 {0} 打开浏览器。请在运行 Kilo 的机器上打开此 URL 以完成登录。 +settings.agentBehavior.mcp.authUrl.open=在浏览器中打开 +settings.agentBehavior.mcp.authUrl.copy=复制 URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=除非服务器需要预注册客户端,否则请保持为自动。客户端密钥会存储在你的 Kilo 配置文件中。 +settings.agentBehavior.mcp.edit.oauth.mode=模式 +settings.agentBehavior.mcp.edit.oauth.mode.automatic=自动 +settings.agentBehavior.mcp.edit.oauth.mode.disabled=已禁用 +settings.agentBehavior.mcp.edit.oauth.mode.custom=自定义客户端 +settings.agentBehavior.mcp.edit.oauth.clientId=客户端 ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=客户端密钥 +settings.agentBehavior.mcp.edit.oauth.scope=范围 +settings.agentBehavior.mcp.edit.oauth.callbackPort=回调端口 +settings.agentBehavior.mcp.edit.oauth.redirectUri=重定向 URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=默认值为 http://127.0.0.1:19876/mcp/oauth/callback,并会覆盖回调端口。 +settings.agentBehavior.mcp.edit.oauth.port.invalid=请输入 1 到 65535 之间的端口。 +settings.agentBehavior.mcp.edit.oauth.secret.invalid=客户端密钥需要客户端 ID。 +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=请输入有效的重定向 URI。 +settings.marketplace.mcp.signIn.title=登录 MCP 服务器 +settings.marketplace.mcp.signIn.message={0} 已安装,但需要先登录才能使用其工具。现在登录吗? +settings.marketplace.mcp.signIn.confirm=登录 +prompt.mcp.needsAuth.one={0} 需要登录 +prompt.mcp.needsAuth.many={0} 个 MCP 服务器需要登录 +prompt.mcp.needsAuth.busy=正在登录\u2026 +prompt.mcp.openSettings=在设置中打开 +prompt.mcp.provider={0} MCP +prompt.issues.title=会话问题 +prompt.mcp.signIn=登录 {0} +prompt.mcp.signingIn=正在登录 {0}\u2026 settings.agentBehavior.skills.displayName=技能 settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=技能设置已保存,但仍有 settings.agentBehavior.skills.saved.notification=技能设置已保存 settings.agentBehavior.skills.delete.title=删除技能 settings.agentBehavior.skills.delete.message=要删除技能 {0} 吗?这会移除技能文件,且无法撤销。 +settings.agentBehavior.skills.delete.bundle.message=要删除技能 {0} 吗?这还会卸载 {1} MCP 服务器,以及同一 Marketplace 安装拥有的所有配套技能。 +settings.agentBehavior.mcp.delete.bundle.message=要删除 MCP 服务器 {0} 及其配套技能吗?这会移除该服务器以及此 Marketplace 安装拥有的所有技能。 settings.agentBehavior.skills.delete.failed=无法删除该技能。 settings.agentBehavior.skills.openInEditor=在编辑器中打开 settings.agentBehavior.skills.openInEditor.pending=关闭设置后将打开技能文件。 @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=无法在编辑器中打开 settings.rules.info=规则是 Kilo 添加到每个请求系统提示词中的指令文件,因此它们会应用于所有会话。 settings.rules.info.more=AGENTS.md 文件会被自动读取;此页面用于配置你希望在此基础上额外包含的路径、通配符和网址。规则适用于诸如编码风格或审查要求等长期性约束:命令是你运行的提示词,技能只在相关时才会加载,而规则则始终生效。Claude Code 兼容模式还会从你的 Claude Code 配置目录中加载 CLAUDE.md 指令和技能,并需要重启才能生效。 settings.agentBehavior.description=配置智能体、MCP 服务器、规则、命令和技能。 + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index 41e326bd226..4476e88df3d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -446,6 +446,47 @@ settings.agentBehavior.undo=Undo settings.agentBehavior.agents.create.failed=Could not create the agent. settings.agentBehavior.mcp.addHint=若要新增 MCP 伺服器,請請代理為你新增。 +settings.agentBehavior.mcp.signIn.progress=正在等待透過瀏覽器登入 {0}\u2026 +settings.agentBehavior.mcp.signIn.cancel=取消 +settings.agentBehavior.mcp.signIn.success=已登入 {0}。 +settings.agentBehavior.mcp.signIn.failed=登入 {0} 失敗。 +settings.agentBehavior.mcp.signIn.timeout=登入 {0} 逾時。 +settings.agentBehavior.mcp.signIn.unsupported={0} 不支援 OAuth 登入。 +settings.agentBehavior.mcp.signIn.notFound=找不到 MCP 伺服器 {0}。 +settings.agentBehavior.mcp.resetAuth=重設登入 +settings.agentBehavior.mcp.resetAuth.title=重設 MCP 登入 +settings.agentBehavior.mcp.resetAuth.message=要清除 {0} 的已儲存登入資訊嗎?你需要重新登入。 +settings.agentBehavior.mcp.resetAuth.failed=無法清除已儲存的登入資訊。 +settings.agentBehavior.mcp.authUrl.title=完成 MCP 登入 +settings.agentBehavior.mcp.authUrl.message=Kilo 無法為 {0} 開啟瀏覽器。請在執行 Kilo 的機器上開啟此 URL 以完成登入。 +settings.agentBehavior.mcp.authUrl.open=在瀏覽器中開啟 +settings.agentBehavior.mcp.authUrl.copy=複製 URL +settings.agentBehavior.mcp.edit.oauth=OAuth +settings.agentBehavior.mcp.edit.oauth.help=除非伺服器需要預先註冊的用戶端,否則請保持自動。用戶端密鑰會儲存在你的 Kilo 設定檔中。 +settings.agentBehavior.mcp.edit.oauth.mode=模式 +settings.agentBehavior.mcp.edit.oauth.mode.automatic=自動 +settings.agentBehavior.mcp.edit.oauth.mode.disabled=已停用 +settings.agentBehavior.mcp.edit.oauth.mode.custom=自訂用戶端 +settings.agentBehavior.mcp.edit.oauth.clientId=用戶端 ID +settings.agentBehavior.mcp.edit.oauth.clientSecret=用戶端密鑰 +settings.agentBehavior.mcp.edit.oauth.scope=範圍 +settings.agentBehavior.mcp.edit.oauth.callbackPort=回呼連接埠 +settings.agentBehavior.mcp.edit.oauth.redirectUri=重新導向 URI +settings.agentBehavior.mcp.edit.oauth.redirectUri.help=預設為 http://127.0.0.1:19876/mcp/oauth/callback,並會覆寫回呼連接埠。 +settings.agentBehavior.mcp.edit.oauth.port.invalid=請輸入 1 到 65535 之間的連接埠。 +settings.agentBehavior.mcp.edit.oauth.secret.invalid=用戶端密鑰需要用戶端 ID。 +settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid=請輸入有效的重新導向 URI。 +settings.marketplace.mcp.signIn.title=登入 MCP 伺服器 +settings.marketplace.mcp.signIn.message={0} 已安裝,但需要先登入才能使用其工具。現在登入嗎? +settings.marketplace.mcp.signIn.confirm=登入 +prompt.mcp.needsAuth.one={0} 需要登入 +prompt.mcp.needsAuth.many={0} 個 MCP 伺服器需要登入 +prompt.mcp.needsAuth.busy=正在登入\u2026 +prompt.mcp.openSettings=在設定中開啟 +prompt.mcp.provider={0} MCP +prompt.issues.title=工作階段問題 +prompt.mcp.signIn=登入 {0} +prompt.mcp.signingIn=正在登入 {0}\u2026 settings.agentBehavior.skills.displayName=技能 settings.agentBehavior.rules.displayName=Rules settings.rules.files.title=Additional Instruction Files @@ -478,6 +519,8 @@ settings.agentBehavior.skills.reload.blocked=技能設定已儲存,但仍有 settings.agentBehavior.skills.saved.notification=技能設定已儲存 settings.agentBehavior.skills.delete.title=刪除技能 settings.agentBehavior.skills.delete.message=要刪除技能 {0} 嗎?這會移除技能檔案,且無法復原。 +settings.agentBehavior.skills.delete.bundle.message=要刪除技能 {0} 嗎?這也會解除安裝 {1} MCP 伺服器,以及同一 Marketplace 安裝擁有的所有配套技能。 +settings.agentBehavior.mcp.delete.bundle.message=要刪除 MCP 伺服器 {0} 及其配套技能嗎?這會移除該伺服器以及此 Marketplace 安裝擁有的所有技能。 settings.agentBehavior.skills.delete.failed=無法刪除該技能。 settings.agentBehavior.skills.openInEditor=在編輯器中開啟 settings.agentBehavior.skills.openInEditor.pending=關閉設定後將開啟技能檔案。 @@ -721,3 +764,8 @@ settings.agentBehavior.commands.openInEditor.failed=無法在編輯器中開啟 settings.rules.info=規則是 Kilo 加入每個請求系統提示詞中的指令檔案,因此適用於所有工作階段。 settings.rules.info.more=AGENTS.md 檔案會自動被讀取;此頁面用於設定您想額外加入的路徑、萬用字元和網址。規則適用於程式碼風格或審查要求等長期限制:命令是您執行的提示詞,技能僅在相關時才會載入,而規則則一律生效。Claude Code 相容模式還會從您的 Claude Code 設定目錄載入 CLAUDE.md 指令與技能,且需要重新啟動。 settings.agentBehavior.description=設定代理、MCP 伺服器、規則、命令與技能。 + +session.prompts.navLabel=Prompt navigator +session.prompts.first=First prompt +session.prompts.latest=Latest prompt +session.prompts.noAnswer=No response yet diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt index 594c64e1cd6..d6aa0ced664 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt @@ -11,6 +11,8 @@ import ai.kilocode.client.testing.deactivateIde import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.GhAvailability import ai.kilocode.rpc.dto.GhState +import ai.kilocode.rpc.dto.SessionActivityDto +import ai.kilocode.rpc.dto.SessionActivityKindDto import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreeDirtyListDto import ai.kilocode.rpc.dto.WorktreePrDto @@ -23,6 +25,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.replaceService import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext @Suppress("UnstableApiUsage") @@ -32,6 +35,9 @@ class WorktreeStatusServiceTest : BasePlatformTestCase() { private lateinit var timers: TestUiTimers private lateinit var service: WorktreeStatusService + /** Stands in for KiloSessionService's live activity map, which the service watches for turn endings. */ + private val activity = MutableStateFlow>(emptyMap()) + override fun setUp() { super.setUp() coroutines = TestCoroutines() @@ -43,7 +49,7 @@ class WorktreeStatusServiceTest : BasePlatformTestCase() { ApplicationManager.getApplication() .replaceService(GhStatusCoordinator::class.java, GhStatusCoordinator(coroutines.scope, TestUiTimers()), testRootDisposable) timers = TestUiTimers() - service = WorktreeStatusService(project, coroutines.scope, timers) + service = WorktreeStatusService(project, coroutines.scope, timers) { activity } } override fun tearDown() { @@ -728,6 +734,177 @@ class WorktreeStatusServiceTest : BasePlatformTestCase() { handle.close() } + fun `test an agent that stops pays a lookup naming only its own worktree`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + val key = normalizeWorktreePath(path) + rpc.prResult = WorktreePrListDto(GhAvailability.OK) + val handle = service.attach() + drain() + assertEquals(1, rpc.prCalls.size) + timers.advanceBy(PR_FLOOR) + + working(path) + drain() + assertEquals("a turn that has only started cannot have opened a pull request", 1, rpc.prCalls.size) + + // What `gh pr create` inside a worktree leaves behind: nothing local. The branch and the head + // commit are what the backend keys "no pull request here" by, and neither moved, so naming the + // path is the only way the badge can appear before that answer expires. + rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(path, 9, GhState.OPEN, "https://pr/9"))) + idle() + drain() + + assertEquals(2, rpc.prCalls.size) + assertEquals(listOf(key), rpc.prFresh.last()) + assertNull("one row's news is no reason to re-run every other row's lookup", rpc.prAges.last()) + assertEquals(9, service.pr.value[key]?.number) + handle.close() + } + + fun `test an agent that stops to ask something still pays a lookup`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + rpc.prResult = WorktreePrListDto(GhAvailability.OK) + val handle = service.attach() + drain() + timers.advanceBy(PR_FLOOR) + working(path) + drain() + + // Opening a PR and then asking a question about it is one turn, and it ends here just as much + // as an idle session does — the agent has stopped working either way. + activity.value = mapOf("ses" to SessionActivityDto(path, SessionActivityKindDto.QUESTION)) + drain() + + assertEquals(2, rpc.prCalls.size) + assertEquals(listOf(normalizeWorktreePath(path)), rpc.prFresh.last()) + handle.close() + } + + fun `test a worktree that keeps working owes no lookup`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + val other = "${project.basePath}/.kilo/worktrees/feature-y" + rpc.prResult = WorktreePrListDto(GhAvailability.OK) + val handle = service.attach() + drain() + timers.advanceBy(PR_FLOOR) + working(path) + drain() + assertEquals(1, rpc.prCalls.size) + + // A second agent starting elsewhere republishes the whole map. The first worktree is still + // running, so nothing about it has concluded and it must not pay a lookup for the news. + activity.value = mapOf( + "ses" to SessionActivityDto(path, SessionActivityKindDto.RUNNING), + "ses-2" to SessionActivityDto(other, SessionActivityKindDto.RUNNING), + ) + drain() + + assertEquals(1, rpc.prCalls.size) + handle.close() + } + + fun `test turn endings inside the floor window collapse onto one lookup naming both`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + val other = "${project.basePath}/.kilo/worktrees/feature-y" + rpc.prResult = WorktreePrListDto(GhAvailability.OK) + val handle = service.attach() + drain() + assertEquals(1, rpc.prCalls.size) + + activity.value = mapOf( + "ses" to SessionActivityDto(path, SessionActivityKindDto.RUNNING), + "ses-2" to SessionActivityDto(other, SessionActivityKindDto.RUNNING), + ) + drain() + // Two agents finishing a few seconds apart, both inside the floor window the attach lookup + // opened. Each is real news and neither may be dropped, but one fan-out answers both. + activity.value = mapOf("ses-2" to SessionActivityDto(other, SessionActivityKindDto.RUNNING)) + drain() + idle() + drain() + assertEquals("the floor holds the requests rather than spending on each", 1, rpc.prCalls.size) + + timers.advanceBy(PR_FLOOR) + drain() + + assertEquals(2, rpc.prCalls.size) + assertEquals( + setOf(normalizeWorktreePath(path), normalizeWorktreePath(other)), + rpc.prFresh.last().toSet(), + ) + handle.close() + } + + fun `test a turn ending held with a return keeps both the ceiling and the path`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + rpc.prResult = WorktreePrListDto(GhAvailability.OK) + val handle = service.attach() + drain() + working(path) + drain() + + // A return wants everything no older than the absence; a turn ending wants one row resolved + // from scratch. Merging them has to keep the stricter of the two claims on every axis, or the + // one that arrived second would quietly relax the first. + deactivateIde(project) + timers.advanceBy(Away.FRESH) + activateIde(project) + drain() + idle() + drain() + assertEquals(1, rpc.prCalls.size) + + timers.advanceBy(PR_FLOOR - Away.FRESH) + drain() + + assertEquals(2, rpc.prCalls.size) + assertEquals(Away.FRESH, rpc.prAges.last()) + assertEquals(listOf(normalizeWorktreePath(path)), rpc.prFresh.last()) + handle.close() + } + + fun `test a spent github budget suppresses a turn ending`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + rpc.prResult = WorktreePrListDto(GhAvailability.RATE_LIMITED) + val handle = service.attach() + drain() + assertEquals(GhAvailability.RATE_LIMITED, service.gh.value) + timers.advanceBy(PR_FLOOR) + working(path) + drain() + val before = rpc.prCalls.size + + // The lookup a turn ending would pay for is the one GitHub is refusing, so it can only confirm + // what the last answer already said. The poll stays the single probe that notices the reset. + idle() + drain() + timers.advanceBy(PR_FLOOR) + drain() + + assertEquals(before, rpc.prCalls.size) + handle.close() + } + + fun `test a turn ending while nothing is attached does not start a lookup`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + rpc.prResult = WorktreePrListDto(GhAvailability.OK) + val handle = service.attach() + drain() + working(path) + drain() + handle.close() + val before = rpc.prCalls.size + + // No surface is showing badges, so there is nothing for a fan-out to update. Re-attaching + // loads at once anyway. + idle() + drain() + timers.advanceBy(PR_FLOOR) + drain() + + assertEquals(before, rpc.prCalls.size) + } + fun `test a forced refresh can demand a fresh backend lookup`() { rpc.prResult = WorktreePrListDto(GhAvailability.OK) val handle = service.attach() @@ -814,6 +991,16 @@ class WorktreeStatusServiceTest : BasePlatformTestCase() { pump() } + /** Publishes an agent working in [path], as the live activity map reports it. */ + private fun working(path: String) { + activity.value = mapOf("ses" to SessionActivityDto(path, SessionActivityKindDto.RUNNING)) + } + + /** Publishes every session idle, which is how a finished turn leaves the activity map. */ + private fun idle() { + activity.value = emptyMap() + } + private fun drain() = coroutines.drain(::pump) private fun pump() = pumpEdt() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAgentBehaviorServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAgentBehaviorServiceTest.kt index d4ff442a93d..e669de63bf1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAgentBehaviorServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloAgentBehaviorServiceTest.kt @@ -3,13 +3,17 @@ package ai.kilocode.client.app import ai.kilocode.client.testing.FakeAgentBehaviorRpcApi import ai.kilocode.rpc.dto.AgentCreateDto import ai.kilocode.rpc.dto.CommandFileDto +import ai.kilocode.rpc.dto.McpAuthEventDto +import ai.kilocode.rpc.dto.McpAuthResultDto import ai.kilocode.rpc.dto.McpStatusDto import ai.kilocode.rpc.dto.SkillDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlin.test.assertFailsWith @@ -144,7 +148,7 @@ class KiloAgentBehaviorServiceTest : BasePlatformTestCase() { assertTrue(connected) assertTrue(disconnected) - assertTrue(authenticated) + assertEquals("connected", authenticated.status) assertEquals(listOf("filesystem"), rpc.mcpConnects) assertEquals(listOf("github"), rpc.mcpDisconnects) assertEquals(listOf("linear"), rpc.mcpAuthentications) @@ -161,4 +165,20 @@ class KiloAgentBehaviorServiceTest : BasePlatformTestCase() { assertFalse(ok) assertTrue(rpc.mcpConnects.isEmpty()) } + + fun `test mcp auth remove forwards name`() = runBlocking { + val ok = withContext(Dispatchers.Default) { service.mcpAuthRemove("/test", "linear") } + + assertTrue(ok) + assertEquals(listOf("linear"), rpc.mcpAuthRemovals) + } + + fun `test mcp auth events pass through`() = runBlocking { + val events = withContext(Dispatchers.Default) { service.mcpAuthEvents() } + val first = async(Dispatchers.Default) { events.first() } + rpc.mcpAuthEventsFlow.subscriptionCount.first { it > 0 } + rpc.mcpAuthEventsFlow.emit(McpAuthEventDto("linear", "https://example.com/authorize")) + + assertEquals(McpAuthEventDto("linear", "https://example.com/authorize"), first.await()) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt new file mode 100644 index 00000000000..10ade4dc02d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloMcpAuthServiceTest.kt @@ -0,0 +1,369 @@ +package ai.kilocode.client.app + +import ai.kilocode.client.testing.FakeAgentBehaviorRpcApi +import ai.kilocode.rpc.dto.McpAuthEventDto +import ai.kilocode.rpc.dto.McpAuthResultDto +import ai.kilocode.rpc.dto.McpStatusDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +/** + * Covers [KiloMcpAuthService]. + * + * The service takes test-only constructor seams for the auth timeout, the dedupe window, and the + * browser-open-failed dialog action, so this timeout and dedupe behavior can be exercised directly + * instead of waiting out the real 6-minute production timeout or opening a real modal dialog. + */ +@Suppress("UnstableApiUsage") +class KiloMcpAuthServiceTest : BasePlatformTestCase() { + + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeAgentBehaviorRpcApi + private lateinit var behavior: KiloAgentBehaviorService + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeAgentBehaviorRpcApi() + behavior = KiloAgentBehaviorService(scope, rpc) + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + private fun service( + authTimeoutMs: Long = 60_000L, + dedupeWindowMs: Long = 4000L, + showAuthUrl: (String, String) -> Unit = { _, _ -> }, + openUrl: (String) -> Unit = { }, + ) = KiloMcpAuthService(scope, behavior, authTimeoutMs, dedupeWindowMs, showAuthUrl, openUrl) + + private fun settle() = runBlocking { + repeat(3) { + delay(50) + UIUtil.dispatchAllInvocationEvents() + } + } + + fun `test refresh collects only needs_auth servers`() = runBlocking(Dispatchers.Default) { + rpc.mcps = listOf( + McpStatusDto("linear", "needs_auth"), + McpStatusDto("filesystem", "connected"), + McpStatusDto("github", "needs_auth"), + ) + val service = service() + + val needs = service.refresh("/test") + + assertEquals(setOf("linear", "github"), needs) + assertEquals(setOf("linear", "github"), service.needsAuth.value["/test"]) + } + + fun `test refresh with blank directory returns empty and does not call rpc`() = runBlocking(Dispatchers.Default) { + rpc.mcps = listOf(McpStatusDto("linear", "needs_auth")) + val service = service() + + val needs = service.refresh("") + + assertTrue(needs.isEmpty()) + assertTrue(rpc.mcpCalls.isEmpty()) + } + + fun `test refresh bounds directory state`() = runBlocking(Dispatchers.Default) { + val service = service() + rpc.mcps = listOf(McpStatusDto("linear", "needs_auth")) + + repeat(65) { service.refresh("/test/$it") } + + assertEquals(64, service.needsAuth.value.size) + assertFalse(service.needsAuth.value.containsKey("/test/0")) + assertTrue(service.needsAuth.value.containsKey("/test/64")) + } + + fun `test signIn returns connected result and clears needs_auth`() = runBlocking(Dispatchers.Default) { + rpc.mcps = listOf(McpStatusDto("linear", "needs_auth")) + val service = service() + service.refresh("/test") + rpc.mcpAuthenticateResult = McpAuthResultDto("connected") + rpc.mcps = listOf(McpStatusDto("linear", "connected")) + + val result = service.signIn("/test", "linear") + + assertEquals("connected", result.status) + assertTrue(service.needsAuth.value["/test"].orEmpty().isEmpty()) + } + + fun `test signIn is single-flight per directory and name`() = runBlocking(Dispatchers.Default) { + // Gates the fake's authenticate call itself, so signIn has already recorded the busy key + // (which happens before the authenticate call) by the time the second call races it. + val gate = CompletableDeferred() + rpc.mcpAuthenticateResult = McpAuthResultDto("connected") + rpc.beforeAuthenticate = { gate.await() } + val service = service() + val first = async { service.signIn("/test", "linear") } + withTimeout(5000) { while (!rpc.mcpAuthenticateStarted) delay(5) } + + // The first call has claimed the busy key and is blocked inside authenticate; the second + // call must see the busy key and refuse to start a duplicate authenticate request. + val second = service.signIn("/test", "linear") + assertEquals("failed", second.status) + assertEquals(0, rpc.mcpAuthentications.size) + + gate.complete(Unit) + val firstResult = first.await() + assertEquals("connected", firstResult.status) + assertEquals(1, rpc.mcpAuthentications.size) + } + + /** + * A timeout must abandon the attempt without discarding credentials, so it cancels rather than + * deleting the stored sign-in. + */ + fun `test signIn on timeout cancels the flow and reports timeout`() = runBlocking(Dispatchers.Default) { + val slowRpc = SlowAuthenticateRpc(CompletableDeferred()) + val slowBehavior = KiloAgentBehaviorService(scope, slowRpc) + val slowService = KiloMcpAuthService(scope, slowBehavior, authTimeoutMs = 100L, dedupeWindowMs = 4000L) + + val result = slowService.signIn("/test", "linear") + + assertEquals("timeout", result.status) + assertEquals(listOf("linear"), slowRpc.mcpAuthCancels) + assertTrue("a timeout must not delete credentials", slowRpc.mcpAuthRemovals.isEmpty()) + } + + fun `test cancel calls mcpAuthCancel and keeps credentials`() = runBlocking(Dispatchers.Default) { + rpc.mcpAuthCancelResult = true + val service = service() + + val ok = service.cancel("/test", "linear") + + assertTrue(ok) + assertEquals(listOf("linear"), rpc.mcpAuthCancels) + assertTrue("cancel must not delete credentials", rpc.mcpAuthRemovals.isEmpty()) + } + + fun `test reset reconnects and refreshes needs auth state`() = runBlocking(Dispatchers.Default) { + rpc.mcps = listOf(McpStatusDto("linear", "connected")) + rpc.afterMcpConnect = { _, name -> rpc.mcps = listOf(McpStatusDto(name, "needs_auth")) } + val service = service() + + assertTrue(service.reset("/test", "linear")) + + assertEquals(listOf("linear"), rpc.mcpAuthRemovals) + assertEquals(listOf("linear"), rpc.mcpDisconnects) + assertEquals(listOf("linear"), rpc.mcpConnects) + assertEquals(setOf("linear"), service.needsAuth.value["/test"]) + } + + fun `test reset marks an active sign in as quietly cancelled`() = runBlocking(Dispatchers.Default) { + val gate = CompletableDeferred() + rpc.beforeAuthenticate = { gate.await() } + rpc.mcpAuthenticateResult = McpAuthResultDto("failed", "Authorization cancelled") + rpc.afterMcpConnect = { _, name -> rpc.mcps = listOf(McpStatusDto(name, "needs_auth")) } + val service = service() + val signIn = async { service.signIn("/test", "linear") } + withTimeout(5000) { while (!rpc.mcpAuthenticateStarted) delay(5) } + + assertTrue(service.reset("/test", "linear")) + gate.complete(Unit) + + assertEquals("cancelled", signIn.await().status) + } + + fun `test reset keeps needs auth state when reconnect fails`() = runBlocking(Dispatchers.Default) { + rpc.mcps = listOf(McpStatusDto("linear", "connected")) + rpc.mcpConnectError = RuntimeException("offline") + val service = service() + + assertFalse(service.reset("/test", "linear")) + + assertEquals(setOf("linear"), service.needsAuth.value["/test"]) + } + + fun `test cancelling an active sign in returns a quiet cancelled result`() = runBlocking(Dispatchers.Default) { + val gate = CompletableDeferred() + rpc.beforeAuthenticate = { gate.await() } + rpc.mcpAuthenticateResult = McpAuthResultDto("failed", "Browser authorization failed: Authorization cancelled") + val service = service() + val result = async { service.signIn("/test", "linear") } + withTimeout(5000) { while (!rpc.mcpAuthenticateStarted) delay(5) } + + assertTrue(service.cancel("/test", "linear")) + gate.complete(Unit) + + assertEquals("cancelled", result.await().status) + assertEquals(listOf("linear"), rpc.mcpAuthCancels) + + rpc.beforeAuthenticate = null + rpc.mcpAuthenticateStarted = false + rpc.mcpAuthenticateResult = McpAuthResultDto("connected") + assertEquals("connected", service.signIn("/test", "linear").status) + } + + fun `test duplicate browser open failed events within the dedupe window are collapsed`() { + val opened = mutableListOf>() + val service = service(dedupeWindowMs = 60_000L, showAuthUrl = { name, url -> opened.add(name to url) }) + + runBlocking(Dispatchers.Default) { + service.refresh("/test") + } + settle() + + runBlocking(Dispatchers.Default) { + rpc.mcpAuthEventsFlow.emit(McpAuthEventDto("linear", "https://auth.example.test/authorize")) + } + settle() + runBlocking(Dispatchers.Default) { + rpc.mcpAuthEventsFlow.emit(McpAuthEventDto("linear", "https://auth.example.test/authorize")) + } + settle() + + assertEquals(listOf("linear" to "https://auth.example.test/authorize"), opened) + } + + fun `test browser open failed events with different urls both open`() { + val opened = mutableListOf>() + val service = service(dedupeWindowMs = 0L, showAuthUrl = { name, url -> opened.add(name to url) }) + + runBlocking(Dispatchers.Default) { + service.refresh("/test") + } + settle() + + runBlocking(Dispatchers.Default) { + rpc.mcpAuthEventsFlow.emit(McpAuthEventDto("linear", "https://auth.example.test/authorize?a=1")) + } + settle() + runBlocking(Dispatchers.Default) { + rpc.mcpAuthEventsFlow.emit(McpAuthEventDto("linear", "https://auth.example.test/authorize?a=2")) + } + settle() + + assertEquals(2, opened.size) + } + + /** + * `mcp.auth.url` means the CLI deliberately did not open a browser, because in split mode it + * runs on the host while the user sits at the client. The client must open it itself. + */ + fun `test external auth url opens in the client browser`() { + val opened = mutableListOf() + val dialogs = mutableListOf() + val service = service( + showAuthUrl = { _, url -> dialogs.add(url) }, + openUrl = { url -> opened.add(url) }, + ) + + runBlocking(Dispatchers.Default) { service.refresh("/test") } + settle() + runBlocking(Dispatchers.Default) { + rpc.mcpAuthEventsFlow.emit( + McpAuthEventDto("linear", "https://auth.example.test/authorize", external = true), + ) + } + settle() + + assertEquals(listOf("https://auth.example.test/authorize"), opened) + assertTrue("the dialog is only a fallback", dialogs.isEmpty()) + } + + /** When the client cannot open a browser either, the URL still has to reach the user. */ + fun `test external auth url falls back to the dialog when opening fails`() { + val dialogs = mutableListOf() + val service = service( + showAuthUrl = { _, url -> dialogs.add(url) }, + openUrl = { throw RuntimeException("no browser") }, + ) + + runBlocking(Dispatchers.Default) { service.refresh("/test") } + settle() + runBlocking(Dispatchers.Default) { + rpc.mcpAuthEventsFlow.emit( + McpAuthEventDto("linear", "https://auth.example.test/authorize", external = true), + ) + } + settle() + + assertEquals(listOf("https://auth.example.test/authorize"), dialogs) + } + + /** `mcp.browser.open.failed` means the CLI already tried, so the dialog is the only option. */ + fun `test browser open failed event does not retry opening a browser`() { + val opened = mutableListOf() + val dialogs = mutableListOf() + val service = service( + showAuthUrl = { _, url -> dialogs.add(url) }, + openUrl = { url -> opened.add(url) }, + ) + + runBlocking(Dispatchers.Default) { service.refresh("/test") } + settle() + runBlocking(Dispatchers.Default) { + rpc.mcpAuthEventsFlow.emit(McpAuthEventDto("linear", "https://auth.example.test/authorize")) + } + settle() + + assertEquals(listOf("https://auth.example.test/authorize"), dialogs) + assertTrue(opened.isEmpty()) + } + + /** + * The authorization URL is supplied by the remote MCP server, so a non-web scheme must never + * reach the browser or the fallback dialog. Otherwise a malicious server could have the IDE + * open `file:///…` or `smb://…` on the user's machine. + */ + fun `test non-web auth urls are dropped instead of opened`() { + val opened = mutableListOf() + val dialogs = mutableListOf() + val service = service( + dedupeWindowMs = 0L, + showAuthUrl = { _, url -> dialogs.add(url) }, + openUrl = { url -> opened.add(url) }, + ) + + runBlocking(Dispatchers.Default) { service.refresh("/test") } + settle() + for (url in listOf("file:///etc/passwd", "smb://host/share", "javascript:alert(1)", "https:///nohost")) { + runBlocking(Dispatchers.Default) { + rpc.mcpAuthEventsFlow.emit(McpAuthEventDto("linear", url, external = true)) + } + settle() + } + + assertTrue("no non-web URL may be opened, got $opened", opened.isEmpty()) + assertTrue("no non-web URL may reach the dialog, got $dialogs", dialogs.isEmpty()) + } + + /** A [KiloAgentBehaviorService] backed by an RPC fake whose `mcpAuthenticate` never returns. */ + private class SlowAuthenticateRpc(private val never: CompletableDeferred) : + ai.kilocode.rpc.KiloAgentBehaviorRpcApi by FakeAgentBehaviorRpcApi() { + val mcpAuthCancels = mutableListOf() + val mcpAuthRemovals = mutableListOf() + + override suspend fun mcpAuthenticate(directory: String, name: String): McpAuthResultDto = never.await() + + override suspend fun mcpAuthCancel(directory: String, name: String): Boolean { + mcpAuthCancels.add(name) + return true + } + + override suspend fun mcpAuthRemove(directory: String, name: String): Boolean { + mcpAuthRemovals.add(name) + return true + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt index 53c4d1bda42..7181ed3d2bb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt @@ -107,6 +107,23 @@ class KiloBundleLocaleTest : BasePlatformTestCase() { } } + fun `test mcp auth keys are present and format in every locale`() { + for (locale in LOCALES) { + val props = load(locale) + for ((key, args) in MCP_AUTH) { + val pattern = props.getProperty(key) + assertNotNull("$locale: missing $key", pattern) + assertTrue("$locale: $key is blank", pattern!!.isNotBlank()) + assertEscaped(locale, key, pattern) + val rendered = format(pattern, *args.toTypedArray()) + for (arg in args) { + assertTrue("$locale: $key dropped $arg -> $rendered", rendered.contains(arg)) + } + assertClean(locale, key, rendered) + } + } + } + fun `test localized bundles do not carry stale keys`() { val base = load("en").stringPropertyNames() for (locale in LOCALES.filterNot { it == "en" }) { @@ -341,6 +358,33 @@ class KiloBundleLocaleTest : BasePlatformTestCase() { "session.empty.worktree.unknown", "action.Kilo.NewSession.toolbar", "action.Kilo.NewWorktree.toolbar", + "settings.agentBehavior.mcp.signIn.cancel", + "settings.agentBehavior.mcp.resetAuth", + "settings.agentBehavior.mcp.resetAuth.title", + "settings.agentBehavior.mcp.resetAuth.failed", + "settings.agentBehavior.mcp.authUrl.title", + "settings.agentBehavior.mcp.authUrl.open", + "settings.agentBehavior.mcp.authUrl.copy", + "settings.agentBehavior.mcp.edit.oauth", + "settings.agentBehavior.mcp.edit.oauth.help", + "settings.agentBehavior.mcp.edit.oauth.mode", + "settings.agentBehavior.mcp.edit.oauth.mode.automatic", + "settings.agentBehavior.mcp.edit.oauth.mode.disabled", + "settings.agentBehavior.mcp.edit.oauth.mode.custom", + "settings.agentBehavior.mcp.edit.oauth.clientId", + "settings.agentBehavior.mcp.edit.oauth.clientSecret", + "settings.agentBehavior.mcp.edit.oauth.scope", + "settings.agentBehavior.mcp.edit.oauth.callbackPort", + "settings.agentBehavior.mcp.edit.oauth.redirectUri", + "settings.agentBehavior.mcp.edit.oauth.redirectUri.help", + "settings.agentBehavior.mcp.edit.oauth.port.invalid", + "settings.agentBehavior.mcp.edit.oauth.secret.invalid", + "settings.agentBehavior.mcp.edit.oauth.redirectUri.invalid", + "settings.marketplace.mcp.signIn.title", + "settings.marketplace.mcp.signIn.confirm", + "prompt.mcp.needsAuth.busy", + "prompt.mcp.openSettings", + "prompt.issues.title", ) val AGENT = mapOf( @@ -460,5 +504,23 @@ class KiloBundleLocaleTest : BasePlatformTestCase() { "worktree.diagnostics.noProject" to emptyList(), "settings.agentBehavior.extended.title" to emptyList(), ) + + val MCP_AUTH = mapOf( + "settings.agentBehavior.mcp.signIn.progress" to listOf("SERVER_NAME"), + "settings.agentBehavior.mcp.signIn.success" to listOf("SERVER_NAME"), + "settings.agentBehavior.mcp.signIn.failed" to listOf("SERVER_NAME"), + "settings.agentBehavior.mcp.signIn.timeout" to listOf("SERVER_NAME"), + "settings.agentBehavior.mcp.signIn.unsupported" to listOf("SERVER_NAME"), + "settings.agentBehavior.mcp.resetAuth.message" to listOf("SERVER_NAME"), + "settings.agentBehavior.mcp.authUrl.message" to listOf("SERVER_NAME"), + "settings.agentBehavior.mcp.delete.bundle.message" to listOf("SERVER_NAME"), + "settings.agentBehavior.skills.delete.bundle.message" to listOf("SKILL_NAME", "SERVER_NAME"), + "settings.marketplace.mcp.signIn.message" to listOf("SERVER_NAME"), + "prompt.mcp.needsAuth.one" to listOf("SERVER_NAME"), + "prompt.mcp.needsAuth.many" to listOf("7"), + "prompt.mcp.signIn" to listOf("SERVER_NAME"), + "prompt.mcp.signingIn" to listOf("SERVER_NAME"), + "prompt.mcp.provider" to listOf("SERVER_NAME"), + ) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptRailLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptRailLayoutTest.kt new file mode 100644 index 00000000000..176e722748a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptRailLayoutTest.kt @@ -0,0 +1,249 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.ui.SessionDropOverlay +import ai.kilocode.client.session.ui.SessionRootPanel +import ai.kilocode.client.session.ui.account.SessionAccountOverlay +import ai.kilocode.client.session.ui.rail.PromptRail +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.UiStyle +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import java.awt.Point +import java.awt.Rectangle +import java.awt.Component +import java.awt.Container +import javax.swing.JLayeredPane +import javax.swing.SwingUtilities + +class PromptRailLayoutTest : SessionUiTestBase() { + fun `test rail is hidden until two prompts exist`() { + showMessages() + val rail = find(ui) + assertFalse(rail.isVisible) + + fillTranscript(1) + layoutAll(ui) + drainScroll() + assertFalse(rail.isVisible) + + fillTranscript(1, start = 1) + layoutAll(ui) + drainScroll() + assertTrue(rail.isVisible) + } + + fun `test wide rail uses standard right inset outside readable lane`() { + showMessages() + fillTranscript(30) + ui.setSize(1600, 600) + layoutAll(ui) + ui.promptRail.refresh() + find(ui).overlay.doLayout() + drainScroll() + val rail = find(ui) + val root = find(ui) + val pane = scrollComponent() as JBScrollPane + val vp = SwingUtilities.convertRectangle(pane.viewport, Rectangle(pane.viewport.size), root.overlay) + val bar = SwingUtilities.convertRectangle(pane.verticalScrollBar, Rectangle(pane.verticalScrollBar.size), root.overlay) + + assertEquals(bar.x - UiStyle.Gap.pad(), rail.x + rail.width) + assertFalse("rail=${rail.bounds} bar=$bar vp=$vp", rail.bounds.intersects(bar)) + } + + fun `test narrow rail covers scrollbar and remains above content`() { + showMessages() + fillTranscript(12) + ui.setSize(420, 600) + layoutAll(ui) + ui.promptRail.refresh() + find(ui).overlay.doLayout() + drainScroll() + val rail = find(ui) + val root = find(ui) + val pane = scrollComponent() as JBScrollPane + val bar = SwingUtilities.convertRectangle(pane.verticalScrollBar, Rectangle(pane.verticalScrollBar.size), root.overlay) + + assertSame(root.overlay, rail.parent) + assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay)) + assertTrue("available=${rail.available()} entries=${rail.entries().size} bounds=${rail.bounds}", rail.isVisible) + assertTrue("rail=${rail.bounds} bar=$bar", rail.bounds.intersects(bar)) + val point = SwingUtilities.convertPoint(rail, Point(rail.width / 2, rail.tickCenterY(0)), root) + assertSame(rail, SwingUtilities.getDeepestComponentAt(root, point.x, point.y)) + val free = SwingUtilities.convertPoint(rail, Point(rail.width / 2, 1), root) + val below = SwingUtilities.getDeepestComponentAt(root, free.x, free.y) + assertTrue(below === pane.verticalScrollBar || SwingUtilities.isDescendingFrom(below, pane.verticalScrollBar)) + assertFalse(root.isOptimizedDrawingEnabled) + } + + fun `test rail stays below the other session overlays`() { + showMessages() + fillTranscript(3) + val rail = find(ui) + val root = find(ui) + val drop = find(ui) + val account = find(ui) + + assertTrue(root.overlay.getComponentZOrder(rail) > root.overlay.getComponentZOrder(drop)) + assertTrue(root.overlay.getComponentZOrder(rail) > root.overlay.getComponentZOrder(account)) + assertTrue(root.overlay.getComponentZOrder(rail) > root.overlay.getComponentZOrder(jumpButton())) + } + + fun `test scrolling changes active prompt and jump scrolls to prompt top`() { + showMessages() + fillTranscript(10) + val rail = find(ui) + val bar = scrollBar() + setValue(bar, 0) + assertEquals(0, rail.active()) + + setValue(bar, bottom(bar) / 2) + assertTrue((rail.active() ?: 0) > 0) + + assertTrue(ui.scroll.scrollMessageTop("msg_2")) + drainScroll() + val turn = find(ui).findTurn("msg_2")!! + val expected = SwingUtilities.convertPoint(turn, Point(0, 0), scrollView()).y + .coerceIn(0, bottom(bar)) + assertEquals(expected, bar.value) + assertFalse(ui.scroll.following()) + } + + /** + * With many prompts the tick band covers the whole viewport, and in the narrow layout the rail is + * drawn over the scrollbar. The rail must therefore claim only the rows its ticks occupy, or the + * thumb cannot be dragged and track clicks never reach the scrollbar. + */ + fun `test presses between ticks reach the scrollbar`() { + showMessages() + // Enough prompts that the band spans the viewport, which is the case that locked the bar. + fillTranscript(60) + ui.setSize(420, 600) + layoutAll(ui) + ui.promptRail.refresh() + val root = find(ui) + root.overlay.doLayout() + drainScroll() + val rail = find(ui) + assertTrue("the rail must be showing for this case", rail.isVisible) + assertNotNull("the narrow layout must hand the rail its scrollbar", rail.barTarget) + + // Hover still works along the whole band, so the card opens wherever the pointer rests. + val x = rail.width / 2 + assertTrue(rail.contains(x, rail.tickCenterY(0))) + assertTrue(rail.contains(x, rail.tickCenterY(rail.entries().lastIndex))) + + // A press off a tick is handed to the bar; one on a tick stays the rail's own. Asserted by what + // the bar receives rather than by its value, which needs a realised UI to move. + val bar = scrollBar() + val got = mutableListOf() + bar.addMouseListener(object : java.awt.event.MouseAdapter() { + override fun mousePressed(e: java.awt.event.MouseEvent) { + got.add(e.y) + } + }) + + val mid = (rail.tickCenterY(0) + rail.tickCenterY(1)) / 2 + rail.dispatchEvent(pressAt(rail, mid)) + assertEquals("a press between ticks must reach the scrollbar", 1, got.size) + + rail.dispatchEvent(pressAt(rail, rail.tickCenterY(2))) + assertEquals("a press on a tick must not reach the scrollbar", 1, got.size) + + var jumped = false + rail.onSelect = { jumped = true } + rail.dispatchEvent(clickAt(rail, rail.tickCenterY(2))) + assertTrue("a click on a tick must still navigate", jumped) + } + + private fun pressAt(rail: PromptRail, y: Int) = java.awt.event.MouseEvent( + rail, + java.awt.event.MouseEvent.MOUSE_PRESSED, + 0L, + 0, + rail.width / 2, + y, + 1, + false, + java.awt.event.MouseEvent.BUTTON1, + ) + + private fun clickAt(rail: PromptRail, y: Int) = java.awt.event.MouseEvent( + rail, + java.awt.event.MouseEvent.MOUSE_CLICKED, + 0L, + 0, + rail.width / 2, + y, + 1, + false, + java.awt.event.MouseEvent.BUTTON1, + ) + + /** + * The rail is drawn over the transcript's right edge, so the transcript has to reserve room for a + * full-size tick. Without it a tick sits on top of the prompt bubble. + */ + fun `test the transcript reserves room for the rail`() { + val inner = SessionUiStyle.SessionLayout.INNER_RIGHT + val tick = SessionUiStyle.PromptRail.TICK_HOVER + + assertTrue( + "right inset $inner must clear a $tick tick", + inner >= tick, + ) + assertEquals("and clear it by the smallest standard gap", UiStyle.Gap.XS, inner - tick) + assertTrue( + "the reserved edge must be wider than the plain inset it replaces", + inner > SessionUiStyle.SessionLayout.INNER_HORIZONTAL, + ) + } + + /** + * A top-anchored jump to the latest turn lands within the follow threshold of the bottom, so + * re-arming follow there would let the next streamed delta pull the view back down and undo it. + */ + fun `test jumping to the latest prompt does not re-arm follow`() { + showMessages() + // The viewport needs a real extent, or the scrollbar reports visibleAmount 0 and the + // near-the-bottom condition this guards against can never be reached. + ui.setSize(900, 400) + layoutAll(ui) + fillTranscript(10) + layoutAll(ui) + drainScroll() + val bar = scrollBar() + + assertTrue(ui.scroll.scrollMessageTop("msg_9")) + drainScroll() + + // The target lands inside the follow threshold of the bottom, which is exactly the case where + // re-arming follow would let the next streamed delta undo the jump. + assertTrue(bar.value + bar.visibleAmount >= bar.maximum - JBUI.scale(32)) + assertFalse("a top jump must leave follow disarmed", ui.scroll.following()) + + val landed = bar.value + fillTranscript(1, start = 10) + drainScroll() + assertEquals("a streamed update must not drag the view off the jump target", landed, bar.value) + } + + fun `test repeated updates retain the rail and overlay tree`() { + showMessages() + fillTranscript(10) + val root = find(ui) + val rail = find(ui) + val count = root.overlay.componentCount + val items = rail.items() + val entries = rail.entries() + + repeat(300) { rail.update(items, entries, it % items.size) } + + assertSame(rail, find(ui)) + assertEquals(count, root.overlay.componentCount) + } + + private fun layoutAll(comp: Component) { + comp.doLayout() + if (comp is Container) comp.components.forEach(::layoutAll) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt index 64711d1397b..f99dbad994f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.ModifiedFilesView import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.ui.header.BranchDock +import ai.kilocode.client.session.ui.header.SessionHeaderPanel import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.ui.style.SessionEditorStyle @@ -38,6 +39,7 @@ import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.Container import java.awt.Point +import java.awt.event.MouseEvent import javax.swing.AbstractButton import javax.swing.JButton import javax.swing.JComponent @@ -51,6 +53,66 @@ import kotlinx.coroutines.CompletableDeferred @Suppress("UnstableApiUsage") class SessionScrollTest : SessionUiTestBase() { + fun `test timeline click reveals the matching transcript part`() { + showMessages() + fillTranscript(12) + val mid = "timeline_message" + val pid = "timeline_part" + emit(ChatEventDto.MessageUpdated("ses_test", message(mid).copy(role = "assistant")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("timeline_first", mid, "text", text(12))), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part(pid, mid, "reasoning", text(13))), flush = false) + fillTranscript(24, start = 12) + + val header = find(ui) + if (!header.isExpanded()) click(header.expandButton()) + drainScroll() + val timeline = header.timelinePanel() + timeline.setSize(timeline.preferredSize) + val bar = scrollBar() + setBottom(bar) + val messages = find(ui) + val target = messages.findMessage(mid)!!.part(pid) as JComponent + val expected = SwingUtilities.convertPoint(target, Point(0, 0), messages).y + assertTrue("target=$expected bottom=${bottom(bar)}", expected < bottom(bar)) + + timeline.dispatchEvent(MouseEvent( + timeline, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, + header.timelineBarWidth() + 1, timeline.height - 1, 1, false, MouseEvent.BUTTON1, + )) + drainScroll() + + assertEquals(expected, bar.value) + assertFalse(ui.scroll.following()) + + fillTranscript(1, start = 36) + + assertEquals(expected, bar.value) + assertFalse(ui.scroll.following()) + } + + fun `test timeline step finish navigates to its containing message`() { + showMessages() + fillTranscript(12) + val mid = "timeline_message" + emit(ChatEventDto.MessageUpdated("ses_test", message(mid).copy(role = "assistant")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("timeline_text", mid, "text", text(12))), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("timeline_finish", mid, "step-finish")), flush = false) + fillTranscript(24, start = 12) + val bar = scrollBar() + setBottom(bar) + val messages = find(ui) + val target = messages.findMessage(mid)!! + assertNull(target.part("timeline_finish")) + val expected = SwingUtilities.convertPoint(target, Point(0, 0), messages).y + assertTrue(expected < bottom(bar)) + + assertTrue(ui.scroll.scrollPart(mid, "timeline_finish")) + drainScroll() + + assertEquals(expected, bar.value) + assertFalse(ui.scroll.following()) + } + fun `test session update follows when transcript is at bottom`() { showMessages() fillTranscript(24) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiPromptRestoreTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiPromptRestoreTest.kt new file mode 100644 index 00000000000..92abf8f567d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiPromptRestoreTest.kt @@ -0,0 +1,170 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.MessageErrorDto +import com.intellij.ui.EditorTextField + +/** + * Regression coverage for the data-loss bug where a prompt that failed before any message was + * persisted (an attachment rejected synchronously, or a prompt_async send that returns 204 and + * then fails asynchronously) permanently lost the user's typed text: the editor was cleared on + * submit and there was no draft store or prompt history to recover it from. See + * SessionUi.sendPrompt/onStateChanged and PromptPanel.restoreLastSubmission. + */ +@Suppress("UnstableApiUsage") +class SessionUiPromptRestoreTest : SessionUiTestBase() { + + fun `test failed send restores the typed prompt into the editor`() { + showMessages() + rpc.prompts.clear() + rpc.promptThrows = RuntimeException("ImageDecodeError: Image could not be decoded") + + val editor = find(ui) + editor.text = "don't lose this message" + + find(ui).send() + // Wait for the dispatch failure to actually land rather than for a fixed budget: the send + // crosses coroutine -> invokeLater -> EDT, which can outlast a short delay under load. + assertTrue( + "the send failure never reached the model", + pumpUntil { controller().model.state is SessionState.Error }, + ) + + assertTrue("A rejected send must not reach the backend as a persisted prompt", rpc.prompts.isEmpty()) + assertEquals("don't lose this message", find(ui).text()) + } + + fun `test successful send does not leave a stale restorable draft`() { + showMessages() + rpc.prompts.clear() + + val editor = find(ui) + editor.text = "this one goes through" + + find(ui).send() + assertTrue("the send never reached the backend", pumpUntil { rpc.prompts.isNotEmpty() }) + + assertEquals(1, rpc.prompts.size) + assertEquals("", find(ui).text()) + + // Simulate the server actually persisting the user's message (what a real backend does + // right after accepting the prompt), then a later, unrelated turn failure. That failure + // must not resurrect text that already made it into the transcript. + emit(ChatEventDto.MessageUpdated("ses_test", message("restore_test_user_msg")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("restore_test_part", "restore_test_user_msg", "text", "this one goes through"))) + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "unknown", message = "boom"))) + assertTrue( + "the error never reached the model", + pumpUntil { controller().model.state is SessionState.Error }, + ) + assertEquals("", find(ui).text()) + } + + // The next two tests are a matched pair: identical setup and the same restoreLastSubmission() + // call, differing only in whether the editor already holds a draft. Together they pin the + // hasDraft() guard -- removing it makes the second test fail. They drive the panel API directly + // because the guard only matters when the user types during the window between the editor being + // cleared on submit and the failure landing, which is not deterministically reachable by + // interleaving a send with settle(). + fun `test restore puts the submission back when the editor is empty`() { + showMessages() + rpc.prompts.clear() + + find(ui).text = "submitted message" + find(ui).send() + assertTrue("the send never reached the backend", pumpUntil { rpc.prompts.isNotEmpty() }) + + // The send reached the backend and no failure arrived, so nothing has consumed the + // retained submission yet; the editor was cleared on submit. + assertEquals(1, rpc.prompts.size) + assertEquals("", find(ui).text()) + + find(ui).restoreLastSubmission() + + assertEquals("submitted message", find(ui).text()) + } + + fun `test restore does not clobber a draft the user already started`() { + showMessages() + rpc.prompts.clear() + + find(ui).text = "submitted message" + find(ui).send() + assertTrue("the send never reached the backend", pumpUntil { rpc.prompts.isNotEmpty() }) + + assertEquals(1, rpc.prompts.size) + assertEquals("", find(ui).text()) + + // The user starts a new draft before anything attempts a restore. + find(ui).text = "a brand new draft" + find(ui).restoreLastSubmission() + + assertEquals("a brand new draft", find(ui).text()) + } + + fun `test a confirmed send stops retaining the submission`() { + showMessages() + rpc.prompts.clear() + + find(ui).text = "submitted message" + find(ui).send() + assertTrue("the send never reached the backend", pumpUntil { rpc.prompts.isNotEmpty() }) + + // The server persists the user message, which confirms the send and releases the draft + // (a pasted image is held as a full base64 data URL, so it must not be pinned forever). + emit(ChatEventDto.MessageUpdated("ses_test", message("confirmed_user_msg"))) + assertTrue( + "the user message never reached the model", + pumpUntil { controller().model.messages().any { it.info.id == "confirmed_user_msg" } }, + ) + + find(ui).restoreLastSubmission() + + assertEquals("", find(ui).text()) + } + + /** + * The prompt box stays enabled while a turn streams, so a send can be queued behind one. When + * the earlier turn ends, that session-scoped TurnEnded must not be mistaken for the queued + * send's own outcome -- doing so discarded the queued draft before its failure arrived. + */ + fun `test an earlier turn ending does not discard a queued send's draft`() { + showMessages() + rpc.prompts.clear() + + // Send A, and let the server persist its user message so A is confirmed. + find(ui).text = "first message" + find(ui).send() + assertTrue("send A never reached the backend", pumpUntil { rpc.prompts.isNotEmpty() }) + emit(ChatEventDto.MessageUpdated("ses_test", message("queued_msg_a"))) + assertTrue( + "A's user message never reached the model", + pumpUntil { controller().model.messages().any { it.info.id == "queued_msg_a" } }, + ) + + // Send B while A is still streaming. Its own user message has not been persisted yet. + rpc.prompts.clear() + find(ui).text = "second message" + find(ui).send() + assertTrue("send B never reached the backend", pumpUntil { rpc.prompts.isNotEmpty() }) + assertEquals("", find(ui).text()) + + // Turn A ends without completing. This must leave B's pending send untouched. + emit(ChatEventDto.TurnClose("ses_test", reason = "interrupted")) + assertTrue( + "turn A never ended", + pumpUntil { controller().model.state is SessionState.TurnEnded }, + ) + + // B now fails before its message was ever persisted, so its draft must come back. + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "unknown", message = "B rejected"))) + assertTrue( + "B's failure never reached the model", + pumpUntil { controller().model.state is SessionState.Error }, + ) + + assertEquals("second message", find(ui).text()) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt index 8a49a51b42c..2c27d504bd5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt @@ -132,6 +132,12 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { pumpEdt() } + /** + * Drains coroutines and the EDT until [cond] holds, instead of waiting a fixed [settleShort] + * budget that can expire under full-suite load. Returns whether [cond] became true. + */ + protected fun pumpUntil(cond: () -> Boolean): Boolean = coroutines.pumpUntil(cond = cond) + protected fun showMessages() { controller().prompt("hello") settle() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptAttachmentExtractorTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptAttachmentExtractorTest.kt index 8b476859313..9e1faf73323 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptAttachmentExtractorTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptAttachmentExtractorTest.kt @@ -63,6 +63,41 @@ class PromptAttachmentExtractorTest : TestCase() { assertTrue(item.part().url.orEmpty().startsWith("data:image/png;base64,")) } + fun `test svg file becomes a text reference, not an embedded image`() { + val file = File.createTempFile("kilo-drop", ".svg") + file.writeText("") + + val item = PromptAttachmentExtractor.files(listOf(file)).single() + val part = item.part() + + assertTrue(item.reference) + assertEquals("text/plain", item.mime) + assertEquals("file", part.type) + assertEquals("text/plain", part.mime) + assertFalse(part.url.orEmpty().startsWith("data:")) + } + + fun `test bmp file becomes a reference, not an embedded image`() { + val file = File.createTempFile("kilo-drop", ".bmp") + file.writeBytes(byteArrayOf(0x42, 0x4D, 1, 2, 3)) + + val item = PromptAttachmentExtractor.files(listOf(file)).single() + + assertTrue(item.reference) + assertEquals("text/plain", item.mime) + assertFalse(PromptAttachmentExtractor.image(item.mime)) + } + + fun `test image() only accepts formats the server can decode`() { + assertTrue(PromptAttachmentExtractor.image("image/png")) + assertTrue(PromptAttachmentExtractor.image("image/jpeg")) + assertTrue(PromptAttachmentExtractor.image("image/gif")) + assertTrue(PromptAttachmentExtractor.image("image/webp")) + assertFalse(PromptAttachmentExtractor.image("image/svg+xml")) + assertFalse(PromptAttachmentExtractor.image("image/bmp")) + assertFalse(PromptAttachmentExtractor.image("image/x-icon")) + } + fun `test oversized image is skipped but oversized code is referenced`() { val image = File.createTempFile("kilo-drop", ".png") image.writeBytes(ByteArray(10 * 1024 * 1024 + 1)) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 31d7de70c9b..32e18d3a93d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -15,6 +15,8 @@ import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteHandler import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteProvider import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.ui.prompt.SessionIssue +import ai.kilocode.client.session.ui.prompt.SessionIssueAction import ai.kilocode.client.session.ui.prompt.PromptTextPasteProvider import ai.kilocode.client.session.ui.prompt.SlashAction import ai.kilocode.client.session.ui.selection.SessionSelection @@ -43,6 +45,7 @@ import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.actionSystem.DataKey import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.IdeActions import com.intellij.openapi.actionSystem.PlatformCoreDataKeys import com.intellij.openapi.actionSystem.UiDataProvider @@ -1117,6 +1120,77 @@ class PromptPanelTest : BasePlatformTestCase() { assertTrue(panel.resetVisibleForTest()) } + fun `test session issues button is hidden when there are no issues`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + + panel.setIssues(emptyList()) + + assertFalse(panel.issuesButtonForTest().isVisible) + } + + fun `test session issues button appears for an actionable issue`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val issue = SessionIssue("mcp-auth:anaconda", "Anaconda MCP", listOf(SessionIssueAction("Sign in") {})) + + panel.setIssues(listOf(issue)) + realize(panel, 260, 400) + panel.setBounds(0, 0, 260, panel.preferredSize.height) + layoutTree(panel) + + val button = panel.issuesButtonForTest() + assertTrue(button.isVisible) + assertEquals(KiloBundle.message("prompt.issues.title"), button.toolTipText) + assertEquals(listOf(issue), panel.issuesForTest()) + val point = SwingUtilities.convertPoint(button.parent, button.location, panel.shellForTest()) + assertTrue("issues button should be in the right half", point.x > panel.shellForTest().width / 2) + assertTrue("issues button should be in the top half", point.y < panel.shellForTest().height / 2) + } + + fun `test session issues keep multiple future actions`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val issues = listOf( + SessionIssue("mcp-auth:anaconda", "Anaconda MCP", listOf(SessionIssueAction("Sign in") {})), + SessionIssue("other", "Other provider", listOf(SessionIssueAction("Resolve") {})), + ) + + panel.setIssues(issues) + + assertEquals(issues, panel.issuesForTest()) + } + + fun `test session issue action invokes its supplied recovery`() { + var clicked = false + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val issue = SessionIssue( + "mcp-auth:anaconda", + "Anaconda MCP", + listOf( + SessionIssueAction("Sign in") { clicked = true }, + SessionIssueAction("Open in Settings") {}, + ), + ) + panel.setIssues(listOf(issue)) + val provider = panel.issueActions().single() as DefaultActionGroup + val actions = provider.getChildren(null) + assertEquals("Anaconda MCP", provider.templatePresentation.text) + assertEquals(listOf("Sign in", "Open in Settings"), actions.map { it.templatePresentation.text }) + assertTrue(actions.all { it.templatePresentation.icon == null }) + val action = actions.first() + + action.actionPerformed( + AnActionEvent.createEvent( + action, + DataContext.EMPTY_CONTEXT, + null, + ActionPlaces.UNKNOWN, + ActionUiKind.NONE, + null, + ), + ) + + assertTrue(clicked) + } + fun `test prompt editor exposes send context`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val sink = TestSink() @@ -1822,6 +1896,11 @@ class PromptPanelTest : BasePlatformTestCase() { return root } + private fun layoutTree(root: Container) { + root.doLayout() + root.components.filterIsInstance().forEach(::layoutTree) + } + private fun realizedEditor(panel: PromptPanel): EditorEx { realize(panel, 260, 400) val field = panel.defaultFocusedComponent as EditorTextField diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 9fc5aadf762..3f4e983395d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -385,7 +385,12 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals("the card must be exactly as tall as the wrapped text", area.preferredSize.height, card.height) } - fun `test transcript content has symmetric side padding`() { + /** + * The right side is wider than the left on purpose: the prompt navigator's rail is drawn over that + * edge, and the transcript reserves a full-size tick plus the smallest standard gap so a tick never + * sits on top of the content. + */ + fun `test transcript reserves the rail edge beside the content`() { model.upsertMessage(msg("a1", "assistant")) panel.setSize(600, 400) @@ -394,7 +399,9 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { val left = turn.x val right = panel.width - turn.x - turn.width - assertEquals(right, left) + assertEquals(JBUI.scale(SessionUiStyle.SessionLayout.INNER_HORIZONTAL), left) + assertEquals(JBUI.scale(SessionUiStyle.SessionLayout.INNER_RIGHT), right) + assertTrue("the rail edge must clear a full-size tick", right >= JBUI.scale(SessionUiStyle.PromptRail.TICK_HOVER)) } fun `test reflow drops cached panel measurements`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt index 026ddc95b5b..f13cf0ada48 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui.header import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.model.StepFinish +import ai.kilocode.client.session.model.TimelineItem import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.ToolKind @@ -544,9 +545,35 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertEquals(panel.timelineBarWidth(), next - first) } + fun `test timeline navigates only on a left click inside a bar`() { + val c = promptedHeader() + val navigated = mutableListOf() + val panel = SessionHeaderPanel(c, parent, onNavigate = { navigated.add(it) }) + val timeline = panel.timelinePanel() + timeline.setSize(timeline.preferredSize) + val x = panel.timelineBarWidth() + 1 + val y = timeline.height - 1 + + for ((at, button) in listOf(x to MouseEvent.BUTTON3, (panel.timelineBarWidth() - 1) to MouseEvent.BUTTON1)) { + timeline.dispatchEvent(MouseEvent( + timeline, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, + at, y, 1, false, button, + )) + } + assertTrue(navigated.isEmpty()) + + timeline.dispatchEvent(MouseEvent( + timeline, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, + x, y, 1, false, MouseEvent.BUTTON1, + )) + + assertEquals(listOf(c.model.header.timeline[1]), navigated) + } + fun `test timeline drags horizontally inside viewport`() { val c = promptedHeader() - val panel = SessionHeaderPanel(c, parent) + val navigated = mutableListOf() + val panel = SessionHeaderPanel(c, parent, onNavigate = { navigated.add(it) }) repeat(12) { idx -> emit(ChatEventDto.PartUpdated("ses_test", tool("tool_more_$idx", "bash", "running", "More $idx")), flush = false) } @@ -581,6 +608,22 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertTrue(panel.timelineViewport().viewPosition.x > x) assertEquals(y, panel.timelineViewport().viewPosition.y) + + for (event in listOf(MouseEvent.MOUSE_RELEASED, MouseEvent.MOUSE_CLICKED)) { + timeline.dispatchEvent(MouseEvent( + timeline, event, System.currentTimeMillis(), 0, + panel.timelineBarWidth() + 1, timeline.height - 1, 1, false, MouseEvent.BUTTON1, + )) + } + assertTrue(navigated.isEmpty()) + + for (event in listOf(MouseEvent.MOUSE_PRESSED, MouseEvent.MOUSE_RELEASED, MouseEvent.MOUSE_CLICKED)) { + timeline.dispatchEvent(MouseEvent( + timeline, event, System.currentTimeMillis(), 0, + panel.timelineBarWidth() + 1, timeline.height - 1, 1, false, MouseEvent.BUTTON1, + )) + } + assertEquals(listOf(c.model.header.timeline[1]), navigated) } fun `test timeline touch scrolls horizontally inside viewport`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailItemsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailItemsTest.kt new file mode 100644 index 00000000000..820d97d03f6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailItemsTest.kt @@ -0,0 +1,366 @@ +package ai.kilocode.client.session.ui.rail + +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.SessionRevertDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class PromptRailItemsTest : BasePlatformTestCase() { + fun `test preview strips markdown and collapses whitespace`() { + val text = """ + # Heading + - Keep [the label](https://example.com) + > Drop `inline` and ![image](image.png) + ```kotlin + hidden() + ``` + """.trimIndent() + + assertEquals("Heading Keep the label Drop and", PromptRailItems.preview(text)) + } + + fun `test items include user turns with first assistant text`() { + val model = SessionModel() + model.upsertMessage(message("u1", "user")) + model.updateContent("u1", part("up1", "u1", "text", " Build this\nnow ")) + model.upsertMessage(message("a1", "assistant")) + model.updateContent("a1", part("tool", "a1", "tool")) + model.upsertMessage(message("a2", "assistant")) + model.updateContent("a2", part("ap1", "a2", "text", "Here is **the** answer")) + model.setQueued(setOf("u1")) + + assertEquals( + listOf(PromptRailItem("u1", true, "Build this now", "Here is **the** answer")), + PromptRailItems.items(model), + ) + } + + /** + * Inline code is a single-line construct. A class that also matched newlines let two unrelated + * backticks on different lines swallow the text between them, which could empty a prompt and flip + * it into the attachment-only branch. + */ + fun `test inline code stripping does not span lines`() { + // One stray backtick per line, so a class that also matched newlines would pair them up and + // delete the prose in between. Balanced spans close on their own line and cannot show this. + val text = "keep this `start\nand this end` too" + + assertEquals("keep this `start and this end` too", PromptRailItems.preview(text)) + } + + /** A promoted answer becomes the title, so it has to take the title's shorter limit. */ + fun `test promoted answer is capped at the prompt limit`() { + val model = SessionModel() + model.upsertMessage(message("u1", "user")) + model.updateContent("u1", part("file", "u1", "file")) + model.upsertMessage(message("a1", "assistant")) + model.updateContent("a1", part("ap1", "a1", "text", "x".repeat(400))) + + val item = PromptRailItems.items(model).single() + + assertEquals(PromptRailItems.PROMPT_LIMIT, item.prompt.length) + assertTrue(item.prompt.endsWith("…")) + assertEquals("", item.answer) + } + + fun `test answer becomes prompt for attachment only turn`() { + val model = SessionModel() + model.upsertMessage(message("u1", "user")) + model.updateContent("u1", part("file", "u1", "file")) + model.upsertMessage(message("a1", "assistant")) + model.updateContent("a1", part("ap1", "a1", "text", "I can inspect that image")) + + assertEquals( + listOf(PromptRailItem("u1", false, "I can inspect that image", "")), + PromptRailItems.items(model), + ) + } + + /** + * The CLI injects user-role messages whose text is marked synthetic — compaction replays assistant + * content that way, and so do task summaries and resumed tool results. The model strips that text, + * which left the turn with an empty prompt and promoted the assistant's reply into the title, so a + * response was listed as if the user had typed it. + */ + fun `test synthetic injected turns are not listed as prompts`() { + val model = SessionModel() + model.upsertMessage(message("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", "a real prompt")) + model.upsertMessage(message("a1", "assistant")) + model.updateContent("a1", part("ap1", "a1", "text", "the real answer")) + // A compaction replay: user role, text flagged synthetic, followed by assistant output. + model.upsertMessage(message("u2", "user")) + model.updateContent("u2", part("p2", "u2", "text", "replayed assistant analysis", synthetic = true)) + model.upsertMessage(message("a2", "assistant")) + model.updateContent("a2", part("ap2", "a2", "text", "more assistant output")) + + val items = PromptRailItems.items(model) + + assertEquals(listOf("u1"), items.map { it.id }) + assertEquals("a real prompt", items.single().prompt) + } + + /** + * Previews are a couple of hundred characters but an answer can be tens of kilobytes, and this runs + * from every streamed delta. The raw text is cut before the stripping regexes see it. + */ + /** + * A fence-free answer must cost the budget, not its own length. The fence scan ran to the end of the + * part looking for an opening marker, so every streamed delta re-read the whole answer and the + * stream came out quadratic — the cost the budget exists to remove. + */ + fun `test the fence scan is bounded by the budget`() { + val plain = Counting("word ".repeat(40_000)) + + val out = StringBuilder() + PromptRailItems.prose(out, plain) + + assertTrue("the preview must still be filled, got ${out.length}", out.length > 1_000) + assertTrue( + "reads ${plain.reads} must track the budget, not the ${plain.length}-char answer", + plain.reads < 5_000, + ) + } + + /** A fenced block is still measured, so stepping over it reads it rather than guessing its end. */ + fun `test a fenced block is still stepped over in full`() { + val code = "val x = 1\n".repeat(400) + val fenced = Counting("lead\n```kotlin\n$code```\ntail") + + val out = StringBuilder() + PromptRailItems.prose(out, fenced) + + // Whitespace is left for preview() to collapse, so assert on what was kept and dropped. + val kept = out.toString() + assertFalse("the fence marker must not survive: $kept", kept.contains("`")) + assertFalse("code inside the fence must not survive: $kept", kept.contains("val x = 1")) + assertTrue("prose on both sides must survive: $kept", kept.contains("lead") && kept.contains("tail")) + } + + /** + * A fenced answer must also cost a bounded amount per call. Measuring the block is unavoidable, so + * the close search has its own cap rather than running to the end of a very large answer. + */ + fun `test the close scan is bounded for a huge fenced answer`() { + val code = "val x = 1\n".repeat(20_000) + val fenced = Counting("lead\n```kotlin\n$code```\ntail") + + val out = StringBuilder() + PromptRailItems.prose(out, fenced) + + assertTrue( + "reads ${fenced.reads} must stay bounded for a ${fenced.length}-char answer", + fenced.reads < 25_000, + ) + // Crucially still no spill: hitting the cap keeps the prose in front of the fence and stops. + val kept = out.toString() + assertFalse("the fence marker must not survive: $kept", kept.contains("`")) + assertFalse("code inside the fence must not survive: $kept", kept.contains("val x = 1")) + assertTrue("prose before the fence must survive: $kept", kept.contains("lead")) + } + + /** + * A genuinely unclosed fence keeps its baseline behaviour: `preview()` only strips a closed pair, so + * the text is kept rather than dropped. This is the case streaming produces mid-code-block. + */ + fun `test a short unclosed fence is kept as text`() { + val partial = Counting("lead\n```kotlin\nval x = 1") + + val out = StringBuilder() + PromptRailItems.prose(out, partial) + + assertEquals("lead\n```kotlin\nval x = 1", out.toString()) + } + + private class Counting(private val src: String) : CharSequence { + var reads = 0 + + override val length get() = src.length + + override fun get(index: Int): Char { + reads++ + return src[index] + } + + override fun subSequence(startIndex: Int, endIndex: Int) = src.subSequence(startIndex, endIndex) + } + + /** + * The lead-in is inline code: it consumes budget but `preview()` discards it, so the marker after it + * is the only thing that could reach the preview. Reading the whole text surfaces the marker, while + * the budget stops before it. A marker placed after plain prose proves nothing, because the preview + * is truncated to the limit long before it either way. + */ + fun `test preview work is bounded by the text budget`() { + val model = SessionModel() + model.upsertMessage(message("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", "a real prompt")) + model.upsertMessage(message("a1", "assistant")) + model.updateContent("a1", part("ap1", "a1", "text", "`a` ".repeat(600) + "SENTINEL_PAST_BUDGET")) + + val item = PromptRailItems.items(model).single() + + assertFalse( + "text beyond the budget must never be read, got: ${item.answer}", + item.answer.contains("SENTINEL"), + ) + } + + /** + * The budget is applied to prose, not to raw characters. Cutting raw text first could land inside a + * fenced block, and `preview()` only strips a closed fence, so a partial code block plus a stray + * fence marker leaked into the preview where the unbounded version dropped the block entirely. + */ + fun `test a large fenced block is dropped rather than cut in half`() { + val model = SessionModel() + model.upsertMessage(message("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", "a real prompt")) + model.upsertMessage(message("a1", "assistant")) + val code = "fun compute(value: Int): Int = value * 2\n".repeat(400) + model.updateContent("a1", part("ap1", "a1", "text", "Here is the fix:\n```kotlin\n$code```\nAll done.")) + + val answer = PromptRailItems.items(model).single().answer + + assertFalse("the fence marker must not leak: $answer", answer.contains("`")) + assertFalse("code inside the fence must not leak: $answer", answer.contains("fun compute")) + assertEquals("Here is the fix: All done.", answer) + } + + /** + * The prompt side has the same exposure: a prompt that is nothing but a large fenced block has to + * still strip to empty, or it stops taking the attachment-only promotion branch. + */ + fun `test a prompt of only fenced code still promotes the answer`() { + val model = SessionModel() + model.upsertMessage(message("u1", "user")) + val code = "val x = 1\n".repeat(400) + model.updateContent("u1", part("p1", "u1", "text", "```kotlin\n$code```")) + model.upsertMessage(message("a1", "assistant")) + model.updateContent("a1", part("ap1", "a1", "text", "I read the snippet")) + + val item = PromptRailItems.items(model).single() + + assertEquals("I read the snippet", item.prompt) + assertEquals("", item.answer) + } + + /** A streamed delta must not re-preview every turn in the transcript, only the one that changed. */ + fun `test the cache reuses turns that did not change`() { + val model = SessionModel() + for (i in 0 until 5) { + model.upsertMessage(message("u$i", "user")) + model.updateContent("u$i", part("p$i", "u$i", "text", "prompt $i")) + model.upsertMessage(message("a$i", "assistant")) + model.updateContent("a$i", part("ap$i", "a$i", "text", "answer $i")) + } + val cache = PromptRailItems.Cache() + + val first = PromptRailItems.items(model, cache) + val again = PromptRailItems.items(model, cache) + + assertEquals(5, cache.size()) + // Unchanged turns come back as the very same instances, so no preview ran for them. + for (i in first.indices) assertSame(first[i], again[i]) + + // Streaming into one turn invalidates only that turn. + model.updateContent("a2", part("ap2", "a2", "text", "answer 2 with more streamed in")) + val third = PromptRailItems.items(model, cache) + for (i in third.indices) { + if (third[i].id == "u2") continue + assertSame("turn ${third[i].id} must be reused", first[i], third[i]) + } + + // A cleared transcript does not leave entries behind. + model.clear() + PromptRailItems.items(model, cache) + assertEquals(0, cache.size()) + } + + /** + * An in-place edit that keeps the length must still invalidate. Folding only the length into the + * stamp left the cache serving the old preview until something else about the turn moved. + */ + fun `test the cache notices an equal length edit`() { + val model = SessionModel() + model.upsertMessage(message("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", "first prompt")) + model.upsertMessage(message("a1", "assistant")) + model.updateContent("a1", part("ap1", "a1", "text", "first answer")) + val cache = PromptRailItems.Cache() + assertEquals("first prompt", PromptRailItems.items(model, cache).single().prompt) + + // Same length, different text. + model.updateContent("u1", part("p1", "u1", "text", "FIRST PROMPT")) + + assertEquals("FIRST PROMPT", PromptRailItems.items(model, cache).single().prompt) + } + + fun `test compaction and reverted turns are excluded`() { + val model = SessionModel() + model.upsertMessage(message("u1", "user")) + model.updateContent("u1", part("p1", "u1", "compaction")) + model.upsertMessage(message("u2", "user")) + model.updateContent("u2", part("p2", "u2", "text", "visible")) + model.upsertMessage(message("u3", "user")) + model.updateContent("u3", part("p3", "u3", "text", "reverted")) + model.setRevert(SessionRevertDto("u3")) + + assertEquals(listOf("u2"), PromptRailItems.items(model).map { it.id }) + } + + fun `test truncate and rail entries`() { + assertEquals("abc…", PromptRailItems.truncate("abcdef", 4)) + val items = List(8) { PromptRailItem("u$it", false, "prompt $it", "") } + + assertEmpty(PromptRailItems.entries(items, 0)) + assertEquals(listOf(PromptRailEntry.Prompt(7)), PromptRailItems.entries(items, 1)) + assertEquals( + listOf(PromptRailEntry.Prompt(0), PromptRailEntry.Prompt(7)), + PromptRailItems.entries(items, 2), + ) + assertEquals( + listOf( + PromptRailEntry.Prompt(0), + PromptRailEntry.Overflow(1..5), + PromptRailEntry.Prompt(6), + PromptRailEntry.Prompt(7), + ), + PromptRailItems.entries(items, 4), + ) + } + + fun `test capacity and active prompt`() { + assertEquals(0, PromptRailItems.capacity(10, 7, 7)) + assertEquals(4, PromptRailItems.capacity(42, 7, 7)) + val tops = listOf(0, 100, 220) + + assertEquals(2, PromptRailItems.active(tops, 0, scrollable = false, atTop = true)) + assertEquals(0, PromptRailItems.active(tops, 0, scrollable = true, atTop = true)) + assertEquals(1, PromptRailItems.active(tops, 160, scrollable = true, atTop = false)) + assertEquals(2, PromptRailItems.active(tops, 500, scrollable = true, atTop = false)) + } + + private fun message(id: String, role: String) = MessageDto( + id = id, + sessionID = "ses", + role = role, + time = MessageTimeDto(created = 0.0), + ) + + private fun part( + id: String, + message: String, + type: String, + text: String? = null, + synthetic: Boolean? = null, + ) = PartDto( + id = id, + sessionID = "ses", + messageID = message, + type = type, + text = text, + synthetic = synthetic, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacementTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacementTest.kt new file mode 100644 index 00000000000..f5ce67a5209 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPlacementTest.kt @@ -0,0 +1,83 @@ +package ai.kilocode.client.session.ui.rail + +import java.awt.Dimension +import java.awt.Rectangle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The navigator balloon has no callout, so the platform centers it on the target point and ignores the + * requested side. These cover the centering math that keeps it against the ticks without covering them. + */ +class PromptRailPlacementTest { + private val area = Rectangle(0, 0, 1000, 800) + + /** + * Flush, with no gap at all. A gap is dead space where the pointer is over neither the card nor the + * ticks, and crossing it starts the hide timer, so the card closes on the way to a tick. + */ + @Test + fun `body ends hard against the rail`() { + val railX = 600 + val margin = 8 + val insets = 20 + + val width = PromptRailPlacement.maxWidth(railX, FLUSH, margin, insets, cap = 5000) + val content = Dimension(width + insets, 200) + val box = PromptRailPlacement.box( + PromptRailPlacement.center(railX, area, FLUSH, margin, content, anchorY = 400), + content, + ) + + assertEquals("the card must touch the rail, leaving no dead corridor", railX, box.x + box.width) + assertTrue("box $box must stay inside the window", box.x >= 0) + } + + @Test + fun `width is capped and never negative`() { + assertEquals(100, PromptRailPlacement.maxWidth(600, FLUSH, margin = 8, chrome = 20, cap = 100)) + assertEquals(572, PromptRailPlacement.maxWidth(600, FLUSH, margin = 8, chrome = 20, cap = 5000)) + // A rail pinned against the window edge leaves nothing to open into. + assertEquals(0, PromptRailPlacement.maxWidth(10, FLUSH, margin = 8, chrome = 20, cap = 5000)) + assertEquals(0, PromptRailPlacement.maxHeight(height = 10, margin = 8, chrome = 20, cap = 5000)) + } + + @Test + fun `centre holds the anchor but stays inside the session`() { + val content = Dimension(200, 200) + + assertEquals(400, PromptRailPlacement.center(600, area, FLUSH, 8, content, anchorY = 400).y) + + // Near either edge the box would hang outside the visible session, so the centre is pulled in. + val top = PromptRailPlacement.center(600, area, FLUSH, 8, content, anchorY = 0) + val bottom = PromptRailPlacement.center(600, area, FLUSH, 8, content, anchorY = 800) + assertEquals(108, top.y) + assertEquals(692, bottom.y) + assertTrue(PromptRailPlacement.box(top, content).y >= 0) + assertTrue(PromptRailPlacement.box(bottom, content).let { it.y + it.height } <= area.height) + } + + @Test + fun `a body taller than the session is centred`() { + val content = Dimension(200, 900) + + assertEquals(area.height / 2, PromptRailPlacement.center(600, area, FLUSH, 8, content, anchorY = 20).y) + } + + @Test + fun `area offset is respected`() { + val offset = Rectangle(0, 300, 1000, 400) + val content = Dimension(200, 200) + + val spot = PromptRailPlacement.center(600, offset, FLUSH, 8, content, anchorY = 0) + + assertEquals(408, spot.y) + assertTrue(PromptRailPlacement.box(spot, content).y >= offset.y) + } + + private companion object { + /** The inset the navigator uses: none, so the card sits against the rail. */ + const val FLUSH = 0 + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopupTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopupTest.kt new file mode 100644 index 00000000000..4d93e52a762 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/rail/PromptRailPopupTest.kt @@ -0,0 +1,394 @@ +package ai.kilocode.client.session.ui.rail + +import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.list.ActiveList +import ai.kilocode.client.ui.list.ActiveListConfig +import ai.kilocode.client.ui.list.ActiveListItem +import ai.kilocode.client.ui.list.ActiveListRowHeight +import com.intellij.openapi.options.advanced.AdvancedSettings +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Component +import java.awt.Container +import java.awt.Dimension +import java.awt.Rectangle +import java.awt.event.ActionEvent +import java.awt.event.KeyEvent +import java.awt.event.MouseEvent +import javax.swing.JList +import javax.swing.JScrollPane +import javax.swing.KeyStroke +import javax.swing.JViewport + +class PromptRailPopupTest : BasePlatformTestCase() { + /** + * Lays the body out the way the platform does: clamp it with [PromptRailPopup.fitWithin], size it to + * the preferred size that clamp produces, then lay out. The earlier version of this test sized the + * body to an arbitrary height instead, which hid the bug where the list never clipped. + */ + private fun shown(count: Int, hovered: Int = 0): Fixture { + val popup = popup(items(count), hovered) + popup.fitWithin(CAP_W, CAP_H) + val root = popup.component + root.size = root.preferredSize + layoutAll(root) + val scroll = findScroll(root) ?: error("expected the row list to own a scroll pane") + return Fixture(popup, scroll.viewport) + } + + private class Fixture(val popup: PromptRailPopup, val port: JViewport) + + /** Exactly one scroll pane: a nested second one left neither owning the scrolling. */ + fun `test the row list owns the only scroll pane`() { + val fix = shown(40) + + assertEquals(1, countScrolls(fix.popup.component)) + + Disposer.dispose(fix.popup.disposable) + } + + /** + * The regression: the viewport has to actually clip. It previously inherited a ~32k extent from the + * wrapper's `Short.MAX_VALUE` measuring pass, so every row counted as visible and nothing scrolled. + */ + fun `test the viewport clips to the balloon instead of the content`() { + val fix = shown(40) + val view = fix.port.view ?: error("expected a view") + + assertTrue("extent ${fix.port.extentSize.height} must be clamped", fix.port.extentSize.height <= CAP_H) + assertTrue( + "content ${view.height} must exceed the extent ${fix.port.extentSize.height} so it can scroll", + view.height > fix.port.extentSize.height, + ) + + Disposer.dispose(fix.popup.disposable) + } + + /** Revealing downwards was the broken direction, so both are covered explicitly. */ + fun `test rows reveal in both directions`() { + val fix = shown(40) + + fix.popup.select(39) + val down = fix.port.viewPosition.y + assertTrue("selecting the last row must scroll down, stayed at $down", down > 0) + + fix.popup.select(0) + assertEquals("selecting the first row must scroll back to the top", 0, fix.port.viewPosition.y) + + fix.popup.select(39) + assertEquals("the last row must scroll down again", down, fix.port.viewPosition.y) + + Disposer.dispose(fix.popup.disposable) + } + + /** A row requested before the body had bounds still has to be revealed once it does. */ + fun `test a row requested before layout is revealed once laid out`() { + val popup = popup(items(40), hovered = 30) + popup.fitWithin(CAP_W, CAP_H) + val root = popup.component + + // Pre-layout there is no viewport to measure, so nothing may be scrolled yet. + assertNull(findScroll(root)?.viewport?.view?.takeIf { it.height > 0 }) + + root.size = root.preferredSize + layoutAll(root) + + val port = findScroll(root)?.viewport ?: error("expected the row list to own a scroll pane") + assertTrue("row 30 must be revealed once laid out", port.viewPosition.y > 0) + + // Replaying layout must not drift the settled position. + val settled = port.viewPosition + layoutAll(root) + assertEquals(settled, port.viewPosition) + + Disposer.dispose(popup.disposable) + } + + /** Every hovered row must end up visible, whichever way the pointer travels. */ + fun `test every hovered row is revealed`() { + val fix = shown(40) + + val list = findList(fix.popup.component) ?: error("expected a JList of rows") + for (index in listOf(39, 0, 20, 7, 33, 12, 38, 1)) { + fix.popup.select(index) + + assertEquals("row $index must be selected", index, list.selectedIndex) + val view = Rectangle(fix.port.viewPosition, fix.port.extentSize) + val cell = list.getCellBounds(index, index) + assertTrue("row $index at $cell must be visible in $view", view.contains(cell)) + } + + Disposer.dispose(fix.popup.disposable) + } + + fun `test the body honors the width and height caps`() { + val fix = shown(40) + val pref = fix.popup.component.preferredSize + + assertTrue("width ${pref.width} must not exceed $CAP_W", pref.width <= CAP_W) + assertTrue("height ${pref.height} must not exceed $CAP_H", pref.height <= CAP_H) + + Disposer.dispose(fix.popup.disposable) + } + + /** + * A clicked row was reachable under the pointer, so the list must not move: re-centring it would + * shift content out from under a deliberate click. Only tick hovers and the buttons scroll it. + */ + fun `test selecting without scroll highlights but never moves the list`() { + val fix = shown(40) + val list = findList(fix.popup.component) ?: error("expected a JList of rows") + + // Park the list somewhere mid-range so a re-centre would be visible either way. + fix.popup.select(20) + val parked = fix.port.viewPosition + + // A row far outside the view: still no movement, because the click came from the user. + fix.popup.select(39, scroll = false) + assertEquals(39, list.selectedIndex) + assertEquals("a clicked row must not move the list", parked, fix.port.viewPosition) + + fix.popup.select(0, scroll = false) + assertEquals(0, list.selectedIndex) + assertEquals(parked, fix.port.viewPosition) + + // A later layout must not replay a reveal the click suppressed. + layoutAll(fix.popup.component) + assertEquals(parked, fix.port.viewPosition) + + Disposer.dispose(fix.popup.disposable) + } + + /** + * The navigator opens on hover, so a tooltip would stack a second floating surface over it. + * + * Rows are asked through `getToolTipText(event)` with the pointer over a cell, which is where + * `ActiveListView` consults `cfg.tooltip`. The `JComponent.toolTipText` property is never set on + * this list, so asserting on it would pass either way and prove nothing. + */ + fun `test the popup shows no row or button tooltips`() { + val fix = shown(40) + val list = findList(fix.popup.component) ?: error("expected a JList of rows") + val cell = list.getCellBounds(0, 0) + val over = MouseEvent( + list, + MouseEvent.MOUSE_MOVED, + 0L, + 0, + cell.x + cell.width / 2, + cell.y + cell.height / 2, + 0, + false, + ) + + assertNull("rows must not serve a tooltip", list.getToolTipText(over)) + + for (button in findButtons(fix.popup.component)) { + assertNull("header buttons must not carry a tooltip", button.toolTipText) + // Still labelled for screen readers. + assertNotNull(button.accessibleContext.accessibleName) + } + + Disposer.dispose(fix.popup.disposable) + } + + /** + * Guards the assertion above: the same list with tooltips left on does serve one, so the suppression + * is what the other test observes rather than some unrelated reason for a null. + */ + fun `test a row tooltip is served when the config leaves it on`() { + val rows = ActiveList( + emptyText = "", + cfg = ActiveListConfig(height = ActiveListRowHeight.PREFERRED, wrapDescription = true), + showSearch = false, + onCell = { _, _ -> }, + ) + rows.update(items(40).map { Row(it.id, it.prompt, it.answer) }) + rows.size = Dimension(CAP_W, CAP_H) + layoutAll(rows) + val list = findList(rows) ?: error("expected a JList of rows") + val cell = list.getCellBounds(0, 0) + val over = MouseEvent( + list, + MouseEvent.MOUSE_MOVED, + 0L, + 0, + cell.x + cell.width / 2, + cell.y + cell.height / 2, + 0, + false, + ) + + assertNotNull("the default config must serve a row tooltip", list.getToolTipText(over)) + } + + /** + * A single click browses, so it navigates with the card left open. A double click is a committed + * pick, so it navigates and reports the flag the controller closes on. + */ + fun `test clicks report the commit flag for single and double`() { + val seen = mutableListOf>() + val popup = PromptRailPopup( + items = items(40), + hovered = 0, + onSelect = { item, commit -> seen.add(item.id to commit) }, + onFirst = {}, + onLatest = {}, + ) + popup.fitWithin(CAP_W, CAP_H) + val root = popup.component + root.size = root.preferredSize + layoutAll(root) + val list = findList(root) ?: error("expected a JList of rows") + + click(list, index = 1, count = 1) + click(list, index = 1, count = 2) + + // Swing delivers count 1 then count 2 for a double click, so the committed pick is the last. + assertEquals("msg_1" to false, seen.first()) + assertEquals("msg_1" to true, seen.last()) + + Disposer.dispose(popup.disposable) + } + + /** + * Enter has to commit like a double click. The list's default provider reports the global + * `edit.source.on.enter.key.request.focus.in.editor` setting, so without an override Enter would + * close the card on installs where that is on and leave it open everywhere else. + */ + fun `test enter commits regardless of the editor focus setting`() { + // The setting defaults to on, which is indistinguishable from the override. Turn it off, which + // is the install state where the default provider would report false and leave the card open. + val restore = AdvancedSettings.getBoolean(ENTER_FOCUS) + AdvancedSettings.setBoolean(ENTER_FOCUS, false) + try { + val seen = mutableListOf>() + val popup = PromptRailPopup( + items = items(40), + hovered = 3, + onSelect = { item, commit -> seen.add(item.id to commit) }, + onFirst = {}, + onLatest = {}, + ) + popup.fitWithin(CAP_W, CAP_H) + val root = popup.component + root.size = root.preferredSize + layoutAll(root) + val list = findList(root) ?: error("expected a JList of rows") + + val enter = list.getActionForKeyStroke(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0)) + ?: error("expected an Enter binding on the row list") + enter.actionPerformed(ActionEvent(list, ActionEvent.ACTION_PERFORMED, null)) + + assertEquals(listOf("msg_3" to true), seen) + + Disposer.dispose(popup.disposable) + } finally { + AdvancedSettings.setBoolean(ENTER_FOCUS, restore) + } + } + + /** + * Only the click is dispatched. A press would reach `BasicListUI`, whose selection handling asks the + * toolkit for the menu shortcut mask and throws headlessly, and `onOpen` is driven from the click. + */ + private fun click(list: JList<*>, index: Int, count: Int) { + val cell = list.getCellBounds(index, index) + list.dispatchEvent( + MouseEvent( + list, + MouseEvent.MOUSE_CLICKED, + 0L, + 0, + cell.x + cell.width / 2, + cell.y + cell.height / 2, + count, + false, + MouseEvent.BUTTON1, + ), + ) + } + + private data class Row( + override val key: String, + override val title: String, + override val description: String?, + ) : ActiveListItem + + /** + * The header buttons have to drive the same navigation the ticks do. Scrolling the transcript alone + * would leave the open card pointing at whichever row was last hovered. + */ + fun `test the header buttons invoke first and latest navigation`() { + val seen = mutableListOf() + val popup = PromptRailPopup( + items = items(40), + hovered = 0, + onSelect = { _, _ -> }, + onFirst = { seen.add("first") }, + onLatest = { seen.add("latest") }, + ) + popup.fitWithin(CAP_W, CAP_H) + val buttons = findButtons(popup.component) + assertEquals("expected the first/latest buttons in the header", 2, buttons.size) + + buttons[0].doClick() + buttons[1].doClick() + + assertEquals(listOf("first", "latest"), seen) + + Disposer.dispose(popup.disposable) + } + + private fun findButtons(c: Component): List { + if (c is HoverIcon) return listOf(c) + if (c !is Container) return emptyList() + return c.components.flatMap { findButtons(it) } + } + + private fun findList(c: Component): JList<*>? { + if (c is JList<*>) return c + if (c !is Container) return null + return c.components.firstNotNullOfOrNull { findList(it) } + } + + private fun countScrolls(c: Component): Int { + val self = if (c is JScrollPane) 1 else 0 + if (c !is Container) return self + return self + c.components.sumOf { countScrolls(it) } + } + + private fun findScroll(c: Component): JScrollPane? { + if (c is JScrollPane) return c + if (c !is Container) return null + return c.components.firstNotNullOfOrNull { findScroll(it) } + } + + private fun layoutAll(c: Component) { + c.doLayout() + if (c is Container) c.components.forEach(::layoutAll) + } + + private fun popup(items: List, hovered: Int = 0) = PromptRailPopup( + items = items, + hovered = hovered, + onSelect = { _, _ -> }, + onFirst = {}, + onLatest = {}, + ) + + private fun items(count: Int) = List(count) { + PromptRailItem( + id = "msg_$it", + queued = false, + prompt = "Prompt $it that is long enough to be clamped by the navigator row width", + answer = "Answer $it that is also long enough to wrap across the lines the row allows", + ) + } + + private companion object { + const val ENTER_FOCUS = "edit.source.on.enter.key.request.focus.in.editor" + const val CAP_W = 320 + const val CAP_H = 260 + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SessionOutcomeViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SessionOutcomeViewTest.kt index 6b30181f4cc..813cd722071 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SessionOutcomeViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SessionOutcomeViewTest.kt @@ -258,6 +258,88 @@ class SessionOutcomeViewTest : BasePlatformTestCase() { private fun retryButton(root: Container) = findAll(root).firstOrNull { it.text == KiloBundle.message("session.outcome.retry") } + private fun dismissButton(root: Container) = + findAll(root).firstOrNull { it.text == KiloBundle.message("session.outcome.dismiss") } + + // ------ dismiss action ------ + + fun `test error card offers dismiss`() { + edt { + var dismissed = 0 + val view = SessionOutcomeView(dismiss = { dismissed++ }) + view.showError("Provider balance is too low", "APIError") + + val button = dismissButton(view) + assertNotNull("Error card should offer Dismiss", button) + button!!.doClick() + assertEquals(1, dismissed) + } + } + + fun `test dismiss is the primary action when retry is unavailable`() { + edt { + val view = SessionOutcomeView(retry = {}, retryable = { false }, dismiss = {}) + view.showError("invalid kilo.json", "UnknownError") + + assertNull("A dead Retry must not be painted", retryButton(view)) + assertNotNull("A standalone error with no Retry still needs a way out", dismissButton(view)) + } + } + + fun `test retry and dismiss can appear together`() { + edt { + var retried = 0 + var dismissed = 0 + val view = SessionOutcomeView(retry = { retried++ }, dismiss = { dismissed++ }) + view.showError("Provider balance is too low", "APIError") + + retryButton(view)!!.doClick() + dismissButton(view)!!.doClick() + assertEquals(1, retried) + assertEquals(1, dismissed) + } + } + + fun `test readonly error card offers no dismiss`() { + edt { + val view = SessionOutcomeView(dismiss = null) + view.showError("Provider balance is too low", "APIError") + + assertNull("Readonly sessions have no dismiss handler", dismissButton(view)) + } + } + + fun `test showRetry and showOutcome never offer dismiss`() { + edt { + val view = SessionOutcomeView(retry = {}, dismiss = {}) + view.showRetry() + assertNull("The transcript card already explains a continuable failure", dismissButton(view)) + + view.showOutcome(Outcome.FAILED) + assertNull("A failed turn with a transcript tail uses Retry, not Dismiss", dismissButton(view)) + + view.showOutcome(Outcome.INTERRUPTED) + assertNull(dismissButton(view)) + } + } + + fun `test toggling outcomes does not accumulate dismiss buttons`() { + edt { + var dismissed = 0 + val view = SessionOutcomeView(dismiss = { dismissed++ }) + repeat(3) { + view.showError("Provider balance is too low", "APIError") + view.showOutcome(Outcome.INTERRUPTED) + } + view.showError("Provider balance is too low", "APIError") + + val buttons = findAll(view).filter { it.text == KiloBundle.message("session.outcome.dismiss") } + assertEquals("Exactly one live Dismiss button", 1, buttons.size) + buttons.single().doClick() + assertEquals(1, dismissed) + } + } + // ------ action-only failures (the transcript owns the reason) ------ fun `test showRetry offers the action with no message of its own`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpAuthUrlDialogTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpAuthUrlDialogTest.kt new file mode 100644 index 00000000000..59415014db7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpAuthUrlDialogTest.kt @@ -0,0 +1,103 @@ +package ai.kilocode.client.settings.agents + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.util.edtWait +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBTextField +import java.awt.Component +import java.awt.Container + +class McpAuthUrlDialogTest : BasePlatformTestCase() { + private var dialog: McpAuthUrlDialog? = null + + override fun tearDown() { + try { + dialog?.let { item -> edt { Disposer.dispose(item.disposable); true } } + dialog = null + } finally { + super.tearDown() + } + } + + fun `test url is rendered read only in the content panel`() { + val d = open("linear", "https://auth.example.test/authorize?state=abc") + + edt { + val field = descendants(d.contentForTest()).filterIsInstance().single() + assertEquals("https://auth.example.test/authorize?state=abc", field.text) + assertFalse("the URL field must not be user-editable", field.isEditable) + true + } + } + + fun `test message includes the server name`() { + val d = open("linear", "https://auth.example.test/authorize") + + edt { + val expected = KiloBundle.message("settings.agentBehavior.mcp.authUrl.message", "linear") + assertTrue(text(d.contentForTest()).contains(expected)) + true + } + } + + fun `test title includes finish sign in wording`() { + val d = open("linear", "https://auth.example.test/authorize") + + edt { + assertEquals(KiloBundle.message("settings.agentBehavior.mcp.authUrl.title"), d.title) + true + } + } + + /** + * The URL comes from the remote server's OAuth metadata, so a non-web scheme must not be + * openable from here. Copy stays available because copying is harmless. + */ + fun `test open is disabled for a non-web authorization URL`() { + for (url in listOf("file:///etc/passwd", "smb://host/share", "javascript:alert(1)", "not a url")) { + val d = open("linear", url) + edt { + val open = d.openActionForTest() + assertFalse("Open must be disabled for $url", open.isEnabled) + true + } + edt { Disposer.dispose(d.disposable); true } + dialog = null + } + } + + fun `test open is enabled for an http authorization URL`() { + for (url in listOf("https://auth.example.test/authorize?state=abc", "http://127.0.0.1:19876/cb")) { + val d = open("linear", url) + edt { + assertTrue("Open must be enabled for $url", d.openActionForTest().isEnabled) + true + } + edt { Disposer.dispose(d.disposable); true } + dialog = null + } + } + + private fun open(name: String, url: String): McpAuthUrlDialog { + val item = edt { McpAuthUrlDialog(name, url) } + dialog = item + return item + } + + private fun text(root: Component): String = descendants(root) + .filterIsInstance() + .joinToString(" ") { it.text.orEmpty() } + + private fun descendants(root: Component): List { + val out = mutableListOf() + fun visit(item: Component) { + out += item + if (item is Container) item.components.forEach(::visit) + } + visit(root) + return out + } + + private fun edt(block: () -> T): T = edtWait(block) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpEditDialogTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpEditDialogTest.kt index e61524dce0b..be34d929d49 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpEditDialogTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpEditDialogTest.kt @@ -6,10 +6,13 @@ import ai.kilocode.client.settings.base.SettingsRow import ai.kilocode.client.settings.base.SettingsStackedRow import ai.kilocode.client.testing.fire import ai.kilocode.rpc.dto.McpConfigDto +import ai.kilocode.rpc.dto.McpOAuthDto import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBPasswordField import com.intellij.ui.components.JBTextArea import com.intellij.ui.components.JBTextField import java.awt.Component @@ -120,6 +123,123 @@ class McpEditDialogTest : BasePlatformTestCase() { assertEquals(mapOf("TOKEN" to "x", "EMPTY" to ""), result.environment) } + fun `test oauth section only shown for remote servers`() { + val local = open(local()) + edt { + assertFalse(hasLabel(local.centerComponent(), title("oauth"))) + true + } + val remote = open(remote()) + edt { + assertTrue(hasLabel(remote.centerComponent(), title("oauth"))) + true + } + } + + fun `test oauth defaults to automatic when config has no oauth block`() { + val d = open(remote()) + + val result = edt { d.result() } + + assertNull(result.oauth) + } + + fun `test oauth mode disabled writes enabled false`() { + val d = open(remote()) + + val result = edt { + val root = d.centerComponent() + oauthModeBox(root).selectedItem = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.mode.disabled") + d.result() + } + + assertEquals(McpOAuthDto(enabled = false), result.oauth) + } + + fun `test oauth mode custom writes client fields`() { + val d = open(remote()) + + val result = edt { + val root = d.centerComponent() + oauthModeBox(root).selectedItem = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.mode.custom") + field(root, title("oauth.clientId")).text = "abc" + descendants(root).filterIsInstance().single().text = "shh" + field(root, title("oauth.scope")).text = "read" + field(root, title("oauth.callbackPort")).text = "19999" + d.result() + } + + assertEquals( + McpOAuthDto(enabled = true, clientId = "abc", clientSecret = "shh", scope = "read", callbackPort = 19999), + result.oauth, + ) + } + + /** + * `URI.isAbsolute` alone accepts `javascript:x` and `file:///x`. The redirect target is a + * loopback HTTP listener, so only http(s) with a host may validate. + */ + fun `test non-web redirect uri fails validation`() { + val d = open(remote()) + + edt { + val root = d.centerComponent() + oauthModeBox(root).selectedItem = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.mode.custom") + for (value in listOf("javascript:alert(1)", "file:///etc/passwd", "smb://host/share", "http:///nohost")) { + field(root, title("oauth.redirectUri")).text = value + assertNotNull("$value must not validate", d.validateForTest()) + } + true + } + } + + fun `test loopback redirect uri passes validation`() { + val d = open(remote()) + + edt { + val root = d.centerComponent() + oauthModeBox(root).selectedItem = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.mode.custom") + field(root, title("oauth.redirectUri")).text = "http://127.0.0.1:19876/mcp/oauth/callback" + assertNull(d.validateForTest()) + true + } + } + + fun `test switching back to automatic after custom clears the oauth block when one existed`() { + val cfg = remote().copy(oauth = McpOAuthDto(enabled = true, clientId = "abc")) + val d = open(cfg) + + val result = edt { + val root = d.centerComponent() + oauthModeBox(root).selectedItem = KiloBundle.message("settings.agentBehavior.mcp.edit.oauth.mode.automatic") + d.result() + } + + assertEquals(McpOAuthDto(clear = true), result.oauth) + } + + /** + * Regression guard for the silent-drop bug: `Config.mergeDeep` clears `headers`/`oauth` whenever + * a remote server's `type` or `url` changes unless the patch explicitly carries them forward. + * Editing only the URL must not lose a previously configured OAuth client. + */ + fun `test editing only the url preserves an existing custom oauth client`() { + val cfg = remote().copy(oauth = McpOAuthDto(enabled = true, clientId = "abc", scope = "read")) + val d = open(cfg) + + val result = edt { + val root = d.centerComponent() + field(root, title("url")).text = "https://retargeted.example.test/mcp" + d.result() + } + + assertEquals("https://retargeted.example.test/mcp", result.url) + assertEquals(McpOAuthDto(enabled = true, clientId = "abc", scope = "read"), result.oauth) + } + + private fun oauthModeBox(root: Component): ComboBox = + descendants(root).filterIsInstance>().single() + private fun local() = McpConfigDto( type = "local", command = listOf("node", "server.js", "--flag"), @@ -160,6 +280,10 @@ class McpEditDialogTest : BasePlatformTestCase() { descendants(item).any { it is JLabel && it.text == title } } + /** Broader than [hasRow]: matches any descendant label text, including section titles. */ + private fun hasLabel(root: Component, text: String): Boolean = + descendants(root).filterIsInstance().any { it.text == text } + private fun envLabels(root: Component): List { val list = descendants(root).filterIsInstance>().singleOrNull() ?: return emptyList() return (0 until list.model.size).map { list.model.getElementAt(it).toString() } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt index 86d546961f6..e41064e2d96 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt @@ -3,28 +3,36 @@ package ai.kilocode.client.settings.agents import ai.kilocode.client.util.edtWait import ai.kilocode.client.app.KiloAgentBehaviorService import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloMcpAuthService +import ai.kilocode.client.app.KiloMarketplaceService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.base.DirectoryReadyConfigurable import ai.kilocode.client.settings.base.SettingsInfo import ai.kilocode.client.testing.FakeAgentBehaviorRpcApi import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeMarketplaceRpcApi import ai.kilocode.client.testing.fire import ai.kilocode.client.testing.rowLines import ai.kilocode.client.ui.list.ActiveListItem import ai.kilocode.client.ui.list.activeListCellBounds +import ai.kilocode.client.ui.list.activeListHits import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.McpAuthResultDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.McpServerConfigDto import ai.kilocode.rpc.dto.McpStatusDto +import ai.kilocode.rpc.dto.MarketplaceBundleDto import com.intellij.icons.AllIcons import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service import com.intellij.openapi.ui.Messages import com.intellij.openapi.ui.TestDialog import com.intellij.openapi.ui.TestDialogManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.replaceService +import com.intellij.ui.SearchTextField import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBList import com.intellij.util.ui.UIUtil @@ -35,12 +43,15 @@ import java.awt.event.InputEvent import java.awt.event.MouseEvent import javax.swing.JButton import javax.swing.JComponent +import javax.swing.JPanel import javax.swing.JTextField import javax.swing.SwingUtilities +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking class McpSettingsUiTest : BasePlatformTestCase() { @@ -49,6 +60,7 @@ class McpSettingsUiTest : BasePlatformTestCase() { private lateinit var app: KiloAppService private lateinit var appRpc: FakeAppRpcApi private lateinit var agentRpc: FakeAgentBehaviorRpcApi + private lateinit var marketRpc: FakeMarketplaceRpcApi override fun tearDown() { try { @@ -84,13 +96,13 @@ class McpSettingsUiTest : BasePlatformTestCase() { assertEquals(listOf("connected", "stdio"), rows.single { it.key == "filesystem" }.badges.map { it.text }) assertEquals("bun mcp-files", rows.single { it.key == "filesystem" }.description) assertEquals(listOf("needs auth", "remote"), rows.single { it.key == "github" }.badges.map { it.text }) + assertEquals(AllIcons.General.Warning, rows.single { it.key == "github" }.badges.first().icon) assertEquals("https://mcp.github.test", rows.single { it.key == "github" }.description) assertEquals(listOf("failed"), rows.single { it.key == "runtime" }.badges.map { it.text }) assertEquals("crashed", rows.single { it.key == "runtime" }.description) val github = rows.single { it.key == "github" } - assertEquals(listOf("connect", "auth", "edit", "remove"), github.cells.map { it.id }) + assertEquals(listOf("auth", "edit", "remove"), github.cells.map { it.id }) assertTrue(github.cells.single { it.id == "edit" }.primary) - assertEquals(KiloBundle.message("settings.agentBehavior.mcp.connect"), github.cells.single { it.id == "connect" }.label) val remove = github.cells.single { it.id == "remove" } assertEquals(AllIcons.Actions.GC, remove.icon) assertTrue(remove.iconOnly) @@ -105,6 +117,79 @@ class McpSettingsUiTest : BasePlatformTestCase() { } } + fun `test predefined search filters to one MCP server`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + + edt { + panel.filter("github") + assertEquals(listOf("github"), rows(panel).map { it.key }) + true + } + } + + fun `test a server needing auth offers sign in`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + + edt { + // github is needs_auth in the fixture; the row must expose sign-in, not just Connect. + val cells = rows(panel).single { it.key == "github" }.cells.map { it.id } + assertTrue("needs_auth must offer sign-in, got $cells", cells.contains("auth")) + assertFalse("needs_auth must not offer connect, got $cells", cells.contains("connect")) + assertFalse("needs_auth must not offer reset sign-in, got $cells", cells.contains("resetAuth")) + val label = rows(panel).single { it.key == "github" }.cells.single { it.id == "auth" }.label + assertEquals(KiloBundle.message("settings.agentBehavior.mcp.signIn"), label) + // A healthy server must not. + assertFalse(rows(panel).single { it.key == "filesystem" }.cells.any { it.id == "auth" }) + true + } + } + + fun `test failed status badge carries the failure reason as its tooltip`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + + edt { + val badge = rows(panel).single { it.key == "runtime" }.badges.first() + assertEquals("failed", badge.text) + val tooltip = badge.tooltip + assertNotNull("the failed badge must explain itself", tooltip) + assertTrue("tooltip should carry the reason, got $tooltip", tooltip!!.contains("crashed")) + assertTrue("tooltip should name the status, got $tooltip", tooltip.contains("failed")) + true + } + } + + fun `test healthy status badge has no tooltip`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + + edt { + // The pill already reads "connected"; a tooltip repeating it would be noise. + assertNull(rows(panel).single { it.key == "filesystem" }.badges.first().tooltip) + true + } + } + + fun `test failure reason is reachable through list hit testing`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + + edt { + val list = list(panel) + list.size = Dimension(460, 260) + list.doLayout() + val idx = rows(panel).indexOfFirst { it.key == "runtime" } + list.selectedIndex = idx + // Proves the badge is hit-tested, so hovering it actually shows the reason rather than + // the text merely existing on the model. + val hit = activeListHits(list, idx, selected = true).single { it.id == "status" } + assertTrue("hovering the badge must reveal the reason", hit.tooltip.orEmpty().contains("crashed")) + true + } + } + fun `test mcp rows keep equal height`() { val panel = panel() flushUntil { rows(panel).size == 3 } @@ -154,6 +239,48 @@ class McpSettingsUiTest : BasePlatformTestCase() { assertEquals(listOf(DIR, DIR), agentRpc.mcpCalls) } + fun `test panel reloads automatically when it becomes visible again`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + val calls = agentRpc.mcpCalls.size + val host = JPanel() + + edt { + host.add(panel) + host.addNotify() + true + } + try { + edt { host.removeNotify(); true } + assertEquals(calls, agentRpc.mcpCalls.size) + + edt { host.addNotify(); true } + + flushUntil { agentRpc.mcpCalls.size > calls } + } finally { + edt { host.removeNotify(); true } + } + } + + /** + * Bundle ownership only decides whether a delete offers to remove a whole Marketplace install. + * A Marketplace outage must not stop the page from listing and managing MCP servers. + */ + fun `test marketplace bundle failure still lists mcp servers`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + val calls = agentRpc.mcpCalls.size + + marketRpc.bundlesError = RuntimeException("marketplace offline") + edt { panel.reload(); true } + flushUntil { agentRpc.mcpCalls.size > calls && searchField(panel).isEnabled } + + edt { + assertEquals(listOf("filesystem", "github", "runtime"), rows(panel).map { it.key }) + true + } + } + fun `test connect action updates runtime status and keeps selection`() { val panel = panel() flushUntil { rows(panel).size == 3 } @@ -161,11 +288,11 @@ class McpSettingsUiTest : BasePlatformTestCase() { agentRpc.mcps = agentRpc.mcps.filterNot { it.name == name } + McpStatusDto(name, "connected") } - click(panel, "github", "connect") + click(panel, "runtime", "connect") - flushUntil { rows(panel).single { it.key == "github" }.badges.first().text == "connected" } - assertEquals(listOf("github"), agentRpc.mcpConnects) - assertEquals("github", edt { list(panel).selectedValue?.key }) + flushUntil { rows(panel).single { it.key == "runtime" }.badges.first().text == "connected" } + assertEquals(listOf("runtime"), agentRpc.mcpConnects) + assertEquals("runtime", edt { list(panel).selectedValue?.key }) } fun `test remove action writes mcp config patch and reloads`() { @@ -183,6 +310,44 @@ class McpSettingsUiTest : BasePlatformTestCase() { assertNull(save.third) } + fun `test removing a marketplace MCP confirms and removes its companion bundle`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + val server = agentRpc.mcpConfigs.getValue("github") + agentRpc.mcpConfigs = agentRpc.mcpConfigs + ("github" to server.copy(scope = "workspace")) + marketRpc.bundles = listOf(MarketplaceBundleDto("github", "project", listOf("$DIR/.kilo/skills/github/SKILL.md"))) + edt { panel.reload(); true } + flushUntil { marketRpc.bundleCalls.size >= 2 && searchField(panel).isEnabled } + var message = "" + TestDialogManager.setTestDialog { + message = it + Messages.YES + } + + click(panel, "github", "remove") + + flushUntil { marketRpc.removeCalls.isNotEmpty() } + assertTrue(message.contains("companion skills")) + assertEquals(FakeMarketplaceRpcApi.RemoveCall(DIR, "github", "mcp", "project"), marketRpc.removeCalls.single()) + assertTrue(agentRpc.mcpSaves.isEmpty()) + assertEquals(listOf(DIR), agentRpc.skillReloads) + } + + fun `test declining marketplace MCP removal keeps both components`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + marketRpc.bundles = listOf(MarketplaceBundleDto("github", "global", listOf("/skills/github/SKILL.md"))) + edt { panel.reload(); true } + flushUntil { marketRpc.bundleCalls.size >= 2 && searchField(panel).isEnabled } + TestDialogManager.setTestDialog { Messages.NO } + + click(panel, "github", "remove") + + edt { UIUtil.dispatchAllInvocationEvents(); true } + assertTrue(marketRpc.removeCalls.isEmpty()) + assertTrue(agentRpc.mcpSaves.isEmpty()) + } + fun `test remove selects the server that took the deleted slot`() { val panel = panel() flushUntil { rows(panel).size == 3 } @@ -331,6 +496,98 @@ class McpSettingsUiTest : BasePlatformTestCase() { assertTrue(edt { rows(panel).any { it.key == "runtime" } }) } + fun `test sign in success reconnects and clears the auth cell`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + agentRpc.mcpAuthenticateResult = McpAuthResultDto("connected") + + click(panel, "github", "auth") + // The fake authenticate call succeeds instantly; the ensuing reload picks up a status that + // the test flips to connected, mirroring what a real reconnect would report. + agentRpc.mcps = agentRpc.mcps.filterNot { it.name == "github" } + McpStatusDto("github", "connected") + + flushUntil { rows(panel).single { it.key == "github" }.badges.first().text == "connected" } + assertEquals(listOf("github"), agentRpc.mcpAuthentications) + } + + fun `test sign in failure shows the CLI error`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + agentRpc.mcpAuthenticateResult = McpAuthResultDto("failed", "denied by server") + + click(panel, "github", "auth") + + flushUntil { text(panel).contains("denied by server") } + assertTrue(edt { rows(panel).any { it.key == "github" } }) + } + + fun `test sign in cancel calls mcpAuthCancel`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + agentRpc.mcpAuthCancelResult = true + + edt { + // Directly exercises the cancel path the progress overlay's Cancel button wires to, + // since driving the real overlay button requires a longer-lived in-flight sign-in. + scope!!.launch { service().cancel(DIR, "github") } + true + } + + flushUntil { agentRpc.mcpAuthCancels.contains("github") } + // Cancelling an attempt must not throw away credentials; only Reset sign-in does that. + assertTrue(agentRpc.mcpAuthRemovals.isEmpty()) + } + + fun `test reset sign in cell only appears for connected remote servers`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + + edt { + val rows = rows(panel) + assertFalse(rows.single { it.key == "github" }.cells.any { it.id == "resetAuth" }) + assertFalse(rows.single { it.key == "filesystem" }.cells.any { it.id == "resetAuth" }) + true + } + + status(panel, "github", "connected") + + edt { + val cells = rows(panel).single { it.key == "github" }.cells.map { it.id } + assertEquals(listOf("disconnect", "resetAuth", "edit", "remove"), cells) + true + } + } + + fun `test reset sign in requires confirmation and calls mcpAuthRemove on accept`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + status(panel, "github", "connected") + agentRpc.mcpAuthRemoveResult = true + agentRpc.afterMcpConnect = { _, name -> + agentRpc.mcps = agentRpc.mcps.filterNot { it.name == name } + McpStatusDto(name, "needs_auth") + } + TestDialogManager.setTestDialog(TestDialog.YES) + + click(panel, "github", "resetAuth") + + flushUntil { service().needsAuth.value[DIR].orEmpty().contains("github") } + assertEquals(listOf("github"), agentRpc.mcpAuthRemovals) + assertEquals(listOf("github"), agentRpc.mcpDisconnects) + assertEquals(listOf("github"), agentRpc.mcpConnects) + } + + fun `test reset sign in decline does not call mcpAuthRemove`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + status(panel, "github", "connected") + TestDialogManager.setTestDialog { Messages.NO } + + click(panel, "github", "resetAuth") + + edt { UIUtil.dispatchAllInvocationEvents(); true } + assertTrue(agentRpc.mcpAuthRemovals.isEmpty()) + } + fun `test missing directory still shows configured mcp servers`() { install() val panel = edt { McpSettingsUi(scope!!, "") } @@ -358,6 +615,27 @@ class McpSettingsUiTest : BasePlatformTestCase() { edt { cfg.disposeUIResources(); true } } + fun `test configurable disposal disposes the mcp settings panel`() { + install() + // Hold the status call so the panel is provably still busy when the dialog closes. Only + // SettingsListPanel.dispose() clears that busy state; cancelling the configurable scope + // leaves the search field disabled, so this fails unless the override delegates to + // DraftReadyConfigurableBase. + val gate = CompletableDeferred() + agentRpc.mcpStatusGate = gate + val cfg = McpConfigurable() + val shell = edt { cfg.createComponent() } + flushUntil { components(shell).filterIsInstance().singleOrNull() != null } + val panel = components(shell).filterIsInstance().single() + ui = panel + flushUntil { agentRpc.mcpCalls.isNotEmpty() && !searchField(panel).isEnabled } + + edt { cfg.disposeUIResources(); true } + gate.complete(Unit) + + assertTrue("dispose() must clear the busy state", edt { searchField(panel).isEnabled }) + } + private fun panel(create: (String, McpConfigDto) -> McpEditDialogHandle = ::McpEditDialog): McpSettingsUi { install() val panel = edt { McpSettingsUi(scope!!, DIR, create) } @@ -366,10 +644,18 @@ class McpSettingsUiTest : BasePlatformTestCase() { return panel } + private fun status(panel: McpSettingsUi, name: String, status: String) { + val calls = agentRpc.mcpCalls.size + agentRpc.mcps = agentRpc.mcps.filterNot { it.name == name } + McpStatusDto(name, status) + edt { panel.reload(); true } + flushUntil { agentRpc.mcpCalls.size > calls && searchField(panel).isEnabled } + } + private fun install() { val cs = CoroutineScope(SupervisorJob()) scope = cs appRpc = FakeAppRpcApi() + marketRpc = FakeMarketplaceRpcApi() agentRpc = FakeAgentBehaviorRpcApi().apply { mcps = listOf( McpStatusDto("filesystem", "connected"), @@ -389,7 +675,14 @@ class McpSettingsUiTest : BasePlatformTestCase() { app._state.value = ready appRpc.state.value = ready ApplicationManager.getApplication().replaceService(KiloAppService::class.java, app, testRootDisposable) - ApplicationManager.getApplication().replaceService(KiloAgentBehaviorService::class.java, KiloAgentBehaviorService(cs, agentRpc), testRootDisposable) + val behavior = KiloAgentBehaviorService(cs, agentRpc) + ApplicationManager.getApplication().replaceService(KiloAgentBehaviorService::class.java, behavior, testRootDisposable) + ApplicationManager.getApplication().replaceService(KiloMcpAuthService::class.java, KiloMcpAuthService(cs, behavior), testRootDisposable) + ApplicationManager.getApplication().replaceService( + KiloMarketplaceService::class.java, + KiloMarketplaceService(cs, marketRpc), + testRootDisposable, + ) } private fun click(panel: McpSettingsUi, key: String, id: String) { @@ -431,6 +724,9 @@ class McpSettingsUiTest : BasePlatformTestCase() { return pane.text.replace(Regex("<[^>]+>"), "").replace(Regex("\\s+"), " ").trim() } + private fun searchField(panel: McpSettingsUi): SearchTextField = + components(panel).filterIsInstance().single() + private fun components(root: java.awt.Component): List { val out = mutableListOf() fun visit(item: java.awt.Component) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/SkillsSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/SkillsSettingsUiTest.kt index f90b7ae853f..58cee395182 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/SkillsSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/SkillsSettingsUiTest.kt @@ -3,12 +3,14 @@ package ai.kilocode.client.settings.agents import ai.kilocode.client.util.edtWait import ai.kilocode.client.app.KiloAgentBehaviorService import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloMarketplaceService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.base.SettingsInfo import ai.kilocode.client.settings.base.SettingsPathDialogHandle import ai.kilocode.client.testing.FakeAgentBehaviorRpcApi import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeMarketplaceRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.client.testing.fire import ai.kilocode.client.testing.rowLines @@ -17,6 +19,7 @@ import ai.kilocode.client.ui.list.activeListCellBounds import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.MarketplaceBundleDto import ai.kilocode.rpc.dto.SkillDto import ai.kilocode.rpc.dto.SkillsConfigDto import com.intellij.openapi.application.ApplicationManager @@ -29,6 +32,7 @@ import com.intellij.openapi.ui.TestDialog import com.intellij.openapi.ui.TestDialogManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.replaceService +import com.intellij.ui.SearchTextField import com.intellij.ui.TitledSeparator import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBList @@ -57,6 +61,7 @@ class SkillsSettingsUiTest : BasePlatformTestCase() { private lateinit var appRpc: FakeAppRpcApi private lateinit var agentRpc: FakeAgentBehaviorRpcApi private lateinit var workspaceRpc: FakeWorkspaceRpcApi + private lateinit var marketRpc: FakeMarketplaceRpcApi private var shown = 0 override fun tearDown() { @@ -319,6 +324,64 @@ class SkillsSettingsUiTest : BasePlatformTestCase() { assertTrue(edt { rows(panel).any { it.key == CUSTOM } }) } + /** + * Bundle ownership only decides whether a delete offers to remove a whole Marketplace install. + * A Marketplace outage must not stop the page from listing and managing skills. + */ + fun `test marketplace bundle failure still lists skills`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + val calls = marketRpc.bundleCalls.size + + marketRpc.bundlesError = RuntimeException("marketplace offline") + edt { panel.reload(); true } + flushUntil { marketRpc.bundleCalls.size > calls && searchField(panel).isEnabled } + + edt { + assertEquals(3, rows(panel).size) + assertTrue(rows(panel).any { it.key == CUSTOM }) + true + } + } + + fun `test deleting a companion skill stages its whole marketplace bundle`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + marketRpc.bundles = listOf(MarketplaceBundleDto("anaconda", "global", listOf(CUSTOM))) + edt { panel.reload(); true } + flushUntil { marketRpc.bundleCalls.size >= 2 && searchField(panel).isEnabled } + var message = "" + TestDialogManager.setTestDialog { + message = it + Messages.YES + } + + click(skillsList(panel), panel, CUSTOM, "delete") + + assertTrue(message.contains("anaconda")) + assertTrue(edt { rows(panel).none { it.key == CUSTOM } }) + assertTrue(marketRpc.removeCalls.isEmpty()) + edt { panel.applyDraft(); true } + flushUntil { marketRpc.removeCalls.isNotEmpty() } + assertEquals(FakeMarketplaceRpcApi.RemoveCall(DIR, "anaconda", "mcp", "global"), marketRpc.removeCalls.single()) + assertTrue(agentRpc.skillRemovals.isEmpty()) + } + + fun `test declining companion skill removal keeps the whole bundle`() { + val panel = panel() + flushUntil { rows(panel).size == 3 } + marketRpc.bundles = listOf(MarketplaceBundleDto("anaconda", "global", listOf(CUSTOM))) + edt { panel.reload(); true } + flushUntil { marketRpc.bundleCalls.size >= 2 && searchField(panel).isEnabled } + TestDialogManager.setTestDialog { Messages.NO } + + click(skillsList(panel), panel, CUSTOM, "delete") + + assertTrue(edt { rows(panel).any { it.key == CUSTOM } }) + assertFalse(edt { panel.modified() }) + assertTrue(marketRpc.removeCalls.isEmpty()) + } + fun `test add path and url write skills config patch on apply`() { var path = "/extra/skills" var url = "https://skills.test/index.json" @@ -510,6 +573,7 @@ class SkillsSettingsUiTest : BasePlatformTestCase() { scope = cs appRpc = FakeAppRpcApi() workspaceRpc = FakeWorkspaceRpcApi() + marketRpc = FakeMarketplaceRpcApi() agentRpc = FakeAgentBehaviorRpcApi().apply { skills = listOf( SkillDto("plan", "Plan work", CUSTOM, "# Plan\nUse steps", editable = true), @@ -530,6 +594,11 @@ class SkillsSettingsUiTest : BasePlatformTestCase() { ApplicationManager.getApplication().replaceService(KiloAppService::class.java, app, testRootDisposable) ApplicationManager.getApplication().replaceService(KiloAgentBehaviorService::class.java, KiloAgentBehaviorService(cs, agentRpc), testRootDisposable) ApplicationManager.getApplication().replaceService(KiloWorkspaceService::class.java, KiloWorkspaceService(cs, workspaceRpc), testRootDisposable) + ApplicationManager.getApplication().replaceService( + KiloMarketplaceService::class.java, + KiloMarketplaceService(cs, marketRpc), + testRootDisposable, + ) } private fun click(list: JBList, panel: SkillsSettingsUi, key: String, id: String) { @@ -568,6 +637,8 @@ class SkillsSettingsUiTest : BasePlatformTestCase() { private fun skillsList(panel: SkillsSettingsUi) = components(panel).filterIsInstance>().first() + private fun searchField(panel: SkillsSettingsUi) = components(panel).filterIsInstance().single() + private fun bannerIntro(info: SettingsInfo): String { val pane = UIUtil.findComponentOfType(info, javax.swing.JEditorPane::class.java) ?: error("no banner text") return pane.text.replace(Regex("<[^>]+>"), "").replace(Regex("\\s+"), " ").trim() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index 91b84697371..07e48ad25b8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -588,6 +588,64 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test double click on a tooltip-only badge still activates the row`() { + edt { + val calls = mutableListOf() + val opened = mutableListOf() + val view = ActiveListView("Empty", onOpen = { row, _ -> opened += row.key }) { key, id -> calls += "$key:$id" } + // A status pill with an id (for tooltip hit-testing) but no action, exactly like the MCP + // Settings status badge — it must not swallow the double-click that falls through to the + // row's own open/activate handler. + val row = object : ActiveListItem { + override val key = "with" + override val title = "Alpha" + override val badges = listOf(ActiveListBadge("needs auth", id = "status", tooltip = "reason")) + } + view.update(listOf(row)) + layout(view) + val bounds = view.list.getCellBounds(0, 0) + val badgeArea = activeListCellBounds(view.list, 0, selected = true).getValue("status") + + fire(view.list, mouse(view, MouseEvent.MOUSE_CLICKED, Point(badgeArea.centerX.toInt(), bounds.y + bounds.height / 2), count = 2)) + + assertEquals(listOf("with"), opened) + assertTrue("no cell action should fire for a non-actionable badge", calls.isEmpty()) + } + } + + fun `test double click on a non-primary onCell action does not also activate the row`() { + edt { + val calls = mutableListOf() + val opened = mutableListOf() + // "Connect" has no per-cell `action`, exactly like every real onCell-routed action + // (Sign In, Connect, Delete, rename, ...) — the click must fire it once through onCell + // and must not also fall through to the row's own open/activate handler. + val view = ActiveListView("Empty", onOpen = { row, _ -> opened += row.key }) { key, id -> calls += "$key:$id" } + val row = item( + "with", + "Alpha", + null, + ActiveListCell("connect", "Connect"), + ActiveListCell("edit", "Edit", primary = true), + ) + view.update(listOf(row)) + layout(view) + val area = activeListCellBounds(view.list, 0, selected = true).getValue("connect") + val point = center(area) + + click(view, point) + fire(view.list, mouse(view, MouseEvent.MOUSE_PRESSED, point, count = 2)) + fire(view.list, mouse(view, MouseEvent.MOUSE_RELEASED, point, count = 2)) + fire(view.list, mouse(view, MouseEvent.MOUSE_CLICKED, point, count = 2)) + + // Each press/release of the two clicks fires the cell action once, same as two + // independent single clicks; the only thing under test is that the trailing + // MOUSE_CLICKED(count=2) does not also treat this as row activation. + assertEquals(listOf("with:connect", "with:connect"), calls) + assertTrue("double-clicking a real action cell must not also open/activate the row", opened.isEmpty()) + } + } + fun `test disabled action click does not invoke`() { edt { val calls = mutableListOf() @@ -675,6 +733,25 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + /** + * Opt-in only: a list whose actions open a dialog would otherwise lose them the moment the click + * moved focus off the list. Lists that do not opt in keep the focused-selection behavior above. + */ + fun `test keepActions shows action cells on an unfocused selected row`() { + edt { + val row = item("with", "Alpha", "Description", ActiveListCell("edit", "Edit")) + val model = CollectionListModel(listOf(row)) + val list = JBList(model) + val renderer = ActiveListRenderer(model, ActiveListConfig.Equal.copy(keepActions = true)) + + renderer.getListCellRendererComponent(list, row, 0, true, false) + assertEquals(listOf("edit"), actionCells(renderer).filter { it.isVisible }.map { it.cellId }) + + renderer.getListCellRendererComponent(list, row, 0, false, false) + assertTrue("an unselected row must still hide its actions", actionCells(renderer).none { it.isVisible }) + } + } + fun `test always visible action cells stay on unfocused row`() { edt { val row = item("with", "Alpha", "Description", ActiveListCell("level", "Allow", alwaysVisible = true)) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUiTest.kt index d690ea98134..71da6b01073 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/marketplace/MarketplaceSettingsUiTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.settings.marketplace import ai.kilocode.client.app.KiloAgentBehaviorService import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloMarketplaceService +import ai.kilocode.client.app.KiloMcpAuthService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.base.DirectoryReadyConfigurable import ai.kilocode.client.testing.FakeAgentBehaviorRpcApi @@ -15,6 +16,8 @@ import ai.kilocode.client.ui.list.activeListCellBounds import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.McpAuthResultDto +import ai.kilocode.rpc.dto.McpStatusDto import ai.kilocode.rpc.dto.MarketplaceItemDto import ai.kilocode.rpc.dto.MarketplaceListDto import ai.kilocode.rpc.dto.MarketplaceResultDto @@ -375,6 +378,88 @@ class MarketplaceSettingsUiTest : BasePlatformTestCase() { assertEquals(listOf(DIR), agentRpc.skillReloads) } + fun `test successful MCP install that needs auth prompts to sign in`() { + val panel = panel { _, _ -> FakeInstallDialog(MarketplaceInstallRequest("project", emptyMap())) } + flushUntil { rows(panel).size == 3 } + agentRpc.mcps = listOf(McpStatusDto("context7", "needs_auth")) + var prompted = 0 + TestDialogManager.setTestDialog { + prompted++ + Messages.NO + } + + click(panel, "mcp:context7", "install") + + flushUntil { prompted == 1 } + assertEquals(1, prompted) + assertTrue(agentRpc.mcpAuthentications.isEmpty()) + } + + fun `test declining the sign in prompt leaves the install successful`() { + val panel = panel { _, _ -> FakeInstallDialog(MarketplaceInstallRequest("project", emptyMap())) } + flushUntil { rows(panel).size == 3 } + agentRpc.mcps = listOf(McpStatusDto("context7", "needs_auth")) + TestDialogManager.setTestDialog { Messages.NO } + + click(panel, "mcp:context7", "install") + + flushUntil { marketRpc.listCalls.size == 2 } + assertTrue(marketRpc.installCalls.isNotEmpty()) + assertTrue(agentRpc.mcpAuthentications.isEmpty()) + } + + fun `test accepting the sign in prompt calls mcpAuthenticate`() { + val panel = panel { _, _ -> FakeInstallDialog(MarketplaceInstallRequest("project", emptyMap())) } + flushUntil { rows(panel).size == 3 } + agentRpc.mcps = listOf(McpStatusDto("context7", "needs_auth")) + agentRpc.mcpAuthenticateResult = McpAuthResultDto("connected") + TestDialogManager.setTestDialog(TestDialog.YES) + + click(panel, "mcp:context7", "install") + + flushUntil { agentRpc.mcpAuthentications.contains("context7") } + } + + fun `test marketplace sign in keeps progress and cancel visible while authentication waits`() { + val panel = panel { _, _ -> FakeInstallDialog(MarketplaceInstallRequest("project", emptyMap())) } + flushUntil { rows(panel).size == 3 } + agentRpc.mcps = listOf(McpStatusDto("context7", "needs_auth")) + agentRpc.mcpAuthenticateResult = McpAuthResultDto("connected") + val gate = CompletableDeferred() + agentRpc.beforeAuthenticate = { gate.await() } + TestDialogManager.setTestDialog(TestDialog.YES) + + click(panel, "mcp:context7", "install") + + flushUntil { agentRpc.mcpAuthenticateStarted } + edt { + val visible = text(panel) + assertTrue(visible.contains(KiloBundle.message("settings.agentBehavior.mcp.signIn.progress", "context7"))) + assertTrue(visible.contains(KiloBundle.message("settings.agentBehavior.mcp.signIn.cancel"))) + true + } + + gate.complete(Unit) + flushUntil { agentRpc.mcpAuthentications.contains("context7") } + } + + fun `test a connected MCP install shows no sign in prompt`() { + val panel = panel { _, _ -> FakeInstallDialog(MarketplaceInstallRequest("project", emptyMap())) } + flushUntil { rows(panel).size == 3 } + agentRpc.mcps = listOf(McpStatusDto("context7", "connected")) + var prompted = 0 + TestDialogManager.setTestDialog { + prompted++ + Messages.NO + } + + click(panel, "mcp:context7", "install") + + flushUntil { marketRpc.listCalls.size == 2 } + edt { UIUtil.dispatchAllInvocationEvents(); true } + assertEquals(0, prompted) + } + fun `test MCP with companion skills uses the combined type badge`() { val skills = listOf(MarketplaceSkillDto("docs-lookup", "https://example.com/docs-lookup.tar.gz")) val rpc = FakeMarketplaceRpcApi().apply { @@ -652,7 +737,9 @@ class MarketplaceSettingsUiTest : BasePlatformTestCase() { appRpc.state.value = ready ApplicationManager.getApplication().replaceService(KiloAppService::class.java, app, testRootDisposable) ApplicationManager.getApplication().replaceService(KiloMarketplaceService::class.java, KiloMarketplaceService(cs, marketRpc), testRootDisposable) - ApplicationManager.getApplication().replaceService(KiloAgentBehaviorService::class.java, KiloAgentBehaviorService(cs, agentRpc), testRootDisposable) + val behavior = KiloAgentBehaviorService(cs, agentRpc) + ApplicationManager.getApplication().replaceService(KiloAgentBehaviorService::class.java, behavior, testRootDisposable) + ApplicationManager.getApplication().replaceService(KiloMcpAuthService::class.java, KiloMcpAuthService(cs, behavior), testRootDisposable) } private fun click(panel: MarketplaceSettingsUi, key: String, id: String) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt index 73fbec775df..95365e0c6f4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt @@ -5,11 +5,15 @@ import ai.kilocode.rpc.dto.AgentCreateDto import ai.kilocode.rpc.dto.AgentDetailDto import ai.kilocode.rpc.dto.CommandFileDto import ai.kilocode.rpc.dto.CommandDto +import ai.kilocode.rpc.dto.McpAuthEventDto +import ai.kilocode.rpc.dto.McpAuthResultDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.McpServerConfigDto import ai.kilocode.rpc.dto.McpStatusDto import ai.kilocode.rpc.dto.SkillDto import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { var agents = emptyList() @@ -33,6 +37,9 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { val mcpConnects = mutableListOf() val mcpDisconnects = mutableListOf() val mcpAuthentications = mutableListOf() + val mcpAuthCancels = mutableListOf() + val mcpAuthRemovals = mutableListOf() + val mcpAuthEventsFlow = MutableSharedFlow(extraBufferCapacity = 8) val creations = mutableListOf() val createDirs = mutableListOf() var afterCreate: (suspend (String, AgentCreateDto) -> Unit)? = null @@ -46,6 +53,9 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { var removeSkillError: Exception? = null var saveSkillError: Exception? = null var mcpStatusError: Exception? = null + + /** Holds `mcpStatus` so a test can observe the list panel while it is still busy. */ + var mcpStatusGate: CompletableDeferred? = null var mcpConnectError: Exception? = null var removeResult = true var removeSkillResult = true @@ -56,7 +66,11 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { var saveCommandResult = true var mcpConnectResult = true var mcpDisconnectResult = true - var mcpAuthenticateResult = true + var mcpAuthenticateResult = McpAuthResultDto("connected") + var mcpAuthCancelResult = true + var mcpAuthRemoveResult = true + @Volatile var mcpAuthenticateStarted = false + var beforeAuthenticate: (suspend () -> Unit)? = null var claudeCodeCompat = false val compatSaves = mutableListOf() @@ -169,6 +183,7 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { assertNotEdt("agentBehavior.mcpStatus") mcpStatusError?.let { throw it } mcpCalls.add(directory) + mcpStatusGate?.await() return mcps } @@ -199,12 +214,31 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { return mcpDisconnectResult } - override suspend fun mcpAuthenticate(directory: String, name: String): Boolean { + override suspend fun mcpAuthenticate(directory: String, name: String): McpAuthResultDto { assertNotEdt("agentBehavior.mcpAuthenticate") + mcpAuthenticateStarted = true + beforeAuthenticate?.invoke() mcpAuthentications.add(name) return mcpAuthenticateResult } + override suspend fun mcpAuthCancel(directory: String, name: String): Boolean { + assertNotEdt("agentBehavior.mcpAuthCancel") + mcpAuthCancels.add(name) + return mcpAuthCancelResult + } + + override suspend fun mcpAuthRemove(directory: String, name: String): Boolean { + assertNotEdt("agentBehavior.mcpAuthRemove") + mcpAuthRemovals.add(name) + return mcpAuthRemoveResult + } + + override suspend fun mcpAuthEvents(): Flow { + assertNotEdt("agentBehavior.mcpAuthEvents") + return mcpAuthEventsFlow + } + override suspend fun claudeCodeCompat(): Boolean { assertNotEdt("agentBehavior.claudeCodeCompat") return claudeCodeCompat diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeMarketplaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeMarketplaceRpcApi.kt index 9803b63ad40..d61372d18b7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeMarketplaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeMarketplaceRpcApi.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.testing import ai.kilocode.rpc.KiloMarketplaceRpcApi +import ai.kilocode.rpc.dto.MarketplaceBundleDto import ai.kilocode.rpc.dto.MarketplaceItemDto import ai.kilocode.rpc.dto.MarketplaceListDto import ai.kilocode.rpc.dto.MarketplaceResultDto @@ -11,10 +12,13 @@ class FakeMarketplaceRpcApi : KiloMarketplaceRpcApi { var listError: Exception? = null var installResult = MarketplaceResultDto(success = true, slug = "test") var removeResult = MarketplaceResultDto(success = true, slug = "test") + var bundles = emptyList() + var bundlesError: Exception? = null /** Holds [install] open until completed, so a test can observe the in-flight state. */ var installGate: CompletableDeferred? = null val listCalls = mutableListOf() + val bundleCalls = mutableListOf() val installCalls = mutableListOf() val removeCalls = mutableListOf() @@ -28,6 +32,13 @@ class FakeMarketplaceRpcApi : KiloMarketplaceRpcApi { return list } + override suspend fun bundles(directory: String): List { + assertNotEdt("marketplace.bundles") + bundleCalls.add(directory) + bundlesError?.let { throw it } + return bundles + } + override suspend fun install( directory: String, item: MarketplaceItemDto, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt index 5ee568a9e85..6ad3788f5d0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt @@ -66,6 +66,8 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { val prCalls = CopyOnWriteArrayList() /** The `maxAge` ceiling of each [prStatus] call, positionally matching [prCalls]. */ val prAges = CopyOnWriteArrayList() + /** The paths each [prStatus] call named as uncacheable, positionally matching [prCalls]. */ + val prFresh = CopyOnWriteArrayList>() val statsCalls = CopyOnWriteArrayList() val dirtyCalls = CopyOnWriteArrayList() /** When set, [stats] throws it instead of answering. */ @@ -146,10 +148,11 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { return ghResult } - override suspend fun prStatus(directory: String, maxAge: Long?): WorktreePrListDto { + override suspend fun prStatus(directory: String, maxAge: Long?, fresh: List): WorktreePrListDto { assertNotEdt("prStatus") prCalls.add(directory) prAges.add(maxAge) + prFresh.add(fresh) // Snapshot before the gate so a call held open answers with what was configured when it // started, letting a test stage a newer result for the calls that follow. val answer = prResult diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/popup/SidePopupControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/popup/SidePopupControllerTest.kt index 7ff767bfb7b..712828d0f75 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/popup/SidePopupControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/popup/SidePopupControllerTest.kt @@ -2,10 +2,12 @@ package ai.kilocode.client.ui.popup import ai.kilocode.client.testing.TestUiTimers import com.intellij.openapi.Disposable +import com.intellij.openapi.ui.popup.Balloon import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.UIUtil import java.awt.Color +import java.awt.Point import javax.swing.JComponent import javax.swing.JPanel @@ -176,6 +178,12 @@ class SidePopupControllerTest : BasePlatformTestCase() { assertNull(field(controller, "target")) } + fun `test popup spot callout defaults on and can be disabled`() { + val pane = JPanel() + assertTrue(SidePopupSpot(pane, Point(), Balloon.Position.atLeft, 0).callout) + assertFalse(SidePopupSpot(pane, Point(), Balloon.Position.atLeft, 0, callout = false).callout) + } + private fun controller(dwell: Int = SidePopupController.SHOW_MS): SidePopupController { val item = SidePopupController(timers, dwell) controllers.add(item) diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index d025286bd90..10585e9a545 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.1.9-rc.1 +kilo.jetbrains.version=7.1.9 # When true (default) the JetBrains plugin uses the pinned CLI release from package.json. # Set to false ONLY for local dev: generate the client from local source + bundle the local binary. # false is NOT releasable -- production builds fail unless this is true. diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index 7f2ecad8407..71770d69cb0 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] intellij-platform = "2026.1" -intellij-gradle-plugin = "2.17.0" +intellij-gradle-plugin = "2.19.0" intellij-rpc-plugin = "2.3.21-RC2-0.1" kotlin-jvm-plugin = "2.3.20" kotlin-serialization-plugin = "2.3.20" diff --git a/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.jar b/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.jar index eddabd2eef8..5097068a8d3 100644 Binary files a/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.jar and b/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.jar differ diff --git a/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.properties b/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.properties index c42672d95b9..299848b169a 100644 --- a/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.properties +++ b/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/packages/kilo-jetbrains/gradlew.bat b/packages/kilo-jetbrains/gradlew.bat index a51ec4f5886..03754c4c3d9 100644 --- a/packages/kilo-jetbrains/gradlew.bat +++ b/packages/kilo-jetbrains/gradlew.bat @@ -1,82 +1,112 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem gradlew startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:execute -@rem Setup the command line - - - -@rem Execute gradlew -@rem endlocal doesn't take effect until after the line is parsed and variables are expanded -@rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel - -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json index 3740ddbc848..44dc2444819 100644 --- a/packages/kilo-jetbrains/package.json +++ b/packages/kilo-jetbrains/package.json @@ -8,7 +8,7 @@ "test": "./gradlew test", "test:ci": "bun script/test-ci.ts" }, - "version": "7.8.1", + "version": "7.8.8", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt index ac82b9de313..dc7501ce1e7 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt @@ -4,6 +4,8 @@ import ai.kilocode.rpc.dto.AgentDetailDto import ai.kilocode.rpc.dto.AgentCreateDto import ai.kilocode.rpc.dto.CommandFileDto import ai.kilocode.rpc.dto.CommandDto +import ai.kilocode.rpc.dto.McpAuthEventDto +import ai.kilocode.rpc.dto.McpAuthResultDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.McpServerConfigDto import ai.kilocode.rpc.dto.McpStatusDto @@ -12,6 +14,7 @@ import com.intellij.platform.rpc.RemoteApiProviderService import fleet.rpc.RemoteApi import fleet.rpc.Rpc import fleet.rpc.remoteApiDescriptor +import kotlinx.coroutines.flow.Flow @Rpc interface KiloAgentBehaviorRpcApi : RemoteApi { @@ -57,7 +60,14 @@ interface KiloAgentBehaviorRpcApi : RemoteApi { suspend fun mcpDisconnect(directory: String, name: String): Boolean - suspend fun mcpAuthenticate(directory: String, name: String): Boolean + suspend fun mcpAuthenticate(directory: String, name: String): McpAuthResultDto + + /** Cancels an in-flight sign-in, leaving any stored credentials in place. */ + suspend fun mcpAuthCancel(directory: String, name: String): Boolean + + suspend fun mcpAuthRemove(directory: String, name: String): Boolean + + suspend fun mcpAuthEvents(): Flow suspend fun claudeCodeCompat(): Boolean diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloMarketplaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloMarketplaceRpcApi.kt index f2b3eeccbdd..32716f85f82 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloMarketplaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloMarketplaceRpcApi.kt @@ -1,6 +1,7 @@ package ai.kilocode.rpc import ai.kilocode.rpc.dto.MarketplaceItemDto +import ai.kilocode.rpc.dto.MarketplaceBundleDto import ai.kilocode.rpc.dto.MarketplaceListDto import ai.kilocode.rpc.dto.MarketplaceResultDto import com.intellij.platform.rpc.RemoteApiProviderService @@ -18,6 +19,8 @@ interface KiloMarketplaceRpcApi : RemoteApi { suspend fun list(directory: String): MarketplaceListDto + suspend fun bundles(directory: String): List + suspend fun install( directory: String, item: MarketplaceItemDto, diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt index 482b24fe993..cbf407bb446 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt @@ -68,8 +68,14 @@ interface KiloWorktreeRpcApi : RemoteApi { * a caller returning from a long absence can ask for `maxAge =
+
{props.children} {(value) => }
@@ -97,12 +132,20 @@ export function BasicTool(props: BasicToolProps) { + } > = { viewBox: "0 0 24 24", path: ``, }, + "warning-filled": { + viewBox: "0 0 20 20", + path: ``, + }, } type Name = keyof typeof icons diff --git a/packages/kilo-ui/src/components/markdown.css b/packages/kilo-ui/src/components/markdown.css index ae502e81192..5074f8cab1d 100644 --- a/packages/kilo-ui/src/components/markdown.css +++ b/packages/kilo-ui/src/components/markdown.css @@ -52,3 +52,15 @@ margin-bottom: 1rem; } } + +[data-browser-links="true"] a[href^="http"]::before { + content: ""; + display: inline-block; + width: 0.95em; + height: 0.95em; + margin-inline-end: 0.3em; + vertical-align: -0.15em; + background-color: currentColor; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20'/%3E%3Cpath d='M2 12h20'/%3E%3C/svg%3E") + center / contain no-repeat; +} diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index f973d1bc908..f0a1f783dcb 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -215,11 +215,15 @@ export function relativizeProjectPath(path: string, directory?: string) { if (!directory) return path if (directory === "/") return path if (directory === "\\") return path - if (path === directory) return "" - const separator = directory.includes("\\") ? "\\" : "/" - const prefix = directory.endsWith(separator) ? directory : directory + separator - if (!path.startsWith(prefix)) return path + // Windows workspaces arrive with mixed separators (e.g. `C:\Sources/`), so compare with `/` only + const target = path.replaceAll("\\", "/") + const root = directory.replaceAll("\\", "/") + const trim = (value: string) => value.replace(/\/+$/, "") + if (trim(target) === trim(root)) return "" + + const prefix = root.endsWith("/") ? root : root + "/" + if (!target.startsWith(prefix)) return path return path.slice(directory.length) } @@ -2421,6 +2425,7 @@ ToolRegistry.register({ name: "grep", render(props) { const i18n = useI18n() + const data = useData() const args: string[] = [] if (props.input.pattern) args.push("pattern=" + props.input.pattern) if (props.input.include) args.push("include=" + props.input.include) @@ -2433,7 +2438,7 @@ ToolRegistry.register({ } @@ -2981,6 +2986,7 @@ ToolRegistry.register({
rect { + [data-component="spinner"] > * { animation: none !important; } } diff --git a/packages/kilo-ui/src/components/task-header.css b/packages/kilo-ui/src/components/task-header.css index 9f44c093e22..ccc25df2be8 100644 --- a/packages/kilo-ui/src/components/task-header.css +++ b/packages/kilo-ui/src/components/task-header.css @@ -5,7 +5,7 @@ align-items: center; gap: 8px; background-color: var(--background-base); - padding: 12px 16px; + padding: 6px 16px; border-bottom: 1px solid var(--border-weak-base); flex-shrink: 0; min-height: 32px; diff --git a/packages/kilo-ui/src/stories/message-part.stories.tsx b/packages/kilo-ui/src/stories/message-part.stories.tsx index 3c1bf353b52..3d08ace124b 100644 --- a/packages/kilo-ui/src/stories/message-part.stories.tsx +++ b/packages/kilo-ui/src/stories/message-part.stories.tsx @@ -1,6 +1,8 @@ /** @jsxImportSource solid-js */ import type { Meta, StoryObj } from "storybook-solidjs-vite" -import { UserMessageDisplay, AssistantParts } from "../components/message-part" +import { For } from "solid-js" +import { Dynamic } from "solid-js/web" +import { UserMessageDisplay, AssistantParts, ToolRegistry } from "../components/message-part" import { AgentAvatarPalette } from "../components/agent-avatar" import { DataProvider } from "@opencode-ai/ui/context/data" import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff" @@ -337,9 +339,9 @@ const boardBroadcastPart: ToolPart = { const mockDataBoardBroadcast = createMockData([boardBroadcastPart]) -function AllProviders(props: { children: any; data?: MockData; onOpenDiff?: () => void }) { +function AllProviders(props: { children: any; data?: MockData; directory?: string; onOpenDiff?: () => void }) { return ( - + @@ -367,6 +369,81 @@ const meta: Meta = { export default meta type Story = StoryObj +export const GrepTargets: Story = { + render: () => ( + + + {(status) => ( +
+

{status}

+ + {(path) => ( +
+ +
+ )} +
+
+ )} +
+
+ ), +} + +// The VS Code webview appends `/` to the workspace directory, so Windows roots arrive as `C:\Sources/` +export const GrepWindowsWorkspace: Story = { + render: () => ( + + {(workspace) => ( +
+ + + {(path) => ( + + )} + + +
+ )} +
+ ), +} + // --- User message bubble --- export const UserMessageStory: Story = { diff --git a/packages/kilo-ui/tests/grep-target.spec.ts b/packages/kilo-ui/tests/grep-target.spec.ts new file mode 100644 index 00000000000..47bcf7faa06 --- /dev/null +++ b/packages/kilo-ui/tests/grep-target.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from "@playwright/test" + +for (const status of ["pending", "running", "completed"]) { + test(`grep displays the search target while ${status}`, async ({ page }) => { + await page.goto("/iframe.html?id=components-messagepart--grep-targets&viewMode=story") + + const rows = page.locator(`[data-status='${status}'] [data-slot='basic-tool-tool-subtitle']`) + await expect(rows).toHaveText([ + "C:\\Sources pattern=Понятно include=*.cs", + "C:\\Sources\\Program.cs pattern=Понятно include=*.cs", + "C:/Sources/ pattern=Понятно include=*.cs", + "C:\\ pattern=Понятно include=*.cs", + "\\\\server\\share\\Sources pattern=Понятно include=*.cs", + "/src pattern=Понятно include=*.cs", + "/src/index.ts pattern=Понятно include=*.cs", + "pattern=Понятно include=*.cs", + "/ pattern=Понятно include=*.cs", + "src pattern=Понятно include=*.cs", + "src/nested pattern=Понятно include=*.cs", + ". pattern=Понятно include=*.cs", + "pattern=Понятно include=*.cs", + ]) + }) +} + +const windows = { + plain: [ + "pattern=Понятно", + "\\Program.cs pattern=Понятно", + "/Program.cs pattern=Понятно", + "C:\\SourcesOld\\Program.cs pattern=Понятно", + ], + webview: [ + "pattern=Понятно", + "Program.cs pattern=Понятно", + "Program.cs pattern=Понятно", + "C:\\SourcesOld\\Program.cs pattern=Понятно", + ], +} + +for (const [name, expected] of Object.entries(windows)) { + test(`grep relativizes targets inside a ${name} Windows workspace`, async ({ page }) => { + await page.goto("/iframe.html?id=components-messagepart--grep-windows-workspace&viewMode=story") + + const rows = page.locator(`[data-workspace='${name}'] [data-slot='basic-tool-tool-subtitle']`) + await expect(rows).toHaveText(expected) + }) +} diff --git a/packages/kilo-vscode/AGENTS.md b/packages/kilo-vscode/AGENTS.md index 2896e72a273..dcc5d8c5d96 100644 --- a/packages/kilo-vscode/AGENTS.md +++ b/packages/kilo-vscode/AGENTS.md @@ -182,9 +182,9 @@ Agent Manager local worktree sessions use the current shared `kilo serve` proces Extension-side code lives in `src/agent-manager/`, webview code in `webview-ui/agent-manager/`. The webview reuses the sidebar's provider chain and `ChatView` component, adding a `WorktreeModeProvider` and a split layout. -### Multi-project migration +### Multi-project support -Multi-project Agent Manager is an incremental migration behind the application-scoped `kilo-code.new.experimental.multiProject` flag (default `false`); flag-off behavior must remain unchanged. The project registry/contexts, per-project state and session routing, project sidebar, sections and drag-and-drop, progress/persistence, and project-targeted worktree creation are implemented. +Agent Manager always supports multiple projects. The current workspace repository is the default project and appears first. The project registry/contexts, per-project state and session routing, project sidebar, sections and drag-and-drop, progress/persistence, and project-targeted worktree creation are implemented. Preserve existing per-repository state and legacy selection migration when changing project routing. Current implementations are in [project/](src/agent-manager/project/) and [indexing-consent.ts](src/indexing-consent.ts). Check the code and tests before treating migration items as unfinished work. Review areas remain explicit project/worktree/session routing, immutable project-bound Settings, machine-local indexing consent, canonical Git identity, multi-window route ownership, shared sidebar convergence, and full two-project E2E/legacy-parity coverage. diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index 9762abd4b90..2e3b0591b2b 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,113 @@ # kilo-code +## 7.8.8 + +### Minor Changes + +- [#14891](https://github.com/Kilo-Org/kilocode/pull/14891) [`ce9a04b`](https://github.com/Kilo-Org/kilocode/commit/ce9a04b0f91531b26d37a0f514621396f85311e8) - Pin worktrees in the Agent Manager sidebar with Shift+click or the context menu. Pinned worktrees stay at the top of the list, get the first jump shortcuts, and keep their section for when you unpin them. + +- [#14885](https://github.com/Kilo-Org/kilocode/pull/14885) [`0abb254`](https://github.com/Kilo-Org/kilocode/commit/0abb2543204636b59be9afc22b7f380b5177fdd2) - Open chat web links in-app by default when the experimental Integrated Browser is enabled: sidebar chats use a browser tab and Agent Manager uses its browser panel. Choose "System browser" under "Open links in" to opt out. In-app links show a browser icon, and links no longer open in two browsers. + +### Patch Changes + +- [#14890](https://github.com/Kilo-Org/kilocode/pull/14890) [`3453059`](https://github.com/Kilo-Org/kilocode/commit/34530596230a4614cb7c6c089ab8a2f1a264d9ca) - Reduce the height of Agent Manager section headers so more sessions fit in the sidebar. + +- [#14884](https://github.com/Kilo-Org/kilocode/pull/14884) [`d6b3b21`](https://github.com/Kilo-Org/kilocode/commit/d6b3b214d22e81cbe9b05ec2ec90735d050292bc) - Make the integrated browser preview feel more like a native browser. Page loads and client-side route changes keep the current page visible until the next page paints, instead of showing a blank preview. Route changes in single-page apps no longer drop input. The preview now updates while you resize its panel, and it does not stretch the page during the resize. + +- [#14886](https://github.com/Kilo-Org/kilocode/pull/14886) [`8781c16`](https://github.com/Kilo-Org/kilocode/commit/8781c165ccfbc23d1135bb556936009d896e3ff2) - Show a clear warning when the Integrated Browser is opened without an active session, and disable its address bar until a session is selected. + +- [#14888](https://github.com/Kilo-Org/kilocode/pull/14888) [`35da0f1`](https://github.com/Kilo-Org/kilocode/commit/35da0f1814fefbfbab3c41a75b93e3cdd2485ca4) - Make the session header more compact to save vertical space, and keep the scroll-to-bottom button clear of the prompt rail. + +- [#14889](https://github.com/Kilo-Org/kilocode/pull/14889) [`7f64374`](https://github.com/Kilo-Org/kilocode/commit/7f6437435e3886a4753c87aa79fd2e0d43844035) Thanks [@WebReflection](https://github.com/WebReflection)! - Keep MCP OAuth credentials bound to the authorization server that issued them, so a server that switches authorization servers can no longer receive stored refresh tokens or client secrets. + +- [#14887](https://github.com/Kilo-Org/kilocode/pull/14887) [`248e9ae`](https://github.com/Kilo-Org/kilocode/commit/248e9ae468efdbd31e34ef3ca863c05be8800337) - Switch between session tabs in the sidebar and Kilo editor tabs with `Cmd+Alt+Left` / `Right` (`Ctrl+Alt+Left` / `Right` on Windows and Linux), with shortcut hints on the tabs and in the prompt. + +- [#14854](https://github.com/Kilo-Org/kilocode/pull/14854) [`2c85991`](https://github.com/Kilo-Org/kilocode/commit/2c859919d8e4c420bed3abd9872da2cc0b5bcc8c) - Use installed Playwright Chromium in WSL when Chrome is missing and no browser preference is set. Document manual browser setup without installing software automatically. + +- Updated dependencies [[`35da0f1`](https://github.com/Kilo-Org/kilocode/commit/35da0f1814fefbfbab3c41a75b93e3cdd2485ca4)]: + - @kilocode/kilo-ui@7.8.8 + +## 7.8.7 + +### Minor Changes + +- [#14797](https://github.com/Kilo-Org/kilocode/pull/14797) [`ecb450b`](https://github.com/Kilo-Org/kilocode/commit/ecb450b9bea49dd099037933d9852dfdd5e05224) - Make multi-project Agent Manager available by default, with the current workspace project first, and remove the experimental toggle and old single-project view. + + Keep restored tabs, background sessions, and branch defaults with their project, and restore the remaining project after removing the active project. + +- [#14806](https://github.com/Kilo-Org/kilocode/pull/14806) [`b85a1ab`](https://github.com/Kilo-Org/kilocode/commit/b85a1ab93bb4b3466cdfba5b898e85f0bea1bf6d) - Show one keyboard shortcut hint in the prompt that fits what you do now: how to add selected code to the chat, return to the prompt, stop a run, jump to a session that needs you, or review changes and the pull request in Agent Manager. Empty Agent Manager sessions list the main shortcuts, the mode picker shows its shortcut on hover, and all shortcut labels follow your custom keybindings. Turn hints off in Display settings. + +- [#14836](https://github.com/Kilo-Org/kilocode/pull/14836) [`760048d`](https://github.com/Kilo-Org/kilocode/commit/760048d906857bc1637189c6386d43bb4bb11ae5) - Open the Integrated Browser in an editor tab for any session, not only Agent Manager. Run the Open Integrated Browser command, or let the browser_open tool reveal the tab automatically. Each session gets its own browser tab. + +- [#14781](https://github.com/Kilo-Org/kilocode/pull/14781) [`fffcf0e`](https://github.com/Kilo-Org/kilocode/commit/fffcf0e2ac3ae821a953e1956cc316d35b4a24ce) Thanks [@eborjaa](https://github.com/eborjaa)! - Choose a separate model for automatic project memory saves with the new `memory_model` setting in `kilo.jsonc`. When it is unset or unavailable, memory keeps using the session model. + +- [#14632](https://github.com/Kilo-Org/kilocode/pull/14632) [`fc3910b`](https://github.com/Kilo-Org/kilocode/commit/fc3910b608e90eeb1eccf4145e123dd65bf5e366) Thanks [@brunoagatao](https://github.com/brunoagatao)! - Add an experimental "Per-Conversation Prompt History" setting that keeps prompt input history separate for each conversation + +- [#14751](https://github.com/Kilo-Org/kilocode/pull/14751) [`829f621`](https://github.com/Kilo-Org/kilocode/commit/829f6214f5c39558d94e5eafff72e37adbe2c89d) - Show the GitHub organization avatar next to each project in the Agent Manager project list, with a letter tile when the project has no GitHub remote. + +- [#14813](https://github.com/Kilo-Org/kilocode/pull/14813) [`8b144bb`](https://github.com/Kilo-Org/kilocode/commit/8b144bbf9188799a371b59c4d16a06cbe720b79f) - Show to-do progress in the session dock above the prompt. A small progress chip shows how many items are done and the current item, next to the working indicator, background agents, and the goal. Click it to see the full list. The to-do bar under the session title is removed. + +- [#14837](https://github.com/Kilo-Org/kilocode/pull/14837) [`94c0514`](https://github.com/Kilo-Org/kilocode/commit/94c0514063a17b924af049d720da7c605c7d766d) - Add search to the Kilo Settings sidebar. A search field at the top of the left rail filters every setting, with results grouped by category in the panel so matching settings stay visible; select one to jump to its tab and setting. The field shows the shortcut as a keycap and focuses with `Ctrl+F` (`Cmd+F` on macOS). + +- [#14855](https://github.com/Kilo-Org/kilocode/pull/14855) [`2049b82`](https://github.com/Kilo-Org/kilocode/commit/2049b821f7d1a1d569c7c1e65af4302ca8943bb4) - Pin and unpin session tabs with Shift+click in the sidebar and Agent Manager. The tab context menu now shows the gesture next to Pin and Unpin. + +- [#14724](https://github.com/Kilo-Org/kilocode/pull/14724) [`72fa65f`](https://github.com/Kilo-Org/kilocode/commit/72fa65fb323a8da067694dbc3460ce8f5860af3e) - Support OAuth sign-in for MCP servers: open authorization links through VS Code, prompt to sign in after installing an OAuth-protected server from the Marketplace, surface servers that need sign-in from the chat prompt with recovery actions, and configure a pre-registered OAuth client when a server requires one. + +### Patch Changes + +- [#14853](https://github.com/Kilo-Org/kilocode/pull/14853) [`b0820ac`](https://github.com/Kilo-Org/kilocode/commit/b0820ac2cf6513de98c5079686c8268328845bf9) - Show the keyboard shortcut hints in an empty Agent Manager session with the same VS Code keycap style used in the prompt input, so the shortcut keys look consistent in both places. + +- [#14721](https://github.com/Kilo-Org/kilocode/pull/14721) [`b299b97`](https://github.com/Kilo-Org/kilocode/commit/b299b9779abaa3e2951c9141a36b9df46cf2f9c2) - Keep pre-warmed Agent Manager worktrees outside the project folder. `.kilo/worktrees/` is now only created when you create a worktree, so build tools, test runners, and file watchers that scan the project no longer find an extra checkout. Pre-warmed worktrees left in `.kilo/worktrees/` by earlier versions are removed automatically. Projects on a different drive than your home folder are no longer pre-warmed. + +- [#14712](https://github.com/Kilo-Org/kilocode/pull/14712) [`4c09359`](https://github.com/Kilo-Org/kilocode/commit/4c09359103883b6904732310b50f57b941dce31e) - Run the Agent Manager worktree setup script for a new worktree even when the setup script of another worktree is still running in the VS Code terminal, and show an error when the setup task cannot start. + +- [#14828](https://github.com/Kilo-Org/kilocode/pull/14828) [`652cd51`](https://github.com/Kilo-Org/kilocode/commit/652cd51c184fd371a50dd8b8e1cf89b2c2e006c1) - Make dragging, hovering, and scrolling in the Agent Manager integrated browser follow the pointer without a growing delay on high refresh rate displays. + +- [#14827](https://github.com/Kilo-Org/kilocode/pull/14827) [`6c79db2`](https://github.com/Kilo-Org/kilocode/commit/6c79db2b80e6a96ceb35750b32a96c795d1e17f6) Thanks [@WebReflection](https://github.com/WebReflection)! - Update DOMPurify to 3.4.16 to fix an XSS vulnerability flagged by Dependabot in markdown sanitization. + +- [#14833](https://github.com/Kilo-Org/kilocode/pull/14833) [`c918730`](https://github.com/Kilo-Org/kilocode/commit/c918730f724002b51be6288f08ce9893a1c94a1f) - Keep local sessions visible and New Session recent lists isolated when switching Agent Manager projects. + +- [#14736](https://github.com/Kilo-Org/kilocode/pull/14736) [`d894e65`](https://github.com/Kilo-Org/kilocode/commit/d894e6535336ce3d4dae48a9c39d0e479aee767b) Thanks [@brunoagatao](https://github.com/brunoagatao)! - Keep Agent Manager peer messages visible in later typed turns so the prompt cache stays warm + +- [#14780](https://github.com/Kilo-Org/kilocode/pull/14780) [`716f722`](https://github.com/Kilo-Org/kilocode/commit/716f722dc506c61f81c70520e5e867440380bd8e) Thanks [@ykakade](https://github.com/ykakade)! - Show the actual Grep search target instead of its parent directory, including searches within a single file, and show tool paths relative to Windows workspaces. + +- [#14791](https://github.com/Kilo-Org/kilocode/pull/14791) [`3e4c6dd`](https://github.com/Kilo-Org/kilocode/commit/3e4c6dd51bf6c54ef8cea471680cc2c207ca10bb) Thanks [@mardausdennis](https://github.com/mardausdennis)! - Stop the hidden working spinner from animating after a session finishes, which kept the chat view redrawing on every frame while idle. + +- [#14848](https://github.com/Kilo-Org/kilocode/pull/14848) [`643cfc3`](https://github.com/Kilo-Org/kilocode/commit/643cfc39a040f86fffd987d597651762077ab4fb) Thanks [@WebReflection](https://github.com/WebReflection)! - Update KaTeX to 0.19.0 to fix a security issue flagged by Dependabot in math rendering. + +- [#14803](https://github.com/Kilo-Org/kilocode/pull/14803) [`21164ba`](https://github.com/Kilo-Org/kilocode/commit/21164ba0b058d1db59b8682f51f811d0407b461d) - Reduce CPU use of the workspace scan for marketplace suggestions. The scan now respects `.gitignore` and other ignore files, searches all file patterns in one pass, and no longer follows symbolic links out of the project. Creating a file no longer starts a new scan unless the file or folder can match a suggestion. + +- [#14798](https://github.com/Kilo-Org/kilocode/pull/14798) [`b8d0cf1`](https://github.com/Kilo-Org/kilocode/commit/b8d0cf1acfa8ea45ec69aa0ffc58d88ef6c1ce9a) - Scroll wide MCP tool input and output horizontally instead of clipping it, and keep words in MCP output tables from breaking into single letters. + +- [#14783](https://github.com/Kilo-Org/kilocode/pull/14783) [`227a8de`](https://github.com/Kilo-Org/kilocode/commit/227a8de8fb2bf7b40324bca0f73c189b8b723229) Thanks [@sylwester-liljegren](https://github.com/sylwester-liljegren)! - Show the full path of files and folders when hovering them in the @-mention list. + +- [#14723](https://github.com/Kilo-Org/kilocode/pull/14723) [`958f1c4`](https://github.com/Kilo-Org/kilocode/commit/958f1c4fff35f6ac200521819229b7da77617a97) Thanks [@WebReflection](https://github.com/WebReflection)! - Fix model precedence so mode-configured models win over remembered picks in new sessions. Manual model picks now apply per agent within each session or draft, reopened sessions immediately show the agent and model they last ran with, and the plan follow-up "Start new session" keeps the planning session in Plan mode. The model picker recovers on its own after a Kilo catalog failure and says when Kilo models are unavailable. + +- [#14775](https://github.com/Kilo-Org/kilocode/pull/14775) [`0883558`](https://github.com/Kilo-Org/kilocode/commit/088355899dd03821d69dab203bf2d3072b0a6184) Thanks [@LEN5010](https://github.com/LEN5010)! - Preserve staged renames and the staged/unstaged split when continuing a session in a worktree. + +- [#14810](https://github.com/Kilo-Org/kilocode/pull/14810) [`3df1371`](https://github.com/Kilo-Org/kilocode/commit/3df13713bd2e8b88895f9b90c68aa31ea6708965) - Show the shortcut in the prompt hint as keycaps, so the keys are easier to see. + +- [#14808](https://github.com/Kilo-Org/kilocode/pull/14808) [`b3e22e7`](https://github.com/Kilo-Org/kilocode/commit/b3e22e72dbd43b73608440ac343f297b19348567) - Keep Agent Manager session switching responsive when visible edit diffs are expanded. + +- [#14750](https://github.com/Kilo-Org/kilocode/pull/14750) [`bf40cc7`](https://github.com/Kilo-Org/kilocode/commit/bf40cc7cb221d7d161a378eeaf522bb1b9070553) - Make `semantic_search` available in VS Code when codebase indexing is enabled for the project, also when the indexing config switch is not set. + +- [#14834](https://github.com/Kilo-Org/kilocode/pull/14834) [`4e0131e`](https://github.com/Kilo-Org/kilocode/commit/4e0131e41c4f41b612c11910845f5d7b9a4e4c35) Thanks [@WebReflection](https://github.com/WebReflection)! - Update simple-git to 4.0.2 to fix command execution vulnerabilities flagged by Dependabot in Git operations. + +- [#14852](https://github.com/Kilo-Org/kilocode/pull/14852) [`7ab5061`](https://github.com/Kilo-Org/kilocode/commit/7ab5061177bef42438eb67d00c6a874b176e06ac) - Allow scrolling while selecting browser elements, refresh element highlights after scrolling, and batch trackpad input without losing scroll distance or direction changes. + +- [#14802](https://github.com/Kilo-Org/kilocode/pull/14802) [`64a9192`](https://github.com/Kilo-Org/kilocode/commit/64a9192819b55a289e70bce279b8f4a7689fd83f) - Show the model and reasoning effort of a subagent in the header of its session view, in the Agent Manager subagent panel and in the subagent editor tab. + +- [#14829](https://github.com/Kilo-Org/kilocode/pull/14829) [`ab04799`](https://github.com/Kilo-Org/kilocode/commit/ab047999c7aa94d63d9743831d5d3a3a44862dee) - Open Settings directly without loading chat, unused previews, model-picker lists, or unselected language dictionaries. + +- [#14821](https://github.com/Kilo-Org/kilocode/pull/14821) [`e80e1a6`](https://github.com/Kilo-Org/kilocode/commit/e80e1a61bc75a393ff8db64d236d5fb6b0b6b3dd) - Fix cloud session history rendering prompts and replies in the wrong order by preserving parentID and sorting messages by creation time + +- Updated dependencies [[`07b18a1`](https://github.com/Kilo-Org/kilocode/commit/07b18a1a250ac7b1b257e4571e7b6050e3abd092), [`ab04799`](https://github.com/Kilo-Org/kilocode/commit/ab047999c7aa94d63d9743831d5d3a3a44862dee)]: + - @kilocode/kilo-gateway@7.8.4 + - @kilocode/kilo-ui@7.8.4 + - @opencode-ai/core@7.8.4 + - @kilocode/kilo-indexing@7.8.4 + - @opencode-ai/ui@7.8.4 + ## 7.8.2 ### Minor Changes diff --git a/packages/kilo-vscode/docs/features/background-agent-visibility.md b/packages/kilo-vscode/docs/features/background-agent-visibility.md index a5126a7fc30..19c13a18c4a 100644 --- a/packages/kilo-vscode/docs/features/background-agent-visibility.md +++ b/packages/kilo-vscode/docs/features/background-agent-visibility.md @@ -73,8 +73,8 @@ An agent counts as running only when its child session status is `busy` or `retr 3. `webview-ui/src/components/chat/background-agents.ts` Pure derivation for backend lifecycle rows and the running-task fallback. 4. `webview-ui/src/components/chat/BackgroundAgents.tsx` - The strip. Collapsed by default, reuses the `task-header-todos-*` slots, opens an agent through the shared helper. -5. `TaskHeader.tsx` renders `` above the to-do strip. + The strip. Collapsed by default, opens an agent through the shared helper. +5. `TaskHeader.tsx` renders ``. 6. `styles/task-header.css` adds `[data-component="task-header-agents"]`. 7. `i18n/*.ts` adds the localized `task.backgroundAgents.*` strings. 8. `tests/unit/background-agents.test.ts` covers lifecycle derivation, attention attribution, and promotion discovery. diff --git a/packages/kilo-vscode/docs/mercury-next-edit-testing.html b/packages/kilo-vscode/docs/mercury-next-edit-testing.html index 5772426f254..e3b2dc43cef 100644 --- a/packages/kilo-vscode/docs/mercury-next-edit-testing.html +++ b/packages/kilo-vscode/docs/mercury-next-edit-testing.html @@ -276,7 +276,7 @@
  • VSCode ≥ 1.105.1 (matches kilocode's engines.vscode)
  • - Bun ≥ 1.3.14 (the build script checks the version) — install via + Bun ≥ 1.4.2 (the build script checks the version) — install via brew install bun or bun.sh
  • GitHub CLI (gh) — optional but makes the PR checkout one command
  • diff --git a/packages/kilo-vscode/esbuild.js b/packages/kilo-vscode/esbuild.js index f081e4fa2df..1ad62d5dbaa 100644 --- a/packages/kilo-vscode/esbuild.js +++ b/packages/kilo-vscode/esbuild.js @@ -438,6 +438,7 @@ function getWebviewsConfig() { marketplace: "webview-ui/marketplace/index.tsx", "diff-viewer": "webview-ui/diff-viewer/index.tsx", documents: "webview-ui/documents/index.tsx", + "browser-tab": "webview-ui/browser-tab/index.tsx", "diff-virtual": "webview-ui/diff-virtual/index.tsx", webview: "webview-ui/src/index.tsx", }, @@ -526,15 +527,47 @@ function wasm() { async function main() { notices() wasm() + // Chunk hashes change between builds. Do not package stale Settings chunks. + fs.rmSync(path.join(__dirname, "dist", "settings"), { recursive: true, force: true }) const extensionConfig = getExtensionConfig() const webviewsConfig = getWebviewsConfig() + const settingsConfig = { + ...webviewsConfig, + entryPoints: { settings: "webview-ui/settings/index.tsx" }, + format: "esm", + splitting: true, + chunkNames: "settings/[name]-[hash]", + metafile: true, + plugins: [ + ...webviewsConfig.plugins, + { + name: "settings-preload", + setup(build) { + build.onEnd((result) => { + if (result.errors.length || !result.metafile) return + const files = new Set(["dist/settings.js"]) + for (const file of files) { + for (const item of result.metafile.outputs[file]?.imports ?? []) { + if (item.kind === "import-statement") files.add(item.path) + } + } + fs.writeFileSync( + path.join(__dirname, "dist", "settings-preload.json"), + JSON.stringify([...files].filter((file) => file !== "dist/settings.js").map((file) => file.slice(5))), + ) + }) + }, + }, + ], + } const shikiWorkerConfig = getShikiWorkerConfig() const markdownShikiWorkerConfig = getMarkdownShikiWorkerConfig() if (watch) { - const [extensionCtx, webviewsCtx, shikiWorkerCtx, markdownShikiWorkerCtx] = await Promise.all([ + const [extensionCtx, webviewsCtx, settingsCtx, shikiWorkerCtx, markdownShikiWorkerCtx] = await Promise.all([ esbuild.context(extensionConfig), esbuild.context(webviewsConfig), + esbuild.context(settingsConfig), esbuild.context(shikiWorkerConfig), esbuild.context(markdownShikiWorkerConfig), ]) @@ -542,6 +575,7 @@ async function main() { await Promise.all([ extensionCtx.watch(), webviewsCtx.watch(), + settingsCtx.watch(), shikiWorkerCtx.watch(), markdownShikiWorkerCtx.watch(), ]) @@ -549,6 +583,7 @@ async function main() { await Promise.all([ esbuild.build(extensionConfig), esbuild.build(webviewsConfig), + esbuild.build(settingsConfig), esbuild.build(shikiWorkerConfig), esbuild.build(markdownShikiWorkerConfig), ]) diff --git a/packages/kilo-vscode/knip.json b/packages/kilo-vscode/knip.json index b7a6a89520a..b94fd2fb068 100644 --- a/packages/kilo-vscode/knip.json +++ b/packages/kilo-vscode/knip.json @@ -3,6 +3,7 @@ "entry": [ "src/extension.ts", "webview-ui/agent-manager/index.tsx", + "webview-ui/browser-tab/index.tsx", "webview-ui/documents/index.tsx", "webview-ui/diff-viewer/index.tsx", "webview-ui/diff-virtual/index.tsx", @@ -10,6 +11,8 @@ "webview-ui/pierre-worker.ts", "webview-ui/src/assets.d.ts", "webview-ui/src/index.tsx", + "webview-ui/settings/index.tsx", + "webview-ui/src/context/language-dictionaries/*.ts", "src/**/__tests__/**/*.{ts,spec.ts}", "src/**/*.test.ts", "tests/**/*.{ts,mts}", diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 7b66a6b8870..9a5a3ed7910 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.8.3", + "version": "7.8.8", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -155,6 +155,12 @@ "category": "Kilo Code", "icon": "$(settings-gear)" }, + { + "command": "kilo-code.new.settingsSearch", + "title": "Search Settings", + "category": "Kilo Code", + "icon": "$(search)" + }, { "command": "kilo-code.new.sidebarTitle.plusButtonClicked", "title": "New Task", @@ -194,6 +200,12 @@ "dark": "assets/icons/kilo-dark.svg" } }, + { + "command": "kilo-code.new.openBrowser", + "title": "Open Integrated Browser", + "category": "Kilo Code", + "icon": "$(globe)" + }, { "command": "kilo-code.new.showChanges", "title": "Show Changes", @@ -246,12 +258,12 @@ }, { "command": "kilo-code.new.agentManager.previousTab", - "title": "Agent Manager: Previous Tab", + "title": "Previous Session Tab", "category": "Kilo Code" }, { "command": "kilo-code.new.agentManager.nextTab", - "title": "Agent Manager: Next Tab", + "title": "Next Session Tab", "category": "Kilo Code" }, { @@ -691,13 +703,13 @@ "command": "kilo-code.new.agentManager.previousTab", "key": "ctrl+alt+left", "mac": "cmd+alt+left", - "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + "when": "kilo-code.new.sidebarFocused || activeWebviewPanelId == 'kilo-code.new.TabPanel' || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" }, { "command": "kilo-code.new.agentManager.nextTab", "key": "ctrl+alt+right", "mac": "cmd+alt+right", - "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + "when": "kilo-code.new.sidebarFocused || activeWebviewPanelId == 'kilo-code.new.TabPanel' || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" }, { "command": "kilo-code.new.agentManager.previousTerminal", @@ -887,6 +899,12 @@ "command": "kilo-code.new.autocomplete.nextEdit.dismiss", "key": "escape", "when": "editorTextFocus && !editorTabMovesFocus && !inSnippetMode && kilo-code.nextEdit.hasPendingSuggestion" + }, + { + "command": "kilo-code.new.settingsSearch", + "key": "ctrl+f", + "mac": "cmd+f", + "when": "activeWebviewPanelId == 'kilo-code.new.settingsPanel'" } ], "configuration": { @@ -1075,12 +1093,6 @@ "scope": "application", "description": "Pre-warm a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space equal to one checkout per open project. Turn off to create worktrees only on demand." }, - "kilo-code.new.experimental.multiProject": { - "type": "boolean", - "default": false, - "scope": "application", - "description": "Allow Agent Manager to manage sessions and worktrees across multiple repositories as projects. The current workspace repository is always the default project; additional projects can be added and persist across restarts." - }, "kilo-code.new.experimental.browserAutomation": { "type": "boolean", "default": false, @@ -1168,6 +1180,20 @@ "scope": "application", "description": "Use system-installed Google Chrome for the Integrated Browser. Disable only when a compatible Playwright Chromium browser is already installed." }, + "kilo-code.new.agentManager.browser.openLinksIn": { + "type": "string", + "enum": [ + "external", + "integrated" + ], + "enumDescriptions": [ + "Open web links in your default browser.", + "Open web links in a browser tab from the sidebar, or in the browser panel from Agent Manager." + ], + "default": "integrated", + "scope": "application", + "description": "Choose where web links open from Kilo chats. Requires the experimental Integrated Browser." + }, "kilo-code.new.attention.enabled": { "type": "boolean", "default": false, @@ -1242,6 +1268,11 @@ "default": true, "description": "Show the task timeline graph in the chat header" }, + "kilo-code.new.showShortcutHints": { + "type": "boolean", + "default": true, + "description": "Show the keyboard shortcut that fits what you do now in the empty prompt, for example how to add selected code or return to the prompt." + }, "kilo-code.new.showTokenThroughput": { "type": "boolean", "default": true, @@ -1350,7 +1381,7 @@ "@types/ws": "8.18.1", "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.5.2", - "@vscode/vsce": "^3.7.1", + "@vscode/vsce": "^4.0.0", "babel-preset-solid": "^1.9.9", "esbuild": "^0.27.2", "esbuild-plugin-solid": "^0.6.0", @@ -1402,7 +1433,7 @@ "openai": "^4.85.4", "playwright-core": "1.57.0", "quick-lru": "^7.0.0", - "simple-git": "3.36.0", + "simple-git": "4.0.2", "solid-js": "^1.9.11", "uri-js": "^4.4.1", "virtua": "catalog:", diff --git a/packages/kilo-vscode/script/publish.ts b/packages/kilo-vscode/script/publish.ts index 7b49cd49dba..41807280ce0 100755 --- a/packages/kilo-vscode/script/publish.ts +++ b/packages/kilo-vscode/script/publish.ts @@ -4,6 +4,7 @@ import { join } from "node:path" import { existsSync } from "node:fs" import { Script } from "@opencode-ai/script" import { Manifest, Policy } from "../../../script/kilocode/sbom/index" +import { upload } from "../../../script/kilocode/release" import { CHECKSUMS } from "./sbom" const prerelease = process.env.KILO_PRE_RELEASE === "true" @@ -71,7 +72,7 @@ for (const target of targets) { if (Script.release) { console.log(`\n📤 Uploading VSIX files to GitHub release v${Script.version}...`) - await $`gh release upload v${Script.version} ${[...vsixFiles, ...evidence]} --clobber` + await upload({ tag: `v${Script.version}`, files: [...vsixFiles, ...evidence] }) console.log(` ✅ Uploaded all VSIX files and SBOM evidence to GitHub release`) } diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index a0598d12159..2fd84d0accd 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1,5 +1,5 @@ import * as path from "path" -import { existsSync } from "fs" +import { existsSync, readFileSync } from "fs" import * as vscode from "vscode" import { TRANSIENT as MEMORY_TRANSIENT } from "@kilocode/kilo-memory/schema" import type { @@ -59,7 +59,10 @@ import { resolveProjectDirectory } from "./project-directory" import { seedSessionStatuses, seedSessionWakeups, clientSessionStatus } from "./session-status" import { normalizeEnhancePromptErrorMessage } from "./enhance-prompt-error" import { retry } from "./services/cli-backend/retry" -import { integratedBrowserUseSystemChrome } from "./services/browser-automation/chrome-setting" +import { + integratedBrowserLinkDestination, + integratedBrowserUseSystemChrome, +} from "./services/browser-automation/chrome-setting" import { removeAgent } from "./services/agent-removal" import { normalize, type SSEPayload, type SyncPayload, type WirePayload } from "./services/cli-backend/sdk-sse-adapter" import { slimInfo, slimPart, slimParts } from "./kilo-provider/slim-metadata" @@ -97,7 +100,6 @@ import { } from "./services/autocomplete/settings" import { routeEarlyMessage } from "./kilo-provider/early-message" import * as Board from "./kilo-provider/session-board" -import * as ModelState from "./kilo-provider/model-state" import { handleModelUsageMessage } from "./kilo-provider/model-usage" import { handleForkSession } from "./kilo-provider/fork-session" import { openConfig } from "./kilo-provider/open-config" @@ -108,6 +110,9 @@ import { watchWorkStyleConfig, } from "./kilo-provider/work-style" import * as McpOAuth from "./kilo-provider/mcp-oauth" +import { mcpAuth } from "./services/mcp-auth" +import { mcpRemoval } from "./services/mcp-removal" +import { marketplaceBundles } from "./services/marketplace/bundles" import { retryable, backoff, MAX_RETRIES } from "./util/retry" import { hasGit } from "./kilo-provider/git-status" import { @@ -163,6 +168,7 @@ import { resolveStoredKey, } from "./provider-actions" import type { StoredProviderKey } from "./provider-actions" +import { createCatalogRetry } from "./kilo-provider/catalog-retry" import { AnacondaDesktopBridge } from "./anaconda-desktop/bridge" import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models" import type { Agent } from "@kilocode/sdk/v2/client" @@ -208,6 +214,12 @@ import { watchAutoApprovalReasonConfig, } from "./kilo-provider/auto-approval-reason-settings" import { buildPushFixesSettingMessage, pushFixes, watchPushFixesConfig } from "./kilo-provider/push-fixes-settings" +import { + buildShortcutHintsSettingMessage, + shortcutHints, + watchShortcutHintsConfig, +} from "./kilo-provider/shortcut-hints-settings" +import { buildShortcutContextMessage, watchShortcutContext } from "./kilo-provider/shortcut-context" type ReviewCommentsHandler = (comments: unknown[], autoSend: boolean, sessionID?: string, directory?: string) => void @@ -222,6 +234,19 @@ type TypedWebviewMessage = { type WebviewMessage = Parameters[0]>[0] +/** Webview messages handled by `KiloProvider.handleMcpMessage`, routed ahead of the main message switch. */ +const MCP_MESSAGE_TYPES = new Set([ + "removeMcp", + "requestMcpStatus", + "connectMcp", + "disconnectMcp", + "requestMcpAuthState", + "signInMcp", + "cancelMcpSignIn", + "resetMcpAuth", + "requestMcpBundles", +]) + function feedbackMessage(message: { text: string; review?: unknown; browserFeedback?: unknown }) { return parseFeedback({ review: message.review, browserFeedback: message.browserFeedback }, message.text) } @@ -423,6 +448,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private providersQueued = false private providersRetry = false private providersGeneration = 0 + /** Re-fetch providers while an organization's Kilo catalog is unavailable. */ + private readonly catalogRetry = createCatalogRetry({ refresh: () => void this.fetchAndSendProviders() }) private sandboxRevision = 0 private cachedAgentsMessage: unknown = null /** Cached skillsLoaded payload so requestSkills can be served before client is ready */ @@ -443,6 +470,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private cachedImageModelsMessage: unknown = null /** Cached mcpStatusLoaded payload so requestMcpStatus can be served before client is ready */ private cachedMcpStatusMessage: unknown = null + /** Cached mcpBundles payload so requestMcpBundles can be served before client is ready */ + private cachedMcpBundlesMessage: unknown = null /** Ref-count of in-flight handleUpdateConfig calls; prevents fetchAndSendConfig from sending stale data */ private pending = 0 private configWarningsShown = false @@ -505,6 +534,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) private unsubscribeEvent: (() => void) | null = null private unsubscribeState: (() => void) | null = null + private unsubscribeMcpAuth: (() => void) | null = null + private mcpAuthRefresh: Promise | undefined + private unsubscribeMcpRemoval: (() => void) | null = null private migrationCache: MigrationContext["migrationCache"] = new Map() private unsubscribeNotificationDismiss: (() => void) | null = null private unsubscribeAcknowledged: (() => void) | null = null @@ -523,6 +555,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private throughputConfigDisposable: vscode.Disposable | null = null private autoApprovalReasonConfigDisposable: vscode.Disposable | null = null private pushFixesConfigDisposable: vscode.Disposable | null = null + private shortcutHintsConfigDisposable: vscode.Disposable | null = null + private shortcutContextDisposable: vscode.Disposable | null = null private telemetryStateDisposable: vscode.Disposable | null = null private viewStateDisposable: vscode.Disposable | null = null private visibilityDisposable: vscode.Disposable | null = null @@ -564,6 +598,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private createWorktreeHandler: ((baseBranch?: string, branchName?: string) => Promise) | null = null + private openLinkHandler: ((url: string, sessionID?: string) => boolean) | null = null + private diffVirtualProvider: import("./DiffVirtualProvider").DiffVirtualProvider | undefined private diffViewerProvider: import("./diff/DiffViewerProvider").DiffViewerProvider | undefined private documentViewerProvider: import("./DocumentViewerProvider").DocumentViewerProvider | undefined @@ -581,6 +617,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.unsubscribeSandboxPreference = this.connectionService.sandboxPreference?.onChange(() => { if (this.connectionState === "connected") void this.fetchAndSendSandboxDefault() }) + this.unsubscribeMcpAuth = mcpAuth(this.connectionService).onChange((dir) => { + if (dir === this.getWorkspaceDirectory()) void this.refreshMcpAuthConsumers() + }) + this.unsubscribeMcpRemoval = mcpRemoval(this.connectionService).on((event) => { + if (!sameDirectory(event.directory, this.getWorkspaceDirectory())) return + if (event.phase === "removed") { + this.postMessage({ type: "mcpRemoved", name: event.name }) + return + } + if (event.phase === "installed") { + this.postMessage({ type: "mcpInstalled", name: event.name }) + this.postMessage({ type: "agentBehaviourInvalidated" }) + return + } + this.postMessage({ type: "mcpRemovalState", name: event.name, removing: event.phase === "removing" }) + }) TelemetryProxy.getInstance().setProvider(this) this.latch = watchRestore({ focused: () => vscode.window.state.focused, @@ -778,7 +830,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper refresh: async () => { this.cachedAgentsMessage = null this.cachedConfigMessage = null - await Promise.all([this.fetchAndSendAgents(), this.fetchAndSendConfig()]) + this.cachedSkillsMessage = null + this.cachedCommandsMessage = null + this.cachedMcpBundlesMessage = null + await Promise.all([ + this.fetchAndSendAgents(), + this.fetchAndSendConfig(), + this.fetchAndSendSkills(), + this.fetchAndSendCommands(), + this.fetchAndSendMcpBundles(), + this.fetchAndSendMcpStatus(), + ]) }, storage: this.extensionContext?.globalStorageUri, } @@ -912,15 +974,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } /** Register a session created externally and notify the webview. */ - public registerSession(session: Session, activate = false): void { + public registerSession( + session: Session, + activate = false, + project = this.opts.projectQualifier?.()?.projectId, + ): void { this.removedSessionIds.delete(session.id) - this.stopCurrentSessionProcesses(session.id) - this.setCurrentSession(session) - this.contextSessionID = session.id + if (project === this.opts.projectQualifier?.()?.projectId) { + this.stopCurrentSessionProcesses(session.id) + this.setCurrentSession(session) + this.contextSessionID = session.id + } this.trackedSessionIds.add(session.id) + this.trackDirectory(session.id, session.directory) this.postMessage({ type: "sessionCreated", - projectId: this.opts.projectQualifier?.()?.projectId, + projectId: project, session: this.sessionToWebview(session), ...(activate ? { activate: true } : {}), }) @@ -1138,6 +1207,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.createWorktreeHandler = handler } + public setOpenLinkHandler(handler: (url: string, sessionID?: string) => boolean): void { + this.openLinkHandler = handler + } + public attachToWebview( webview: vscode.Webview, options?: { onBeforeMessage?: (msg: Record) => Promise | null> }, @@ -1176,6 +1249,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.autoApprovalReasonConfigDisposable = watchAutoApprovalReasonConfig((msg) => this.postMessage(msg)) this.pushFixesConfigDisposable?.dispose() this.pushFixesConfigDisposable = watchPushFixesConfig((msg) => this.postMessage(msg)) + this.shortcutHintsConfigDisposable?.dispose() + this.shortcutHintsConfigDisposable = watchShortcutHintsConfig((msg) => this.postMessage(msg)) + this.shortcutContextDisposable?.dispose() + this.shortcutContextDisposable = watchShortcutContext(this.extensionContext, (msg) => this.postMessage(msg)) this.telemetryStateDisposable?.dispose() this.telemetryStateDisposable = watchTelemetryState((msg) => this.postMessage(msg)) this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => { @@ -1219,6 +1296,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } if (this.handleEditorOpenMessage(message)) return + if (await this.handleMcpMessage(message)) return if (await this.handleAgentManagerSettingsMessage(message)) return if (await this.handleAutoCleanupMessage(message)) return if ( @@ -1322,6 +1400,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await this.handleCreateSession() break case "clearSession": + this.loadMessagesAbort?.abort() this.stopCurrentSessionProcesses() this.contextSessionID = undefined this.setCurrentSession(null) @@ -1445,40 +1524,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "removeAgent": this.handleRemoveAgent(message.name).catch((e) => console.error("[Kilo New] handleRemoveAgent failed:", e)) break - case "removeMcp": - this.handleRemoveMcp(message.name).catch((e) => console.error("[Kilo New] handleRemoveMcp failed:", e)) - break - case "requestMcpStatus": - this.fetchAndSendMcpStatus().catch((e) => console.error("[Kilo New] fetchAndSendMcpStatus failed:", e)) - break - case "connectMcp": { - const c1 = this.client - if (c1) { - void McpOAuth.connectMcpServer(c1, message.name, this.getWorkspaceDirectory(), () => - this.refreshMcpStatus(), - ).catch((e) => console.error("[Kilo New] connectMcpServer failed:", e)) - } - break - } - case "disconnectMcp": { - const c2 = this.client - if (c2) { - void McpOAuth.disconnectMcpServer(c2, message.name, this.getWorkspaceDirectory(), () => - this.refreshMcpStatus(), - ).catch((e) => console.error("[Kilo New] disconnectMcpServer failed:", e)) - } - break - } - case "authenticateMcp": { - const c = this.client - if (c) { - void McpOAuth.authenticateMcpServer(c, message.name, this.getWorkspaceDirectory(), () => - this.refreshMcpStatus(), - ).catch((e) => console.error("[Kilo New] authenticateMcpServer failed:", e)) - } - break - } - case "questionReply": this.noteFollowup(message.answers, message.sessionID) if (!(await handleQuestionReply(this.questionCtx, message.requestID, message.answers, message.sessionID))) { @@ -1544,6 +1589,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "openSettingsTab": if (message.tab === "indexing") { await vscode.commands.executeCommand("kilo-code.new.openIndexingSettings") + } else if (message.tab === "agentBehaviour" && message.subtab === "mcpServers") { + await vscode.commands.executeCommand("kilo-code.new.openMcpSettings", message.focus) } break case "setLanguage": @@ -1850,6 +1897,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper dir: (sessionID) => this.getWorkspaceDirectory(sessionID ?? this.currentSession?.id), diff: this.diffVirtualProvider, openPRComment: (comment, sessionID) => this.openChanges(sessionID, undefined, comment), + openLink: this.openLinkHandler ? (url) => this.openLinkHandler!(url, this.currentSession?.id) : undefined, openMarkdown: (file, sessionID) => { if (!this.documentViewerProvider) return false this.documentViewerProvider.openFromCommand({ @@ -2157,6 +2205,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.sendTimelineSetting() this.postMessage(buildThroughputSettingMessage()) this.postMessage(buildAutoApprovalReasonSettingMessage()) + this.postMessage(buildShortcutContextMessage(this.extensionContext)) this.postMessage({ type: "extensionDataReady" }) console.log("[Kilo New] KiloProvider: ✅ initializeConnection completed successfully") @@ -2190,23 +2239,26 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const workspaceDir = this.getContextDirectory() + const project = this.opts.projectQualifier?.()?.projectId const metadata = await sandboxSessionMetadata(this.connectionService.sandboxPreference, this.client, workspaceDir) const { data: session } = await this.client.session.create( { directory: workspaceDir, platform: this.opts.platform, metadata }, { throwOnError: true }, ) - this.stopCurrentSessionProcesses(session.id) - this.setCurrentSession(session) - this.contextSessionID = session.id - this.focusSession(session.id) + if (project === this.opts.projectQualifier?.()?.projectId) { + this.stopCurrentSessionProcesses(session.id) + this.setCurrentSession(session) + this.contextSessionID = session.id + this.focusSession(session.id) + } this.trackDirectory(session.id, workspaceDir) this.trackedSessionIds.add(session.id) // Notify webview of the new session this.postMessage({ type: "sessionCreated", - projectId: this.opts.projectQualifier?.()?.projectId, - session: this.sessionToWebview(this.currentSession!), + projectId: project, + session: this.sessionToWebview(session), }) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to create session:", error) @@ -2308,6 +2360,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (mode === "replace" || mode === "focus") { this.trackedSessionIds.add(sessionID) if (options.focus !== false) { + this.loadMessagesAbort?.abort() this.stopCurrentSessionProcesses(sessionID) this.focusSession(sessionID) this.contextSessionID = sessionID @@ -2328,7 +2381,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Replace competes for the spinner and cancels earlier loads; prepend/reconcile run in parallel. const abort = mode === "replace" && options.focus !== false ? new AbortController() : undefined if (abort) { - this.loadMessagesAbort?.abort() this.loadMessagesAbort = abort } const revision = this.revisions.get(sessionID) @@ -2486,6 +2538,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper */ private getSessionRefreshContext(revision: number): SessionRefreshContext { const client = this.client + const project = this.opts.projectQualifier?.() return { pendingSessionRefresh: this.pendingSessionRefresh, connectionState: this.connectionState, @@ -2499,7 +2552,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper worktreeDirectories: this.opts.worktreeDirectories, workspaceDirectory: this.getWorkspaceDirectory(), isCurrent: () => revision === this.sessionRefreshRevision, - postMessage: (msg: unknown) => this.postMessage(msg), + postMessage: (msg: unknown) => + this.postMessage(project && typeof msg === "object" && msg !== null ? { ...msg, ...project } : msg), } } @@ -2817,10 +2871,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } try { - const { response, authMethods, authStates, storedKeys, organizationId, ready } = await fetchProviderData( - client, - this.getWorkspaceDirectory(), - ) + const { response, authMethods, authStates, storedKeys, organizationId, ready, unavailable } = + await fetchProviderData(client, this.getWorkspaceDirectory()) if (generation !== this.providersGeneration || client !== this.client) { if (!this.providersQueued) return generation = this.providersGeneration @@ -2842,11 +2894,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ), authMethods, authStates, + kiloUnavailable: unavailable, } this.cachedProvidersMessage = message this.cachedProvidersDirectory = this.settingsDirectory() this.providersRetry = false this.postMessage(message) + this.catalogRetry.update(unavailable) } catch (error) { if (generation !== this.providersGeneration) { if (!this.providersQueued) return @@ -2855,6 +2909,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } // A rejected fetch leaves nothing cached, so retry on the next connect. if (!this.cachedProvidersMessage) this.providersRetry = true + // Keep backing off while the last loaded Kilo catalog was unavailable. + const last = this.cachedProvidersMessage as { kiloUnavailable?: boolean } | null + this.catalogRetry.update(last?.kiloUnavailable === true) console.error("[Kilo New] KiloProvider: Failed to fetch providers:", error) } if (!this.providersQueued) return @@ -3060,10 +3117,19 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } private async handleRemoveMcp(name: string): Promise { + const bus = mcpRemoval(this.connectionService) + const directory = this.getWorkspaceDirectory() + bus.emit({ directory, name, phase: "removing" }) const removed = await removeMcp(this.removeConfigItemCtx, name) if (!removed) { console.error("[Kilo New] KiloProvider: Failed to remove MCP server:", name) + } else { + bus.emit({ directory, name, phase: "removed" }) + // Clears the removed server's stale "needs sign-in" state via the same + // mcpAuth broadcast handleSignInMcp/handleResetMcpAuth already use. + await this.refreshMcpAuthConsumers() } + bus.emit({ directory, name, phase: "idle" }) } private async refreshMcpStatus(): Promise { @@ -3091,6 +3157,109 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + /** + * Handle the MCP status/connect and OAuth sign-in webview messages. + * Routed ahead of the main message switch (rather than as cases in it) to + * keep that function within its complexity budget. + * Returns true when the message belonged to this family and was consumed. + */ + private async handleMcpMessage(message: { type?: unknown; name?: unknown; notify?: unknown }): Promise { + if (typeof message.type !== "string" || !MCP_MESSAGE_TYPES.has(message.type)) return false + await this.routeMcpMessage(message.type, typeof message.name === "string" ? message.name : undefined, message) + return true + } + + private async routeMcpMessage(type: string, name: string | undefined, message: { notify?: unknown }): Promise { + const fail = (e: unknown) => console.error("[Kilo New] handleMcpMessage failed:", type, e) + if (type === "requestMcpStatus") return this.fetchAndSendMcpStatus().catch(fail) + if (type === "requestMcpAuthState") return this.refreshMcpAuthConsumers().catch(fail) + if (type === "requestMcpBundles") return this.fetchAndSendMcpBundles().catch(fail) + if (!name) return + if (type === "removeMcp") return this.handleRemoveMcp(name).catch(fail) + if (type === "signInMcp") return this.handleSignInMcp(name, message.notify !== false).catch(fail) + if (type === "resetMcpAuth") return this.handleResetMcpAuth(name).catch(fail) + if (type === "cancelMcpSignIn") { + await mcpAuth(this.connectionService).cancel(this.getWorkspaceDirectory(), name).catch(fail) + return + } + const client = this.client + if (!client) return + const dir = this.getWorkspaceDirectory() + const refresh = () => this.refreshMcpStatus() + if (type === "connectMcp") return McpOAuth.connectMcpServer(client, name, dir, refresh).catch(fail) + await McpOAuth.disconnectMcpServer(client, name, dir, refresh).catch(fail) + } + + /** Push the current MCP auth state (needs-auth / busy server names) for this provider's workspace directory. */ + private fetchAndSendMcpAuthState(): void { + const dir = this.getWorkspaceDirectory() + const auth = mcpAuth(this.connectionService) + const message = { + type: "mcpAuthState", + directory: dir, + needsAuth: auth.needsAuth(dir), + busy: auth.busy(dir), + } + this.postMessage(message) + } + + /** Keep Settings and prompt consumers synchronized when any webview completes MCP authentication. */ + private refreshMcpAuthConsumers(): Promise { + const active = this.mcpAuthRefresh + if (active) return active + const refresh = this.performMcpAuthRefresh().finally(() => { + if (this.mcpAuthRefresh === refresh) this.mcpAuthRefresh = undefined + }) + this.mcpAuthRefresh = refresh + return refresh + } + + private async performMcpAuthRefresh(): Promise { + const dir = this.getWorkspaceDirectory() + const status = await mcpAuth(this.connectionService).refreshStatus(dir) + this.fetchAndSendMcpAuthState() + if (status) { + const message = { type: "mcpStatusLoaded", status } + this.cachedMcpStatusMessage = message + this.postMessage(message) + } + this.postMessage({ type: "agentBehaviourInvalidated" }) + } + + private async handleSignInMcp(name: string, notify: boolean): Promise { + const dir = this.getWorkspaceDirectory() + const result = await mcpAuth(this.connectionService).signIn(dir, name) + this.postMessage({ type: "mcpAuthResult", name, status: result.status, error: result.error }) + await this.refreshMcpAuthConsumers() + if (notify) McpOAuth.notifySignInResult(name, result) + } + + private async handleResetMcpAuth(name: string): Promise { + const dir = this.getWorkspaceDirectory() + const reconnected = await mcpAuth(this.connectionService).reset(dir, name) + await this.refreshMcpAuthConsumers() + if (!reconnected) McpOAuth.notifyResetFailed(name) + } + + private async fetchAndSendMcpBundles(): Promise { + if (!this.client) { + if (this.cachedMcpBundlesMessage) { + this.postMessage(this.cachedMcpBundlesMessage) + } + return + } + + try { + const dir = this.getWorkspaceDirectory() + const bundles = await marketplaceBundles(this.client, dir) + const message = { type: "mcpBundles", bundles } + this.cachedMcpBundlesMessage = message + this.postMessage(message) + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to fetch MCP bundles:", error) + } + } + private async handleMemoryMessage(message: Record): Promise { return this.memory.handle(message) } @@ -4032,6 +4201,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } private async resolveSession(sessionID?: string, draftID?: string, context?: string, contextDirectory?: string) { if (!this.client) return undefined + const project = this.opts.projectQualifier?.()?.projectId // Agent Manager: consult the shared route service for an exact directory // before the legacy sessionDirectories/workspace-root resolution. When a @@ -4088,15 +4258,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await this.client!.session.delete({ sessionID: session.id, directory: dir }, { throwOnError: true }) return undefined } - this.stopCurrentSessionProcesses(session.id) - this.setCurrentSession(session) - this.contextSessionID = session.id - this.focusSession(session.id) + if (project === this.opts.projectQualifier?.()?.projectId) { + this.stopCurrentSessionProcesses(session.id) + this.setCurrentSession(session) + this.contextSessionID = session.id + this.focusSession(session.id) + } this.trackDirectory(session.id, dir) this.trackedSessionIds.add(session.id) this.postMessage({ type: "sessionCreated", - projectId: this.opts.projectQualifier?.()?.projectId, + projectId: project, session: this.sessionToWebview(session), draftID, }) @@ -4204,10 +4376,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return vscode.workspace.getConfiguration("kilo-code.new").get("languageCommitMessage", "sync") } - private multiProjectSetting(): boolean { - return vscode.workspace.getConfiguration("kilo-code.new.experimental").get("multiProject", false) - } - private claudeMigrationSetting(): boolean { return vscode.workspace.getConfiguration("kilo-code.new.experimental").get("claudeMigration", false) } @@ -4297,15 +4465,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting(), - multiProject: this.multiProjectSetting(), claudeMigration: this.claudeMigrationSetting(), browserAutomation: this.browserAutomationSetting(), conversationPromptHistory: this.conversationPromptHistorySetting(), agentManagerBrowserUseSystemChrome: integratedBrowserUseSystemChrome(), + agentManagerBrowserOpenLinksIn: integratedBrowserLinkDestination(), "agentManager.autoBranchNaming": naming.get("autoBranchNaming", true), "agentManager.branchPrefix": naming.get("branchPrefix", ""), "agentManager.worktreePool": naming.get("worktreePool", true), "agentManager.pushFixes": pushFixes(), + showShortcutHints: shortcutHints(), } } @@ -5054,8 +5223,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage(buildThroughputSettingMessage()) this.postMessage(buildAutoApprovalReasonSettingMessage()) this.postMessage(buildPushFixesSettingMessage()) + this.postMessage(buildShortcutHintsSettingMessage()) + this.postMessage(buildShortcutContextMessage(this.extensionContext)) this.sendWorkStyle() - await ModelState.reset(this.client, (msg) => this.postMessage(msg)) // Re-send globalState items to the webview this.postMessage({ type: "variantsLoaded", variants: {} }) @@ -5347,11 +5517,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } - if (event.type === "mcp.browser.open.failed") { - McpOAuth.openMcpOAuthUrlOnce(event.properties.url) - return - } - if (event.type === "message.updated") { this.confirmations.confirm(event.properties.info.id) } @@ -5437,7 +5602,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const dir = typeof props?.directory === "string" ? props.directory : undefined if (dir) for (const sid of this.aborts.dispose(dir)) this.sessionStatusMap.set(sid, "idle") if (dir && !sameDirectory(dir, this.getWorkspaceDirectory())) return - void this.reloadAfterAuthChange() + void this.reloadAfterAuthChange().finally(() => this.postMessage({ type: "agentBehaviourInvalidated" })) return } @@ -5445,7 +5610,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Fetch and push the updated config + refresh agents and providers so the // Settings panel and mode/model pickers reflect the change. if (event.type === "global.config.updated") { - void Promise.all([this.fetchAndSendConfigUpdated(), this.fetchAndSendAgents(), this.fetchAndSendProviders()]) + void Promise.all([ + this.fetchAndSendConfigUpdated(), + this.fetchAndSendAgents(), + this.fetchAndSendProviders(), + ]).finally(() => this.postMessage({ type: "agentBehaviourInvalidated" })) + return + } + + // The model catalog was refreshed, so the picker's providers may be stale. + if (event.type === "models-dev.refreshed") { + void this.fetchAndSendProviders() return } @@ -6045,6 +6220,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } private _getHtmlForWebview(webview: vscode.Webview, sidebar = false): string { + const bundle = this.opts.settingsPanel ? "settings" : "webview" + const file = path.join(this.extensionUri.fsPath, "dist", "settings-preload.json") + const preloads: string[] = this.opts.settingsPanel && existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : [] return buildWebviewHtml(webview, { // The rail follows the physical workbench edge. RTL text direction must not move it between chat and code. sidebar: sidebar @@ -6052,8 +6230,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ? "right" : "left" : undefined, - scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.js")), - styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.css")), + scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", `${bundle}.js`)), + styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", `${bundle}.css`)), iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")), workerUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "shiki-worker.js")), title: "Kilo Code", @@ -6065,6 +6243,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper topBar: this.opts.hideTopBar !== true && isCursorHost(), topBarSurface: this.opts.topBarSurface === "tab" ? "tab_title" : "sidebar_title", agentManagerSettings: this.opts.agentManagerSettings !== undefined, + settings: this.opts.settingsPanel?.(), + module: this.opts.settingsPanel !== undefined, + preloads: preloads.map((file) => webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", file))), }) } @@ -6124,6 +6305,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.setFocusTarget("other") this.latch?.dispose() this.latch = undefined + this.catalogRetry.dispose() this.unsubscribeRemote?.() this.streams.focus(undefined) this.connectionService.unregisterVisible(this.instanceId) @@ -6132,6 +6314,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.statsGitOps?.dispose() this.unsubscribeEvent?.() this.unsubscribeState?.() + this.unsubscribeMcpAuth?.() + this.unsubscribeMcpRemoval?.() this.unsubscribeNotificationDismiss?.() this.unsubscribeLanguageChange?.() this.unsubscribeProfileChange?.() @@ -6150,9 +6334,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.throughputConfigDisposable?.dispose() this.autoApprovalReasonConfigDisposable?.dispose() this.pushFixesConfigDisposable?.dispose() + this.shortcutHintsConfigDisposable?.dispose() + this.shortcutContextDisposable?.dispose() this.telemetryStateDisposable?.dispose() this.autoApproveBridge?.dispose() - this.marketplace.dispose() this.visibleTaskStreams.clear() this.inputs.dispose() this.streams.dispose() diff --git a/packages/kilo-vscode/src/MarketplacePanelProvider.ts b/packages/kilo-vscode/src/MarketplacePanelProvider.ts index 2149f76e531..f59778eb920 100644 --- a/packages/kilo-vscode/src/MarketplacePanelProvider.ts +++ b/packages/kilo-vscode/src/MarketplacePanelProvider.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode" import type { GlobalEvent, SessionStatus } from "@kilocode/sdk/v2/client" import { buildWebviewHtml, getWebviewFontSize } from "./utils" import { watchFontSizeConfig } from "./kilo-provider/font-size" -import { mapSSEEventToWebviewMessage } from "./kilo-provider-utils" +import { mapSSEEventToWebviewMessage, sameDirectory } from "./kilo-provider-utils" import { resolvePanelProjectDirectory } from "./project-directory" import { seedSessionStatuses } from "./session-status" import { type KiloConnectionService, ServerStartupError } from "./services/cli-backend" @@ -17,6 +17,9 @@ import { import type { InstallMarketplaceItemOptions, MarketplaceItem } from "./services/marketplace/types" import { TelemetryProxy } from "./services/telemetry" import { TelemetryEventName } from "./services/telemetry/types" +import { mcpAuth } from "./services/mcp-auth" +import { mcpRemoval } from "./services/mcp-removal" +import { notifySignInResult } from "./kilo-provider/mcp-oauth" interface MarketplaceMessage { type?: string @@ -25,6 +28,8 @@ interface MarketplaceMessage { url?: unknown event?: string properties?: Record + name?: string + notify?: boolean } export class MarketplacePanelProvider implements vscode.Disposable { @@ -103,7 +108,6 @@ export class MarketplacePanelProvider implements vscode.Disposable { dispose(): void { this.panel?.dispose() this.cleanup() - this.marketplace.dispose() } private attach(panel: vscode.WebviewPanel, project: string | null): void { @@ -150,6 +154,13 @@ export class MarketplacePanelProvider implements vscode.Disposable { if (event.type === "session.status") this.handleStatus(event) }, ), + mcpAuth(this.connection).onChange((dir) => { + if (dir === this.directory()) this.sendMcpAuthState() + }), + mcpRemoval(this.connection).on((event) => { + if (!sameDirectory(event.directory, this.directory())) return + if (event.phase === "removed" || event.phase === "installed") void this.fetchData() + }), ) void this.connect() } @@ -242,9 +253,42 @@ export class MarketplacePanelProvider implements vscode.Disposable { case "telemetry": if (msg.event) TelemetryProxy.capture(msg.event as TelemetryEventName, msg.properties) return + case "requestMcpAuthState": + case "signInMcp": + case "cancelMcpSignIn": + await this.handleMcpAuth(msg) + return } } + /** Dispatch the MCP OAuth sign-in messages, kept off `handle` to bound its complexity. */ + private async handleMcpAuth(msg: MarketplaceMessage): Promise { + if (msg.type === "requestMcpAuthState") { + this.sendMcpAuthState() + return + } + if (!msg.name) return + if (msg.type === "signInMcp") { + await this.signInMcp(msg.name, msg.notify !== false) + return + } + await mcpAuth(this.connection).cancel(this.directory(), msg.name) + } + + private sendMcpAuthState(): void { + const dir = this.directory() + const auth = mcpAuth(this.connection) + this.post({ type: "mcpAuthState", directory: dir, needsAuth: auth.needsAuth(dir), busy: auth.busy(dir) }) + } + + private async signInMcp(name: string, notify: boolean): Promise { + const dir = this.directory() + const result = await mcpAuth(this.connection).signIn(dir, name) + this.post({ type: "mcpAuthResult", name, status: result.status, error: result.error }) + this.sendMcpAuthState() + if (notify) notifySignInResult(name, result) + } + /** Ask the webview to open the install dialog for a queued suggestion, once it can receive it. */ private flushPendingInstall(): void { if (!this.pendingInstall || !this.ready) return @@ -274,7 +318,7 @@ export class MarketplacePanelProvider implements vscode.Disposable { const generation = ++this.generation try { const project = this.project ?? undefined - const data = await fetchMarketplaceData(this.marketplaceCtx, project, this.directory(), this.relevanceRoots()) + const data = await fetchMarketplaceData(this.marketplaceCtx, project, this.directory()) if (generation !== this.generation) return const dismissed = this.context.globalState.get("kilo.agentMigrationBannerDismissed") ?? false this.post({ type: "marketplaceData", ...data, showAgentMigrationBanner: !dismissed }) @@ -301,10 +345,20 @@ export class MarketplacePanelProvider implements vscode.Disposable { this.directory(), ) if (result.success) void vscode.window.showInformationMessage(`Successfully installed ${item.name}`) - this.post({ type: "marketplaceInstallResult", ...result }) + if (result.success && item.type === "mcp") { + mcpRemoval(this.connection).emit({ directory: this.directory(), name: item.id, phase: "installed" }) + } + const needsAuth = + result.success && item.type === "mcp" + ? (await mcpAuth(this.connection).refresh(this.directory())).includes(item.id) + : false + this.post({ type: "marketplaceInstallResult", ...result, needsAuth }) } private async remove(item: MarketplaceItem, scope: "project" | "global"): Promise { + const directory = this.directory() + const bus = item.type === "mcp" ? mcpRemoval(this.connection) : undefined + bus?.emit({ directory, name: item.id, phase: "removing" }) const result = await removeMarketplaceItem( this.marketplaceCtx, item, @@ -313,6 +367,12 @@ export class MarketplacePanelProvider implements vscode.Disposable { this.directory(), ) if (result.success) void vscode.window.showInformationMessage(`Successfully removed ${item.name}`) + // Mirror install()'s auth refresh so a removed server's stale "needs + // sign-in" state clears for every provider sharing this McpAuthService + // (sidebar, chat tabs, Settings) via its onChange broadcast. + if (result.success && item.type === "mcp") await mcpAuth(this.connection).refresh(directory) + if (result.success) bus?.emit({ directory, name: item.id, phase: "removed" }) + bus?.emit({ directory, name: item.id, phase: "idle" }) this.post({ type: "marketplaceRemoveResult", ...result }) } @@ -343,12 +403,6 @@ export class MarketplacePanelProvider implements vscode.Disposable { return this.project ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? os.homedir() } - private relevanceRoots(): vscode.Uri[] { - if (!this.project) return vscode.workspace.workspaceFolders?.map((folder) => folder.uri) ?? [] - const folder = vscode.workspace.workspaceFolders?.find((item) => item.uri.fsPath === this.project) - return [folder?.uri ?? vscode.Uri.file(this.project)] - } - private openExternal(raw: unknown): void { if (typeof raw !== "string") return const uri = vscode.Uri.parse(raw) diff --git a/packages/kilo-vscode/src/SettingsEditorProvider.ts b/packages/kilo-vscode/src/SettingsEditorProvider.ts index fff1a91d171..2d0690d5024 100644 --- a/packages/kilo-vscode/src/SettingsEditorProvider.ts +++ b/packages/kilo-vscode/src/SettingsEditorProvider.ts @@ -28,6 +28,9 @@ export class SettingsEditorProvider implements vscode.Disposable { private panels = new Map() private providers = new Map() private tabs = new Map() + private subtabs = new Map() + /** Pending deep-link focus (e.g. an MCP server name), consumed after the first navigate it is sent with. */ + private focuses = new Map() private projects = new Map() private remoteService: RemoteStatusService | null = null @@ -57,8 +60,10 @@ export class SettingsEditorProvider implements vscode.Disposable { return view } - openPanel(view: PanelView, tab?: string, projectId?: string): void { + openPanel(view: PanelView, tab?: string, projectId?: string, subtab?: string, focus?: string): void { if (tab) this.tabs.set(view, tab) + if (subtab) this.subtabs.set(view, subtab) + if (focus) this.focuses.set(view, focus) if (projectId) this.projects.set(view, projectId) else this.projects.delete(view) @@ -71,8 +76,11 @@ export class SettingsEditorProvider implements vscode.Disposable { type: "navigate", view, ...(tab ? { tab } : {}), + ...(subtab ? { subtab } : {}), + ...(focus ? { focus } : {}), ...(projectId ? { projectId } : {}), }) + this.focuses.delete(view) return } @@ -90,6 +98,16 @@ export class SettingsEditorProvider implements vscode.Disposable { this.wirePanel(panel, view, projectDirectory) } + /** Open (or reveal) the Settings panel and focus its search field. */ + focusSearch(): void { + this.openPanel("settings") + const provider = this.providers.get("settings") + if (!provider) return + void provider.waitForReady().then(() => { + provider.postMessage({ type: "action", action: "focusSettingsSearch" }) + }) + } + /** Re-wire a deserialized panel after extension restart. */ deserializePanel(panel: vscode.WebviewPanel): void { const view = SettingsEditorProvider.viewFromType(panel.viewType) @@ -112,6 +130,9 @@ export class SettingsEditorProvider implements vscode.Disposable { projectDirectory, hideTopBar: true, agentManagerSettings: view === "settings" ? this.agentManagerSettings : undefined, + settingsPanel: + view === "settings" ? () => ({ tab: this.tabs.get(view), projectId: this.projects.get(view) }) : undefined, + disableStatsPolling: view === "settings", }) if (this.remoteService) { provider.setRemoteService(this.remoteService) @@ -129,15 +150,16 @@ export class SettingsEditorProvider implements vscode.Disposable { // "Developer: Reload Webviews" which re-creates the JS context). const readyDisposable = panel.webview.onDidReceiveMessage((msg) => { if (msg.type === "webviewReady") { - // Small delay to let KiloProvider's own webviewReady handler finish first - setTimeout(() => { - provider.postMessage({ - type: "navigate", - view, - tab: this.tabs.get(view), - projectId: this.projects.get(view), - }) - }, 50) + const focus = this.focuses.get(view) + this.focuses.delete(view) + provider.postMessage({ + type: "navigate", + view, + tab: this.tabs.get(view), + subtab: this.subtabs.get(view), + focus, + projectId: this.projects.get(view), + }) } }) @@ -161,6 +183,8 @@ export class SettingsEditorProvider implements vscode.Disposable { this.panels.delete(view) this.providers.delete(view) this.tabs.delete(view) + this.subtabs.delete(view) + this.focuses.delete(view) this.projects.delete(view) }) } @@ -180,6 +204,8 @@ export class SettingsEditorProvider implements vscode.Disposable { this.panels.clear() this.providers.clear() this.tabs.clear() + this.subtabs.clear() + this.focuses.clear() this.projects.clear() } } diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 47e08b2ec86..0722abd4810 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -226,6 +226,7 @@ export class AgentManagerProvider implements Disposable { log: (...args) => this.log(...args), output: (msg) => this.outputChannel.appendLine(msg), activate: (ctx) => this.activateProject(ctx), + empty: () => this.pushEmptyState(), expand: (ctx) => this.initExpanded(ctx), ready: (ctx, opts) => initContextState(ctx, (...args) => this.log(...args), opts), push: () => this.pushProjects(), @@ -380,6 +381,7 @@ export class AgentManagerProvider implements Disposable { worktreeDirectories: () => this.getWorktreeDirectories(), workspaceRoot: () => this.getRoot(), projectId: () => this.contexts.active()?.id, + sessionProject: () => this.sessionProject(), }) this.attachPanel(panel) if (!preserveFocus) focusPanelPrompt(panel, this.waitForPanelReady(panel), this.waitForPanelActive(panel)) @@ -433,6 +435,7 @@ export class AgentManagerProvider implements Disposable { this.pushProjects() void this.sendRepoInfo() this.sendKeybindings() + const keys = this.host.onDidChangeKeybindings?.(() => this.sendKeybindings()) void ctx.waitForReady().then(() => this.browserLifecycle.replay()) this.prBridge.attachPanel(ctx) ctx.onDidDispose(() => { @@ -455,6 +458,7 @@ export class AgentManagerProvider implements Disposable { void this.terminalRouter.dispose() this.onVisibilityChange?.(false) } + keys?.dispose() ctx.sessions.dispose() }) } @@ -796,9 +800,8 @@ export class AgentManagerProvider implements Disposable { } if (m.type === "agentManager.setSessionsCollapsed") { this.state?.setSessionsCollapsed(m.collapsed) - // Multi-project bodies render collapsed purely from pushed state, so the - // mutation must round-trip; legacy mode is covered by its optimistic - // signal and the push is a no-op update. + // Project bodies render collapsed from pushed state, so the mutation + // must round-trip. this.pushState() return null } @@ -1069,7 +1072,7 @@ export class AgentManagerProvider implements Disposable { this.pushState() this.postToWebview({ type: "agentManager.worktreeSetup", - projectId: this.host.multiProject() ? this.context?.id : undefined, + projectId: this.context?.id, status: "ready", message: "Worktree ready", sessionId, @@ -1102,7 +1105,7 @@ export class AgentManagerProvider implements Disposable { directory, { byDirectory: (value) => this.contexts.byDirectory(value), - usable: (id) => this.contexts.usable(id), + usable: (id) => this.contexts.resolve(id), }, this.projectScope, (req) => this.startToolRequest(req), @@ -1312,12 +1315,13 @@ export class AgentManagerProvider implements Disposable { // Repo info private async sendRepoInfo(): Promise { + const ctx = this.context const manager = this.getWorktreeManager() if (!manager) return try { const branch = await manager.currentBranch() const defaultBranch = await manager.defaultBranch() - this.postToWebview({ type: "agentManager.repoInfo", branch, defaultBranch, projectId: this.context?.id }) + this.postToWebview({ type: "agentManager.repoInfo", branch, defaultBranch, projectId: ctx?.id }) } catch (error) { this.log(`Failed to get current branch: ${error}`) } @@ -1530,7 +1534,6 @@ export class AgentManagerProvider implements Disposable { */ private runKey(worktreeId: string): string { if (worktreeId !== "local") return worktreeId - if (!this.host.multiProject()) return worktreeId const ctx = this.context if (!ctx) return worktreeId return `${ctx.id}:local` @@ -1548,10 +1551,7 @@ export class AgentManagerProvider implements Disposable { const ids = new Set((ctx.peekState()?.getWorktrees() ?? []).map((wt) => wt.id)) const localKey = `${ctx.id}:local` const runStatuses = state.runStatuses - .filter( - (status) => - ids.has(status.worktreeId) || status.worktreeId === localKey || (status.worktreeId === "local" && ctx.pinned), - ) + .filter((status) => ids.has(status.worktreeId) || status.worktreeId === localKey) .map((status) => (status.worktreeId === localKey ? { ...status, worktreeId: "local" } : status)) return { ...state, runStatuses } } @@ -1576,9 +1576,7 @@ export class AgentManagerProvider implements Disposable { private messageProject(m: AgentManagerInMessage): ProjectContext | undefined { const pid = (m as { projectId?: unknown }).projectId if (typeof pid !== "string") return this.contexts.active() - // Re-check trust and enablement on every project-stamped message: a context - // instance can be cached before trust is confirmed, and get() checks neither. - return this.contexts.usable(pid) + return this.contexts.resolve(pid) } private activateProject(ctx: ProjectContext): void { @@ -1613,7 +1611,6 @@ export class AgentManagerProvider implements Disposable { void this.activity.sync() this.postToWebview({ type: "agentManager.projects", - multiProject: this.host.multiProject(), projects, }) if (this.panel) @@ -1764,6 +1761,10 @@ export class AgentManagerProvider implements Disposable { public workspaceRoot = () => this.getRoot() public projectId = () => this.contexts.active()?.id + + public sessionProject(): string | undefined { + return this.projectScope.current()?.id ?? this.contexts.active()?.id + } /** * Continue a sidebar session in a new worktree. * Captures git state, creates worktree, applies state, forks session. diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index d5906af94a5..048df70485c 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -3,7 +3,7 @@ import * as os from "os" import * as fs from "fs/promises" import { spawn } from "../util/process" import type { GitExecutable } from "../util/git-executable" -import simpleGit from "simple-git" +import { simpleGit } from "simple-git" import { parseWorktreeList, normalizePath, @@ -91,6 +91,7 @@ export function nonInteractiveEnv(): NodeJS.ProcessEnv { delete env.SSH_ASKPASS delete env.GIT_ASKPASS delete env.EDITOR + delete env.VISUAL delete env.GIT_EDITOR delete env.GIT_SEQUENCE_EDITOR delete env.PAGER diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index 591d952f0d8..d81905205c1 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -9,14 +9,14 @@ import * as path from "path" import * as fs from "fs" import { createHash, randomUUID } from "crypto" -import simpleGit, { type SimpleGit } from "simple-git" +import { simpleGit, type SimpleGit } from "simple-git" import { generateBranchName, sanitizeBranchName } from "./branch-name" import { type GitOps, isKiloOwnedSshCommand, nonInteractiveEnv } from "./GitOps" import { execWithShellEnv } from "./shell-env" import { execGhRead } from "./gh" import { markNoIndex } from "../util/spotlight" import { BUDGET, isTimeout } from "./command-budget" -import { WorktreePool, type PoolStart } from "./worktree-pool" +import { WorktreePool, type PoolStart } from "./pool/pool" import { parsePRUrl, localBranchName, @@ -157,6 +157,7 @@ export class WorktreeManager { ops?: GitOps, binary?: string, poolSize: number | (() => number) = 1, + home?: string, ) { this.root = root this.dir = path.join(root, KILO_DIR, "worktrees") @@ -166,8 +167,11 @@ export class WorktreeManager { this.log = log this.pool = new WorktreePool({ root, - dir: this.dir, + home, + local: this.dir, + folder: directory, poolSize, + rewarm: () => this.rewarmDelay, log, client: (cwd) => this.client(cwd), lock: (fn) => this.withGitLock(fn), @@ -234,9 +238,10 @@ export class WorktreeManager { return result } - private client(cwd: string, ssh = false): SimpleGit { + private client(cwd: string, ssh = false, allow: readonly string[] = []): SimpleGit { return simpleGit(cwd, { binary: this.binary, + allowEnvironment: allow, unsafe: { allowUnsafeCustomBinary: this.binary !== "git", allowUnsafeSshCommand: ssh, @@ -275,16 +280,9 @@ export class WorktreeManager { this.pool.warm(base) } - /** Adopt leftover pooled slots at startup and discard broken ones. */ + /** Adopt leftover pooled slots at startup and discard broken ones. Never creates `.kilo/worktrees/`. */ async reconcilePool(): Promise { await this.ensureMigrated() - // With the pool disabled there is nothing to warm, so never create the - // directory. Leftover pooled slots are still adopted and removed. - if (!this.pool.enabled()) return this.pool.reconcile() - // Exclude before creating anything: a repository that cannot be excluded - // must not leave an untracked `.kilo/worktrees` directory behind. - await this.ensureGitExclude() - await this.ensureDir() return this.pool.reconcile() } @@ -331,33 +329,16 @@ export class WorktreeManager { return { resolvedRemote } } - /** Claim a pooled slot for a new branch and schedule a replacement warm-up. */ + /** Claim a pooled slot for a new branch. The pool moves it into `.kilo/worktrees/` and warms a replacement. */ private async tryClaimPool( branch: string, oid: string, auto: boolean, base?: string, ): Promise<{ path: string; branch: string } | undefined> { - const slot = await this.pool.claim(branch, oid, auto) - if (!slot) return undefined - setTimeout(() => this.pool.warm(base), this.rewarmDelay) - - // Keep the folder name aligned with the branch, as the normal path does. - const target = path.join(this.dir, directory(slot.branch)) - if (target === slot.path || fs.existsSync(target)) { - this.log(`Reused pooled worktree: ${slot.path} (branch: ${slot.branch})`) - return slot - } - const moved = await this.git - .raw(["worktree", "move", slot.path, target]) - .then(() => true) - .catch((error: unknown) => { - this.log(`Pooled worktree move failed, keeping ${slot.path}: ${error}`) - return false - }) - const result = moved ? { path: target, branch: slot.branch } : slot - this.log(`Reused pooled worktree: ${result.path} (branch: ${result.branch})`) - return result + const slot = await this.pool.claim(branch, oid, auto, base) + if (slot) this.log(`Reused pooled worktree: ${slot.path} (branch: ${slot.branch})`) + return slot } async renameBranch(worktreePath: string, current: string, requested: string): Promise { @@ -1313,11 +1294,19 @@ export class WorktreeManager { const cached = WorktreeManager.fetchCache.get(key) if (cached && Date.now() - cached < WorktreeManager.FETCH_CACHE_TTL) return - // Only opt into simple-git's allowUnsafeSshCommand when the SSH command - // is the fixed value Kilo injects — never for an inherited one, which - // could be attacker-controlled. + // Only fetch with the fixed SSH command Kilo injects. An inherited one + // could be attacker-controlled, and dropping it would let ssh prompt. const env = nonInteractiveEnv() - await this.client(this.root, isKiloOwnedSshCommand(env)) + if (!isKiloOwnedSshCommand(env)) throw new Error("Refusing to fetch with an inherited GIT_SSH_COMMAND") + // simple-git rejects guarded variables (any GIT_* key, EDITOR, VISUAL, ...) + // passed through .env() unless they are listed in allowEnvironment. Allow + // only the keys Kilo sets and drop any other inherited GIT_* key, matching + // how simple-git strips them for every other call. + const allow = ["GIT_TERMINAL_PROMPT", "GIT_SSH_COMMAND"] + for (const key of Object.keys(env)) { + if (key.toUpperCase().startsWith("GIT_") && !allow.includes(key)) delete env[key] + } + await this.client(this.root, true, allow) .env(env) .raw(["fetch", "--quiet", "--no-tags", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`]) WorktreeManager.fetchCache.set(key, Date.now()) diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index 0acfcb98153..ae9a9a8ca39 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -58,6 +58,8 @@ export interface Worktree { autoNamePromptCount?: number /** Section this worktree belongs to, or undefined for ungrouped. */ sectionId?: string + /** Pinned worktrees show at the top of the sidebar. The section is kept for when the pin is removed. */ + pinned?: boolean } export interface Section { @@ -320,6 +322,18 @@ export class WorktreeStateManager { void this.save() } + /** Pin or unpin a worktree. Multi-version siblings follow so the group stays together. */ + setWorktreePinned(id: string, pinned: boolean): void { + const wt = this.worktrees.get(id) + if (!wt) return + for (const item of this.worktrees.values()) { + if (item.id !== id && (!wt.groupId || item.groupId !== wt.groupId)) continue + item.pinned = pinned || undefined + } + this.setNormalizedWorktreeOrder(this.worktreeOrder) + void this.save() + } + updateWorktreePR(id: string, prNumber?: number, prUrl?: string, prState?: string): void { const wt = this.worktrees.get(id) if (!wt) return @@ -483,10 +497,16 @@ export class WorktreeStateManager { void this.save() } + /** Top-level worktrees: ungrouped and pinned. Pinned worktrees render above sections. */ + private top(id: string): boolean { + const wt = this.worktrees.get(id) + return !!wt && (!wt.sectionId || wt.pinned === true) + } + private ordered(order: string[]): string[] { const idx = new Map(order.map((id, i) => [id, i] as const)) return [...this.worktrees.values()] - .filter((wt) => !wt.sectionId) + .filter((wt) => this.top(wt.id)) .sort((a, b) => (idx.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (idx.get(b.id) ?? Number.MAX_SAFE_INTEGER)) .map((wt) => wt.id) } @@ -511,7 +531,7 @@ export class WorktreeStateManager { const normalized = [ ...this.ordered(result), ...result.filter((id) => this.sections.has(id)), - ...result.filter((id) => this.worktrees.get(id)?.sectionId), + ...result.filter((id) => this.worktrees.has(id) && !this.top(id)), ] const changed = diff --git a/packages/kilo-vscode/src/agent-manager/base-update.ts b/packages/kilo-vscode/src/agent-manager/base-update.ts index 9a711a7c0dc..62ea38804d8 100644 --- a/packages/kilo-vscode/src/agent-manager/base-update.ts +++ b/packages/kilo-vscode/src/agent-manager/base-update.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "crypto" import type { ProjectContext } from "./project/context" import type { LifecycleHost } from "./provider-lifecycle" import type { Worktree } from "./WorktreeStateManager" @@ -104,7 +103,6 @@ export async function handleBaseUpdate( state, sessionID: id, text: baseUpdatePrompt(worktree, push), - messageID: randomUUID(), metadata: injectedMetadata(`Update from ${worktree.parentBranch}`), questions: "dismiss", model: msg.model, diff --git a/packages/kilo-vscode/src/agent-manager/browser-lifecycle.ts b/packages/kilo-vscode/src/agent-manager/browser-lifecycle.ts index bfc71ca42a4..520f95f1ab2 100644 --- a/packages/kilo-vscode/src/agent-manager/browser-lifecycle.ts +++ b/packages/kilo-vscode/src/agent-manager/browser-lifecycle.ts @@ -16,7 +16,7 @@ export function createBrowserLifecycle(input: { log: (...args: unknown[]) => void }) { const browser = input.browser ?? new BrowserBroker({ log: input.log }) - browser.bind( + const owner = browser.bind( (route: BrowserRoute) => { const directory = canonicalizePath(route.directory) const ctx = input.contexts().byDirectory(directory) @@ -36,6 +36,10 @@ export function createBrowserLifecycle(input: { }, ) const post = (state: BrowserState) => { + // Browser entries opened for sidebar or editor-tab sessions carry no Agent + // Manager project. The tab browser owns and renders those, so Agent Manager + // must not broadcast them or reveal its own panel. + if (!state.projectId) return const active = input.contexts().active() if ((state.status === "starting" || state.status === "loading") && state.projectId === active?.id) { input.openPanel() @@ -43,7 +47,14 @@ export function createBrowserLifecycle(input: { input.post(browserMessage(state)) } const off = browser.subscribe(post) - const frames = browser.frames((frame) => input.post({ type: "agentManager.browserFrame", ...frame })) + const frames = browser.frames((frame) => { + if (!frame.projectId) return + input.post({ type: "agentManager.browserFrame", ...frame }) + }) + const cursors = browser.cursors((cursor) => { + if (!cursor.projectId) return + input.post({ type: "agentManager.browserCursor", ...cursor }) + }) let current: Panel | undefined return { attach(panel: Panel): void { @@ -78,12 +89,14 @@ export function createBrowserLifecycle(input: { for (const session of browser.sessions()) this.close(session, projectId) }, closeAll(): Promise { - return Promise.all([...browser.sessions()].map((sessionId) => browser.close(sessionId))).then(() => undefined) + return browser.closeOwned(owner) }, dispose(): Promise { current = undefined off() frames() + cursors() + browser.unbind(owner) return browser.disposeAsync() }, } diff --git a/packages/kilo-vscode/src/agent-manager/browser-message.ts b/packages/kilo-vscode/src/agent-manager/browser-message.ts index ab3b339fe9d..96e35b5e4d8 100644 --- a/packages/kilo-vscode/src/agent-manager/browser-message.ts +++ b/packages/kilo-vscode/src/agent-manager/browser-message.ts @@ -1,4 +1,13 @@ -import { BrowserLaunchError, diagnostic, type BrowserBroker, type BrowserState } from "../services/browser-automation" +import { type BrowserBroker, type BrowserState } from "../services/browser-automation" +import { + browserControlMessage, + browserStatePayload, + controlBrowser, + controlBrowserFailure, + type BrowserControlKind, + type BrowserControlMessage, + type BrowserControlSurface, +} from "../services/browser-automation/browser-control" import type { AgentManagerInMessage, AgentManagerOutMessage } from "./types" import type { ProjectContexts } from "./project/contexts" import type { Host } from "./host" @@ -12,51 +21,10 @@ type Dependencies = { log: (...args: unknown[]) => void } -function position(message: BrowserMessage): { x: number; y: number; width: number; height: number } | undefined { - if ( - typeof message.x !== "number" || - typeof message.y !== "number" || - typeof message.width !== "number" || - typeof message.height !== "number" - ) { - return - } - return { x: message.x, y: message.y, width: message.width, height: message.height } -} +const prefix = "agentManager.browser." -function fail( - deps: { post: (message: AgentManagerOutMessage) => void }, - m: BrowserMessage, - error: string, - state?: BrowserState, - missing?: BrowserState["missing"], -): void { - if (m.type === "agentManager.browser.inspect") { - deps.post({ - type: "agentManager.browserInspection", - projectId: m.projectId, - sessionId: m.sessionId, - requestId: m.requestId ?? "", - hover: m.hover, - logs: [], - error, - }) - return - } - if (state) { - deps.post(browserMessage({ ...state, error })) - return - } - deps.post({ - type: "agentManager.browserState", - browserId: "", - projectId: m.projectId, - sessionId: m.sessionId, - status: "error", - errors: 0, - error, - missing, - }) +function control(message: BrowserMessage): BrowserControlMessage { + return browserControlMessage(message.type.slice(prefix.length) as BrowserControlKind, message) } function route(contexts: ProjectContexts, message: BrowserMessage): { project: string; directory: string } | undefined { @@ -71,177 +39,98 @@ function route(contexts: ProjectContexts, message: BrowserMessage): { project: s return directory ? { project: ctx.id, directory } : undefined } -function streaming(m: BrowserMessage, project: string, deps: Dependencies): boolean { - if (m.type === "agentManager.browser.viewport") { - if (!m.browserId || typeof m.navigation !== "number" || !Number.isInteger(m.navigation) || !m.viewport) return true - void deps.browser.viewport(m.sessionId, project, m.browserId, m.navigation, m.viewport).catch((error: unknown) => { - deps.log("Browser stream failed:", error) - fail(deps, m, diagnostic(error), deps.browser.get(m.sessionId, project)) - }) - return true - } - if (m.type === "agentManager.browser.acknowledge") { - if (m.identity && typeof m.sequence === "number") { - deps.browser.acknowledge(m.sessionId, project, m.identity, m.sequence) - } - return true - } - if (m.type !== "agentManager.browser.interact") return false - const identity = m.identity - const event = m.event - if (!identity || !event) return true - void (async () => { - if (!deps.browser.accepts(m.sessionId, project, identity)) return - if (event.kind === "clipboard") { - await deps.browser.interact( - m.sessionId, - project, - identity, - event, - deps.host.readClipboard?.bind(deps.host), - (text) => deps.host.copyToClipboard(text), - ) - return - } - await deps.browser.interact(m.sessionId, project, identity, event) - })().catch((error: unknown) => { - deps.log("Browser input failed:", error) - fail(deps, m, diagnostic(error), deps.browser.get(m.sessionId, project)) - }) - return true -} - -function action(m: BrowserMessage, deps: Dependencies): boolean { - const scope = route(deps.contexts, m) - if (!scope) { - fail(deps, m, "Browser session is not available in the selected project.") - return true - } - if (m.type === "agentManager.browser.state") { - const current = deps.browser.get(m.sessionId, scope.project) - if (current) deps.post(browserMessage(current)) - return true - } - if (streaming(m, scope.project, deps)) return true - if (m.type === "agentManager.browser.devtools") { - void deps.browser - .devtools(m.sessionId, scope.project, m.theme === "light" ? "light" : "dark") - .then((tools) => - deps.post({ - type: "agentManager.browserDevtools", - projectId: scope.project, - sessionId: m.sessionId, - ...tools, - }), - ) - .catch((error: unknown) => { - deps.log("Browser developer tools failed:", error) - fail(deps, m, diagnostic(error), deps.browser.get(m.sessionId, scope.project)) +function surface( + deps: Dependencies, + message: BrowserMessage, + projectId: string | undefined, + directory?: string, +): BrowserControlSurface { + return { + sessionId: message.sessionId, + projectId, + openDirectory: () => directory, + readClipboard: deps.host.readClipboard?.bind(deps.host), + writeClipboard: (text) => deps.host.copyToClipboard(text), + state: (state, error, missing) => { + if (state) { + deps.post(browserMessage(error ? { ...state, error } : state)) + return + } + deps.post({ + type: "agentManager.browserState", + browserId: "", + projectId, + sessionId: message.sessionId, + status: "error", + errors: 0, + error, + missing, }) - return true - } - if (m.type === "agentManager.browser.open") { - if (!m.url) return true - void deps.browser - .open({ projectId: scope.project, sessionId: m.sessionId, directory: scope.directory }, m.url, false) - .catch((error: unknown) => { - deps.log("Browser open failed:", error) - fail( - deps, - m, - diagnostic(error, m.url), - deps.browser.get(m.sessionId, scope.project), - error instanceof BrowserLaunchError ? error.missing : undefined, - ) - }) - return true - } - if (m.type === "agentManager.browser.back" || m.type === "agentManager.browser.forward") { - const direction = m.type === "agentManager.browser.back" ? "back" : "forward" - void deps.browser.history(m.sessionId, scope.project, direction).catch((error: unknown) => { - deps.log("Browser history navigation failed:", error) - const current = deps.browser.get(m.sessionId, scope.project) - if (current) deps.post(browserMessage(current)) - }) - return true - } - if (m.type === "agentManager.browser.refresh") { - void deps.browser.refresh(m.sessionId, scope.project, false).catch((error: unknown) => { - deps.log("Browser refresh failed:", error) - const current = deps.browser.get(m.sessionId, scope.project) - if (current) deps.post(browserMessage(current)) - }) - return true - } - if (m.type === "agentManager.browser.inspect" || m.type === "agentManager.browser.input") { - const point = position(m) - if (!point) { - fail(deps, m, "Browser element coordinates are required.", deps.browser.get(m.sessionId, scope.project)) - return true - } - if (m.type === "agentManager.browser.input") { - void deps.browser.input(m.sessionId, scope.project, point, m.click === true).catch((error: unknown) => { - deps.log("Browser developer tools input failed:", error) - fail(deps, { ...m, projectId: scope.project }, diagnostic(error), deps.browser.get(m.sessionId, scope.project)) - }) - return true - } - void deps.browser - .inspect(m.sessionId, scope.project, point, m.hover !== true) - .then((inspection) => + }, + inspection: ({ requestId, hover, value, error }) => { + if (value) { deps.post({ type: "agentManager.browserInspection", - projectId: scope.project, - sessionId: m.sessionId, - requestId: m.requestId ?? "", - ...inspection, - hover: m.hover, - }), - ) - .catch((error: unknown) => { - deps.log("Browser element inspection failed:", error) - fail(deps, { ...m, projectId: scope.project }, diagnostic(error)) + projectId, + sessionId: message.sessionId, + requestId, + ...value, + hover, + }) + return + } + deps.post({ + type: "agentManager.browserInspection", + projectId, + sessionId: message.sessionId, + requestId, + hover, + logs: [], + error, }) - return true + }, + devtools: (tools) => + deps.post({ type: "agentManager.browserDevtools", projectId, sessionId: message.sessionId, ...tools }), + log: deps.log, } - if (m.type !== "agentManager.browser.close") return false - void deps.browser - .close(m.sessionId, scope.project) - .catch((error: unknown) => deps.log("Browser close failed:", error)) - return true } export function handleBrowserMessage(message: AgentManagerInMessage, deps: Dependencies): boolean { - if (!message.type.startsWith("agentManager.browser.")) return false + if (!message.type.startsWith(prefix)) return false const m = message as BrowserMessage + const controlMessage = control(m) if (!deps.host.isTrusted()) { - fail(deps, m, "Browser preview requires a trusted workspace.") + controlBrowserFailure( + controlMessage, + surface(deps, m, m.projectId), + "Browser preview requires a trusted workspace.", + ) return true } if (!deps.host.browserAutomation()) { - fail(deps, m, "Browser automation is disabled. Enable it in Kilo Settings > Experimental.") + controlBrowserFailure( + controlMessage, + surface(deps, m, m.projectId), + "Browser automation is disabled. Enable it in Kilo Settings > Experimental.", + ) return true } - return action(m, deps) + const scope = route(deps.contexts, m) + if (!scope) { + controlBrowserFailure( + controlMessage, + surface(deps, m, m.projectId), + "Browser session is not available in the selected project.", + ) + return true + } + controlBrowser(controlMessage, deps.browser, surface(deps, m, scope.project, scope.directory)) + return true } export function browserMessage(state: BrowserState): AgentManagerOutMessage { return { type: "agentManager.browserState", - browserId: state.browserId, - projectId: state.projectId, - sessionId: state.sessionId, - navigation: state.navigation, - status: state.status, - inspecting: state.inspecting, - url: state.url, - title: state.title, - errors: state.errors, - logs: state.logs, - error: state.error, - missing: state.missing, - frameError: state.frameError, - back: state.back, - forward: state.forward, + ...browserStatePayload(state), } } diff --git a/packages/kilo-vscode/src/agent-manager/format-keybinding.ts b/packages/kilo-vscode/src/agent-manager/format-keybinding.ts index 07380289671..d46b632f903 100644 --- a/packages/kilo-vscode/src/agent-manager/format-keybinding.ts +++ b/packages/kilo-vscode/src/agent-manager/format-keybinding.ts @@ -22,15 +22,22 @@ const SPECIAL_KEYS: Record = { * Mac: "⌘⇧W" Windows/Linux: "Ctrl+Shift+W" */ export function formatKeybinding(raw: string, mac: boolean): string { - const symbols = raw - .split("+") - .map((p) => p.trim().toLowerCase()) - .map((part) => { - const mod = KEY_SYMBOLS[part] - if (mod) return mac ? mod.mac : mod.other - return SPECIAL_KEYS[part] ?? part.toUpperCase() + // Chords such as "cmd+k cmd+m" are formatted part by part. + return raw + .trim() + .split(/\s+/) + .map((chord) => { + const symbols = chord + .split("+") + .map((p) => p.trim().toLowerCase()) + .map((part) => { + const mod = KEY_SYMBOLS[part] + if (mod) return mac ? mod.mac : mod.other + return SPECIAL_KEYS[part] ?? part.toUpperCase() + }) + return mac ? symbols.join("") : symbols.join("+") }) - return mac ? symbols.join("") : symbols.join("+") + .join(" ") } /** Agent Manager command prefix for keybinding extraction. */ @@ -41,8 +48,23 @@ const GLOBAL_KEYBINDINGS: Record = { "kilo-code.new.agentManagerOpen": "agentManagerOpen", "kilo-code.new.cycleAgentMode": "cycleAgentMode", "kilo-code.new.cyclePreviousAgentMode": "cyclePreviousAgentMode", + "kilo-code.new.focusChatInput": "focusChatInput", + "kilo-code.new.addToContext": "addToContext", + "kilo-code.new.settingsSearch": "settingsSearch", } +/** [binding name, command suffix, key after the cmd/ctrl modifier] */ +const FALLBACKS: Array<[string, string, string]> = [ + ["search", "search", "f"], + ["runScript", "runScript", "e"], + ["toggleDiff", "toggleDiff", "d"], + ["showShortcuts", "showShortcuts", "shift+/"], + ["previousTerminal", "previousTerminal", "shift+["], + ["nextTerminal", "nextTerminal", "shift+]"], + ["newTerminalCenter", "newTerminalTab", "shift+t"], + ["newTerminalTerminal", "newSideTerminal", "t"], +] + function addBinding(bindings: Record, name: string, value: string, when?: string): void { if (name === "newTerminalTab" && when?.includes("!kilo-code.new.agentManagerSideTerminalFocused")) { bindings.newTerminalCenter = value @@ -86,18 +108,14 @@ export function buildKeybindingMap( addRawBinding(bindings, kb, mac) } - // Ensure fallback bindings are always present (may be missing from - // cached packageJSON if the extension hasn't been fully reloaded) - if (!bindings.search) bindings.search = formatKeybinding(mac ? "cmd+f" : "ctrl+f", mac) - if (!bindings.runScript) bindings.runScript = formatKeybinding(mac ? "cmd+e" : "ctrl+e", mac) - if (!bindings.toggleDiff) bindings.toggleDiff = formatKeybinding(mac ? "cmd+d" : "ctrl+d", mac) - if (!bindings.showShortcuts) bindings.showShortcuts = formatKeybinding(mac ? "cmd+shift+/" : "ctrl+shift+/", mac) - if (!bindings.previousTerminal) - bindings.previousTerminal = formatKeybinding(mac ? "cmd+shift+[" : "ctrl+shift+[", mac) - if (!bindings.nextTerminal) bindings.nextTerminal = formatKeybinding(mac ? "cmd+shift+]" : "ctrl+shift+]", mac) - if (!bindings.newTerminalCenter) - bindings.newTerminalCenter = formatKeybinding(mac ? "cmd+shift+t" : "ctrl+shift+t", mac) - if (!bindings.newTerminalTerminal) bindings.newTerminalTerminal = formatKeybinding(mac ? "cmd+t" : "ctrl+t", mac) + // Ensure fallback bindings are present when the command is missing from a + // cached packageJSON (extension not fully reloaded). A command the user + // unbound has a key-less entry, so its fallback is not added. + const removed = new Set(keybindings.filter((kb) => !kb.key && !kb.mac).map((kb) => kb.command)) + for (const [name, command, key] of FALLBACKS) { + if (bindings[name] || removed.has(AM_PREFIX + command)) continue + bindings[name] = formatKeybinding(mac ? `cmd+${key}` : `ctrl+${key}`, mac) + } return bindings } diff --git a/packages/kilo-vscode/src/agent-manager/git-transfer.ts b/packages/kilo-vscode/src/agent-manager/git-transfer.ts index 1beb223ce28..5f39aa5e632 100644 --- a/packages/kilo-vscode/src/agent-manager/git-transfer.ts +++ b/packages/kilo-vscode/src/agent-manager/git-transfer.ts @@ -118,18 +118,14 @@ export async function apply( log: (...args: unknown[]) => void, binary = "git", ): Promise<{ ok: boolean; error?: string }> { - // Apply staged patch first, then re-stage those files + // Apply staged changes to both the index and working tree, including rename sources. if (snapshot.staged) { - const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.staged, binary) + const result = await git(["apply", "--index", "--whitespace=nowarn", "-"], target, snapshot.staged, binary) if (result.code !== 0) { const msg = result.stderr.trim() || "Patch did not apply" log("Failed to apply staged patch:", msg) return { ok: false, error: `Staged patch failed: ${msg}` } } - const files = parsePatchFiles(snapshot.staged) - if (files.length > 0) { - await git(["add", "--", ...files], target, undefined, binary) - } } // Apply unstaged patch (leave as unstaged working-tree changes) @@ -155,13 +151,3 @@ export async function apply( return { ok: true } } - -/** Extract file paths from a unified diff's `diff --git a/... b/...` headers. */ -function parsePatchFiles(patch: string): string[] { - const files: string[] = [] - for (const line of patch.split("\n")) { - const match = /^diff --git a\/.+ b\/(.+)$/.exec(line) - if (match && match[1]) files.push(match[1]) - } - return files -} diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index 4687c5d648a..1b4ada3dc4b 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -125,6 +125,8 @@ export interface Host { /** Dynamic root directory for the panel's session provider (follows the active project). */ workspaceRoot?: () => string | undefined projectId?: () => string | undefined + /** Source of an externally created session, including async background work. */ + sessionProject?: () => string | undefined }): PanelContext /** Get the workspace/project root path. */ @@ -149,8 +151,6 @@ export interface Host { /** Clone without changing workspace membership; return the verified checkout path. */ cloneRepository(url: string, parent: string): Promise - /** Whether the experimental multi-project Agent Manager mode is enabled. */ - multiProject(): boolean browserAutomation(): boolean approveBrowserNavigation?(origin: string): Promise @@ -175,8 +175,6 @@ export interface Host { /** Subscribe to workspace folder changes (pinned project re-derivation). */ onDidChangeWorkspaceFolders(cb: () => void): Disposable - /** Subscribe to multi-project flag changes. */ - onDidChangeMultiProject(cb: (enabled: boolean) => void): Disposable /** Whether the workspace permits executing configured scripts. */ isTrusted(): boolean @@ -207,9 +205,12 @@ export interface Host { /** Create an output channel for logging. */ createOutput(name: string): OutputHandle - /** Read extension keybinding metadata. */ + /** Read extension keybindings, with the user's keybindings.json applied. */ extensionKeybindings(): Array<{ command: string; key?: string; mac?: string; when?: string }> + /** Notify when the user's keybindings.json changes. */ + onDidChangeKeybindings?(cb: () => void): Disposable + /** Copy text to the system clipboard. */ copyToClipboard(text: string): void | Promise readClipboard?(): Promise diff --git a/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts b/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts index c4a7858afbc..2b19a0f1769 100644 --- a/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts +++ b/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts @@ -425,7 +425,6 @@ export class AgentManagerOrchestrationBridge { attribute(reply ? peerReply(input.request, reply) : peerPrompt(input.request, input.origin), source), MAX_PROMPT, ), - messageID: input.request.id, signal: input.active.controller.signal, ...(reply ? { directory: reply.directory } : {}), ...(reply ? {} : { managed: this.options.resolve?.(input.request.targetSessionID, input.origin.directory) }), diff --git a/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts b/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts index 8dcb7dd5390..d0699a2060e 100644 --- a/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts +++ b/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts @@ -396,7 +396,6 @@ export async function prompt(input: { state: WorktreeStateManager sessionID: string text: string - messageID: string signal?: AbortSignal managed?: ManagedSession directory?: string @@ -415,7 +414,6 @@ export async function prompt(input: { { sessionID: input.sessionID, directory: target.dir, - messageID: `msg_agent_manager_${input.messageID}`, parts: [{ type: "text", text: input.text, ...(input.metadata ? { metadata: input.metadata } : {}) }], model: input.model, variant: input.variant, diff --git a/packages/kilo-vscode/src/agent-manager/pool/home.ts b/packages/kilo-vscode/src/agent-manager/pool/home.ts new file mode 100644 index 00000000000..3c7bfe3ecf5 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/pool/home.ts @@ -0,0 +1,56 @@ +/** + * Where pool slots live. Slots are only warmed in a per-user directory outside + * the project, so pre-warming never creates `.kilo/worktrees/`. A slot is a full + * checkout, and inside the project every tool that walks the tree sees it: + * `conda-build .` finds a second recipe, test runners collect duplicate tests, + * file watchers and language servers index a copy. Ignore rules do not help, + * because these tools do not read them. + * + * A claim moves the slot into `.kilo/worktrees/` with `git worktree move`, + * which is a plain rename. So a project on another filesystem gets no slot and + * creates its worktrees on demand. vscode-free. + */ + +import * as fs from "fs" +import * as os from "os" +import * as path from "path" +import { createHash } from "crypto" +import { sanitizeBranchName } from "../branch-name" +import { pathKey } from "../project/paths" +import { markNoIndex } from "../../util/spotlight" + +/** Per-user pool home next to the Kilo CLI data. Honors `XDG_DATA_HOME` and is never roaming on Windows. */ +export function poolHome(): string { + const data = process.env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), ".local", "share") + return path.join(data, "kilo", "worktree-pool") +} + +/** Stable slot directory for one repository root. The readable prefix helps users who inspect disk usage. */ +export function poolDir(home: string, root: string): string { + const slug = sanitizeBranchName(path.basename(root), 32) || "repo" + const hash = createHash("sha256").update(pathKey(root)).digest("hex").slice(0, 12) + return path.join(home, `${slug}-${hash}`) +} + +/** + * Return `shared` when a rename from it into the project can work, otherwise + * undefined, which means no slots for this project. Creates only the pool + * home, never anything in the project. + */ +export async function locate(root: string, shared: string, log: (msg: string) => void): Promise { + const home = path.dirname(shared) + const same = await fs.promises + .mkdir(home, { recursive: true }) + .then(() => Promise.all([fs.promises.stat(root), fs.promises.stat(home)])) + .then(([a, b]) => a.dev === b.dev) + .catch((e) => { + log(`worktree pool: cannot use ${home}: ${e}`) + return false + }) + if (!same) { + log(`worktree pool: ${home} is not on the filesystem of ${root}, not pre-warming worktrees`) + return undefined + } + await markNoIndex(home, log) + return shared +} diff --git a/packages/kilo-vscode/src/agent-manager/worktree-pool.ts b/packages/kilo-vscode/src/agent-manager/pool/pool.ts similarity index 58% rename from packages/kilo-vscode/src/agent-manager/worktree-pool.ts rename to packages/kilo-vscode/src/agent-manager/pool/pool.ts index 2ac76c82d68..83615a46b91 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-pool.ts +++ b/packages/kilo-vscode/src/agent-manager/pool/pool.ts @@ -3,22 +3,31 @@ * sessions can claim a ready worktree instead of paying the full * `git worktree add` checkout cost. * - * Slots are created at their final path under `.kilo/worktrees/` and tagged - * with pooled metadata. A later claim turns a - * slot into a named branch with a cheap ref update (exact match) or a bounded - * checkout (small delta). This module is vscode-free so it can be tested with a - * real temporary git repository. + * Slots are warmed in the per-user pool home (see `home.ts`), never in the + * project, and tagged with pooled metadata. A claim turns a slot into a named + * branch with a cheap ref update (exact match) or a bounded checkout (small + * delta), then moves it to `.kilo/worktrees/`. A claimed slot is never + * left in the pool home: when the move fails, the slot and its branch are + * removed and the caller falls back to a normal `git worktree add`. + * + * The pool also removes slots that older versions left in `.kilo/worktrees/`, + * and runs the machine-wide sweep (see `sweep.ts`) once per process. This + * module is vscode-free so it can be tested with a real temporary repository. */ import * as path from "path" import * as fs from "fs" import type { SimpleGit } from "simple-git" -import { generateBranchName } from "./branch-name" -import { normalizePath, parseWorktreeList } from "./git-import" +import { generateBranchName } from "../branch-name" +import { normalizePath, parseWorktreeList } from "../git-import" +import { locate, poolDir } from "./home" +import { METADATA_FILE, alive, readMeta, subdirs, type PoolMeta } from "./slot" +import { sweep } from "./sweep" -const METADATA_FILE = "kilo-agent-manager-metadata.json" /** Maximum commits between a slot base and the requested base for a delta claim. */ const MAX_DELTA = 50 +/** Spotlight marker that `.kilo/worktrees/` can hold without any worktree in it. */ +const MARKER = ".metadata_never_index" export interface PoolStart { ref: string @@ -28,9 +37,16 @@ export interface PoolStart { export interface PoolDeps { root: string - dir: string + /** Per-user pool home shared by every repository. Without it, no slots are pre-warmed. */ + home?: string + /** The project's `.kilo/worktrees/`. Claimed slots move here, and older versions left slots here. */ + local: string + /** Folder name in {@link local} for a branch. */ + folder: (branch: string) => string /** Target slot count. A function is read live so a settings change applies without a restart. */ poolSize: number | (() => number) + /** Delay before a claimed slot is replaced, so the warm-up does not compete with the new session. */ + rewarm: () => number log: (msg: string) => void client: (cwd: string) => SimpleGit lock: (fn: () => Promise) => Promise @@ -48,15 +64,16 @@ interface PoolSlot { refreshed: boolean } -interface PoolMeta { - pooled?: boolean - owner?: number - baseRef?: string - baseOid?: string -} - export class WorktreePool { + // Pool homes already swept by this process, keyed by home and pool state. + private static swept = new Set() private readonly deps: PoolDeps + /** This repository's slot directory in the pool home. */ + private readonly shared: string | undefined + /** Every directory that can hold slots of this repository: the pool home and older `.kilo/worktrees/`. */ + private readonly dirs: string[] + /** Resolved directory for new slots, see {@link dir}. */ + private place: Promise | undefined private slots: PoolSlot[] = [] private warming = false @@ -67,6 +84,18 @@ export class WorktreePool { constructor(deps: PoolDeps) { this.deps = deps + this.shared = deps.home ? poolDir(deps.home, deps.root) : undefined + this.dirs = this.shared ? [this.shared, deps.local] : [deps.local] + } + + /** + * Directory for new slots: this repository's directory in the pool home + * when it is on the same filesystem, otherwise undefined and no slots. + */ + private dir(): Promise { + if (!this.shared) return Promise.resolve(undefined) + this.place ??= locate(this.deps.root, this.shared, this.deps.log) + return this.place } /** @@ -95,15 +124,34 @@ export class WorktreePool { } /** - * Claim a ready slot for a new branch. Runs while the caller already holds - * the git lock. Returns the slot path on success, or undefined to fall back - * to a normal `git worktree add`. + * Claim a ready slot for a new branch and move it to `.kilo/worktrees/`. + * Runs while the caller already holds the git lock. Returns the worktree on + * success, or undefined to fall back to a normal `git worktree add`. A + * replacement slot is warmed after {@link PoolDeps.rewarm}. */ - async claim(branch: string, oid: string, auto = false): Promise<{ path: string; branch: string } | undefined> { + async claim( + branch: string, + oid: string, + auto = false, + base?: string, + ): Promise<{ path: string; branch: string } | undefined> { if (!this.has()) return undefined - // take() discards a slot it cannot use (for example one deleted on disk), - // so keep trying the remaining slots, exact base first, then a small delta, - // before falling back to a cold worktree add. + const taken = await this.pick(branch, oid, auto) + if (!taken) return undefined + setTimeout(() => this.warm(base), this.deps.rewarm()) + return this.move(taken) + } + + /** + * take() discards a slot it cannot use (for example one deleted on disk), + * so keep trying the remaining slots, exact base first, then a small delta, + * before falling back to a cold worktree add. + */ + private async pick( + branch: string, + oid: string, + auto: boolean, + ): Promise<{ path: string; branch: string } | undefined> { for (const slot of this.slots.filter((known) => known.baseOid === oid)) { const claimed = await this.take(slot, branch, oid, true, auto) if (claimed) return claimed @@ -117,6 +165,29 @@ export class WorktreePool { return undefined } + /** + * Move a claimed slot to `.kilo/worktrees/`, so the folder matches + * the branch as it does without the pool. A slot in the pool home must never + * become a session worktree, so a failed move removes it and its new branch. + */ + private async move(slot: { path: string; branch: string }): Promise<{ path: string; branch: string } | undefined> { + const target = path.join(this.deps.local, this.deps.folder(slot.branch)) + const error = fs.existsSync(target) + ? new Error(`${target} already exists`) + : await this.raw(["worktree", "move", slot.path, target]).then( + () => undefined, + (err: unknown) => err, + ) + if (!error) return { path: target, branch: slot.branch } + this.deps.log(`worktree pool: move failed, discarding ${slot.path}: ${error}`) + // A device check cannot see every mount layout, for example two bind + // mounts of one disk. Stop pre-warming this project for the session. + if (/cross-device/i.test(String(error))) this.place = Promise.resolve(undefined) + await this.removePath(slot.path) + await this.deleteBranch(slot.branch) + return undefined + } + /** True when at least one slot is available. Pure in-memory check. */ has(): boolean { return this.size() > 0 && this.slots.length > 0 @@ -127,9 +198,17 @@ export class WorktreePool { return this.size() > 0 } - /** Adopt leftover pooled slots from a previous run and discard broken ones. */ + /** + * Adopt leftover pooled slots from a previous run and discard broken ones. + * Never creates `.kilo/worktrees/`, and removes it when older versions left + * only their slots there. + */ async reconcile(): Promise { - await this.deps.lock(() => this.adopt()) + await this.deps.lock(async () => { + await this.adopt() + await this.tidy() + }) + this.sweep() } /** Remove every idle slot, used when the feature is turned off in settings. */ @@ -139,6 +218,7 @@ export class WorktreePool { this.slots = [] for (const slot of slots) await this.removePath(slot.path) }) + this.sweep() } /** Forget a slot so the normal removal path can clean it up. */ @@ -146,27 +226,75 @@ export class WorktreePool { this.slots = this.slots.filter((slot) => normalizePath(slot.path) !== normalizePath(wtPath)) } + /** Clean slots of every repository in the pool home, once per process and pool state. */ + private sweep(): void { + const home = this.deps.home + if (!home) return + const enabled = this.enabled() + const key = `${home}\0${enabled}` + if (WorktreePool.swept.has(key)) return + WorktreePool.swept.add(key) + void sweep(home, enabled, this.deps.client, this.deps.log).catch((err: unknown) => + this.deps.log(`worktree pool: sweep failed: ${err}`), + ) + } + + /** + * Remove a `.kilo/worktrees/` that holds no worktree, for example after the + * slot of an older version was removed, so the project is clean again. + * The git lock covers this extension host only, so only the marker file is + * deleted and the directories are removed with `rmdir`, which fails as soon + * as another process puts a worktree there. + */ + private async tidy(): Promise { + const dir = this.deps.local + // A missing directory is the normal case. + const names = await fs.promises.readdir(dir).catch(() => undefined) + if (!names || names.some((name) => name !== MARKER)) return + const quiet = (err: NodeJS.ErrnoException) => ["ENOENT", "ENOTEMPTY", "EEXIST"].includes(err.code ?? "") + const remove = (target: string) => + fs.promises.rmdir(target).then( + () => true, + (err: NodeJS.ErrnoException) => { + if (!quiet(err)) this.deps.log(`worktree pool: remove ${target}: ${err}`) + return false + }, + ) + const marker = path.join(dir, MARKER) + await fs.promises + .rm(marker, { force: true }) + .catch((err) => this.deps.log(`worktree pool: remove ${marker}: ${err}`)) + if (!(await remove(dir))) return + this.deps.log(`worktree pool: removed empty ${dir}`) + // `.kilo/` usually holds project config, so only an empty one is removed. + await remove(path.dirname(dir)) + } + private async fill(point: PoolStart, oid: string): Promise { if (this.size() <= 0) return - await fs.promises.mkdir(this.deps.dir, { recursive: true }) + const dir = await this.dir() + if (!dir) return + await fs.promises.mkdir(dir, { recursive: true }) await this.prune() await this.retarget(point, oid) const missing = this.size() - this.slots.length if (missing <= 0) return - const names = await this.dirNames() + // A claim can reuse the slot name for the branch and its folder in + // `.kilo/worktrees/`, so avoid names that are taken in any slot directory. + const names = (await Promise.all(this.dirs.map(subdirs))).flat() for (let i = 0; i < missing; i++) { - const slot = await this.build(point, oid, names) + const slot = await this.build(point, oid, dir, names) if (!slot) continue names.push(path.basename(slot.path)) this.slots.push(slot) } } - private async build(point: PoolStart, oid: string, names: string[]): Promise { + private async build(point: PoolStart, oid: string, dir: string, names: string[]): Promise { const name = generateBranchName("pool", names) - const slotPath = path.join(this.deps.dir, name) + const slotPath = path.join(dir, name) const ok = await this.attempt(async () => { await this.raw(["worktree", "add", "--detach", slotPath, oid]) await this.writeMeta(slotPath, { pooled: true, owner: process.pid, baseRef: point.ref, baseOid: oid }) @@ -242,7 +370,14 @@ export class WorktreePool { } } - await this.attempt(() => this.clearMeta(slot.path), `clear metadata ${slot.path}`) + // A session worktree that still looks pooled would be removed by a later + // reconcile, so a failed write fails the claim. + const cleared = await this.attempt(() => this.clearMeta(slot.path), `clear metadata ${slot.path}`) + if (!cleared) { + await this.discard(slot) + await this.deleteBranch(branch) + return undefined + } this.slots = this.slots.filter((known) => known !== slot) return { path: slot.path, branch } } @@ -264,19 +399,34 @@ export class WorktreePool { return ok === true } + /** + * Adopt slots in the directory for new slots. Slots in other directories, + * for example `.kilo/worktrees/` slots from an older version, are removed. + */ private async adopt(): Promise { - if (!fs.existsSync(this.deps.dir)) return + const active = this.size() > 0 ? await this.dir() : undefined + for (const dir of this.dirs) await this.collect(dir, dir === active) + } + + private async collect(dir: string, keep: boolean): Promise { + if (!fs.existsSync(dir)) return const known = new Set(this.slots.map((slot) => normalizePath(slot.path))) - const entries = await fs.promises.readdir(this.deps.dir, { withFileTypes: true }) + const entries = await fs.promises.readdir(dir, { withFileTypes: true }) for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith(".kilo-delete-")) continue - const slotPath = path.join(this.deps.dir, entry.name) + const slotPath = path.join(dir, entry.name) if (known.has(normalizePath(slotPath))) continue const meta = await this.readMeta(slotPath) if (!meta?.pooled) continue - if (meta.owner !== process.pid && this.alive(meta.owner)) continue + if (meta.owner !== process.pid && alive(meta.owner)) continue + // Slots are always detached. A branch means a claim that was interrupted + // before it cleared the metadata, so this is a session worktree. + if (await this.attached(slotPath)) { + this.deps.log(`worktree pool: ${slotPath} has a branch checked out, keeping it`) + continue + } // Turning the feature off must clean slots owned by this or a dead process. - if (this.size() <= 0) { + if (!keep) { await this.removePath(slotPath) continue } @@ -328,15 +478,15 @@ export class WorktreePool { } private async prune(): Promise { - const alive: PoolSlot[] = [] + const kept: PoolSlot[] = [] for (const slot of this.slots) { if (await this.registered(slot.path)) { - alive.push(slot) + kept.push(slot) continue } await this.removePath(slot.path) } - this.slots = alive + this.slots = kept } private async registered(wtPath: string): Promise { @@ -382,12 +532,6 @@ export class WorktreePool { }) } - private async dirNames(): Promise { - if (!fs.existsSync(this.deps.dir)) return [] - const entries = await fs.promises.readdir(this.deps.dir, { withFileTypes: true }) - return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) - } - private raw(args: string[]): Promise { return this.deps.client(this.deps.root).raw(args) } @@ -405,38 +549,22 @@ export class WorktreePool { private async clearMeta(wtPath: string): Promise { const file = await this.metaPath(wtPath) - if (!file) return + if (!file) throw new Error(`git directory not found for ${wtPath}`) await fs.promises.writeFile(file, "{}", "utf-8") } + /** True when the worktree has a branch checked out. Reads HEAD directly to avoid a git process. */ + private async attached(wtPath: string): Promise { + const dir = await this.attemptValue(() => this.deps.gitdir(wtPath), `resolve gitdir ${wtPath}`) + if (!dir) return false + // A missing HEAD leaves nothing git can check out, so it counts as detached. + const head = await fs.promises.readFile(path.join(dir, "HEAD"), "utf-8").catch(() => "") + return head.startsWith("ref:") + } + private async readMeta(wtPath: string): Promise { - return this.readMetaFile(await this.metaPath(wtPath)) - } - - private async readMetaFile(file: string | undefined): Promise { - if (!file) return undefined - // A missing file is the normal case for a non-pooled worktree, so stay quiet. - const content = await fs.promises.readFile(file, "utf-8").catch((e: NodeJS.ErrnoException) => { - if (e.code !== "ENOENT") this.deps.log(`worktree pool: read metadata ${file}: ${e}`) - return undefined - }) - if (content === undefined) return undefined - return await Promise.resolve() - .then(() => JSON.parse(content) as PoolMeta) - .catch((e) => { - this.deps.log(`worktree pool: parse metadata ${file}: ${e}`) - return undefined - }) - } - - private alive(pid: number | undefined): boolean { - if (pid === undefined) return false - try { - process.kill(pid, 0) - return true - } catch (e) { - return (e as NodeJS.ErrnoException).code === "EPERM" - } + const file = await this.metaPath(wtPath) + return file ? readMeta(file, this.deps.log) : undefined } private async attempt(fn: () => Promise, label: string): Promise { diff --git a/packages/kilo-vscode/src/agent-manager/pool/slot.ts b/packages/kilo-vscode/src/agent-manager/pool/slot.ts new file mode 100644 index 00000000000..a238fa77057 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/pool/slot.ts @@ -0,0 +1,52 @@ +/** + * Facts about one pool slot on disk, shared by the pool and the machine-wide + * sweep: its metadata file, whether its owner process is alive, and the + * directories that can hold slots. vscode-free. + */ + +import * as fs from "fs" + +export const METADATA_FILE = "kilo-agent-manager-metadata.json" + +/** Metadata in a slot's git directory. A claim clears it to `{}`. */ +export interface PoolMeta { + pooled?: boolean + owner?: number + baseRef?: string + baseOid?: string +} + +export async function readMeta(file: string, log: (msg: string) => void): Promise { + // A missing file is the normal case for a non-pooled worktree, so stay quiet. + const content = await fs.promises.readFile(file, "utf-8").catch((e: NodeJS.ErrnoException) => { + if (e.code !== "ENOENT") log(`worktree pool: read metadata ${file}: ${e}`) + return undefined + }) + if (content === undefined) return undefined + return await Promise.resolve() + .then(() => JSON.parse(content) as PoolMeta) + .catch((e) => { + log(`worktree pool: parse metadata ${file}: ${e}`) + return undefined + }) +} + +/** True when the process that owns a slot still runs. */ +export function alive(pid: number | undefined): boolean { + if (pid === undefined) return false + try { + process.kill(pid, 0) + return true + } catch (e) { + return (e as NodeJS.ErrnoException).code === "EPERM" + } +} + +export async function subdirs(dir: string): Promise { + // Another process can remove the directory at any time, for example in its own sweep. + const entries = await fs.promises.readdir(dir, { withFileTypes: true }).catch((e: NodeJS.ErrnoException) => { + if (e.code === "ENOENT" || e.code === "ENOTDIR") return [] + throw e + }) + return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) +} diff --git a/packages/kilo-vscode/src/agent-manager/pool/sweep.ts b/packages/kilo-vscode/src/agent-manager/pool/sweep.ts new file mode 100644 index 00000000000..6ec2c319688 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/pool/sweep.ts @@ -0,0 +1,80 @@ +/** + * Machine-wide cleanup of the pool home. Slots live outside the projects, so + * without this a deleted project or one that nobody opens again would leave a + * full checkout behind in a directory the user never sees. vscode-free. + */ + +import * as fs from "fs" +import * as path from "path" +import type { SimpleGit } from "simple-git" +import { METADATA_FILE, alive, readMeta, subdirs } from "./slot" + +/** A slot that no live process owns and that nothing touched for this long is removed. */ +const STALE = 14 * 24 * 60 * 60 * 1000 +/** A slot without metadata can still be in creation by another process for at most this long. */ +const CREATION = 60 * 60 * 1000 + +/** + * Remove slots that no Kilo process can claim, across every repository in the + * pool home. A slot whose repository is gone (deleted, moved, or pruned) is + * removed at once. Other slots are removed when no live process owns them and + * nothing touched them for {@link STALE}, or at once when the pool is disabled. + */ +export async function sweep( + home: string, + enabled: boolean, + client: (cwd: string) => SimpleGit, + log: (msg: string) => void, +): Promise { + const age = enabled ? STALE : 0 + for (const project of await subdirs(home)) { + const dir = path.join(home, project) + for (const name of await subdirs(dir)) await reap(path.join(dir, name), age, client, log) + await fs.promises.rmdir(dir).catch((e: NodeJS.ErrnoException) => { + if (e.code !== "ENOTEMPTY" && e.code !== "EEXIST" && e.code !== "ENOENT") { + log(`worktree pool: remove ${dir}: ${e}`) + } + }) + } +} + +async function reap(slot: string, age: number, client: (cwd: string) => SimpleGit, log: (msg: string) => void) { + const gitdir = await linked(slot) + // git creates the registration before it writes the `.git` file, so a + // missing target means git no longer tracks this slot. + if (gitdir && !fs.existsSync(gitdir)) { + log(`worktree pool: removing slot of a missing repository ${slot}`) + return purge(slot, log) + } + const file = gitdir ? path.join(gitdir, METADATA_FILE) : undefined + const meta = file ? await readMeta(file, log) : undefined + if (meta?.pooled && alive(meta.owner)) return + const wait = meta?.pooled ? age : Math.max(age, CREATION) + // Metadata is rewritten whenever a process adopts or retargets the slot. + const stamp = await fs.promises + .stat(meta && file ? file : slot) + .then((stat) => stat.mtimeMs) + .catch(() => undefined) + if (stamp === undefined) return + // A fresh mtime can be slightly ahead of the clock, so no wait means remove at once. + if (wait > 0 && Date.now() - stamp < wait) return + log(`worktree pool: removing unused slot ${slot}`) + if (gitdir) { + // simple-git throws at once when the directory vanished meanwhile. + await Promise.resolve() + .then(() => client(slot).raw(["worktree", "remove", "--force", "--force", slot])) + .catch((e) => log(`worktree pool: remove ${slot}: ${e}`)) + } + if (fs.existsSync(slot)) await purge(slot, log) +} + +async function purge(dir: string, log: (msg: string) => void): Promise { + await fs.promises.rm(dir, { recursive: true, force: true }).catch((e) => log(`worktree pool: rm ${dir}: ${e}`)) +} + +/** Git directory a linked worktree points at, or undefined when it has no `.git` file. */ +async function linked(dir: string): Promise { + const content = await fs.promises.readFile(path.join(dir, ".git"), "utf-8").catch(() => undefined) + const match = content?.match(/^gitdir:\s*(.+)$/m) + return match ? path.resolve(dir, match[1].trim()) : undefined +} diff --git a/packages/kilo-vscode/src/agent-manager/project/avatar.ts b/packages/kilo-vscode/src/agent-manager/project/avatar.ts new file mode 100644 index 00000000000..613696b66c6 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/project/avatar.ts @@ -0,0 +1,120 @@ +/** + * Resolve an organization avatar for a project from its Git origin remote. + * + * The image is downloaded once and kept in a disk cache, then sent to the + * webview as a data URL. The sidebar shows it at once on later loads and + * also when the network is not available. + */ + +import * as fs from "fs" +import * as os from "os" +import * as path from "path" + +const DAY = 24 * 60 * 60 * 1000 +/** How long to wait before retrying an avatar that failed to download. */ +const RETRY = 5 * 60 * 1000 +const owners = new Map() +const images = new Map() +const pending = new Set() +const failed = new Map() + +function config(root: string): string | undefined { + const dot = path.join(root, ".git") + const stat = fs.statSync(dot, { throwIfNoEntry: false }) + if (!stat) return undefined + if (stat.isDirectory()) return path.join(dot, "config") + const match = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dot, "utf8")) + if (!match) return undefined + const dir = path.resolve(root, match[1].trim()) + const common = path.join(dir, "commondir") + if (!fs.existsSync(common)) return path.join(dir, "config") + return path.join(path.resolve(dir, fs.readFileSync(common, "utf8").trim()), "config") +} + +/** Parse the owner of a GitHub remote URL. SSH host aliases such as github.com-work are accepted. */ +export function owner(url: string): string | undefined { + const match = /github\.com[^:/\s]*[:/]([^/\s]+)\//i.exec(url) + return match?.[1] +} + +/** GitHub owner of the project's origin remote, cached per root. */ +export function origin(root: string): string | undefined { + if (owners.has(root)) return owners.get(root) + const file = config(root) + const text = file && fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "" + const section = /\[remote "origin"\]([^[]*)/.exec(text)?.[1] ?? "" + const url = /^\s*url\s*=\s*(.+)$/m.exec(section)?.[1]?.trim() + const name = url ? owner(url) : undefined + owners.set(root, name) + return name +} + +function dir() { + const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache") + return path.join(base, "kilo", "project-avatars") +} + +function file(name: string) { + return path.join(dir(), `${name.toLowerCase().replace(/[^a-z0-9._-]/g, "_")}.png`) +} + +async function download(name: string, done: () => void, log: (msg: string) => void) { + if (pending.has(name)) return + pending.add(name) + try { + // Request and accept PNG only, so the disk cache matches the image/png data URL. + const res = await fetch(`https://github.com/${encodeURIComponent(name)}.png?size=64`) + const type = res.headers.get("content-type") ?? "" + if (!res.ok || !type.startsWith("image/png")) { + failed.set(name, Date.now()) + return + } + const buf = Buffer.from(await res.arrayBuffer()) + await fs.promises.mkdir(dir(), { recursive: true }) + await fs.promises.writeFile(file(name), buf) + images.set(name, `data:${type};base64,${buf.toString("base64")}`) + failed.delete(name) + done() + } catch (err) { + // Offline or a transient write failure. Remember the failure so later + // renders do not retry on every push, and clear `pending` in `finally`. + failed.set(name, Date.now()) + log(`avatar: failed to load ${name}: ${err}`) + } finally { + pending.delete(name) + } +} + +/** + * Avatar data URL for a project, or undefined while it is not cached yet. + * A missing or stale cache entry starts a download; `done` runs when a new image is ready. + * Download failures are reported through `log`. Never throws: an unreadable + * `.git` or cache file falls back to the letter tile. + */ +export function avatar( + root: string, + done: () => void = () => {}, + log: (msg: string) => void = () => {}, +): string | undefined { + try { + const name = origin(root) + if (!name) return undefined + const cached = images.get(name) + if (cached) return cached + const entry = file(name) + const stat = fs.statSync(entry, { throwIfNoEntry: false }) + if (stat) { + const url = `data:image/png;base64,${fs.readFileSync(entry).toString("base64")}` + images.set(name, url) + if (Date.now() - stat.mtimeMs > 7 * DAY) void download(name, done, log) + return url + } + const retryAt = failed.get(name) + if (retryAt !== undefined && Date.now() - retryAt < RETRY) return undefined + void download(name, done, log) + return undefined + } catch (err) { + log(`avatar: failed to read avatar for ${root}: ${err}`) + return undefined + } +} diff --git a/packages/kilo-vscode/src/agent-manager/project/context.ts b/packages/kilo-vscode/src/agent-manager/project/context.ts index 4f1b405a4e1..829a9b9df22 100644 --- a/packages/kilo-vscode/src/agent-manager/project/context.ts +++ b/packages/kilo-vscode/src/agent-manager/project/context.ts @@ -14,8 +14,6 @@ * created on demand (expand/select), never eagerly at panel open. * - Only the active context gets full Git/PR polling; the pollers follow the * active context through the provider's accessors. - * - Non-pinned contexts require the multi-project flag before they can be - * expanded or activated. */ import * as fs from "fs" @@ -41,6 +39,8 @@ export interface ProjectContextDeps { sized?: (ctx: ProjectContext) => void /** Whether background worktree pre-warming is enabled for this project. */ worktreePool?: () => boolean + /** Per-user directory for pooled slots. When omitted, no slots are pre-warmed. */ + poolHome?: string /** Factory overrides for tests. */ state?: (root: string, log: (msg: string) => void) => WorktreeStateManager worktrees?: (root: string, log: (msg: string) => void, git?: GitOps) => WorktreeManager @@ -180,7 +180,14 @@ export class ProjectContext { this.worktrees ??= ( this.deps.worktrees ?? ((root, log, git) => - new WorktreeManager(root, log, git, undefined, () => (this.deps.worktreePool?.() === false ? 0 : 1))) + new WorktreeManager( + root, + log, + git, + undefined, + () => (this.deps.worktreePool?.() === false ? 0 : 1), + this.deps.poolHome, + )) )(this.root, (msg) => this.deps.log(`[WorktreeManager] ${msg}`), this.deps.git) return this.worktrees } diff --git a/packages/kilo-vscode/src/agent-manager/project/contexts.ts b/packages/kilo-vscode/src/agent-manager/project/contexts.ts index 04682058518..72a261365f0 100644 --- a/packages/kilo-vscode/src/agent-manager/project/contexts.ts +++ b/packages/kilo-vscode/src/agent-manager/project/contexts.ts @@ -12,6 +12,7 @@ import * as path from "path" import { canonicalizePath, projectIdFor, samePath } from "./paths" import type { StoredProject } from "./registry" import { ProjectContext, type ProjectContextDeps } from "./context" +import { avatar } from "./avatar" /** Serializable project description for the webview. */ export interface ProjectSnapshot { @@ -23,6 +24,8 @@ export interface ProjectSnapshot { expanded: boolean initialized: boolean missing: boolean + /** Organization avatar URL derived from the GitHub origin remote. */ + avatar?: string } interface ContextsOptions { @@ -33,9 +36,9 @@ interface ContextsOptions { get(id: string): StoredProject | undefined expanded?(id: string): boolean | undefined } - /** Whether the multi-project experiment is enabled. */ - enabled: () => boolean remove?: (id: string) => void + /** Called when a project avatar finished downloading. */ + changed?: () => void deps: ProjectContextDeps } @@ -69,7 +72,6 @@ export class ProjectContexts { if (existing) return existing const pinned = this.pinned() if (pinned?.id === id) return pinned - if (!this.opts.enabled()) return undefined const stored = this.opts.registry.get(id) if (!stored) return undefined return this.ensure(stored.id, stored.root, false) @@ -84,8 +86,7 @@ export class ProjectContexts { this.rememberExpansion(pinned.id, true) return pinned } - if (!this.opts.enabled()) return undefined - const first = this.opts.registry.list()[0] + const first = this.opts.registry.list().at(0) if (!first) return undefined const ctx = this.ensure(first.id, first.root, false) this.activeId = ctx.id @@ -132,11 +133,6 @@ export class ProjectContexts { return this.resolveCtx(id) } - /** Whether a project may be shown or initialized: known and flag-gated. */ - usable(id: string): ProjectContext | undefined { - return this.usableCtx(id) - } - isActive(id: string): boolean { return this.active()?.id === id } @@ -152,7 +148,7 @@ export class ProjectContexts { /** Make a project the active context and expand it. Returns undefined when not allowed. */ activate(id: string): ProjectContext | undefined { - const ctx = this.usableCtx(id) + const ctx = this.resolveCtx(id) if (!ctx) return undefined this.activeId = id this.rememberExpansion(id, false) @@ -161,7 +157,7 @@ export class ProjectContexts { /** Expand a project without activating it. Returns undefined when not allowed. */ expand(id: string): ProjectContext | undefined { - const ctx = this.usableCtx(id) + const ctx = this.resolveCtx(id) if (!ctx) return undefined this.expansion.set(id, true) return ctx @@ -173,30 +169,6 @@ export class ProjectContexts { this.contexts.get(id)?.suspend() } - /** Return ownership to pinned Local and suspend all secondary contexts. */ - disable(): ProjectContext | undefined { - const pinned = this.pinned() - this.activeId = pinned?.id - if (pinned) this.expansion.set(pinned.id, true) - for (const ctx of this.contexts.values()) { - if (ctx.pinned) continue - this.expansion.set(ctx.id, false) - ctx.suspend() - // Match remove()/syncPinned(): drop the routes too, otherwise the shared - // route service accumulates entries for every disabled project. - this.opts.remove?.(ctx.id) - } - return pinned - } - - private usableCtx(id: string): ProjectContext | undefined { - const ctx = this.resolveCtx(id) - if (!ctx) return undefined - if (ctx.pinned) return ctx - if (!this.opts.enabled()) return undefined - return ctx - } - /** Remove a non-pinned project context. Falls back to the pinned project when it was active. */ async remove(id: string): Promise { const ctx = this.contexts.get(id) @@ -236,7 +208,6 @@ export class ProjectContexts { const out: ProjectSnapshot[] = [] const pinned = this.pinned() if (pinned) out.push(this.snapshot(pinned, undefined)) - if (!this.opts.enabled()) return out for (const stored of this.opts.registry.list()) { if (pinned?.id === stored.id) continue out.push(this.snapshot(this.contexts.get(stored.id), stored)) @@ -258,6 +229,7 @@ export class ProjectContexts { expanded: !missing && this.isExpanded(id), initialized: ctx?.loaded ?? false, missing, + avatar: missing ? undefined : avatar(root, () => this.opts.changed?.(), this.opts.deps.log), } } diff --git a/packages/kilo-vscode/src/agent-manager/project/messages.ts b/packages/kilo-vscode/src/agent-manager/project/messages.ts index 0a7a60a0268..65baabe557b 100644 --- a/packages/kilo-vscode/src/agent-manager/project/messages.ts +++ b/packages/kilo-vscode/src/agent-manager/project/messages.ts @@ -3,7 +3,7 @@ * * Extracted from AgentManagerProvider (file-size cap) and kept free of VS Code * imports so the flows are unit-testable. All handlers fail closed: unknown - * projects and disabled experiments leave state untouched. + * projects leave state untouched. */ import { GitOps } from "../GitOps" @@ -41,13 +41,13 @@ export function routeProjectSession( export interface ProjectMessageDeps { registry: ProjectRegistry contexts: ProjectContexts - /** Whether the multi-project experiment is enabled. */ - enabled: () => boolean /** Show a folder picker; resolves undefined when cancelled. */ pickFolder: Host["pickFolder"] onboarding: Onboarding["host"] /** Re-initialize provider state for a freshly activated context. */ activate: (ctx: ProjectContext) => void + /** Clear the applied project when the last project is removed. */ + empty?: () => void /** Initialize an expanded background context and push its state. */ expand: (ctx: ProjectContext) => void /** Push the current project snapshots to the webview. */ @@ -129,13 +129,21 @@ export async function handleProjectMessage(m: AgentManagerInMessage, deps: Proje await setExpanded(m.projectId, m.expanded, deps) return true } + if (m.type === "agentManager.setProjectOrder") { + // Always push, so a failed write replaces the webview's optimistic order with the stored one. + if (Array.isArray(m.order)) + await deps.registry + .reorder(m.order.filter((id) => typeof id === "string")) + .catch((err) => deps.log("Failed to save project order:", err)) + deps.push() + return true + } return false } async function activateSelection(requested: SidebarTarget, deps: ProjectMessageDeps, restore = false): Promise { - if (disabled(deps)) return const ctx = deps.contexts.resolve(requested.projectId) - if (!ctx || !deps.contexts.usable(requested.projectId)) { + if (!ctx) { deps.error("The project is unavailable. Check that the repository still exists.") return } @@ -145,7 +153,7 @@ async function activateSelection(requested: SidebarTarget, deps: ProjectMessageD selections.set(deps, token) const result = await deps.ready(ctx, { warm: true }) // A newer click can finish while this project's readiness is pending. - if (selections.get(deps) !== token || !deps.enabled()) return + if (selections.get(deps) !== token) return if (!result.current || !result.ok) { deps.error("The project is not ready yet. Expand it before selecting a worktree or session.") deps.push() @@ -175,9 +183,8 @@ async function activateSelection(requested: SidebarTarget, deps: ProjectMessageD * already gone (the session may be live only). */ async function openSessionLocally(projectId: string, sessionId: string, deps: ProjectMessageDeps): Promise { - if (disabled(deps)) return const ctx = deps.contexts.resolve(projectId) - if (!ctx || !deps.contexts.usable(projectId)) { + if (!ctx) { deps.error("The project is unavailable. Check that the repository still exists.") return } @@ -230,19 +237,12 @@ function rememberTarget(projectId: string, target: SidebarTarget, deps: ProjectM state.setActiveTarget(target) } -function disabled(deps: ProjectMessageDeps): boolean { - if (deps.enabled()) return false - deps.error("Multi-project Agent Manager is disabled. Enable it in Kilo Settings > Experimental to add projects.") - return true -} - function onboardingDeps(deps: ProjectMessageDeps, git: GitOps): Onboarding { return { host: deps.onboarding, pickFolder: deps.pickFolder, primary: deps.contexts.pinned()?.root, git, - enabled: deps.enabled, registered: (dir) => Boolean(deps.registry.get(projectIdFor(canonicalizePath(dir)))), } } @@ -261,7 +261,6 @@ async function createNewProject(parent: string, name: string, deps: ProjectMessa /** Post the canonical parent folder for a new project: the primary checkout's parent. */ async function postParent(deps: ProjectMessageDeps): Promise { - if (disabled(deps)) return const git = deps.git ?? new GitOps({ log: deps.log }) try { deps.post({ @@ -281,7 +280,7 @@ async function attachPrepared( prepare: (git: GitOps) => Promise, deps: ProjectMessageDeps, ): Promise { - if (disabled(deps) || pending.has(deps)) return + if (pending.has(deps)) return pending.add(deps) const git = deps.git ?? new GitOps({ log: deps.log }) let root: string | undefined @@ -305,10 +304,6 @@ async function attachPrepared( /** Register a prepared root, then select it without warming a worktree of its own. */ async function attach(root: string, deps: ProjectMessageDeps, git: GitOps): Promise { root = canonicalizePath(root) - if (!deps.enabled()) - throw new Error( - "Multi-project Agent Manager was disabled. Enable it and use Open local folder to attach this project.", - ) const pinned = deps.contexts.pinned() const primary = pinned && (await resolveProjectRoot(pinned.root, runner(git))) const id = pinned && samePath(primary ?? pinned.root, root) ? pinned.id : projectIdFor(root) @@ -319,20 +314,24 @@ async function attach(root: string, deps: ProjectMessageDeps, git: GitOps): Prom if (!ctx) throw new Error("The project is unavailable.") const result = await deps.ready(ctx, { warm: false }) deps.push() - if (!result.current || !result.ok || !deps.enabled()) throw new Error("Expand the project to retry initialization.") + if (!result.current || !result.ok) throw new Error("Expand the project to retry initialization.") finish({ projectId: id, kind: "local" }, deps) if (existing) deps.onboarding.notify("info", `Opened the existing project at ${root}.`) } async function removeProject(id: string, deps: ProjectMessageDeps): Promise { - if (disabled(deps)) return - await deps.contexts.remove(id) + if (deps.contexts.pinned()?.id === id) return + const active = deps.contexts.active()?.id === id await deps.registry.remove(id) + await deps.contexts.remove(id) deps.push() + if (!active) return + const next = deps.contexts.active() + if (next) deps.activate(next) + else deps.empty?.() } function selectProject(id: string, deps: ProjectMessageDeps): void { - if (disabled(deps)) return const ctx = deps.contexts.activate(id) if (!ctx) { deps.error("The project is unavailable. Check that the repository still exists.") @@ -345,8 +344,7 @@ function selectProject(id: string, deps: ProjectMessageDeps): void { } async function setExpanded(id: string, expanded: boolean, deps: ProjectMessageDeps): Promise { - if (disabled(deps)) return - const ctx = expanded ? deps.contexts.usable(id) : deps.contexts.resolve(id) + const ctx = deps.contexts.resolve(id) if (!ctx) { deps.push() return diff --git a/packages/kilo-vscode/src/agent-manager/project/onboarding.ts b/packages/kilo-vscode/src/agent-manager/project/onboarding.ts index 5799ccb0df4..f013d9f506e 100644 --- a/packages/kilo-vscode/src/agent-manager/project/onboarding.ts +++ b/packages/kilo-vscode/src/agent-manager/project/onboarding.ts @@ -13,13 +13,11 @@ export interface Onboarding { pickFolder: Host["pickFolder"] primary?: string git: GitOps - enabled: () => boolean /** True when the folder already resolves to a registered project. */ registered: (dir: string) => boolean } function trusted(deps: Onboarding) { - if (!deps.enabled()) throw new Error("Multi-project Agent Manager is disabled.") if (!deps.host.isTrusted()) throw new Error("Trust this VS Code window before creating or initializing a repository.") } @@ -112,7 +110,7 @@ export async function onboard(deps: Onboarding): Promise { defaultPath: primary, title: "Open local project", }) - if (!dir || !deps.enabled()) return + if (!dir) return trusted(deps) return prepareTarget(dir, "local", deps) } @@ -127,7 +125,7 @@ export async function cloneProject(url: string, parent: string, deps: Onboarding const candidate = name ? path.join(parent, name) : undefined if (candidate && deps.registered(candidate)) return candidate const dir = await deps.host.cloneRepository(url, parent) - if (!dir || !deps.enabled()) return + if (!dir) return trusted(deps) return prepareTarget(dir, "clone", deps) } diff --git a/packages/kilo-vscode/src/agent-manager/project/registry.ts b/packages/kilo-vscode/src/agent-manager/project/registry.ts index a8099711b8e..1ca0f764e5e 100644 --- a/packages/kilo-vscode/src/agent-manager/project/registry.ts +++ b/packages/kilo-vscode/src/agent-manager/project/registry.ts @@ -201,6 +201,29 @@ export class ProjectRegistry { return true } + /** + * Apply a new catalog order. Unknown ids are ignored. Projects that are not + * in `ids` (for example added by another window) keep their relative order + * after the listed ones. + */ + reorder(ids: string[]): Promise { + return this.run(() => this.doReorder(ids)) + } + + private async doReorder(ids: string[]): Promise { + const current = this.fresh() + const wanted = new Set(ids) + const listed = [...wanted].flatMap((id) => current.projects.filter((p) => p.id === id)) + const next = [...listed, ...current.projects.filter((p) => !wanted.has(p.id))].map((p, index) => ({ + ...p, + order: index + 1, + })) + if (next.every((p, index) => p.id === current.projects[index]?.id && p.order === current.projects[index]?.order)) + return false + await this.write(next, current.pinnedExpanded) + return true + } + setExpanded(id: string, expanded: boolean): Promise { return this.run(() => this.doSetExpanded(id, expanded)) } diff --git a/packages/kilo-vscode/src/agent-manager/project/state-gate.ts b/packages/kilo-vscode/src/agent-manager/project/state-gate.ts index 6cce802bee0..716cbaa4a4a 100644 --- a/packages/kilo-vscode/src/agent-manager/project/state-gate.ts +++ b/packages/kilo-vscode/src/agent-manager/project/state-gate.ts @@ -37,6 +37,7 @@ export const STATE_GATED = new Set([ "agentManager.toggleSectionCollapsed", "agentManager.moveToSection", "agentManager.moveSection", + "agentManager.setWorktreePinned", "agentManager.browser.open", "agentManager.browser.refresh", "agentManager.browser.back", diff --git a/packages/kilo-vscode/src/agent-manager/project/wiring.ts b/packages/kilo-vscode/src/agent-manager/project/wiring.ts index 1efd0d2eab4..d597c1b2deb 100644 --- a/packages/kilo-vscode/src/agent-manager/project/wiring.ts +++ b/packages/kilo-vscode/src/agent-manager/project/wiring.ts @@ -13,6 +13,7 @@ import type { ProjectContext, ProjectInitResult } from "./context" import { ProjectContexts, type ProjectSnapshot } from "./contexts" import type { ProjectMessageDeps } from "./messages" import { createSettingsHandler, type SettingsHandler } from "./settings" +import { poolHome } from "../pool/home" export interface ProjectWiring { registry: ProjectRegistry @@ -21,7 +22,7 @@ export interface ProjectWiring { settings: SettingsHandler messages: ProjectMessageDeps /** Payload for the agentManager.projects webview message. */ - snapshots(): { type: "agentManager.projects"; multiProject: boolean; projects: ProjectSnapshot[] } + snapshots(): { type: "agentManager.projects"; projects: ProjectSnapshot[] } dispose(): void } @@ -32,6 +33,8 @@ export function createProjectWiring(opts: { output: (msg: string) => void /** Re-initialize provider state for a freshly activated context. */ activate: (ctx: ProjectContext) => void + /** Clear the applied project when the last project is removed. */ + empty: () => void /** Initialize an expanded background context and push its state. */ expand: (ctx: ProjectContext) => void /** Ensure a context's repository state is ready (no-op once initialized). */ @@ -57,7 +60,7 @@ export function createProjectWiring(opts: { const contexts = new ProjectContexts({ workspaceRoot: () => opts.host.workspacePath(), registry, - enabled: () => opts.host.multiProject(), + changed: () => opts.push(), remove: (id) => { opts.host.unregisterProjectRoutes(id) opts.removed?.(id) @@ -66,16 +69,17 @@ export function createProjectWiring(opts: { log: opts.output, git: opts.git, worktreePool: () => opts.host.worktreePool(), + poolHome: poolHome(), sized: (ctx) => opts.pushState(ctx), }, }) const messages: ProjectMessageDeps = { registry, contexts, - enabled: () => opts.host.multiProject(), pickFolder: (input) => opts.host.pickFolder(input), onboarding: opts.host, activate: opts.activate, + empty: opts.empty, expand: opts.expand, ready: opts.ready, push: opts.push, @@ -96,14 +100,6 @@ export function createProjectWiring(opts: { }) const listeners: Disposable[] = [ opts.host.onDidChangeWorkspaceFolders(() => opts.changed()), - opts.host.onDidChangeMultiProject((enabled) => { - if (!enabled) { - const pinned = contexts.disable() - if (pinned) opts.activate(pinned) - } - opts.push() - opts.pushState() - }), opts.host.onDidChangeWorktreePool((enabled) => { for (const project of contexts.snapshots()) { const manager = contexts.get(project.id)?.peekWorktrees() @@ -120,7 +116,6 @@ export function createProjectWiring(opts: { messages, snapshots: () => ({ type: "agentManager.projects", - multiProject: opts.host.multiProject(), projects: contexts.snapshots(), }), dispose: () => { diff --git a/packages/kilo-vscode/src/agent-manager/section-handler.ts b/packages/kilo-vscode/src/agent-manager/section-handler.ts index 01bbd0c6cd4..eb4be8575f5 100644 --- a/packages/kilo-vscode/src/agent-manager/section-handler.ts +++ b/packages/kilo-vscode/src/agent-manager/section-handler.ts @@ -9,9 +9,10 @@ const SECTION_TYPES = new Set([ "agentManager.toggleSectionCollapsed", "agentManager.moveToSection", "agentManager.moveSection", + "agentManager.setWorktreePinned", ]) -/** Handle section CRUD messages. Returns true if handled. */ +/** Handle section CRUD and worktree pin messages. Returns true if handled. */ export function handleSection( state: WorktreeStateManager | undefined, m: AgentManagerInMessage, @@ -29,6 +30,7 @@ export function handleSection( else if (m.type === "agentManager.toggleSectionCollapsed") state.toggleSection(m.sectionId) else if (m.type === "agentManager.moveToSection") state.moveToSection(m.worktreeIds, m.sectionId) else if (m.type === "agentManager.moveSection") state.moveSection(m.sectionId, m.dir) + else if (m.type === "agentManager.setWorktreePinned") state.setWorktreePinned(m.worktreeId, m.pinned) else return false push() return true diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 9c7bb46eb27..72a44cf25d1 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -22,7 +22,13 @@ import type { SidebarTarget } from "./project/route" import type { TerminalDestination } from "./terminal-destination" import type { ScriptTerminalView } from "./ScriptTerminalManager" import type { BrowserFeedbackData } from "../shared/browser-feedback" -import type { BrowserFrame, BrowserInteraction, BrowserViewport, BrowserViewIdentity } from "../shared/browser-stream" +import type { + BrowserCursor, + BrowserFrame, + BrowserInteraction, + BrowserViewport, + BrowserViewIdentity, +} from "../shared/browser-stream" export type { TerminalFont } export type { ProjectSnapshot } @@ -105,7 +111,7 @@ export type { interface WorktreeStatsMessage { type: "agentManager.worktreeStats" - /** Owning project; absent in single-project mode. */ + /** Owning project, when available. */ projectId?: string stats: WorktreeStats[] } @@ -123,14 +129,14 @@ interface WorktreeDeletedMessage { interface LocalStatsMessage { type: "agentManager.localStats" - /** Owning project; absent in single-project mode. */ + /** Owning project, when available. */ projectId?: string stats: LocalStats } interface WorktreeSetupMessage { type: "agentManager.worktreeSetup" - /** Owning project; absent in single-project mode. */ + /** Owning project, when available. */ projectId?: string status: "creating" | "starting" | "ready" | "error" message: string @@ -162,7 +168,7 @@ interface StateMessage { runStatuses?: RunStatus[] runScriptConfigured?: boolean runScriptPath?: string - /** Owning project for this state payload. Absent in legacy single-project payloads. */ + /** Owning project for this state payload. Absent when no project is ready. */ projectId?: string /** Last selected sidebar target for seamless project-switch restore. */ activeTarget?: SidebarTarget @@ -175,8 +181,6 @@ interface StateMessage { /** Project catalog pushed to the webview after registry or context changes. */ interface ProjectsMessage { type: "agentManager.projects" - /** Whether the multi-project experiment is enabled. */ - multiProject: boolean projects: ProjectSnapshot[] } @@ -294,7 +298,7 @@ interface SessionClosedMessage { interface MultiVersionProgressMessage { type: "agentManager.multiVersionProgress" - /** Owning project; absent in single-project mode. */ + /** Owning project, when available. */ projectId?: string status: "creating" | "done" total: number @@ -304,7 +308,7 @@ interface MultiVersionProgressMessage { interface SetSessionModelMessage { type: "agentManager.setSessionModel" - /** Owning project; absent in single-project mode. */ + /** Owning project, when available. */ projectId?: string sessionId: string providerID: string @@ -313,7 +317,7 @@ interface SetSessionModelMessage { interface SendInitialMessage { type: "agentManager.sendInitialMessage" - /** Owning project; absent in single-project mode. */ + /** Owning project, when available. */ projectId?: string sessionId: string worktreeId: string @@ -433,7 +437,7 @@ interface DiffBranchesMessage { interface PRStatusOutMessage { type: "agentManager.prStatus" - /** Owning project; absent in single-project mode. */ + /** Owning project, when available. */ projectId?: string worktreeId: string pr: PRStatus | null @@ -509,6 +513,12 @@ interface BrowserFrameMessage extends BrowserFrame { sessionId: string } +interface BrowserCursorMessage extends BrowserCursor { + type: "agentManager.browserCursor" + projectId?: string + sessionId: string +} + interface BrowserDevtoolsMessage { type: "agentManager.browserDevtools" browserId: string @@ -519,7 +529,7 @@ interface BrowserDevtoolsMessage { interface RunStatusMessage extends RunStatus { type: "agentManager.runStatus" - /** Owning project for this status. Absent in legacy single-project mode. */ + /** Owning project for this status, when available. */ projectId?: string } @@ -564,6 +574,7 @@ export type AgentManagerOutMessage = | BrowserInspectionMessage | BrowserDevtoolsMessage | BrowserFrameMessage + | BrowserCursorMessage | RunStatusMessage | TerminalCreatedMessage | TerminalRestartedMessage @@ -653,6 +664,12 @@ interface SetProjectExpandedIn { expanded: boolean } +/** Persist the sidebar order of the additional (not pinned) projects. */ +interface SetProjectOrderIn { + type: "agentManager.setProjectOrder" + order: string[] +} + interface DeleteWorktreeIn { type: "agentManager.deleteWorktree" projectId?: string @@ -721,6 +738,7 @@ interface CloseSessionIn { /** Persist a non-worktree session to agent-manager.json (worktreeId = null). */ interface PersistSessionIn { type: "agentManager.persistSession" + projectId?: string sessionId: string draftID?: string } @@ -1175,6 +1193,13 @@ interface ToggleSectionCollapsedIn { sectionId: string } +interface SetWorktreePinnedIn { + type: "agentManager.setWorktreePinned" + projectId?: string + worktreeId: string + pinned: boolean +} + interface MoveToSectionIn { type: "agentManager.moveToSection" projectId?: string @@ -1282,6 +1307,7 @@ export type AgentManagerInMessage = | ActivateSelectionIn | RememberTargetIn | SetProjectExpandedIn + | SetProjectOrderIn | DeleteWorktreeIn | RemoveStaleWorktreeIn | RestoreWorktreeIn @@ -1358,6 +1384,7 @@ export type AgentManagerInMessage = | SetSectionColorIn | ToggleSectionCollapsedIn | MoveToSectionIn + | SetWorktreePinnedIn | MoveSectionIn | TerminalCreateIn | TerminalCloseIn diff --git a/packages/kilo-vscode/src/agent-manager/user-keybindings.ts b/packages/kilo-vscode/src/agent-manager/user-keybindings.ts new file mode 100644 index 00000000000..6314cfa932a --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/user-keybindings.ts @@ -0,0 +1,97 @@ +import * as path from "node:path" + +type Binding = { command: string; key?: string; mac?: string; when?: string } + +/** Parse VS Code's keybindings.json, which allows comments and trailing commas. */ +export function parseJsonc(text: string): unknown { + const out: string[] = [] + const len = text.length + let i = 0 + while (i < len) { + const ch = text[i]! + if (ch === '"') { + const start = i + i++ + while (i < len && text[i] !== '"') i += text[i] === "\\" ? 2 : 1 + out.push(text.slice(start, i + 1)) + i++ + continue + } + if (ch === "/" && text[i + 1] === "/") { + while (i < len && text[i] !== "\n") i++ + continue + } + if (ch === "/" && text[i + 1] === "*") { + const end = text.indexOf("*/", i + 2) + i = end === -1 ? len : end + 2 + continue + } + out.push(ch) + i++ + } + const json = out.join("").replace(/,(\s*[}\]])/g, "$1") + // A half-written or invalid file must not break the labels: use the defaults. + try { + return JSON.parse(json) as unknown + } catch { + return undefined + } +} + +const norm = (key: string) => + key + .trim() + .toLowerCase() + .split(/\s+/) + .map((part) => + part + .split("+") + .map((p) => p.trim()) + .join("+"), + ) + .join(" ") + +/** + * Apply the user's keybindings.json on top of the extension defaults. + * Entries are resolved for the current platform. Later entries win, like in VS Code. + * A `-command` entry removes matching defaults and leaves a key-less entry, + * so fallback labels do not bring a removed shortcut back. + */ +export function mergeUserKeybindings(defaults: Binding[], user: unknown, mac: boolean): Binding[] { + const list: Binding[] = defaults.map((b) => ({ + command: b.command, + key: mac ? (b.mac ?? b.key) : b.key, + when: b.when, + })) + if (!Array.isArray(user)) return list + for (const entry of user as Array>) { + if (!entry || typeof entry.command !== "string") continue + const key = typeof entry.key === "string" && entry.key.trim() ? norm(entry.key) : undefined + const when = typeof entry.when === "string" ? entry.when : undefined + if (entry.command.startsWith("-")) { + const command = entry.command.slice(1) + const keep = list.filter( + (b) => + b.command !== command || + (key !== undefined && (b.key === undefined || norm(b.key) !== key)) || + (when !== undefined && b.when !== when), + ) + list.splice(0, list.length, ...keep, { command }) + continue + } + if (key) list.push({ command: entry.command, key, when }) + } + return list +} + +/** + * Candidate keybindings.json files for a global storage path + * (`/globalStorage/`). A profile without its own + * keybindings uses the default profile file. + */ +export function userKeybindingFiles(storage: string): string[] { + const dir = path.dirname(path.dirname(storage)) + const files = [path.join(dir, "keybindings.json")] + if (path.basename(path.dirname(dir)) === "profiles") files.push(path.join(dir, "..", "..", "keybindings.json")) + return files.map((file) => path.normalize(file)) +} diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index 076f89f85b9..d579440bd5b 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -17,9 +17,11 @@ import { samePath } from "./project/paths" import type { KiloConnectionService } from "../services/cli-backend" import { KiloProvider } from "../KiloProvider" import { PLATFORM, SNAPSHOT_INITIALIZATION } from "./constants" +import { keybindings, watchKeybindings } from "../kilo-provider/shortcut-context" import { DiffVirtualProvider } from "../DiffVirtualProvider" import { buildWebviewHtml } from "../utils" import { openFileInEditor, getWorkspaceRoot } from "../review-utils" +import { isWebLink } from "../browser-links" import { TelemetryProxy, type TelemetryEventName } from "../services/telemetry" import type { AutoApproveController } from "../commands/toggle-auto-approve" import type { RemoteStatusService } from "../services/RemoteStatusService" @@ -86,6 +88,7 @@ export class VscodeHost implements Host { worktreeDirectories?: () => string[] workspaceRoot?: () => string | undefined projectId?: () => string | undefined + sessionProject?: () => string | undefined }, ): PanelContext { return this.wirePanel(panel, opts) @@ -98,6 +101,7 @@ export class VscodeHost implements Host { worktreeDirectories?: () => string[] workspaceRoot?: () => string | undefined projectId?: () => string | undefined + sessionProject?: () => string | undefined }, ): PanelContext { panel.webview.options = { @@ -191,7 +195,7 @@ export class VscodeHost implements Host { listSessions: (dir) => this.listProjectSessions(dir), trackSession: (id) => provider.trackSession(id), refreshSessions: () => provider.refreshSessions(), - registerSession: (s) => provider.registerSession(s), + registerSession: (s) => provider.registerSession(s, false, opts.sessionProject?.()), recoverPendingPrompts: () => provider.recoverPendingPrompts(), onFollowupAdopted: (cb) => provider.onFollowupAdopted(cb), acknowledgeDraft: (draftID, sessionID) => provider.acknowledgeDraft(draftID, sessionID), @@ -350,13 +354,6 @@ export class VscodeHost implements Host { if (invalid) throw new Error(vscode.l10n.t(invalid)) const git = await this.git() const selected = await this.directory(parent, vscode.l10n.t("Select a parent folder for the cloned repository.")) - if (!this.multiProject() || !vscode.workspace.isTrusted) { - throw new Error( - vscode.l10n.t( - "Cloning was cancelled because multi-project Agent Manager is disabled or the window is not trusted.", - ), - ) - } // Opening an existing checkout beats failing a clone into an occupied folder. const name = repoName(url) const existing = await this.existingCheckout(name, selected) @@ -410,10 +407,6 @@ export class VscodeHost implements Host { return root } - multiProject(): boolean { - return vscode.workspace.getConfiguration("kilo-code.new.experimental").get("multiProject", false) - } - browserAutomation(): boolean { return vscode.workspace.getConfiguration("kilo-code.new.experimental").get("browserAutomation", false) } @@ -459,12 +452,6 @@ export class VscodeHost implements Host { return vscode.workspace.onDidChangeWorkspaceFolders(() => cb()) } - onDidChangeMultiProject(cb: (enabled: boolean) => void): Disposable { - return vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration("kilo-code.new.experimental.multiProject")) cb(this.multiProject()) - }) - } - onDidChangeWorktreePool(cb: (enabled: boolean) => void): Disposable { return vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration("kilo-code.new.agentManager.worktreePool")) cb(this.worktreePool()) @@ -536,8 +523,11 @@ export class VscodeHost implements Host { } extensionKeybindings(): Array<{ command: string; key?: string; mac?: string; when?: string }> { - const ext = vscode.extensions.getExtension("kilocode.kilo-code") - return ext?.packageJSON?.contributes?.keybindings ?? [] + return keybindings(this.context) + } + + onDidChangeKeybindings(cb: () => void): Disposable { + return watchKeybindings(this.context, cb) } async copyToClipboard(text: string): Promise { @@ -553,6 +543,7 @@ export class VscodeHost implements Host { } openExternal(url: string): void { + if (!isWebLink(url)) return void vscode.env.openExternal(vscode.Uri.parse(url)) } diff --git a/packages/kilo-vscode/src/browser-links.ts b/packages/kilo-vscode/src/browser-links.ts new file mode 100644 index 00000000000..15d47bc636f --- /dev/null +++ b/packages/kilo-vscode/src/browser-links.ts @@ -0,0 +1,49 @@ +import * as vscode from "vscode" +import { integratedBrowserLinkDestination } from "./services/browser-automation/chrome-setting" + +const BROWSER_AUTOMATION = "kilo-code.new.experimental" + +/** True when the URL is an http(s) web link, which is all the browser handles. */ +export function isWebLink(url: string): boolean { + try { + const protocol = new URL(url).protocol + return protocol === "http:" || protocol === "https:" + } catch { + return false + } +} + +/** + * In-app routing needs a trusted workspace, the experimental Integrated Browser + * flag, the "integrated" destination, and an http(s) URL. Everything else stays + * external so a link is never swallowed when the browser is unavailable. + */ +export function shouldOpenLinkInIntegratedBrowser(url: string): boolean { + if (!vscode.workspace.isTrusted) return false + if (vscode.workspace.getConfiguration(BROWSER_AUTOMATION).get("browserAutomation", false) !== true) + return false + if (integratedBrowserLinkDestination() !== "integrated") return false + return isWebLink(url) +} + +/** + * Open a web link in the Kilo Integrated Browser through the provided opener, + * and fall back to the system browser when integration is unavailable or the + * opener cannot take the link. A navigation error inside the browser does not + * reject the opener, so this never opens both. + */ +export async function openBrowserLink(url: string, openIntegrated?: () => boolean): Promise { + const uri = vscode.Uri.parse(url) + if (shouldOpenLinkInIntegratedBrowser(url) && openerAccepted(openIntegrated)) return + await vscode.env.openExternal(uri) +} + +function openerAccepted(openIntegrated?: () => boolean): boolean { + if (!openIntegrated) return false + try { + return openIntegrated() + } catch (error) { + console.warn("[Kilo New] Integrated Browser open failed, opening externally:", error) + return false + } +} diff --git a/packages/kilo-vscode/src/browser-tab/BrowserTabProvider.ts b/packages/kilo-vscode/src/browser-tab/BrowserTabProvider.ts new file mode 100644 index 00000000000..5958ada7ae1 --- /dev/null +++ b/packages/kilo-vscode/src/browser-tab/BrowserTabProvider.ts @@ -0,0 +1,324 @@ +import * as vscode from "vscode" +import { + BrowserLaunchError, + diagnostic, + type BrowserBroker, + type BrowserOwner, + type BrowserRoute, + type BrowserState, +} from "../services/browser-automation" +import { + browserControlMessage, + browserStatePayload, + controlBrowser, + controlBrowserFailure, + type BrowserControlKind, + type BrowserControlMessage, + type BrowserControlSurface, +} from "../services/browser-automation/browser-control" +import type { BrowserCursor, BrowserInteraction, BrowserViewIdentity, BrowserViewport } from "../shared/browser-stream" +import type { BrowserReference } from "../shared/browser-feedback" +import { buildWebviewHtml } from "../utils" + +interface Panel { + panel: vscode.WebviewPanel + sessionId: string + ready: boolean + pendingUrl?: string +} + +export interface BrowserTabProviderOptions { + enabled: () => boolean + trusted: () => boolean + resolve: (route: BrowserRoute) => BrowserRoute | undefined + directory: (sessionId: string) => string | undefined + approve: (route: BrowserRoute, origin: string) => Promise + reference: (sessionId: string, reference: BrowserReference) => void + log?: (...args: unknown[]) => void +} + +type InMessage = { + type: string + sessionId?: string + browserId?: string + navigation?: number + viewport?: BrowserViewport + identity?: BrowserViewIdentity + event?: BrowserInteraction + sequence?: number + url?: string + requestId?: string + x?: number + y?: number + width?: number + height?: number + hover?: boolean + click?: boolean + theme?: "dark" | "light" + reference?: BrowserReference +} + +const prefix = "browserTab." + +/** + * Host side of the editor-tab Integrated Browser. Renders one webview panel per + * session and proxies browser commands to the shared {@link BrowserBroker}. + * + * Command handling is delegated to the shared {@link controlBrowser}, the same + * implementation the Agent Manager browser uses, so the two surfaces cannot + * drift. This provider only supplies the tab-specific message framing and route + * owner (sidebar sessions carry no Agent Manager project id). + */ +export class BrowserTabProvider { + private readonly panels = new Map() + private readonly disposables: vscode.Disposable[] = [] + readonly owner?: BrowserOwner + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly opts: { + browser?: BrowserBroker + connectionPort: () => number | undefined + }, + private readonly options: BrowserTabProviderOptions, + ) { + this.owner = this.opts.browser?.bind( + (route) => this.options.resolve(route), + (route, url) => this.options.approve(route, url.origin), + ) + if (this.opts.browser) { + this.disposables.push( + vscode.Disposable.from({ dispose: this.opts.browser.subscribe((state) => this.state(state)) }), + ) + this.disposables.push(vscode.Disposable.from({ dispose: this.opts.browser.frames((frame) => this.frame(frame)) })) + this.disposables.push( + vscode.Disposable.from({ dispose: this.opts.browser.cursors((cursor) => this.cursor(cursor)) }), + ) + } + } + + private log(...args: unknown[]): void { + this.options.log?.(...args) + } + + has(sessionId: string): boolean { + return this.panels.has(sessionId) + } + + open(sessionId: string): void { + const existing = this.panels.get(sessionId) + if (existing) { + existing.panel.reveal(existing.panel.viewColumn ?? vscode.ViewColumn.Active, false) + return + } + const directory = this.options.directory(sessionId) + const route = directory ? { sessionId, directory } : undefined + if (!route || !this.options.resolve(route)) { + this.log("Browser tab open rejected: session is not available", sessionId) + return + } + const panel = vscode.window.createWebviewPanel( + "kilo-code.new.browser", + "Integrated Browser", + vscode.ViewColumn.Active, + { + enableScripts: true, + enableForms: true, + retainContextWhenHidden: true, + localResourceRoots: [this.extensionUri], + }, + ) + panel.iconPath = { + light: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-light.svg"), + dark: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-dark.svg"), + } + panel.webview.html = this.html(panel.webview) + const entry: Panel = { panel, sessionId, ready: false } + this.panels.set(sessionId, entry) + panel.webview.onDidReceiveMessage((message: InMessage) => this.input(entry, message)) + panel.onDidDispose(() => { + this.panels.delete(sessionId) + void this.opts.browser?.closeScoped(sessionId).catch((error: unknown) => this.log("Browser close failed:", error)) + }) + } + + /** + * Open (or reveal) the browser tab for a session and navigate to a URL. Used + * when a chat web link should open in the Integrated Browser. Returns false + * when the browser cannot take the link, so the caller can fall back. + */ + openUrl(sessionId: string, url: string): boolean { + if (!this.options.trusted() || !this.options.enabled()) return false + const directory = this.options.directory(sessionId) + const route = directory ? { sessionId, directory } : undefined + if (!route || !this.options.resolve(route) || !this.opts.browser) return false + this.open(sessionId) + const entry = this.panels.get(sessionId) + if (!entry) return false + if (entry.ready) this.navigate(entry, route, url) + else entry.pendingUrl = url + return true + } + + private navigate(entry: Panel, route: BrowserRoute, url: string): void { + entry.pendingUrl = undefined + void this.opts.browser?.open(route, url, false).catch((error: unknown) => { + this.log("Browser open failed:", error) + // Surface early failures (for example no Chromium) instead of leaving the + // link silently unopened. Keep the `missing` hint so the tab can offer the + // install affordance, matching the shared in-panel error path. + entry.panel.webview.postMessage({ + type: "browserTab.state", + browserId: "", + sessionId: entry.sessionId, + status: "error", + errors: 0, + error: diagnostic(error, url), + missing: error instanceof BrowserLaunchError ? error.missing : undefined, + }) + }) + } + + dispose(): void { + for (const entry of this.panels.values()) entry.panel.dispose() + this.panels.clear() + if (this.owner) this.opts.browser?.unbind(this.owner) + for (const disposable of this.disposables.splice(0)) disposable.dispose() + } + + private html(webview: vscode.Webview): string { + return buildWebviewHtml(webview, { + scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "browser-tab.js")), + styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "browser-tab.css")), + iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")), + workerUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "shiki-worker.js")), + title: "Integrated Browser", + port: this.opts.connectionPort(), + browserAutomation: this.options.enabled(), + frameSrc: ["localhost", "127.0.0.1"].map((host) => `http://${host}:*`).join(" "), + }) + } + + private state(state: BrowserState): void { + if (state.projectId) return + const entry = this.panels.get(state.sessionId) + if (!entry) { + // Open on any live status so a fast open that reaches "ready" before this + // listener runs still reveals the tab. + if (state.status !== "closed" && this.options.enabled()) this.open(state.sessionId) + return + } + if (!entry.ready) return + entry.panel.webview.postMessage(this.stateMessage(state)) + } + + private frame(frame: object & { sessionId: string; projectId?: string }): void { + if (frame.projectId) return + const entry = this.panels.get(frame.sessionId) + if (!entry?.ready) return + entry.panel.webview.postMessage({ ...frame, type: "browserTab.frame" }) + } + + private cursor(cursor: BrowserCursor & { sessionId: string; projectId?: string }): void { + if (cursor.projectId) return + const entry = this.panels.get(cursor.sessionId) + if (!entry?.ready) return + entry.panel.webview.postMessage({ ...cursor, type: "browserTab.cursor" }) + } + + private stateMessage(state: BrowserState) { + return { type: "browserTab.state" as const, ...browserStatePayload(state) } + } + + private control(message: InMessage): BrowserControlMessage { + return browserControlMessage(message.type.slice(prefix.length) as BrowserControlKind, message) + } + + private surface(entry: Panel): BrowserControlSurface { + const sessionId = entry.sessionId + return { + sessionId, + projectId: undefined, + openDirectory: () => this.options.directory(sessionId), + readClipboard: async () => vscode.env.clipboard.readText(), + writeClipboard: async (text) => { + await vscode.env.clipboard.writeText(text) + }, + state: (state, error, missing) => { + if (state) { + entry.panel.webview.postMessage(this.stateMessage(error ? { ...state, error } : state)) + return + } + entry.panel.webview.postMessage({ + type: "browserTab.state", + browserId: "", + sessionId, + status: "error", + errors: 0, + error, + missing, + }) + }, + inspection: ({ requestId, hover, value, error }) => { + if (value) { + entry.panel.webview.postMessage({ type: "browserTab.inspection", sessionId, requestId, ...value, hover }) + return + } + entry.panel.webview.postMessage({ type: "browserTab.inspection", sessionId, requestId, hover, logs: [], error }) + }, + devtools: (tools) => entry.panel.webview.postMessage({ type: "browserTab.devtools", sessionId, ...tools }), + log: (...args) => this.log(...args), + } + } + + private input(entry: Panel, message: InMessage): void { + if (message.type === "browserTab.ready") { + entry.ready = true + entry.panel.webview.postMessage({ + type: "browserTab.scope", + sessionId: entry.sessionId, + browserAutomation: this.options.enabled(), + }) + if (entry.pendingUrl) { + const url = entry.pendingUrl + const directory = this.options.directory(entry.sessionId) + const route = directory ? { sessionId: entry.sessionId, directory } : undefined + if (route && this.options.resolve(route)) this.navigate(entry, route, url) + else entry.pendingUrl = undefined + } + return + } + if (message.type === "browserTab.openSettings") { + void vscode.commands.executeCommand("kilo-code.new.settingsButtonClicked", "experimental") + return + } + if (message.type === "browserTab.openExternal") { + if (!message.url) return + const uri = vscode.Uri.parse(message.url) + if (uri.scheme !== "http" && uri.scheme !== "https") return + void vscode.env.openExternal(uri) + return + } + if (message.type === "browserTab.reference") { + if (message.reference) this.options.reference(entry.sessionId, message.reference) + return + } + if (!message.type.startsWith(prefix)) return + const control = this.control(message) + if (!this.options.trusted()) { + controlBrowserFailure(control, this.surface(entry), "Browser preview requires a trusted workspace.") + return + } + if (!this.options.enabled()) { + controlBrowserFailure( + control, + this.surface(entry), + "Browser automation is disabled. Enable it in Kilo Settings > Experimental.", + ) + return + } + const browser = this.opts.browser + if (!browser) return + controlBrowser(control, browser, this.surface(entry)) + } +} diff --git a/packages/kilo-vscode/src/documents/document-reader.ts b/packages/kilo-vscode/src/documents/document-reader.ts index 1ab6c6075a1..1e6f3f63637 100644 --- a/packages/kilo-vscode/src/documents/document-reader.ts +++ b/packages/kilo-vscode/src/documents/document-reader.ts @@ -1,4 +1,5 @@ import * as fs from "fs" +import * as os from "os" import * as path from "path" const MAX_TEXT_BYTES = 2_000_000 @@ -30,6 +31,24 @@ export function isInsideWorktree( return target === base || target.startsWith(base + sep) } +/** + * Canonical plans dir for non-git projects, mirroring the CLI's + * `Global.Path.data` resolution (`XDG_DATA_HOME` or `~/.local/share`, same + * fallback pattern as `config-file.ts` and `cli-resources.ts`). For non-git + * projects plan files legitimately live here, outside the worktree, and the + * CLI's plan-file logic already admits the dir — see #14588. + */ +export function plansDir(dataHome = process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share")): string { + return path.join(dataHome, "kilo", "plans") +} + +/** Whether `resolved` lives in the canonical plans dir (realpath-folded for tmpfs/symlink cases). */ +function isPlan(resolved: string): boolean { + const dir = plansDir() + if (!fs.existsSync(dir)) return false + return isInsideWorktree(fs.realpathSync(dir), resolved) +} + function mime(file: string): string | undefined { const ext = path.extname(file).toLowerCase() if (ext === ".png") return "image/png" @@ -47,7 +66,8 @@ export function readDocument(root: string, file: string): DocumentResult { const base = fs.realpathSync(root) const target = path.isAbsolute(file) ? file : path.resolve(root, file) const resolved = fs.realpathSync(target) - if (!isInsideWorktree(base, resolved)) return { error: "Document is outside the worktree." } + const inside = isInsideWorktree(base, resolved) + if (!inside && !isPlan(resolved)) return { error: "Document is outside the worktree." } const stat = fs.statSync(resolved) if (!stat.isFile()) return { error: "Document is not a file." } @@ -56,7 +76,7 @@ export function readDocument(root: string, file: string): DocumentResult { const limit = type ? MAX_IMAGE_BYTES : MAX_TEXT_BYTES if (stat.size > limit) return { error: "Document is too large to preview." } - const relative = path.relative(base, resolved).split(path.sep).join("/") + const relative = inside ? path.relative(base, resolved).split(path.sep).join("/") : resolved if (type) return { file: relative, kind: "image", mime: type, data: fs.readFileSync(resolved).toString("base64") } const content = fs.readFileSync(resolved) diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 2270e8de822..ba71211f2ae 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode" import { basename } from "node:path" +import { realpathSync } from "node:fs" import { KiloProvider } from "./KiloProvider" import { AgentManagerProvider } from "./agent-manager/AgentManagerProvider" import { VscodeHost } from "./agent-manager/vscode-host" @@ -11,6 +12,8 @@ import { SettingsEditorProvider } from "./SettingsEditorProvider" import { MarketplacePanelProvider } from "./MarketplacePanelProvider" import { MarketplaceNotifier } from "./services/marketplace/notifier" import { SubAgentViewerProvider } from "./SubAgentViewerProvider" +import { BrowserTabProvider } from "./browser-tab/BrowserTabProvider" +import { formatBrowserFeedback } from "./shared/browser-feedback" import { EXTENSION_DISPLAY_NAME } from "./constants" import { KiloConnectionService } from "./services/cli-backend" import { retention } from "./services/task-cleanup/retention" @@ -23,6 +26,7 @@ import { confirmCaffeination } from "./services/caffeination/confirm" import { createCaffeinationDriver } from "./services/caffeination/inhibitor" import { BrowserAutomationService, BrowserBroker } from "./services/browser-automation" import { + integratedBrowserFallback, integratedBrowserUseSystemChrome, migrateIntegratedBrowserUseSystemChrome, } from "./services/browser-automation/chrome-setting" @@ -38,6 +42,8 @@ import { createNotebookBridge } from "./services/notebook" import { createGitExecutable } from "./util/git-executable" import { isCursorHost } from "./utils" import { sameDirectory } from "./kilo-provider-utils" +import { mcpAuth } from "./services/mcp-auth" +import { showAuthUrl } from "./kilo-provider/mcp-oauth" let agentManager: AgentManagerProvider | undefined let caffeination: CaffeinationService | undefined @@ -76,7 +82,10 @@ export async function activate(context: vscode.ExtensionContext) { enabled: () => vscode.workspace.getConfiguration("kilo-code.new.experimental").get("browserAutomation", false), trusted: () => vscode.workspace.isTrusted, useSystemChrome: () => integratedBrowserUseSystemChrome(), + fallback: () => process.platform === "linux" && integratedBrowserFallback(), + theme: browserTheme, }) + context.subscriptions.push(vscode.window.onDidChangeActiveColorTheme(() => browserBroker.retheme())) // Create shared connection service (one server for all webviews) const connectionService = new KiloConnectionService( @@ -85,6 +94,12 @@ export async function activate(context: vscode.ExtensionContext) { (dir): Promise => browserAutomationService.ready(dir), ) + // One MCP OAuth sign-in service for the whole extension (sidebar, Kilo tabs, Settings, Marketplace). + // Initialize it eagerly so its MCP OAuth URL subscriptions and `onUrl` + // fallback are wired up exactly once, instead of racing whichever + // KiloProvider happens to construct first. + mcpAuth(connectionService, { onUrl: showAuthUrl }) + // Manages the built-in Playwright MCP server for ordinary sessions. This is // independent from the Agent Manager browser broker above. const browserAutomationService = new BrowserAutomationService(connectionService) @@ -179,6 +194,93 @@ export async function activate(context: vscode.ExtensionContext) { }) provider.setRemoteService(remoteService) + // Editor-tab Integrated Browser for sidebar and Kilo editor-tab sessions. It + // registers its route owner before the Agent Manager so sidebar sessions are + // claimed here (no project id) while Agent Manager worktree requests, which + // carry a project id, still resolve to Agent Manager. + // + // macOS resolves /tmp to /private/tmp, and the CLI reports canonical + // directories. Canonicalize both sides so a workspace opened through a + // symlinked path still matches the session directory the backend records. + const canonical = new Map() + const real = (value: string): string => { + const cached = canonical.get(value) + if (cached !== undefined) return cached + const resolved = (() => { + try { + return realpathSync.native(value) + } catch { + return value + } + })() + canonical.set(value, resolved) + return resolved + } + const sessionDirectory = (sessionId: string): string | undefined => { + const directory = + provider.getSessionDirectories().get(sessionId) ?? + [...tabPanels.values()] + .map((item) => item.getSessionDirectories().get(sessionId)) + .find((value) => value !== undefined) ?? + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + return directory ? real(directory) : undefined + } + const browserTabProvider = new BrowserTabProvider( + context.extensionUri, + { browser: browserBroker, connectionPort: () => connectionService.getServerInfo()?.port }, + { + enabled: () => vscode.workspace.getConfiguration("kilo-code.new.experimental").get("browserAutomation", false), + trusted: () => vscode.workspace.isTrusted, + resolve: (route) => { + if (!vscode.workspace.isTrusted) return + if (!vscode.workspace.getConfiguration("kilo-code.new.experimental").get("browserAutomation", false)) return + // The Agent Manager owns project-scoped browser entries and keeps them + // alive across session switches. Defer while it holds one for this + // session so a shared root session does not get two browser entries. + if (browserTabProvider.owner && browserBroker.ownedByOther(route.sessionId, browserTabProvider.owner)) return + const tabs = [...tabPanels.values()] + const surfaced = + provider.getCurrentSessionId() === route.sessionId || + tabs.some((item) => item.getCurrentSessionId() === route.sessionId) || + // Keep an open browser tab's session owned while the user views another + // session, so switching sessions does not invalidate its live browser. + browserTabProvider.has(route.sessionId) + if (!surfaced) return + const directory = sessionDirectory(route.sessionId) + if (!directory || !sameDirectory(directory, real(route.directory))) return + return { sessionId: route.sessionId, directory: real(route.directory) } + }, + directory: sessionDirectory, + approve: async (_route, origin) => { + if (!vscode.workspace.isTrusted) return false + const answer = await vscode.window.showWarningMessage( + `Allow the Integrated Browser to navigate to ${origin}?`, + { modal: true }, + "Allow", + ) + return answer === "Allow" + }, + reference: (sessionId, value) => { + const target = + [...tabPanels.values()].find((item) => item.getCurrentSessionId() === sessionId) ?? + (provider.getCurrentSessionId() === sessionId ? provider : undefined) + if (!target) return + target.postMessage({ type: "appendChatBoxMessage", text: formatBrowserFeedback([value]), browser: value }) + }, + log: (...args) => console.warn("[Kilo New] BrowserTabProvider:", ...args), + }, + ) + context.subscriptions.push({ dispose: () => browserTabProvider.dispose() }) + + const openBrowserTab = () => { + const sessionId = activeTabProvider()?.getCurrentSessionId() ?? provider.getCurrentSessionId() + if (!sessionId) { + void vscode.window.showWarningMessage("Start a session before opening the Integrated Browser.") + return + } + browserTabProvider.open(sessionId) + } + const deliver = (comments: unknown[], autoSend: boolean, sessionID?: string, directory?: string): void => { const target = sessionID ? [...tabPanels.values()].find((item) => { @@ -304,6 +406,9 @@ export async function activate(context: vscode.ExtensionContext) { provider.setCreateWorktreeHandler((baseBranch, branchName) => agentManagerProvider.createFromSidebar(baseBranch, branchName), ) + // Chat web links open in the Integrated Browser tab for the current session. + const openLink = (url: string, sessionId?: string) => (sessionId ? browserTabProvider.openUrl(sessionId, url) : false) + provider.setOpenLinkHandler(openLink) // Register toggle auto-approve shortcut (Ctrl+Alt+A / Cmd+Alt+A) const defaultDir = () => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd() @@ -368,6 +473,7 @@ export async function activate(context: vscode.ExtensionContext) { worktreeDirectories: () => agentManagerProvider.getWorktreeDirectories(), workspaceRoot: () => agentManagerProvider.workspaceRoot(), projectId: () => agentManagerProvider.projectId(), + sessionProject: () => agentManagerProvider.sessionProject(), }) agentManagerProvider.deserializePanel(ctx) return Promise.resolve() @@ -396,6 +502,7 @@ export async function activate(context: vscode.ExtensionContext) { tabProvider.setCreateWorktreeHandler((baseBranch, branchName) => agentManagerProvider.createFromSidebar(baseBranch, branchName), ) + tabProvider.setOpenLinkHandler(openLink) tabProvider.setDiffVirtualProvider(diffVirtualProvider) tabProvider.setDiffViewerProvider(diffViewerProvider) tabProvider.setReviewCommentsHandler(deliver) @@ -601,9 +708,15 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("kilo-code.new.settingsButtonClicked", (tab?: string, projectId?: string) => { settingsEditorProvider.openPanel("settings", tab, projectId) }), + vscode.commands.registerCommand("kilo-code.new.settingsSearch", () => { + settingsEditorProvider.focusSearch() + }), vscode.commands.registerCommand("kilo-code.new.openIndexingSettings", () => { settingsEditorProvider.openPanel("settings", "indexing") }), + vscode.commands.registerCommand("kilo-code.new.openMcpSettings", (focus?: string) => { + settingsEditorProvider.openPanel("settings", "agentBehaviour", undefined, "mcpServers", focus) + }), vscode.commands.registerCommand("kilo-code.new.showMemory", async () => { if (agentManagerProvider.isActive()) { await agentManagerProvider.showMemory() @@ -652,6 +765,9 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("kilo-code.new.openInTab", () => { return openKiloInNewTab(context, tabPanels, attach) }), + vscode.commands.registerCommand("kilo-code.new.openBrowser", () => { + openBrowserTab() + }), vscode.commands.registerCommand( "kilo-code.new.showChanges", (arg?: Parameters[0]) => { @@ -664,6 +780,9 @@ export async function activate(context: vscode.ExtensionContext) { subAgentViewerProvider.openPanel(sessionID, title, directory) }, ), + // One shortcut pair serves the sidebar, Kilo editor tabs, and Agent Manager. + // Every surface receives the action and acts only while it has focus, matching + // cycleAgentMode, so one binding never depends on a shared focus context key. vscode.commands.registerCommand("kilo-code.new.agentManager.previousSession", () => { agentManagerProvider.postMessage({ type: "action", action: "sessionPrevious" }) }), @@ -671,9 +790,15 @@ export async function activate(context: vscode.ExtensionContext) { agentManagerProvider.postMessage({ type: "action", action: "sessionNext" }) }), vscode.commands.registerCommand("kilo-code.new.agentManager.previousTab", () => { + const tab = activeTabProvider() + if (tab) tab.postMessage({ type: "action", action: "tabPrevious" }) + provider.postMessage({ type: "action", action: "tabPrevious" }) agentManagerProvider.postMessage({ type: "action", action: "tabPrevious" }) }), vscode.commands.registerCommand("kilo-code.new.agentManager.nextTab", () => { + const tab = activeTabProvider() + if (tab) tab.postMessage({ type: "action", action: "tabNext" }) + provider.postMessage({ type: "action", action: "tabNext" }) agentManagerProvider.postMessage({ type: "action", action: "tabNext" }) }), vscode.commands.registerCommand("kilo-code.new.agentManager.previousTerminal", () => { @@ -826,6 +951,12 @@ export async function deactivate() { TelemetryProxy.getInstance().shutdown() } +/** Current IDE color scheme, so the Integrated Browser matches the editor theme. */ +function browserTheme(): "dark" | "light" { + const kind = vscode.window.activeColorTheme.kind + return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? "light" : "dark" +} + function openKiloInNewTab( context: vscode.ExtensionContext, tabPanels: Map, diff --git a/packages/kilo-vscode/src/indexing-consent.ts b/packages/kilo-vscode/src/indexing-consent.ts index 54f85f33f72..58444335e53 100644 --- a/packages/kilo-vscode/src/indexing-consent.ts +++ b/packages/kilo-vscode/src/indexing-consent.ts @@ -1,6 +1,6 @@ import * as path from "path" import type * as vscode from "vscode" -import simpleGit from "simple-git" +import { simpleGit } from "simple-git" import { canonicalizePath, projectIdFor, resolveProjectRoot, samePath } from "./agent-manager/project/paths" import { ProjectRegistry } from "./agent-manager/project/registry" diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index b8edc0b6364..cd584f80bf8 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -111,11 +111,13 @@ export function getConfigErrorDetails(error: unknown): string | undefined { if (!data || typeof data !== "object") return undefined const scoped = data as Record const path = typeof scoped.path === "string" ? scoped.path : undefined + const shadowedBy = typeof scoped.shadowedBy === "string" ? scoped.shadowedBy : undefined const issues = Array.isArray(scoped.issues) ? scoped.issues : undefined - if (!path && (!issues || issues.length === 0)) return undefined + if (!path && !shadowedBy && (!issues || issues.length === 0)) return undefined const out: string[] = [] if (path) out.push(`File: ${path}`) + if (shadowedBy) out.push(`Shadowed by: ${shadowedBy}`) if (issues && issues.length > 0) { if (out.length > 0) out.push("") // prettifyError accepts any object with an `issues` array; the cast is @@ -201,7 +203,7 @@ export async function runWithMessageConfirmation( } export function sessionToWebview( - session: Pick, + session: Pick, ) { const goal = session.metadata?.["kilo.goal"] return { @@ -210,6 +212,14 @@ export function sessionToWebview( title: session.title, createdAt: new Date(session.time.created).toISOString(), updatedAt: new Date(session.time.updated).toISOString(), + // The agent and model the server last ran, so a reopened session shows + // them before its history loads. The server stores Default as "default". + agent: session.agent, + model: session.model && { + providerID: session.model.providerID, + modelID: session.model.id, + ...(session.model.variant && session.model.variant !== "default" ? { variant: session.model.variant } : {}), + }, // Use null (not undefined) so the value survives postMessage JSON serialization. // Without this, unrevert responses lose the revert key entirely and the // SolidJS store merge never clears the existing revert state. @@ -780,6 +790,7 @@ export function mapCloudSessionMessageToWebviewMessage(message: CloudSessionMess id: message.info.id, sessionID: message.info.sessionID, role: message.info.role as "user" | "assistant", + parentID: message.info.parentID, parts: message.parts, createdAt: message.info.time?.created ? new Date(message.info.time.created).toISOString() diff --git a/packages/kilo-vscode/src/kilo-provider/catalog-retry.ts b/packages/kilo-vscode/src/kilo-provider/catalog-retry.ts new file mode 100644 index 00000000000..4cbb56512df --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/catalog-retry.ts @@ -0,0 +1,51 @@ +/** + * Re-fetches providers while an organization's Kilo catalog is unavailable. + * The CLI recovers the catalog in the background but does not notify clients, + * so without this the model picker would stay empty until a reload. + */ +export function createCatalogRetry(opts: { + refresh: () => void + schedule?: (run: () => void, ms: number) => () => void + delays?: readonly number[] +}) { + const schedule = + opts.schedule ?? + ((run: () => void, ms: number) => { + const timer = setTimeout(run, ms) + return () => clearTimeout(timer) + }) + // Matches the CLI's own catalog recovery backoff. + const delays = opts.delays ?? [30_000, 60_000, 120_000, 240_000, 300_000] + let step = 0 + let cancel: (() => void) | undefined + let disposed = false + + const stop = () => { + cancel?.() + cancel = undefined + } + + return { + /** Arm one retry while the catalog stays unavailable; reset once it loads. */ + update(unavailable: boolean) { + // A fetch that settles after dispose must not re-arm the loop. + if (disposed) return + if (!unavailable) { + stop() + step = 0 + return + } + if (cancel) return + const delay = delays.at(Math.min(step, delays.length - 1)) ?? 300_000 + step++ + cancel = schedule(() => { + cancel = undefined + opts.refresh() + }, delay) + }, + dispose() { + disposed = true + stop() + }, + } +} diff --git a/packages/kilo-vscode/src/kilo-provider/chat-settings.ts b/packages/kilo-vscode/src/kilo-provider/chat-settings.ts index 8defb9dae74..9c385cd2028 100644 --- a/packages/kilo-vscode/src/kilo-provider/chat-settings.ts +++ b/packages/kilo-vscode/src/kilo-provider/chat-settings.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode" +import { integratedBrowserLinkDestination } from "../services/browser-automation/chrome-setting" type Post = (msg: unknown) => void @@ -8,6 +9,11 @@ export function buildChatSettingsMessage() { type: "chatSettingsLoaded" as const, settings: { shiftTabCyclesVariant: config.get("shiftTabCyclesVariant", true), + browserAutomation: vscode.workspace + .getConfiguration("kilo-code.new.experimental") + .get("browserAutomation", false), + agentManagerBrowserOpenLinksIn: integratedBrowserLinkDestination(), + workspaceTrusted: vscode.workspace.isTrusted, }, } } @@ -22,7 +28,11 @@ export function buildTimelineSettingMessage() { export function watchChatConfig(post: Post): vscode.Disposable { return vscode.workspace.onDidChangeConfiguration((event) => { - if (event.affectsConfiguration("kilo-code.new.chat")) { + if ( + event.affectsConfiguration("kilo-code.new.chat") || + event.affectsConfiguration("kilo-code.new.experimental.browserAutomation") || + event.affectsConfiguration("kilo-code.new.agentManager.browser.openLinksIn") + ) { post(buildChatSettingsMessage()) } if (event.affectsConfiguration("kilo-code.new.showTaskTimeline")) { diff --git a/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts index be2f7f1dc30..7180d54792e 100644 --- a/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts +++ b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts @@ -7,7 +7,6 @@ type Client = Pick type Settings = { maxCost: number languageCommitMessage: string - multiProject: boolean claudeMigration: boolean "agentManager.autoBranchNaming": boolean "agentManager.branchPrefix": string diff --git a/packages/kilo-vscode/src/kilo-provider/early-message.ts b/packages/kilo-vscode/src/kilo-provider/early-message.ts index 7b36339de6a..2e0493f82e5 100644 --- a/packages/kilo-vscode/src/kilo-provider/early-message.ts +++ b/packages/kilo-vscode/src/kilo-provider/early-message.ts @@ -1,5 +1,4 @@ import { routeSuggestionWebviewMessage } from "./handlers/suggestion" -import * as ModelState from "./model-state" import { routeInputToolMessage } from "../services/input-tools" import type { KiloConnectionService } from "../services/cli-backend/connection-service" import type { SpeechToTextSource } from "../speech-to-text/source" @@ -110,7 +109,6 @@ export async function routeEarlyMessage( return true } await routeSuggestionWebviewMessage(ctx.question, message) - if (await ModelState.handleMessage(message.type, message, ctx.client, ctx.post)) return true if (message.type === "exportSessionTranscript") { const input = message as { sessionID?: unknown } if (typeof input.sessionID === "string") await ctx.exportTranscript(input.sessionID) diff --git a/packages/kilo-vscode/src/kilo-provider/editor-actions.ts b/packages/kilo-vscode/src/kilo-provider/editor-actions.ts index 866f8538bef..ef646fb460e 100644 --- a/packages/kilo-vscode/src/kilo-provider/editor-actions.ts +++ b/packages/kilo-vscode/src/kilo-provider/editor-actions.ts @@ -4,6 +4,7 @@ import { escapeGlob, isAbsolutePath } from "../path-utils" import { validateFiles } from "./file-links" import type { DiffVirtualFile, DiffVirtualProvider } from "../DiffVirtualProvider" import { isPRReviewComment, parseReview, type PRReviewCommentData } from "../shared/review-comments" +import { isWebLink, openBrowserLink } from "../browser-links" type EditorOpenMessage = { type?: string @@ -21,9 +22,18 @@ function isMarkdownFile(file: string): boolean { function openExternal(url: unknown): void { if (typeof url !== "string") return + if (!isWebLink(url)) return void vscode.env.openExternal(vscode.Uri.parse(url)) } +function openLink(url: unknown, openIntegrated?: (url: string) => boolean): void { + if (typeof url !== "string") return + void openBrowserLink(url, openIntegrated ? () => openIntegrated(url) : undefined).catch((err) => { + console.error("[Kilo New] KiloProvider: open link failed:", err) + void vscode.window.showErrorMessage(err instanceof Error ? err.message : String(err)) + }) +} + function openDiffVirtual(provider: DiffVirtualProvider | undefined, diff: unknown, initialDiffStyle?: unknown): void { if (!provider || !diff) return const file = diff as DiffVirtualFile @@ -94,6 +104,7 @@ export function handleEditorAction( diff?: DiffVirtualProvider openMarkdown?: (file: string, sessionID?: string) => boolean openPRComment?: (comment: PRReviewCommentData, sessionID?: string) => void + openLink?: (url: string) => boolean storage?: vscode.Uri post?: (msg: unknown) => void }, @@ -136,6 +147,10 @@ export function handleEditorAction( openExternal(message.url) return true } + if (message.type === "openWebLink") { + openLink(message.url, opts.openLink) + return true + } if (message.type === "openDiffVirtual") { openDiffVirtual(opts.diff, message.diff, message.initialDiffStyle) return true diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts index 1db39056c44..f534ced3a8f 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts @@ -91,7 +91,11 @@ export async function handleRequestCloudSessionData(ctx: CloudSessionContext, se return } - const messages = (data.messages ?? []).filter((m) => m.info).map(mapCloudSessionMessageToWebviewMessage) + // The export can invert same-millisecond ties; order by time, then id, like the CLI DB. + const messages = (data.messages ?? []) + .filter((m) => m.info) + .sort((a, b) => (a.info.time?.created ?? 0) - (b.info.time?.created ?? 0) || (a.info.id < b.info.id ? -1 : 1)) + .map(mapCloudSessionMessageToWebviewMessage) ctx.postMessage({ type: "cloudSessionDataLoaded", 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 65d16535d1e..4d50f480d38 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts @@ -106,8 +106,8 @@ export async function handlePermissionResponse( return } - const dir = ctx.getPermissionDirectory(permissionId) const target = ctx.getPermissionSession?.(permissionId) ?? sessionID + const dir = ctx.getPermissionDirectory(permissionId) ?? (target ? ctx.getWorkspaceDirectory(target) : undefined) const claimed = ctx.isPermissionResponseClaimed?.(permissionId) ?? false if (!target || (!dir && !claimed) || (ctx.getPermissionSession?.(permissionId) && target !== sessionID)) { console.error("[Kilo New] KiloProvider: Unknown permission route") diff --git a/packages/kilo-vscode/src/kilo-provider/mcp-oauth.ts b/packages/kilo-vscode/src/kilo-provider/mcp-oauth.ts index bf2327e2fac..9fca1d99896 100644 --- a/packages/kilo-vscode/src/kilo-provider/mcp-oauth.ts +++ b/packages/kilo-vscode/src/kilo-provider/mcp-oauth.ts @@ -1,28 +1,58 @@ import * as vscode from "vscode" -import type { KiloClient, McpStatus } from "@kilocode/sdk/v2/client" -import { getErrorMessage } from "../kilo-provider-utils" +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { t } from "../services/i18n" +import type { McpAuthResult } from "../services/mcp-auth" -let lastMcpBrowserOpen: { url: string; at: number } | null = null +/** + * Shown when the CLI could not open a browser for an in-progress MCP OAuth + * sign-in (`mcp.browser.open.failed`). Non-modal — the CLI is still waiting + * on the OAuth callback — and offers to open the URL or copy it for use on + * another machine (e.g. remote development over SSH). + */ +export function showAuthUrl(name: string, url: string): void { + void vscode.window + .showInformationMessage( + t("mcp.auth.browserFailed", { name }), + t("mcp.auth.browserFailed.open"), + t("mcp.auth.browserFailed.copy"), + ) + .then((choice) => { + if (choice === t("mcp.auth.browserFailed.open")) { + void vscode.env.openExternal(vscode.Uri.parse(url)) + return + } + if (choice === t("mcp.auth.browserFailed.copy")) { + void vscode.env.clipboard.writeText(url) + } + }) +} -/** Dedupe when several webviews receive the same `mcp.browser.open.failed` SSE. */ -export function openMcpOAuthUrlOnce(url: string): void { - const now = Date.now() - if (lastMcpBrowserOpen && lastMcpBrowserOpen.url === url && now - lastMcpBrowserOpen.at < 4000) return - lastMcpBrowserOpen = { url, at: now } - void vscode.env.openExternal(vscode.Uri.parse(url)).then( - (opened) => { - if (opened) return - void vscode.window.showErrorMessage( - "MCP sign-in failed to open the browser. Check the Kilo logs for the authentication URL.", - ) - }, - (error) => { - console.error("[Kilo New] Failed to open MCP OAuth URL:", error) - void vscode.window.showErrorMessage( - "MCP sign-in failed to open the browser. Check the Kilo logs for the authentication URL.", - ) - }, - ) +/** Native sign-in outcome notification shown when the caller did not request a silent/inline result (see `SignInMcpMessage.notify`). */ +export function notifySignInResult(name: string, result: McpAuthResult): void { + if (result.status === "cancelled") return + if (result.status === "connected") { + void vscode.window.showInformationMessage(t("mcp.signIn.success", { name })) + return + } + if (result.status === "timeout") { + void vscode.window.showErrorMessage(t("mcp.signIn.timeout", { name })) + return + } + if (result.status === "unsupported") { + void vscode.window.showErrorMessage(t("mcp.signIn.unsupported", { name })) + return + } + if (result.status === "not_found") { + void vscode.window.showErrorMessage(t("mcp.signIn.notFound", { name })) + return + } + const base = t("mcp.signIn.failed", { name }) + void vscode.window.showErrorMessage(result.error ? `${base} ${result.error}` : base) +} + +/** Shown when `McpAuthService.reset` fails to clear stored credentials. */ +export function notifyResetFailed(name: string): void { + void vscode.window.showErrorMessage(t("mcp.auth.resetFailed", { name })) } export async function connectMcpServer( @@ -54,29 +84,3 @@ export async function disconnectMcpServer( await refreshStatus() } } - -export async function authenticateMcpServer( - client: KiloClient, - name: string, - directory: string, - refreshStatus: () => Promise, -): Promise { - try { - const { data, error } = await client.mcp.auth.authenticate({ name, directory }) - if (error) { - vscode.window.showErrorMessage(`MCP sign-in failed: ${getErrorMessage(error)}`) - return - } - const status = data as McpStatus | undefined - if (status?.status === "failed") { - vscode.window.showErrorMessage(status.error || "MCP OAuth failed") - } else if (status?.status === "needs_client_registration") { - vscode.window.showErrorMessage(status.error || "MCP server requires client registration in config") - } - } catch (error) { - console.error("[Kilo New] Failed to authenticate MCP:", name, error) - vscode.window.showErrorMessage(getErrorMessage(error) || "MCP sign-in failed") - } finally { - await refreshStatus() - } -} diff --git a/packages/kilo-vscode/src/kilo-provider/model-state.ts b/packages/kilo-vscode/src/kilo-provider/model-state.ts deleted file mode 100644 index 815d85a2e23..00000000000 --- a/packages/kilo-vscode/src/kilo-provider/model-state.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Model selection persistence in the CLI state directory. - * - * Per-mode choices use the shared model.json. The explicit preferred combo uses - * vscode-model.json so CLI/TUI writers cannot discard extension-only state. - */ - -import * as fs from "fs" -import * as path from "path" -import { randomUUID } from "crypto" -import type { KiloClient } from "@kilocode/sdk/v2/client" -import { validateModelSelections } from "../provider-actions" - -type PostMessage = (msg: unknown) => void - -let cached: string | undefined -let queue: Promise = Promise.resolve() - -async function resolve(client: KiloClient | null): Promise { - if (cached) return cached - if (!client) return undefined - try { - const resp = await client.path.get() - if (typeof resp?.data?.state !== "string" || !resp.data.state) return undefined - cached = resp.data.state - return cached - } catch { - return undefined - } -} - -async function read(file: string): Promise> { - try { - const raw = await fs.promises.readFile(file, "utf-8") - const parsed = JSON.parse(raw) - return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) - ? (parsed as Record) - : {} - } catch { - return {} - } -} - -function selection(raw: unknown) { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined - const value = raw as Record - if ( - typeof value.providerID !== "string" || - !value.providerID || - typeof value.modelID !== "string" || - !value.modelID || - (value.variant !== undefined && typeof value.variant !== "string") - ) { - return undefined - } - return { - providerID: value.providerID, - modelID: value.modelID, - ...(typeof value.variant === "string" ? { variant: value.variant } : {}), - } -} - -function write( - client: KiloClient | null, - update: (data: Record) => void, - preferred?: ReturnType, -): Promise { - const op = queue.then(async () => { - const dir = await resolve(client) - if (!dir) return - const file = path.join(dir, "model.json") - const existing = await read(file) - update(existing) - await fs.promises.writeFile(file, JSON.stringify(existing, null, 2)) - const target = path.join(dir, "vscode-model.json") - const temp = `${target}.${randomUUID()}.tmp` - try { - await fs.promises.writeFile(temp, JSON.stringify({ preferred }, null, 2)) - await fs.promises.rename(temp, target) - } finally { - await fs.promises.rm(temp, { force: true }).catch((err) => { - console.error("[Kilo New] Failed to remove temporary model preferences:", err) - }) - } - }) - queue = op.catch((err) => { - console.error("[Kilo New] Failed to persist model selections:", err) - }) - return op -} - -/** - * Handle a model-state webview message. Returns true if handled. - */ -export async function handleMessage( - type: string, - message: Record, - client: KiloClient | null, - post: PostMessage, -): Promise { - if (type === "persistModelSelection") { - const preferred = selection(message) - const agent = message.agent - if (!preferred || typeof agent !== "string" || !agent) return true - await write( - client, - (data) => { - const model = validateModelSelections(data.model) - model[agent] = { providerID: preferred.providerID, modelID: preferred.modelID } - data.model = model - }, - { ...preferred, variant: preferred.variant ?? "" }, - ) - return true - } - if (type === "requestModelSelections") { - await queue - const dir = await resolve(client) - if (!dir) return true - const [data, prefs] = await Promise.all([ - read(path.join(dir, "model.json")), - read(path.join(dir, "vscode-model.json")), - ]) - const selections = validateModelSelections(data.model) - const preferred = selection(prefs.preferred) - post({ type: "modelSelectionsLoaded", selections, ...(preferred ? { preferred } : {}) }) - return true - } - return false -} - -export async function reset(client: KiloClient | null, post: PostMessage): Promise { - await write(client, (data) => { - data.model = {} - }) - post({ type: "modelSelectionsLoaded", selections: {} }) -} diff --git a/packages/kilo-vscode/src/kilo-provider/options.ts b/packages/kilo-vscode/src/kilo-provider/options.ts index b58c140f47a..0ab05c6b9d5 100644 --- a/packages/kilo-vscode/src/kilo-provider/options.ts +++ b/packages/kilo-vscode/src/kilo-provider/options.ts @@ -62,4 +62,6 @@ export type KiloProviderOptions = { topBarSurface?: "tab" /** Project-aware settings used by the standalone Agent Manager settings tab. */ agentManagerSettings?: AgentManagerSettingsHandler + /** Dedicated Settings entry and its current route, including webview reloads. */ + settingsPanel?: () => { tab?: string; projectId?: string } } diff --git a/packages/kilo-vscode/src/kilo-provider/shortcut-context.ts b/packages/kilo-vscode/src/kilo-provider/shortcut-context.ts new file mode 100644 index 00000000000..e9132b4787b --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/shortcut-context.ts @@ -0,0 +1,72 @@ +import * as vscode from "vscode" +import * as path from "node:path" +import { readFileSync } from "node:fs" +import { buildKeybindingMap } from "../agent-manager/format-keybinding" +import { mergeUserKeybindings, parseJsonc, userKeybindingFiles } from "../agent-manager/user-keybindings" + +type Post = (msg: unknown) => void + +const files = (ctx?: vscode.ExtensionContext) => + ctx?.globalStorageUri ? userKeybindingFiles(ctx.globalStorageUri.fsPath) : [] + +/** Read one keybindings file. A missing or transiently unreadable file must not break setup. */ +function readKeybindings(file: string): unknown { + try { + return parseJsonc(readFileSync(file, "utf8")) + } catch { + return undefined + } +} + +/** Extension keybindings with the user's keybindings.json applied, for the current platform. */ +export function keybindings(ctx?: vscode.ExtensionContext) { + const ext = vscode.extensions.getExtension("kilocode.kilo-code") + const defaults = ext?.packageJSON?.contributes?.keybindings ?? [] + const user = files(ctx) + .map(readKeybindings) + .find((value) => value !== undefined) + return mergeUserKeybindings(defaults, user, process.platform === "darwin") +} + +/** Call cb when the user's keybindings.json is created, changed, or deleted. */ +export function watchKeybindings(ctx: vscode.ExtensionContext | undefined, cb: () => void): vscode.Disposable { + const watchers = files(ctx).map((file) => + vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(vscode.Uri.file(path.dirname(file)), path.basename(file)), + ), + ) + for (const watcher of watchers) { + watcher.onDidChange(cb) + watcher.onDidCreate(cb) + watcher.onDidDelete(cb) + } + return vscode.Disposable.from(...watchers) +} + +const selected = () => vscode.window.activeTextEditor?.selections.some((s) => !s.isEmpty) ?? false + +/** Shortcut labels and editor state that decide which shortcut the prompt suggests. */ +export function buildShortcutContextMessage(ctx?: vscode.ExtensionContext) { + return { + type: "shortcutContext" as const, + bindings: buildKeybindingMap(keybindings(ctx), process.platform === "darwin"), + selection: selected(), + } +} + +/** Push a new context when keybindings change or the editor selection appears or goes away. */ +export function watchShortcutContext(ctx: vscode.ExtensionContext | undefined, post: Post): vscode.Disposable { + let last = selected() + const send = () => post(buildShortcutContextMessage(ctx)) + const sync = () => { + const next = selected() + if (next === last) return + last = next + send() + } + return vscode.Disposable.from( + watchKeybindings(ctx, send), + vscode.window.onDidChangeTextEditorSelection(sync), + vscode.window.onDidChangeActiveTextEditor(sync), + ) +} diff --git a/packages/kilo-vscode/src/kilo-provider/shortcut-hints-settings.ts b/packages/kilo-vscode/src/kilo-provider/shortcut-hints-settings.ts new file mode 100644 index 00000000000..96842dd3ed2 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/shortcut-hints-settings.ts @@ -0,0 +1,23 @@ +import * as vscode from "vscode" + +type Post = (msg: unknown) => void + +/** Whether the prompt and empty Agent Manager sessions show keyboard shortcut hints. */ +export function shortcutHints(): boolean { + return vscode.workspace.getConfiguration("kilo-code.new").get("showShortcutHints", true) +} + +export function buildShortcutHintsSettingMessage() { + return { + type: "shortcutHintsSettingLoaded" as const, + visible: shortcutHints(), + } +} + +/** Push the setting to every webview when another surface changes it. */ +export function watchShortcutHintsConfig(post: Post): vscode.Disposable { + return vscode.workspace.onDidChangeConfiguration((event) => { + if (!event.affectsConfiguration("kilo-code.new.showShortcutHints")) return + post(buildShortcutHintsSettingMessage()) + }) +} diff --git a/packages/kilo-vscode/src/provider-actions.ts b/packages/kilo-vscode/src/provider-actions.ts index 188fc17bb01..d5ba331a156 100644 --- a/packages/kilo-vscode/src/provider-actions.ts +++ b/packages/kilo-vscode/src/provider-actions.ts @@ -112,9 +112,20 @@ export async function fetchProviderData(client: KiloClient, dir: string) { storedKeys, organizationId, ready: !!kiloAuth, + unavailable: catalogUnavailable(response.failed, all, organizationId), } } +/** An organization's Kilo catalog failed to load, so Kilo has no models to pick. */ +function catalogUnavailable( + failed: string[] | undefined, + all: ReadonlyArray<{ id: string; models: Record }>, + organizationId: string | null | undefined, +) { + if (!organizationId || !failed?.includes(KILO_PROVIDER_ID)) return false + return Object.keys(all.find((item) => item.id === KILO_PROVIDER_ID)?.models ?? {}).length === 0 +} + /** * Resolve the stored API key for a model fetch on an existing provider. * The key is only applied when the requested URL matches the provider's diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts b/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts index 6d504ac91c4..8a9b7ffeede 100644 --- a/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts +++ b/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts @@ -19,6 +19,7 @@ import { BrowserNetwork } from "./browser-network" import { BrowserProxy, UNREACHABLE } from "./browser-proxy" import { parse } from "./browser-policy" import type { + BrowserCursor, BrowserFrame, BrowserInteraction, BrowserViewport, @@ -80,10 +81,13 @@ interface BrowserDevtoolsInfo { export interface BrowserBrokerOptions { log: (...args: unknown[]) => void + /** Current IDE color scheme, applied to pages so `prefers-color-scheme` matches the editor. */ + theme?: () => "dark" | "light" enabled?: () => boolean trusted?: () => boolean launch?: (options: LaunchOptions) => Promise useSystemChrome?: () => boolean + fallback?: () => boolean network?: ( page: Page, options: Parameters[1], @@ -97,8 +101,14 @@ export interface BrowserContextFactory { on?(event: "disconnected", listener: () => void): unknown } +export interface BrowserOwner { + resolve: (route: BrowserRoute) => BrowserRoute | undefined + approve?: (route: BrowserRoute, url: URL) => Promise +} + interface Entry { route: BrowserRoute + owner?: BrowserOwner browserId: string context: BrowserContext page: Page @@ -156,6 +166,7 @@ const MAX_BODY = 32 * 1024 const MAX_SCREENSHOT = 2 * 1024 * 1024 const TIMEOUT = /ERR_CONNECTION_TIMED_OUT|ETIMEDOUT|Timeout \d+ms exceeded/i const CRASHED = "The browser stopped unexpectedly. Refresh to start it again." +const MISSING = /Chrom(?:e|ium) distribution ("|')chrom(?:e|ium)(\1) is not found\b|Executable doesn't exist at\b/i function unreachable(url?: string): string { return `Cannot connect to ${url ?? "the local application"}. Make sure the local server is running.` @@ -235,13 +246,14 @@ function read(req: IncomingMessage): Promise { export class BrowserBroker { private readonly entries = new Map() private readonly pending = new Map>() + private readonly claims = new Map() private readonly listeners = new Set<(state: BrowserState) => void>() private readonly viewers = new Set<(frame: BrowserFrame & Pick) => void>() + private readonly pointers = new Set<(cursor: BrowserCursor & Pick) => void>() private readonly token = randomBytes(32).toString("hex") private readonly proxies = new Set() private gateway: BrowserProxy | undefined - private owner: ((route: BrowserRoute) => BrowserRoute | undefined) | undefined - private approval: ((route: BrowserRoute, url: URL) => Promise) | undefined + private readonly owners: BrowserOwner[] = [] private server: Server | undefined private readonly sockets = new Set() private port: number | undefined @@ -254,6 +266,25 @@ export class BrowserBroker { constructor(private readonly opts: BrowserBrokerOptions) {} + private scheme(): "dark" | "light" { + return this.opts.theme?.() ?? "light" + } + + /** + * Re-apply the current IDE color scheme to every live page. A page keeps the + * emulated scheme across navigations, so this only needs to run when the IDE + * theme changes, not on every navigation. + */ + retheme(): void { + const colorScheme = this.scheme() + for (const entry of this.entries.values()) { + if (entry.dead) continue + void entry.page + .emulateMedia({ colorScheme }) + .catch((error: unknown) => this.opts.log("Browser theme update failed", error)) + } + } + async start(): Promise { if (this.closed) throw new Error("Browser broker is closed") if (this.port !== undefined) return @@ -297,12 +328,57 @@ export class BrowserBroker { } } + /** + * Register a route owner. Owners are tried in registration order, so a surface + * that wants to claim a request first (for example the editor-tab browser for + * sidebar sessions) must register before the Agent Manager. + * + * A request that carries a project id only resolves against owners that return + * the same project id, which keeps Agent Manager worktree entries from being + * captured by the sidebar resolver. A request without a project id resolves + * against the first owner that accepts it. + * + * Each owner carries its own navigation approval so a route is gated by the + * surface that owns it, not by another surface's registration order. + */ bind( - owner: (route: BrowserRoute) => BrowserRoute | undefined, + resolve: (route: BrowserRoute) => BrowserRoute | undefined, approve?: (route: BrowserRoute, url: URL) => Promise, - ): void { - this.owner = owner - this.approval = approve + ): BrowserOwner { + const owner: BrowserOwner = { resolve, approve } + this.owners.push(owner) + return owner + } + + /** Remove a previously registered owner so its resolver cannot be called again. */ + unbind(owner: BrowserOwner): void { + const index = this.owners.indexOf(owner) + if (index >= 0) this.owners.splice(index, 1) + } + + private resolve(route: BrowserRoute): { scope: BrowserRoute; owner?: BrowserOwner } | undefined { + if (this.owners.length === 0) return { scope: route } + for (const owner of this.owners) { + const scope = owner.resolve(route) + if (!scope) continue + if (route.projectId !== undefined && scope.projectId !== route.projectId) continue + return { scope, owner } + } + return + } + + private revalid(route: BrowserRoute, owner?: BrowserOwner): BrowserRoute | undefined { + if (this.owners.length === 0) return route + for (const item of owner ? [owner] : this.owners) { + const scope = item.resolve(route) + if (scope && scope.projectId === route.projectId && scope.directory === route.directory) return scope + } + return + } + + private async approve(route: BrowserRoute, url: URL, owner?: BrowserOwner): Promise { + if (!owner?.approve) return false + return owner.approve(route, url) } subscribe(listener: (state: BrowserState) => void): () => void { @@ -319,6 +395,11 @@ export class BrowserBroker { return () => this.viewers.delete(listener) } + cursors(listener: (cursor: BrowserCursor & Pick) => void): () => void { + this.pointers.add(listener) + return () => this.pointers.delete(listener) + } + async viewport( sessionId: string, projectId: string | undefined, @@ -345,6 +426,11 @@ export class BrowserBroker { for (const viewer of this.viewers) viewer({ ...frame, projectId: route.projectId, sessionId: route.sessionId }) }, this.opts.log, + (cursor) => { + if (!this.accepts(route.sessionId, route.projectId, cursor)) return + for (const listener of this.pointers) + listener({ ...cursor, projectId: route.projectId, sessionId: route.sessionId }) + }, ) return entry.stream } @@ -406,7 +492,7 @@ export class BrowserBroker { entry.state.navigation !== identity.navigation ) return - const scope = this.owner ? this.owner(entry.route) : entry.route + const scope = this.revalid(entry.route, entry.owner) return scope?.directory === entry.route.directory && scope.projectId === entry.route.projectId ? entry : undefined } @@ -422,14 +508,31 @@ export class BrowserBroker { return [...new Set([...this.entries.keys(), ...this.pending.keys()].map((key) => key.slice(key.indexOf("\0") + 1)))] } - open(route: BrowserRoute, target: string, capture = true): Promise { - const scope = this.owner ? this.owner(route) : route - if (!scope) - return Promise.reject(new Error("Browser session does not belong to the requested project or directory")) - return this.serial(this.key(scope.sessionId, scope.projectId), () => this.create(scope, target, capture)) + /** True when a surface other than `owner` holds an entry or in-flight open for the session. */ + ownedByOther(sessionId: string, owner: BrowserOwner): boolean { + for (const key of [...this.entries.keys(), ...this.pending.keys()]) { + if (key.slice(key.indexOf("\0") + 1) !== sessionId) continue + const current = this.ownerOf(key) + if (current && current !== owner) return true + } + return false } - private async create(scope: BrowserRoute, target: string, capture: boolean): Promise { + open(route: BrowserRoute, target: string, capture = true): Promise { + const resolved = this.resolve(route) + if (!resolved) + return Promise.reject(new Error("Browser session does not belong to the requested project or directory")) + const key = this.key(resolved.scope.sessionId, resolved.scope.projectId) + if (resolved.owner) this.claims.set(key, resolved.owner) + return this.serial(key, () => this.create(resolved.scope, target, capture, resolved.owner)) + } + + private async create( + scope: BrowserRoute, + target: string, + capture: boolean, + owner?: BrowserOwner, + ): Promise { this.available() const url = this.validate(target) const existing = this.entries.get(this.key(scope.sessionId, scope.projectId)) @@ -464,6 +567,7 @@ export class BrowserBroker { serviceWorkers: "block", viewport: { width: 1280, height: 720 }, deviceScaleFactor: 2, + colorScheme: this.scheme(), proxy: proxy.proxy, ignoreHTTPSErrors: false, acceptDownloads: false, @@ -481,6 +585,7 @@ export class BrowserBroker { }) const entry: Entry = { route: { ...scope }, + owner, browserId: randomUUID(), context, page, @@ -504,7 +609,8 @@ export class BrowserBroker { this.attach(entry) try { this.available() - if (this.owner && !this.owner(scope)) throw new Error("Browser session is no longer available") + if (this.owners.length > 0 && !this.revalid(scope, owner)) + throw new Error("Browser session is no longer available") entry.network = await (this.opts.network ?? BrowserNetwork.attach)(page, { url, proxy, @@ -513,7 +619,7 @@ export class BrowserBroker { if (!this.view(scope.sessionId, scope.projectId, identity)) return false entry.waiting.add(target.origin) try { - const approved = await this.approval?.(entry.route, target) + const approved = await this.approve(entry.route, target, entry.owner) return ( approved === true && entry.network?.active !== false && @@ -527,10 +633,21 @@ export class BrowserBroker { entry.state.error = message this.emit(entry.state) }, + // A new document starts a new view identity, so input for the old document cannot reach it. Same-document + // navigations keep the identity, so a client-side route change does not restart the view or drop input. + committed: () => { + if (entry.navigating) return + entry.state.navigation++ + this.emit(entry.state) + void this.update(entry) + .then(() => this.emit(entry.state)) + .catch((error: unknown) => this.fail(entry, error)) + }, log: this.opts.log, }) this.available() - if (this.owner && !this.owner(scope)) throw new Error("Browser session is no longer available") + if (this.owners.length > 0 && !this.revalid(scope, owner)) + throw new Error("Browser session is no longer available") } catch (error) { await this.retire(entry) throw error @@ -624,9 +741,9 @@ export class BrowserBroker { const entry = this.require(sessionId, undefined, projectId) const url = this.validate(entry.state.url ?? entry.origin) if (entry.dead) { - const scope = this.owner ? this.owner(entry.route) : entry.route + const scope = this.revalid(entry.route, entry.owner) if (!scope) throw new Error("Browser session does not belong to the requested project or directory") - return this.create(scope, url.href, capture) + return this.create(scope, url.href, capture, entry.owner) } await this.goto(entry, url, true, capture) return this.copy(entry.state) @@ -646,6 +763,19 @@ export class BrowserBroker { ).then(() => undefined) } + /** Close only the entry for this exact session and project, leaving other projects' entries intact. */ + closeScoped(sessionId: string, projectId?: string): Promise { + return this.stop(this.key(sessionId, projectId)).then(() => undefined) + } + + /** Close every entry and in-flight open owned by one surface, leaving other owners intact. */ + closeOwned(owner: BrowserOwner): Promise { + const keys = new Set([...this.entries.keys(), ...this.pending.keys()]) + return Promise.all([...keys].filter((key) => this.ownerOf(key) === owner).map((key) => this.stop(key))).then( + () => undefined, + ) + } + private stop(key: string): Promise { const entry = this.entries.get(key) void entry?.proxy.close().catch((error: unknown) => this.opts.log("Browser proxy close failed", error)) @@ -699,6 +829,7 @@ export class BrowserBroker { this.port = undefined this.listeners.clear() this.viewers.clear() + this.pointers.clear() } dispose(): void { @@ -709,7 +840,7 @@ export class BrowserBroker { if (this.closed) return Promise.reject(new Error("Browser broker is closed")) if (this.browser) return Promise.resolve(this.browser) if (this.browserStarting) return this.browserStarting - const system = this.opts.useSystemChrome?.() !== false + let system = this.opts.useSystemChrome?.() !== false const starting = (async () => { const port = this.opts.launch ? undefined : await reserve() const base = options(system, port) @@ -730,7 +861,15 @@ export class BrowserBroker { "--force-webrtc-ip-handling-policy=disable_non_proxied_udp", ], } - const browser = await (this.opts.launch?.(config) ?? chromium.launch(config)) + const launch = (config: LaunchOptions): Promise => + this.opts.launch?.(config) ?? chromium.launch(config) + const browser = await launch(config).catch((error: unknown) => { + const detail = error instanceof Error ? error.message : String(error) + if (!system || !this.opts.fallback?.() || !MISSING.test(detail)) throw error + system = false + this.opts.log("Chrome is not installed in WSL. Trying Playwright Chromium.") + return launch({ ...config, channel: undefined }) + }) this.debugging = ("debugging" in browser ? browser.debugging : undefined) ?? port this.browser = browser browser.on?.("disconnected", () => this.lost(browser)) @@ -739,13 +878,7 @@ export class BrowserBroker { .catch(async (error: unknown) => { await this.release() const detail = error instanceof Error ? error.message : String(error) - const missing = /Chromium distribution ['"]chrome['"] is not found\b|Executable doesn't exist at\b/i.test( - detail, - ) - ? system - ? "chrome" - : "chromium" - : undefined + const missing = MISSING.test(detail) ? (system ? "chrome" : "chromium") : undefined throw new BrowserLaunchError(missing, error) }) .finally(() => { @@ -824,7 +957,6 @@ export class BrowserBroker { }) entry.page.on("framenavigated", (frame) => { if (frame !== entry.page.mainFrame()) return - if (!entry.navigating) entry.state.navigation++ void this.update(entry) .then(() => this.emit(entry.state)) .catch((error: unknown) => this.fail(entry, error)) @@ -948,6 +1080,13 @@ export class BrowserBroker { return `${project ?? ""}\0${session}` } + /** Owner of a keyed session, preferring a live entry over a pending open's claim. */ + private ownerOf(key: string): BrowserOwner | undefined { + const entry = this.entries.get(key) + if (entry) return entry.owner + return this.pending.has(key) ? this.claims.get(key) : undefined + } + private async serial(session: string, operation: () => Promise): Promise { const previous = this.pending.get(session)?.catch(() => undefined) ?? Promise.resolve() const next = previous.then(operation) @@ -955,7 +1094,10 @@ export class BrowserBroker { try { return await next } finally { - if (this.pending.get(session) === next) this.pending.delete(session) + if (this.pending.get(session) === next) { + this.pending.delete(session) + this.claims.delete(session) + } } } diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-control.ts b/packages/kilo-vscode/src/services/browser-automation/browser-control.ts new file mode 100644 index 00000000000..d0f15cf9019 --- /dev/null +++ b/packages/kilo-vscode/src/services/browser-automation/browser-control.ts @@ -0,0 +1,268 @@ +import { + BrowserLaunchError, + diagnostic, + type BrowserBroker, + type BrowserInspection, + type BrowserState, +} from "./browser-broker" +import type { BrowserInteraction, BrowserViewIdentity, BrowserViewport } from "../../shared/browser-stream" + +/** + * A browser command in a surface-neutral shape. Each surface strips its own + * message prefix (for example `agentManager.browser.` or `browserTab.`) to + * produce `kind`, so the Agent Manager panel and the editor-tab browser share + * one implementation of the command handling in {@link controlBrowser}. + */ +export type BrowserControlKind = + | "state" + | "viewport" + | "acknowledge" + | "interact" + | "devtools" + | "open" + | "back" + | "forward" + | "refresh" + | "inspect" + | "input" + | "close" + +export interface BrowserControlFields { + browserId?: string + navigation?: number + viewport?: BrowserViewport + identity?: BrowserViewIdentity + event?: BrowserInteraction + sequence?: number + url?: string + requestId?: string + x?: number + y?: number + width?: number + height?: number + hover?: boolean + click?: boolean + theme?: "dark" | "light" +} + +export interface BrowserControlMessage extends BrowserControlFields { + kind: BrowserControlKind +} + +/** Builds a neutral control message from a surface message that shares these fields. */ +export function browserControlMessage(kind: BrowserControlKind, fields: BrowserControlFields): BrowserControlMessage { + return { ...fields, kind } +} + +/** + * Surface-specific output and environment for {@link controlBrowser}. `state`, + * `inspection`, and `devtools` format neutral results into the surface's own + * out-messages; `readClipboard`/`writeClipboard` are host capabilities. + */ +export interface BrowserControlSurface { + sessionId: string + projectId?: string + /** Resolved lazily because only the `open` command needs the session directory. */ + openDirectory?: () => string | undefined + readClipboard?: () => Promise + writeClipboard: (text: string) => Promise | void + state: (state: BrowserState | undefined, error?: string, missing?: BrowserState["missing"]) => void + inspection: (input: { requestId: string; hover?: boolean; value?: BrowserInspection; error?: string }) => void + devtools: (tools: { browserId: string; url: string }) => void + log: (...args: unknown[]) => void +} + +function position(message: BrowserControlMessage): { x: number; y: number; width: number; height: number } | undefined { + if ( + typeof message.x !== "number" || + typeof message.y !== "number" || + typeof message.width !== "number" || + typeof message.height !== "number" + ) { + return + } + return { x: message.x, y: message.y, width: message.width, height: message.height } +} + +/** + * Shared `BrowserState` to out-message field list. Both the Agent Manager and + * the editor-tab browser spread this so a new state field reaches both surfaces. + */ +export function browserStatePayload(state: BrowserState) { + return { + browserId: state.browserId, + projectId: state.projectId, + sessionId: state.sessionId, + navigation: state.navigation, + status: state.status, + inspecting: state.inspecting, + url: state.url, + title: state.title, + errors: state.errors, + logs: state.logs, + error: state.error, + missing: state.missing, + frameError: state.frameError, + back: state.back, + forward: state.forward, + } +} + +function failure( + surface: BrowserControlSurface, + message: BrowserControlMessage, + error: string, + state?: BrowserState, + missing?: BrowserState["missing"], +): void { + if (message.kind === "inspect") { + surface.inspection({ requestId: message.requestId ?? "", hover: message.hover, error }) + return + } + surface.state(state, error, missing) +} + +function streaming(message: BrowserControlMessage, browser: BrowserBroker, surface: BrowserControlSurface): boolean { + const { sessionId, projectId } = surface + if (message.kind === "viewport") { + if ( + !message.browserId || + typeof message.navigation !== "number" || + !Number.isInteger(message.navigation) || + !message.viewport + ) { + return true + } + void browser + .viewport(sessionId, projectId, message.browserId, message.navigation, message.viewport) + .catch((error: unknown) => { + surface.log("Browser stream failed:", error) + failure(surface, message, diagnostic(error), browser.get(sessionId, projectId)) + }) + return true + } + if (message.kind === "acknowledge") { + if (message.identity && typeof message.sequence === "number") { + browser.acknowledge(sessionId, projectId, message.identity, message.sequence) + } + return true + } + if (message.kind !== "interact") return false + const identity = message.identity + const event = message.event + if (!identity || !event) return true + void (async () => { + if (!browser.accepts(sessionId, projectId, identity)) return + if (event.kind === "clipboard") { + await browser.interact(sessionId, projectId, identity, event, surface.readClipboard, (text) => + surface.writeClipboard(text), + ) + return + } + await browser.interact(sessionId, projectId, identity, event) + })().catch((error: unknown) => { + surface.log("Browser input failed:", error) + failure(surface, message, diagnostic(error), browser.get(sessionId, projectId)) + }) + return true +} + +function action(message: BrowserControlMessage, browser: BrowserBroker, surface: BrowserControlSurface): boolean { + const { sessionId, projectId } = surface + if (message.kind === "state") { + const current = browser.get(sessionId, projectId) + if (current) surface.state(current) + return true + } + if (streaming(message, browser, surface)) return true + if (message.kind === "devtools") { + void browser + .devtools(sessionId, projectId, message.theme === "light" ? "light" : "dark") + .then((tools) => surface.devtools(tools)) + .catch((error: unknown) => { + surface.log("Browser developer tools failed:", error) + failure(surface, message, diagnostic(error), browser.get(sessionId, projectId)) + }) + return true + } + if (message.kind === "open") { + if (!message.url) return true + const directory = surface.openDirectory?.() + if (!directory) { + failure(surface, message, "Browser session is not available in the selected project.") + return true + } + void browser.open({ projectId, sessionId, directory }, message.url, false).catch((error: unknown) => { + surface.log("Browser open failed:", error) + failure( + surface, + message, + diagnostic(error, message.url), + browser.get(sessionId, projectId), + error instanceof BrowserLaunchError ? error.missing : undefined, + ) + }) + return true + } + if (message.kind === "back" || message.kind === "forward") { + void browser.history(sessionId, projectId, message.kind).catch((error: unknown) => { + surface.log("Browser history navigation failed:", error) + const current = browser.get(sessionId, projectId) + if (current) surface.state(current) + }) + return true + } + if (message.kind === "refresh") { + void browser.refresh(sessionId, projectId, false).catch((error: unknown) => { + surface.log("Browser refresh failed:", error) + const current = browser.get(sessionId, projectId) + if (current) surface.state(current) + }) + return true + } + if (message.kind === "inspect" || message.kind === "input") { + const point = position(message) + if (!point) { + failure(surface, message, "Browser element coordinates are required.", browser.get(sessionId, projectId)) + return true + } + if (message.kind === "input") { + void browser.input(sessionId, projectId, point, message.click === true).catch((error: unknown) => { + surface.log("Browser developer tools input failed:", error) + failure(surface, message, diagnostic(error), browser.get(sessionId, projectId)) + }) + return true + } + void browser + .inspect(sessionId, projectId, point, message.hover !== true) + .then((value) => surface.inspection({ requestId: message.requestId ?? "", hover: message.hover, value })) + .catch((error: unknown) => { + surface.log("Browser element inspection failed:", error) + failure(surface, message, diagnostic(error)) + }) + return true + } + if (message.kind !== "close") return false + void browser.close(sessionId, projectId).catch((error: unknown) => surface.log("Browser close failed:", error)) + return true +} + +/** Runs one neutralized browser command against the shared broker. */ +export function controlBrowser( + message: BrowserControlMessage, + browser: BrowserBroker, + surface: BrowserControlSurface, +): boolean { + return action(message, browser, surface) +} + +/** Formats a pre-route failure (trust, feature flag, or missing session) through `surface`. */ +export function controlBrowserFailure( + message: BrowserControlMessage, + surface: BrowserControlSurface, + error: string, + state?: BrowserState, + missing?: BrowserState["missing"], +): void { + failure(surface, message, error, state, missing) +} diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-network.ts b/packages/kilo-vscode/src/services/browser-automation/browser-network.ts index 90cb25344f9..95daf2fbd05 100644 --- a/packages/kilo-vscode/src/services/browser-automation/browser-network.ts +++ b/packages/kilo-vscode/src/services/browser-automation/browser-network.ts @@ -149,6 +149,8 @@ interface Options { proxy: BrowserProxy approve: (url: URL) => Promise blocked: (message: string) => void + /** Called when the main frame commits a new document. Same-document navigations, such as pushState, do not call it. */ + committed?: () => void log: (...args: unknown[]) => void } @@ -308,6 +310,7 @@ export class BrowserNetwork { if (this.root !== event.frame.id) this.removed({ frameId: this.root, reason: "remove" }) this.root = event.frame.id this.documents.set(this.root, undefined) + if (this.active) this.opts.committed?.() } private readonly lost = () => { diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-stream.ts b/packages/kilo-vscode/src/services/browser-automation/browser-stream.ts index d9a679c6ff6..ab3eefc3856 100644 --- a/packages/kilo-vscode/src/services/browser-automation/browser-stream.ts +++ b/packages/kilo-vscode/src/services/browser-automation/browser-stream.ts @@ -1,11 +1,20 @@ import { setTimeout as wait } from "node:timers/promises" -import type { CDPSession, Frame, Page } from "playwright-core" -import type { BrowserFrame, BrowserInteraction, BrowserViewport } from "../../shared/browser-stream" +import type { CDPSession, Page } from "playwright-core" +import { + CURSORS, + mergeWheel, + VIEWPORT_LIMIT, + type BrowserCursor, + type BrowserFrame, + type BrowserInteraction, + type BrowserViewport, + type WheelInteraction, +} from "../../shared/browser-stream" type Scope = { browserId: string; navigation: number } type Key = Extract type Pointer = Extract -type Wheel = Extract +type Wheel = WheelInteraction type Clipboard = Extract["action"] type Cast = { sessionId: number @@ -16,6 +25,8 @@ type Cast = { const PAYLOAD = 2 * 1024 * 1024 const TEXT = 64 * 1024 const SETTLE = 250 +const BINDING = "__kiloCursor" +const WORLD = "kilo-cursor" const BUTTONS = { left: 1, right: 2, middle: 4 } as const const MODIFIERS = [ { key: "Alt", code: "AltLeft", keyCode: 18, mask: 1 }, @@ -72,17 +83,6 @@ function position(event: { x: number; y: number; modifiers: number }): boolean { return range(event.x, 0, 1) && range(event.y, 0, 1) && range(event.modifiers, 0, 15, true) } -function merge(current: Wheel, next: Wheel): boolean { - if (current.x !== next.x || current.y !== next.y || current.modifiers !== next.modifiers) return false - for (const axis of ["deltaX", "deltaY"] as const) { - if (Math.sign(current[axis]) !== Math.sign(next[axis])) return false - if (!range(current[axis] + next[axis], -10000, 10000)) return false - } - current.deltaX += next.deltaX - current.deltaY += next.deltaY - return true -} - function pointer(event: Pointer): boolean { return ( position(event) && @@ -195,6 +195,102 @@ function selection(opts: { action: Clipboard; limit: number; text?: string }): { return { focused: true, text: result } } +function kept(current: BrowserViewport | undefined, next: BrowserViewport) { + return ( + !!current && + next.active && + next.width === current.width && + next.height === current.height && + next.scale === current.scale + ) +} + +// Runs only in an isolated world. Read the hovered element, not the page's full text or layout. +function reporter(name: string, epoch: number, keywords: string[]) { + const globals = globalThis as unknown as Record + const cleanup = `${name}Cleanup` + const previous = globals[cleanup] + if (typeof previous === "function") previous() + const report = globals[name] + if (typeof report !== "function") return + const fields = ["text", "search", "email", "url", "tel", "password", "number"] + const resolve = (event: MouseEvent, node: unknown = event.composedPath()[0]) => { + if (!(node instanceof Element)) return "default" + const style = getComputedStyle(node) + const keyword = style.cursor.slice(style.cursor.lastIndexOf(",") + 1).trim() + if (keyword !== "auto") return keywords.includes(keyword) ? keyword : "default" + if (node instanceof HTMLTextAreaElement) return node.disabled ? "default" : "text" + if (node instanceof HTMLElement && node.isContentEditable) return "text" + if (node instanceof HTMLInputElement) return !node.disabled && fields.includes(node.type) ? "text" : "default" + if (node.closest("button, select")) return "default" + if (style.userSelect === "none") return "default" + const caret = document.caretRangeFromPoint?.(event.clientX, event.clientY) + const text = caret?.startContainer + if (!caret || !(text instanceof Text) || !text.length) return "default" + // Check only the characters next to the caret, which can fall on either side of the hovered glyph. + const range = document.createRange() + range.setStart(text, Math.max(0, caret.startOffset - 1)) + range.setEnd(text, Math.min(text.length, caret.startOffset + 1)) + return [...range.getClientRects()].some( + (rect) => + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom, + ) + ? "text" + : "default" + } + let last = "" + const emit = (value: string) => { + if (value === last) return + last = value + report(`${epoch}:${value}`) + } + const event = typeof PointerEvent === "undefined" ? "mousemove" : "pointermove" + const enter = event === "pointermove" ? "pointerover" : "mouseover" + const presses = + event === "pointermove" + ? (["pointerdown", "pointerup", "pointercancel"] as const) + : (["mousedown", "mouseup"] as const) + let scheduled = 0 + const move = (event: MouseEvent) => { + if (event.type === enter) last = "" + emit(resolve(event)) + } + const press = (event: MouseEvent) => { + const node = event.composedPath()[0] + cancelAnimationFrame(scheduled) + // Read pressed styles after the page's event handlers, without observing or changing the document. + scheduled = requestAnimationFrame(() => { + scheduled = 0 + emit( + resolve( + event, + node instanceof Element && node.isConnected ? node : document.elementFromPoint(event.clientX, event.clientY), + ), + ) + }) + } + const leave = () => { + cancelAnimationFrame(scheduled) + scheduled = 0 + emit("default") + } + addEventListener(event, move, { capture: true, passive: true }) + addEventListener(enter, move, { capture: true, passive: true }) + for (const event of presses) addEventListener(event, press, { capture: true, passive: true }) + document.addEventListener("mouseleave", leave, { passive: true }) + globals[cleanup] = () => { + cancelAnimationFrame(scheduled) + removeEventListener(event, move, true) + removeEventListener(enter, move, true) + for (const event of presses) removeEventListener(event, press, true) + document.removeEventListener("mouseleave", leave) + delete globals[cleanup] + } +} + export class BrowserStream { private session?: CDPSession private view?: BrowserViewport @@ -216,16 +312,20 @@ export class BrowserStream { private y = 0 private composing = false private readonly keys = new Map() + private readonly contexts = new Set() + private reporter: Promise = Promise.resolve() + private script?: string + private keyword?: string constructor( private readonly page: Page, private readonly identity: () => Scope, private readonly emit: (frame: BrowserFrame) => void, private readonly log: (...args: unknown[]) => void, + private readonly cursor?: (value: BrowserCursor) => void, ) { this.scope = { ...identity() } page.on("close", this.ended) - page.on("framenavigated", this.navigated) } async configure(view: BrowserViewport): Promise { @@ -243,19 +343,29 @@ export class BrowserStream { const current = this.view const suspend = current && current.active && !view.active && view.revision === current.revision if (current && view.revision <= current.revision && !suspend) return - const width = Math.max(32, Math.min(4096, Math.round(view.width))) - const height = Math.max(32, Math.min(2160, Math.round(view.height))) + const width = Math.max(32, Math.min(VIEWPORT_LIMIT.width, Math.round(view.width))) + const height = Math.max(32, Math.min(VIEWPORT_LIMIT.height, Math.round(view.height))) const next = suspend ? { ...current, active: false } : { width, height, - scale: Math.max(1, Math.min(view.scale ?? 1, 2, 4096 / width, 2160 / height)), + scale: Math.max( + 1, + Math.min(view.scale ?? 1, 2, VIEWPORT_LIMIT.width / width, VIEWPORT_LIMIT.height / height), + ), revision: view.revision, active: view.active, } this.view = next this.reset() + // The screencast continues across navigations. A new document with the same size only needs the new identity, so + // the preview does not wait for a stream restart and a resize settle on each page load. + if (this.casting === current && kept(current, next)) { + this.casting = next + await this.install() + return + } await this.serial(async () => { if (this.closed || this.view !== next) return const session = await this.connect() @@ -291,6 +401,7 @@ export class BrowserStream { maxHeight: Math.round(next.height * (next.scale ?? 1)), everyNthFrame: 1, }) + await this.install() }).catch((error: unknown) => { this.casting = undefined if (!this.closed) throw error @@ -337,15 +448,17 @@ export class BrowserStream { return case "wheel": this.coordinates(input, view) - await session.send("Input.dispatchMouseEvent", { - type: "mouseWheel", - x: this.x, - y: this.y, - modifiers: this.modifiers, - buttons: this.buttons, - deltaX: input.deltaX, - deltaY: input.deltaY, - }) + this.dispatch( + session.send("Input.dispatchMouseEvent", { + type: "mouseWheel", + x: this.x, + y: this.y, + modifiers: this.modifiers, + buttons: this.buttons, + deltaX: input.deltaX, + deltaY: input.deltaY, + }), + ) return case "key": await this.keyboard(session, input) @@ -404,7 +517,7 @@ export class BrowserStream { private coalesce(event: BrowserInteraction): Promise | undefined { const queued = this.wheel this.wheel = undefined - if (event.kind !== "wheel" || !queued || !merge(queued.event, event)) return + if (event.kind !== "wheel" || !queued || !mergeWheel(queued.event, event)) return this.wheel = queued return queued.result } @@ -419,13 +532,39 @@ export class BrowserStream { this.closed = true this.reset() this.page.off("close", this.ended) - this.page.off("framenavigated", this.navigated) this.closing = this.serial(async () => { await this.release() const session = this.session if (!session) return await this.stop().catch(() => this.report("stop failed")) + await this.reporter + if (this.script) { + await session + .send("Page.removeScriptToEvaluateOnNewDocument", { identifier: this.script }) + .catch(() => this.report("cursor script removal failed")) + this.script = undefined + } + await Promise.all( + [...this.contexts].map((contextId) => + session + .send("Runtime.evaluate", { + contextId, + expression: `globalThis.${BINDING}Cleanup?.(); delete globalThis.${BINDING}`, + }) + .catch(() => this.report("cursor listener removal failed")), + ), + ) + if (this.cursor) + await session + .send("Runtime.removeBinding", { name: BINDING }) + .catch(() => this.report("cursor binding removal failed")) session.off("Page.screencastFrame", this.receive) + session.off("Page.frameNavigated", this.navigated) + session.off("Runtime.bindingCalled", this.pointed) + session.off("Runtime.executionContextCreated", this.created) + session.off("Runtime.executionContextDestroyed", this.destroyed) + session.off("Runtime.executionContextsCleared", this.cleared) + this.contexts.clear() this.session = undefined await session.detach().catch(() => this.report("detach failed")) }) @@ -436,10 +575,12 @@ export class BrowserStream { void this.close().catch(() => this.report("close failed")) } - private readonly navigated = (frame: Frame): void => { - if (this.closed || frame !== this.page.mainFrame()) return + // Only a new main-frame document releases held input. Same-document navigations, such as pushState, keep it. + private readonly navigated = (event: { frame: { parentId?: string } }): void => { + if (this.closed || event.frame.parentId) return this.synchronize() this.reset() + void this.install() void this.serial(() => this.release()).catch(() => this.report("navigation release failed")) } @@ -463,6 +604,7 @@ export class BrowserStream { this.wheel = undefined this.outstanding = undefined this.buffered = undefined + this.keyword = undefined } private synchronize(): void { @@ -477,10 +619,67 @@ export class BrowserStream { const session = await this.page.context().newCDPSession(this.page) this.session = session session.on("Page.screencastFrame", this.receive) + session.on("Page.frameNavigated", this.navigated) if (!this.closed) await session.send("Page.enable") + if (!this.closed && this.cursor) { + session.on("Runtime.bindingCalled", this.pointed) + session.on("Runtime.executionContextCreated", this.created) + session.on("Runtime.executionContextDestroyed", this.destroyed) + session.on("Runtime.executionContextsCleared", this.cleared) + await session + .send("Runtime.enable") + .then(() => session.send("Runtime.addBinding", { name: BINDING, executionContextName: WORLD })) + .catch(() => this.report("cursor binding setup failed")) + } return session } + private readonly created = (event: { context: { id: number; name: string } }): void => { + if (event.context.name === WORLD) this.contexts.add(event.context.id) + } + + private readonly destroyed = (event: { executionContextId: number }): void => { + this.contexts.delete(event.executionContextId) + } + + private readonly cleared = (): void => { + this.contexts.clear() + } + + private install(): Promise { + if (!this.cursor || this.closed || !this.session) return Promise.resolve() + this.synchronize() + const view = this.view + const epoch = this.epoch + this.reporter = this.reporter + .then(async () => { + const session = this.session + if (this.closed || !session || !view?.active || this.view !== view || this.epoch !== epoch) return + if (this.script) await session.send("Page.removeScriptToEvaluateOnNewDocument", { identifier: this.script }) + const result = await session.send("Page.addScriptToEvaluateOnNewDocument", { + source: `(${reporter.toString()})(${JSON.stringify(BINDING)}, ${epoch}, ${JSON.stringify([...CURSORS])})`, + worldName: WORLD, + runImmediately: true, + }) + this.script = result.identifier + }) + .catch(() => this.report("cursor reporter setup failed")) + return this.reporter + } + + private readonly pointed = (event: { name: string; payload: string; executionContextId: number }): void => { + if (this.closed || event.name !== BINDING || !this.contexts.has(event.executionContextId)) return + this.synchronize() + const view = this.casting + if (!view?.active || this.view !== view || typeof event.payload !== "string" || event.payload.length > 64) return + const prefix = `${this.epoch}:` + if (!event.payload.startsWith(prefix)) return + const cursor = event.payload.slice(prefix.length) + if (!CURSORS.has(cursor) || cursor === this.keyword) return + this.keyword = cursor + this.cursor?.({ ...this.scope, revision: view.revision, cursor }) + } + private async stop(): Promise { this.casting = undefined if (!this.started || !this.session) return @@ -551,7 +750,7 @@ export class BrowserStream { this.buttons |= event.buttons if (event.action === "down") this.buttons |= BUTTONS[event.button] if (event.action === "up") this.buttons &= ~BUTTONS[event.button] - await session.send("Input.dispatchMouseEvent", { + const sent = session.send("Input.dispatchMouseEvent", { type: event.action === "move" ? "mouseMoved" : event.action === "down" ? "mousePressed" : "mouseReleased", x: this.x, y: this.y, @@ -560,6 +759,17 @@ export class BrowserStream { clickCount: event.clicks, modifiers: this.modifiers, }) + if (event.action === "move") return this.dispatch(sent) + await sent + } + + // Chrome answers a mouse move or wheel event only after the page renders the next frame. Waiting for that answer + // limits input to the frame rate, so input from a display with a higher refresh rate lags more and more. CDP keeps + // the event order, and Chrome merges these events while the page is busy, so they do not wait for the answer. + private dispatch(sent: Promise): void { + void sent.catch(() => { + if (!this.closed) this.report("mouse input failed") + }) } private async clipboard( diff --git a/packages/kilo-vscode/src/services/browser-automation/chrome-setting.ts b/packages/kilo-vscode/src/services/browser-automation/chrome-setting.ts index d60ec7f66bd..556078eeb4a 100644 --- a/packages/kilo-vscode/src/services/browser-automation/chrome-setting.ts +++ b/packages/kilo-vscode/src/services/browser-automation/chrome-setting.ts @@ -14,6 +14,19 @@ export function integratedBrowserUseSystemChrome(): boolean { return vscode.workspace.getConfiguration(INTEGRATED_BROWSER).get("useSystemChrome", true) } +export function integratedBrowserFallback(remote = vscode.env.remoteName): boolean { + return ( + remote === "wsl" && + vscode.workspace.getConfiguration(INTEGRATED_BROWSER).inspect("useSystemChrome")?.globalValue == null + ) +} + +/** Keep the settings payload and effective link destination in sync. */ +export function integratedBrowserLinkDestination() { + const value = vscode.workspace.getConfiguration(INTEGRATED_BROWSER).get("openLinksIn", "integrated") + return value === "external" ? "external" : "integrated" +} + /** * Copy a user-level Chrome preference that was stored before this setting had * its own key. diff --git a/packages/kilo-vscode/src/services/browser-automation/index.ts b/packages/kilo-vscode/src/services/browser-automation/index.ts index d7f7f06c8a2..0732edc318e 100644 --- a/packages/kilo-vscode/src/services/browser-automation/index.ts +++ b/packages/kilo-vscode/src/services/browser-automation/index.ts @@ -4,6 +4,7 @@ export type { BrowserContextFactory, BrowserElement, BrowserInspection, + BrowserOwner, BrowserRoute, BrowserState, BrowserStatus, diff --git a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts index 03803d8114c..8735d69a387 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -6,7 +6,7 @@ import * as path from "path" import * as vscode from "vscode" import { resolveLocalBwrapEnv, resolveTreeSitterEnv } from "./cli-resources" import { t } from "./i18n" -import { scanServerPort } from "./server-utils" +import { ensureServerCwd, scanServerPort } from "./server-utils" export interface ServerInstance { port: number @@ -108,7 +108,7 @@ export class ServerManager { // or "$HOME" in empty VS Code windows. const folders = vscode.workspace.workspaceFolders const spawnCwd = resolveServerCwd(folders, this.context.globalStorageUri.fsPath) - fs.mkdirSync(spawnCwd, { recursive: true }) + ensureServerCwd(spawnCwd) const indexingEnv = resolveIndexingEnv(folders) const localCli = this.context.extensionMode === vscode.ExtensionMode.Development || diff --git a/packages/kilo-vscode/src/services/cli-backend/server-utils.ts b/packages/kilo-vscode/src/services/cli-backend/server-utils.ts index bedad7d1db8..38405589184 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-utils.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-utils.ts @@ -1,3 +1,9 @@ +import * as fs from "fs" + +export function ensureServerCwd(dir: string): void { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) +} + /** * Parse the port number from CLI server startup output. * Matches lines like: "kilo server listening on http://127.0.0.1:12345" diff --git a/packages/kilo-vscode/src/services/cli-backend/types.ts b/packages/kilo-vscode/src/services/cli-backend/types.ts index 9ff83a941fc..27156fb8076 100644 --- a/packages/kilo-vscode/src/services/cli-backend/types.ts +++ b/packages/kilo-vscode/src/services/cli-backend/types.ts @@ -60,6 +60,7 @@ export interface CloudSessionMessage { id: string sessionID: string role: "user" | "assistant" + parentID?: string time: { created: number; completed?: number } cost?: { input: number; output: number; reasoning?: number; cache?: { read: number; write: number } } tokens?: { input: number; output: number; reasoning?: number; cache?: { read: number; write: number } } diff --git a/packages/kilo-vscode/src/services/i18n/ar.ts b/packages/kilo-vscode/src/services/i18n/ar.ts index ba3a426c379..f6cba22c7bf 100644 --- a/packages/kilo-vscode/src/services/i18n/ar.ts +++ b/packages/kilo-vscode/src/services/i18n/ar.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/ar" import { dict as attentionDict } from "./attention/ar" +import { dict as mcpDict } from "./mcp/ar" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/br.ts b/packages/kilo-vscode/src/services/i18n/br.ts index 488e32d18af..51d9d3db4d8 100644 --- a/packages/kilo-vscode/src/services/i18n/br.ts +++ b/packages/kilo-vscode/src/services/i18n/br.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/br" import { dict as attentionDict } from "./attention/br" +import { dict as mcpDict } from "./mcp/br" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/bs.ts b/packages/kilo-vscode/src/services/i18n/bs.ts index 59597555882..c3e7281ef98 100644 --- a/packages/kilo-vscode/src/services/i18n/bs.ts +++ b/packages/kilo-vscode/src/services/i18n/bs.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/bs" import { dict as attentionDict } from "./attention/bs" +import { dict as mcpDict } from "./mcp/bs" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/da.ts b/packages/kilo-vscode/src/services/i18n/da.ts index cd94fb44509..0ff8161a03d 100644 --- a/packages/kilo-vscode/src/services/i18n/da.ts +++ b/packages/kilo-vscode/src/services/i18n/da.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/da" import { dict as attentionDict } from "./attention/da" +import { dict as mcpDict } from "./mcp/da" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/de.ts b/packages/kilo-vscode/src/services/i18n/de.ts index 6882d647ca0..0cdfc333c14 100644 --- a/packages/kilo-vscode/src/services/i18n/de.ts +++ b/packages/kilo-vscode/src/services/i18n/de.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/de" import { dict as attentionDict } from "./attention/de" +import { dict as mcpDict } from "./mcp/de" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/en.ts b/packages/kilo-vscode/src/services/i18n/en.ts index 2c66ab9075b..7a996756240 100644 --- a/packages/kilo-vscode/src/services/i18n/en.ts +++ b/packages/kilo-vscode/src/services/i18n/en.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/en" import { dict as attentionDict } from "./attention/en" +import { dict as mcpDict } from "./mcp/en" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/es.ts b/packages/kilo-vscode/src/services/i18n/es.ts index 5c2fdad7b5d..0b9d04caac4 100644 --- a/packages/kilo-vscode/src/services/i18n/es.ts +++ b/packages/kilo-vscode/src/services/i18n/es.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/es" import { dict as attentionDict } from "./attention/es" +import { dict as mcpDict } from "./mcp/es" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/fa.ts b/packages/kilo-vscode/src/services/i18n/fa.ts index 55a388ea5dd..5ae92ce72af 100644 --- a/packages/kilo-vscode/src/services/i18n/fa.ts +++ b/packages/kilo-vscode/src/services/i18n/fa.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/fa" import { dict as attentionDict } from "./attention/fa" +import { dict as mcpDict } from "./mcp/fa" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/fr.ts b/packages/kilo-vscode/src/services/i18n/fr.ts index 3ef34c35423..43525699a88 100644 --- a/packages/kilo-vscode/src/services/i18n/fr.ts +++ b/packages/kilo-vscode/src/services/i18n/fr.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/fr" import { dict as attentionDict } from "./attention/fr" +import { dict as mcpDict } from "./mcp/fr" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/it.ts b/packages/kilo-vscode/src/services/i18n/it.ts index 53cef67c656..4d1787bf447 100644 --- a/packages/kilo-vscode/src/services/i18n/it.ts +++ b/packages/kilo-vscode/src/services/i18n/it.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/it" import { dict as attentionDict } from "./attention/it" +import { dict as mcpDict } from "./mcp/it" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/ja.ts b/packages/kilo-vscode/src/services/i18n/ja.ts index dff31016c15..45338c41194 100644 --- a/packages/kilo-vscode/src/services/i18n/ja.ts +++ b/packages/kilo-vscode/src/services/i18n/ja.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/ja" import { dict as attentionDict } from "./attention/ja" +import { dict as mcpDict } from "./mcp/ja" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/ko.ts b/packages/kilo-vscode/src/services/i18n/ko.ts index d3d289b1367..177787ace2b 100644 --- a/packages/kilo-vscode/src/services/i18n/ko.ts +++ b/packages/kilo-vscode/src/services/i18n/ko.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/ko" import { dict as attentionDict } from "./attention/ko" +import { dict as mcpDict } from "./mcp/ko" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/mcp/ar.ts b/packages/kilo-vscode/src/services/i18n/mcp/ar.ts new file mode 100644 index 00000000000..bbc5711f03d --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/ar.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "تم تسجيل الدخول إلى {{name}}.", + "mcp.signIn.failed": "فشل تسجيل الدخول إلى {{name}}.", + "mcp.signIn.timeout": "انتهت مهلة تسجيل الدخول إلى {{name}}.", + "mcp.signIn.unsupported": "{{name}} لا يدعم تسجيل الدخول عبر OAuth.", + "mcp.signIn.notFound": "لم يتم العثور على خادم MCP {{name}}.", + "mcp.auth.browserFailed": + "تعذّر على Kilo فتح متصفح لـ {{name}}. افتح رابط التفويض على الجهاز الذي يشغّل Kilo لإكمال تسجيل الدخول.", + "mcp.auth.browserFailed.open": "فتح في المتصفح", + "mcp.auth.browserFailed.copy": "نسخ الرابط", + "mcp.auth.resetFailed": "تعذّر مسح تسجيل الدخول المحفوظ لـ {{name}}.", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/br.ts b/packages/kilo-vscode/src/services/i18n/mcp/br.ts new file mode 100644 index 00000000000..591669ea68e --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/br.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "Login em {{name}} realizado.", + "mcp.signIn.failed": "Falha ao fazer login em {{name}}.", + "mcp.signIn.timeout": "O login em {{name}} expirou.", + "mcp.signIn.unsupported": "{{name}} não suporta login via OAuth.", + "mcp.signIn.notFound": "O servidor MCP {{name}} não foi encontrado.", + "mcp.auth.browserFailed": + "O Kilo não conseguiu abrir um navegador para {{name}}. Abra a URL de autorização na máquina que executa o Kilo para concluir o login.", + "mcp.auth.browserFailed.open": "Abrir no navegador", + "mcp.auth.browserFailed.copy": "Copiar URL", + "mcp.auth.resetFailed": "Não foi possível limpar o login salvo de {{name}}.", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/bs.ts b/packages/kilo-vscode/src/services/i18n/mcp/bs.ts new file mode 100644 index 00000000000..21e1ec4e7b6 --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/bs.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "Prijava na {{name}} uspješna.", + "mcp.signIn.failed": "Prijava na {{name}} nije uspjela.", + "mcp.signIn.timeout": "Prijava na {{name}} je istekla.", + "mcp.signIn.unsupported": "{{name}} ne podržava OAuth prijavu.", + "mcp.signIn.notFound": "MCP server {{name}} nije pronađen.", + "mcp.auth.browserFailed": + "Kilo nije mogao otvoriti pretraživač za {{name}}. Otvori URL za autorizaciju na uređaju na kojem radi Kilo da bi završio prijavu.", + "mcp.auth.browserFailed.open": "Otvori u pretraživaču", + "mcp.auth.browserFailed.copy": "Kopiraj URL", + "mcp.auth.resetFailed": "Nije moguće obrisati sačuvanu prijavu za {{name}}.", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/da.ts b/packages/kilo-vscode/src/services/i18n/mcp/da.ts new file mode 100644 index 00000000000..41f91483e72 --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/da.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "Logget ind på {{name}}.", + "mcp.signIn.failed": "Login til {{name}} mislykkedes.", + "mcp.signIn.timeout": "Login til {{name}} fik timeout.", + "mcp.signIn.unsupported": "{{name}} understøtter ikke OAuth-login.", + "mcp.signIn.notFound": "MCP-serveren {{name}} blev ikke fundet.", + "mcp.auth.browserFailed": + "Kilo kunne ikke åbne en browser for {{name}}. Åbn godkendelses-URL'en på den maskine, der kører Kilo, for at afslutte login.", + "mcp.auth.browserFailed.open": "Åbn i browser", + "mcp.auth.browserFailed.copy": "Kopiér URL", + "mcp.auth.resetFailed": "Kunne ikke rydde det gemte login for {{name}}.", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/de.ts b/packages/kilo-vscode/src/services/i18n/mcp/de.ts new file mode 100644 index 00000000000..d4d6f48201e --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/de.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "Bei {{name}} angemeldet.", + "mcp.signIn.failed": "Anmeldung bei {{name}} fehlgeschlagen.", + "mcp.signIn.timeout": "Zeitüberschreitung bei der Anmeldung bei {{name}}.", + "mcp.signIn.unsupported": "{{name}} unterstützt keine OAuth-Anmeldung.", + "mcp.signIn.notFound": "MCP-Server {{name}} wurde nicht gefunden.", + "mcp.auth.browserFailed": + "Kilo konnte für {{name}} keinen Browser öffnen. Öffne die Autorisierungs-URL auf dem Gerät, auf dem Kilo läuft, um die Anmeldung abzuschließen.", + "mcp.auth.browserFailed.open": "Im Browser öffnen", + "mcp.auth.browserFailed.copy": "URL kopieren", + "mcp.auth.resetFailed": "Die gespeicherte Anmeldung für {{name}} konnte nicht gelöscht werden.", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/en.ts b/packages/kilo-vscode/src/services/i18n/mcp/en.ts new file mode 100644 index 00000000000..d567bb377df --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/en.ts @@ -0,0 +1,16 @@ +// English runtime translations for MCP OAuth sign-in notifications shown by +// the extension host (native showInformationMessage/showErrorMessage), used +// when a webview did not request a silent/inline outcome. + +export const dict = { + "mcp.signIn.success": "Signed in to {{name}}.", + "mcp.signIn.failed": "Sign-in to {{name}} failed.", + "mcp.signIn.timeout": "Sign-in to {{name}} timed out.", + "mcp.signIn.unsupported": "{{name}} does not support OAuth sign-in.", + "mcp.signIn.notFound": "MCP server {{name}} was not found.", + "mcp.auth.browserFailed": + "Kilo could not open a browser for {{name}}. Open the authorization URL on the machine running Kilo to finish signing in.", + "mcp.auth.browserFailed.open": "Open in Browser", + "mcp.auth.browserFailed.copy": "Copy URL", + "mcp.auth.resetFailed": "Could not clear the stored sign-in for {{name}}.", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/es.ts b/packages/kilo-vscode/src/services/i18n/mcp/es.ts new file mode 100644 index 00000000000..f2c3c91359c --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/es.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "Sesión iniciada en {{name}}.", + "mcp.signIn.failed": "Error al iniciar sesión en {{name}}.", + "mcp.signIn.timeout": "El inicio de sesión en {{name}} agotó el tiempo de espera.", + "mcp.signIn.unsupported": "{{name}} no admite el inicio de sesión con OAuth.", + "mcp.signIn.notFound": "No se encontró el servidor MCP {{name}}.", + "mcp.auth.browserFailed": + "Kilo no pudo abrir un navegador para {{name}}. Abre la URL de autorización en la máquina que ejecuta Kilo para completar el inicio de sesión.", + "mcp.auth.browserFailed.open": "Abrir en el navegador", + "mcp.auth.browserFailed.copy": "Copiar URL", + "mcp.auth.resetFailed": "No se pudo borrar el inicio de sesión guardado de {{name}}.", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/fa.ts b/packages/kilo-vscode/src/services/i18n/mcp/fa.ts new file mode 100644 index 00000000000..4f1632772d8 --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/fa.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "ورود به {{name}} انجام شد.", + "mcp.signIn.failed": "ورود به {{name}} ناموفق بود.", + "mcp.signIn.timeout": "ورود به {{name}} به زمان پایان رسید.", + "mcp.signIn.unsupported": "{{name}} از ورود با OAuth پشتیبانی نمی‌کند.", + "mcp.signIn.notFound": "سرور MCP با نام {{name}} یافت نشد.", + "mcp.auth.browserFailed": + "Kilo نتوانست مرورگری برای {{name}} باز کند. برای تکمیل ورود، نشانی تأیید هویت را روی دستگاهی که Kilo روی آن اجرا می‌شود باز کنید.", + "mcp.auth.browserFailed.open": "باز کردن در مرورگر", + "mcp.auth.browserFailed.copy": "کپی نشانی", + "mcp.auth.resetFailed": "پاک‌کردن ورود ذخیره‌شده برای {{name}} ممکن نشد.", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/fr.ts b/packages/kilo-vscode/src/services/i18n/mcp/fr.ts new file mode 100644 index 00000000000..924b8b13fa6 --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/fr.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "Connecté à {{name}}.", + "mcp.signIn.failed": "La connexion à {{name}} a échoué.", + "mcp.signIn.timeout": "La connexion à {{name}} a expiré.", + "mcp.signIn.unsupported": "{{name}} ne prend pas en charge la connexion OAuth.", + "mcp.signIn.notFound": "Le serveur MCP {{name}} n'a pas été trouvé.", + "mcp.auth.browserFailed": + "Kilo n'a pas pu ouvrir de navigateur pour {{name}}. Ouvrez l'URL d'autorisation sur la machine qui exécute Kilo pour terminer la connexion.", + "mcp.auth.browserFailed.open": "Ouvrir dans le navigateur", + "mcp.auth.browserFailed.copy": "Copier l'URL", + "mcp.auth.resetFailed": "Impossible d'effacer la connexion enregistrée pour {{name}}.", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/it.ts b/packages/kilo-vscode/src/services/i18n/mcp/it.ts new file mode 100644 index 00000000000..942d23036d4 --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/it.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "Accesso a {{name}} effettuato.", + "mcp.signIn.failed": "Accesso a {{name}} non riuscito.", + "mcp.signIn.timeout": "Accesso a {{name}} scaduto.", + "mcp.signIn.unsupported": "{{name}} non supporta l'accesso OAuth.", + "mcp.signIn.notFound": "Il server MCP {{name}} non è stato trovato.", + "mcp.auth.browserFailed": + "Kilo non è riuscito ad aprire un browser per {{name}}. Apri l'URL di autorizzazione sul computer su cui è in esecuzione Kilo per completare l'accesso.", + "mcp.auth.browserFailed.open": "Apri nel browser", + "mcp.auth.browserFailed.copy": "Copia URL", + "mcp.auth.resetFailed": "Non è stato possibile eliminare l'accesso salvato per {{name}}.", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/ja.ts b/packages/kilo-vscode/src/services/i18n/mcp/ja.ts new file mode 100644 index 00000000000..94b8d8a50db --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/ja.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "{{name}} にサインインしました。", + "mcp.signIn.failed": "{{name}} へのサインインに失敗しました。", + "mcp.signIn.timeout": "{{name}} へのサインインがタイムアウトしました。", + "mcp.signIn.unsupported": "{{name}} は OAuth サインインに対応していません。", + "mcp.signIn.notFound": "MCP サーバー {{name}} が見つかりませんでした。", + "mcp.auth.browserFailed": + "Kilo は {{name}} 用のブラウザーを開けませんでした。サインインを完了するには、Kilo を実行しているマシンで認証 URL を開いてください。", + "mcp.auth.browserFailed.open": "ブラウザーで開く", + "mcp.auth.browserFailed.copy": "URL をコピー", + "mcp.auth.resetFailed": "{{name}} の保存済みサインインをクリアできませんでした。", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/ko.ts b/packages/kilo-vscode/src/services/i18n/mcp/ko.ts new file mode 100644 index 00000000000..1231da243d2 --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/ko.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "{{name}}에 로그인했습니다.", + "mcp.signIn.failed": "{{name}} 로그인에 실패했습니다.", + "mcp.signIn.timeout": "{{name}} 로그인이 시간 초과되었습니다.", + "mcp.signIn.unsupported": "{{name}}은 OAuth 로그인을 지원하지 않습니다.", + "mcp.signIn.notFound": "MCP 서버 {{name}}을 찾을 수 없습니다.", + "mcp.auth.browserFailed": + "Kilo가 {{name}}용 브라우저를 열지 못했습니다. 로그인을 완료하려면 Kilo를 실행 중인 기기에서 인증 URL을 열어주세요.", + "mcp.auth.browserFailed.open": "브라우저에서 열기", + "mcp.auth.browserFailed.copy": "URL 복사", + "mcp.auth.resetFailed": "{{name}}의 저장된 로그인 정보를 지울 수 없습니다.", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/nl.ts b/packages/kilo-vscode/src/services/i18n/mcp/nl.ts new file mode 100644 index 00000000000..488d1580f93 --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/nl.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "Aangemeld bij {{name}}.", + "mcp.signIn.failed": "Aanmelden bij {{name}} is mislukt.", + "mcp.signIn.timeout": "Aanmelden bij {{name}} is verlopen.", + "mcp.signIn.unsupported": "{{name}} ondersteunt geen OAuth-aanmelding.", + "mcp.signIn.notFound": "MCP-server {{name}} is niet gevonden.", + "mcp.auth.browserFailed": + "Kilo kon geen browser openen voor {{name}}. Open de autorisatie-URL op de machine waarop Kilo draait om het aanmelden te voltooien.", + "mcp.auth.browserFailed.open": "Openen in browser", + "mcp.auth.browserFailed.copy": "URL kopiëren", + "mcp.auth.resetFailed": "De opgeslagen aanmelding voor {{name}} kon niet worden gewist.", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/no.ts b/packages/kilo-vscode/src/services/i18n/mcp/no.ts new file mode 100644 index 00000000000..ee9fbf334d5 --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/no.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "Logget inn på {{name}}.", + "mcp.signIn.failed": "Innlogging til {{name}} mislyktes.", + "mcp.signIn.timeout": "Innlogging til {{name}} fikk tidsavbrudd.", + "mcp.signIn.unsupported": "{{name}} støtter ikke OAuth-innlogging.", + "mcp.signIn.notFound": "Fant ikke MCP-serveren {{name}}.", + "mcp.auth.browserFailed": + "Kilo kunne ikke åpne en nettleser for {{name}}. Åpne autorisasjons-URL-en på maskinen som kjører Kilo for å fullføre innloggingen.", + "mcp.auth.browserFailed.open": "Åpne i nettleser", + "mcp.auth.browserFailed.copy": "Kopiér URL", + "mcp.auth.resetFailed": "Kunne ikke fjerne den lagrede innloggingen for {{name}}.", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/pl.ts b/packages/kilo-vscode/src/services/i18n/mcp/pl.ts new file mode 100644 index 00000000000..f267dff5825 --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/pl.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "Zalogowano do {{name}}.", + "mcp.signIn.failed": "Logowanie do {{name}} nie powiodło się.", + "mcp.signIn.timeout": "Logowanie do {{name}} przekroczyło limit czasu.", + "mcp.signIn.unsupported": "{{name}} nie wspiera logowania OAuth.", + "mcp.signIn.notFound": "Nie znaleziono serwera MCP {{name}}.", + "mcp.auth.browserFailed": + "Kilo nie mógł otworzyć przeglądarki dla {{name}}. Otwórz adres URL autoryzacji na komputerze, na którym działa Kilo, aby zakończyć logowanie.", + "mcp.auth.browserFailed.open": "Otwórz w przeglądarce", + "mcp.auth.browserFailed.copy": "Kopiuj adres URL", + "mcp.auth.resetFailed": "Nie udało się usunąć zapisanego logowania dla {{name}}.", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/ru.ts b/packages/kilo-vscode/src/services/i18n/mcp/ru.ts new file mode 100644 index 00000000000..44374f52d6d --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/ru.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "Выполнен вход в {{name}}.", + "mcp.signIn.failed": "Не удалось войти в {{name}}.", + "mcp.signIn.timeout": "Истекло время ожидания входа в {{name}}.", + "mcp.signIn.unsupported": "{{name}} не поддерживает вход через OAuth.", + "mcp.signIn.notFound": "Сервер MCP {{name}} не найден.", + "mcp.auth.browserFailed": + "Kilo не удалось открыть браузер для {{name}}. Откройте URL авторизации на устройстве, где запущен Kilo, чтобы завершить вход.", + "mcp.auth.browserFailed.open": "Открыть в браузере", + "mcp.auth.browserFailed.copy": "Скопировать URL", + "mcp.auth.resetFailed": "Не удалось очистить сохранённый вход для {{name}}.", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/th.ts b/packages/kilo-vscode/src/services/i18n/mcp/th.ts new file mode 100644 index 00000000000..e3f2b12d46f --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/th.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "ลงชื่อเข้าใช้ {{name}} แล้ว", + "mcp.signIn.failed": "ลงชื่อเข้าใช้ {{name}} ไม่สำเร็จ", + "mcp.signIn.timeout": "การลงชื่อเข้าใช้ {{name}} หมดเวลา", + "mcp.signIn.unsupported": "{{name}} ไม่รองรับการลงชื่อเข้าใช้ด้วย OAuth", + "mcp.signIn.notFound": "ไม่พบเซิร์ฟเวอร์ MCP {{name}}", + "mcp.auth.browserFailed": + "Kilo ไม่สามารถเปิดเบราว์เซอร์สำหรับ {{name}} ได้ โปรดเปิด URL การอนุญาตบนเครื่องที่รัน Kilo เพื่อลงชื่อเข้าใช้ให้เสร็จสมบูรณ์", + "mcp.auth.browserFailed.open": "เปิดในเบราว์เซอร์", + "mcp.auth.browserFailed.copy": "คัดลอก URL", + "mcp.auth.resetFailed": "ไม่สามารถล้างการลงชื่อเข้าใช้ที่บันทึกไว้สำหรับ {{name}} ได้", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/tr.ts b/packages/kilo-vscode/src/services/i18n/mcp/tr.ts new file mode 100644 index 00000000000..ccca04147c7 --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/tr.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "{{name}} oturumu açıldı.", + "mcp.signIn.failed": "{{name}} oturum açma işlemi başarısız oldu.", + "mcp.signIn.timeout": "{{name}} oturum açma işlemi zaman aşımına uğradı.", + "mcp.signIn.unsupported": "{{name}} OAuth ile oturum açmayı desteklemiyor.", + "mcp.signIn.notFound": "{{name}} MCP sunucusu bulunamadı.", + "mcp.auth.browserFailed": + "Kilo, {{name}} için bir tarayıcı açamadı. Oturum açmayı tamamlamak için Kilo'nun çalıştığı makinede yetkilendirme URL'sini açın.", + "mcp.auth.browserFailed.open": "Tarayıcıda aç", + "mcp.auth.browserFailed.copy": "URL'yi kopyala", + "mcp.auth.resetFailed": "{{name}} için kaydedilmiş oturum açma bilgisi temizlenemedi.", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/uk.ts b/packages/kilo-vscode/src/services/i18n/mcp/uk.ts new file mode 100644 index 00000000000..101b9c96b4d --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/uk.ts @@ -0,0 +1,12 @@ +export const dict = { + "mcp.signIn.success": "Виконано вхід до {{name}}.", + "mcp.signIn.failed": "Не вдалося увійти до {{name}}.", + "mcp.signIn.timeout": "Час очікування входу до {{name}} минув.", + "mcp.signIn.unsupported": "{{name}} не підтримує вхід через OAuth.", + "mcp.signIn.notFound": "Сервер MCP {{name}} не знайдено.", + "mcp.auth.browserFailed": + "Kilo не вдалося відкрити браузер для {{name}}. Відкрийте URL авторизації на пристрої, де запущено Kilo, щоб завершити вхід.", + "mcp.auth.browserFailed.open": "Відкрити в браузері", + "mcp.auth.browserFailed.copy": "Скопіювати URL", + "mcp.auth.resetFailed": "Не вдалося очистити збережений вхід для {{name}}.", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/zh.ts b/packages/kilo-vscode/src/services/i18n/mcp/zh.ts new file mode 100644 index 00000000000..b87ad375e1c --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/zh.ts @@ -0,0 +1,11 @@ +export const dict = { + "mcp.signIn.success": "已登录 {{name}}。", + "mcp.signIn.failed": "登录 {{name}} 失败。", + "mcp.signIn.timeout": "登录 {{name}} 超时。", + "mcp.signIn.unsupported": "{{name}} 不支持 OAuth 登录。", + "mcp.signIn.notFound": "未找到 MCP 服务器 {{name}}。", + "mcp.auth.browserFailed": "Kilo 无法为 {{name}} 打开浏览器。请在运行 Kilo 的设备上打开授权链接以完成登录。", + "mcp.auth.browserFailed.open": "在浏览器中打开", + "mcp.auth.browserFailed.copy": "复制链接", + "mcp.auth.resetFailed": "无法清除 {{name}} 已保存的登录信息。", +} diff --git a/packages/kilo-vscode/src/services/i18n/mcp/zht.ts b/packages/kilo-vscode/src/services/i18n/mcp/zht.ts new file mode 100644 index 00000000000..0e32530d2e2 --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/mcp/zht.ts @@ -0,0 +1,11 @@ +export const dict = { + "mcp.signIn.success": "已登入 {{name}}。", + "mcp.signIn.failed": "登入 {{name}} 失敗。", + "mcp.signIn.timeout": "登入 {{name}} 逾時。", + "mcp.signIn.unsupported": "{{name}} 不支援 OAuth 登入。", + "mcp.signIn.notFound": "找不到 MCP 伺服器 {{name}}。", + "mcp.auth.browserFailed": "Kilo 無法為 {{name}} 開啟瀏覽器。請在執行 Kilo 的裝置上開啟授權網址以完成登入。", + "mcp.auth.browserFailed.open": "在瀏覽器中開啟", + "mcp.auth.browserFailed.copy": "複製網址", + "mcp.auth.resetFailed": "無法清除 {{name}} 已儲存的登入資訊。", +} diff --git a/packages/kilo-vscode/src/services/i18n/nl.ts b/packages/kilo-vscode/src/services/i18n/nl.ts index a477b5cf121..dc8c837d124 100644 --- a/packages/kilo-vscode/src/services/i18n/nl.ts +++ b/packages/kilo-vscode/src/services/i18n/nl.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/nl" import { dict as attentionDict } from "./attention/nl" +import { dict as mcpDict } from "./mcp/nl" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/no.ts b/packages/kilo-vscode/src/services/i18n/no.ts index 6630bbe4af7..df53799bc65 100644 --- a/packages/kilo-vscode/src/services/i18n/no.ts +++ b/packages/kilo-vscode/src/services/i18n/no.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/no" import { dict as attentionDict } from "./attention/no" +import { dict as mcpDict } from "./mcp/no" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/pl.ts b/packages/kilo-vscode/src/services/i18n/pl.ts index 7791e3d1b7a..35057a653bf 100644 --- a/packages/kilo-vscode/src/services/i18n/pl.ts +++ b/packages/kilo-vscode/src/services/i18n/pl.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/pl" import { dict as attentionDict } from "./attention/pl" +import { dict as mcpDict } from "./mcp/pl" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/ru.ts b/packages/kilo-vscode/src/services/i18n/ru.ts index 3ccf7fbcfc7..aeb876d468d 100644 --- a/packages/kilo-vscode/src/services/i18n/ru.ts +++ b/packages/kilo-vscode/src/services/i18n/ru.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/ru" import { dict as attentionDict } from "./attention/ru" +import { dict as mcpDict } from "./mcp/ru" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/th.ts b/packages/kilo-vscode/src/services/i18n/th.ts index c0ef0daad59..17f1e170225 100644 --- a/packages/kilo-vscode/src/services/i18n/th.ts +++ b/packages/kilo-vscode/src/services/i18n/th.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/th" import { dict as attentionDict } from "./attention/th" +import { dict as mcpDict } from "./mcp/th" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/tr.ts b/packages/kilo-vscode/src/services/i18n/tr.ts index 071f1303c22..3310d2729ee 100644 --- a/packages/kilo-vscode/src/services/i18n/tr.ts +++ b/packages/kilo-vscode/src/services/i18n/tr.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/tr" import { dict as attentionDict } from "./attention/tr" +import { dict as mcpDict } from "./mcp/tr" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/uk.ts b/packages/kilo-vscode/src/services/i18n/uk.ts index 1b3e7218403..4faac1342dd 100644 --- a/packages/kilo-vscode/src/services/i18n/uk.ts +++ b/packages/kilo-vscode/src/services/i18n/uk.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/uk" import { dict as attentionDict } from "./attention/uk" +import { dict as mcpDict } from "./mcp/uk" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/zh.ts b/packages/kilo-vscode/src/services/i18n/zh.ts index d211c47970d..7a25dc98ab9 100644 --- a/packages/kilo-vscode/src/services/i18n/zh.ts +++ b/packages/kilo-vscode/src/services/i18n/zh.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/zh" import { dict as attentionDict } from "./attention/zh" +import { dict as mcpDict } from "./mcp/zh" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/i18n/zht.ts b/packages/kilo-vscode/src/services/i18n/zht.ts index 46b3b07d2e4..136be20533e 100644 --- a/packages/kilo-vscode/src/services/i18n/zht.ts +++ b/packages/kilo-vscode/src/services/i18n/zht.ts @@ -1,9 +1,11 @@ import { dict as autocompleteDict } from "./autocomplete/zht" import { dict as attentionDict } from "./attention/zht" +import { dict as mcpDict } from "./mcp/zht" export { autocompleteDict } export const dict = { ...autocompleteDict, ...attentionDict, + ...mcpDict, } as const diff --git a/packages/kilo-vscode/src/services/marketplace/actions.ts b/packages/kilo-vscode/src/services/marketplace/actions.ts index 586ebf5e173..db0d61ff053 100644 --- a/packages/kilo-vscode/src/services/marketplace/actions.ts +++ b/packages/kilo-vscode/src/services/marketplace/actions.ts @@ -29,7 +29,6 @@ export async function fetchMarketplaceData( ctx: MarketplaceActionContext, project: string | undefined, dir: string | undefined, - roots: readonly vscode.Uri[], ): Promise { const route = project ?? dir if (!route) { @@ -41,7 +40,7 @@ export async function fetchMarketplaceData( } } const client = await ctx.connection.getClientAsync(route) - return retry(() => ctx.marketplace.fetchData(client, project, route, roots)) + return retry(() => ctx.marketplace.fetchData(client, project, route)) } export async function installMarketplaceItem( @@ -102,7 +101,7 @@ export async function removeMarketplaceItemFromAllScopes( if (item.type === "mcp") await removeLegacyMcp(ctx, item.id, project, "all") const local = project ? await removeScoped(ctx, item, "project", project) : undefined const global = await removeScoped(ctx, item, "global", dir) - return Boolean(local?.success || global.success) + return (local?.success ?? true) && global.success } catch (err) { console.warn("[Kilo New] Marketplace removal failed:", err) return false diff --git a/packages/kilo-vscode/src/services/marketplace/bundles.ts b/packages/kilo-vscode/src/services/marketplace/bundles.ts new file mode 100644 index 00000000000..8f7b151c11e --- /dev/null +++ b/packages/kilo-vscode/src/services/marketplace/bundles.ts @@ -0,0 +1,94 @@ +import * as path from "path" +import * as vscode from "vscode" +import type { KiloClient } from "@kilocode/sdk/v2/client" + +export interface McpBundle { + id: string + scope: "project" | "global" + skills: string[] +} + +const OWNER_FILE = ".kilo-marketplace.json" +const MAX_MARKER_BYTES = 4096 +const BUILTIN_LOCATIONS = new Set(["builtin", ""]) +const SAFE_ID = /^[A-Za-z0-9_@.-]+$/ +const RESERVED_ID = /^(?:\.|\.\.|con|prn|aux|nul|com[1-9]|lpt[1-9])$/i +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +interface Marker { + version: 1 + id: string + token: string +} + +function validMarker(value: unknown): value is Marker { + if (!value || typeof value !== "object") return false + const obj = value as Record + if (obj.version !== 1) return false + if (typeof obj.id !== "string" || !SAFE_ID.test(obj.id) || RESERVED_ID.test(obj.id)) return false + if (typeof obj.token !== "string" || !UUID.test(obj.token)) return false + return true +} + +async function readMarker(markerUri: vscode.Uri): Promise { + const stat = await vscode.workspace.fs.stat(markerUri).then( + (s) => s, + () => undefined, + ) + if (!stat) return undefined + if ((stat.type & vscode.FileType.SymbolicLink) !== 0) return undefined + if ((stat.type & vscode.FileType.File) === 0) return undefined + if (stat.size > MAX_MARKER_BYTES) return undefined + const bytes = await vscode.workspace.fs.readFile(markerUri).then( + (data) => data, + () => undefined, + ) + if (!bytes) return undefined + const parsed = ((): unknown => { + try { + return JSON.parse(Buffer.from(bytes).toString("utf8")) + } catch { + return undefined + } + })() + return validMarker(parsed) ? parsed : undefined +} + +function scopeFor(skillPath: string, directory: string): "project" | "global" { + const relative = path.relative(directory, skillPath) + return relative.startsWith("..") || path.isAbsolute(relative) ? "global" : "project" +} + +/** + * Derive marketplace MCP+companion-skill bundles by reading the ownership + * marker (`.kilo-marketplace.json`) next to each skill's `SKILL.md`. There is + * no CLI endpoint for this — the CLI only writes the marker, so bundle + * membership is reconstructed client-side the same way for every consumer + * (the Skills settings subtab uses this to confirm bundle-aware removal). + */ +export async function marketplaceBundles(client: KiloClient, directory: string): Promise { + const { data: skills } = await client.app.skills({ directory }, { throwOnError: true }) + const owned = new Map }>() + + for (const skill of skills) { + if (BUILTIN_LOCATIONS.has(skill.location)) continue + if (!path.isAbsolute(skill.location)) continue + const dir = path.dirname(skill.location) + const markerUri = vscode.Uri.file(path.join(dir, OWNER_FILE)) + const marker = await readMarker(markerUri) + if (!marker) continue + const scope = scopeFor(skill.location, directory) + const key = `${scope}\u0000${marker.id}` + const entry = owned.get(key) ?? { scope, skills: new Set() } + entry.skills.add(skill.location) + owned.set(key, entry) + } + + return [...owned.entries()] + .map(([key, entry]) => ({ + id: key.slice(key.indexOf("\u0000") + 1), + scope: entry.scope, + skills: [...entry.skills].sort(), + })) + .sort((a, b) => (a.scope === b.scope ? a.id.localeCompare(b.id) : a.scope.localeCompare(b.scope))) +} diff --git a/packages/kilo-vscode/src/services/marketplace/index.ts b/packages/kilo-vscode/src/services/marketplace/index.ts index 4754818d4db..8b969593e3c 100644 --- a/packages/kilo-vscode/src/services/marketplace/index.ts +++ b/packages/kilo-vscode/src/services/marketplace/index.ts @@ -1,28 +1,19 @@ -import * as vscode from "vscode" import type { KiloClient } from "@kilocode/sdk/v2/client" import { detectMarketplaceRelevance } from "./relevance" import type { MarketplaceItem, InstallMarketplaceItemOptions, MarketplaceDataResponse, - MarketplaceRelevanceMetadata, InstallResult, RemoveResult, MarketplaceItemRef, } from "./types" export class MarketplaceService { - private scans = new Map>() - - async fetchData( - client: KiloClient, - project: string | undefined, - dir: string, - roots: readonly vscode.Uri[], - ): Promise { + async fetchData(client: KiloClient, project: string | undefined, dir: string): Promise { const { data } = await client.kilocode.marketplace.list({ directory: dir }, { throwOnError: true }) const items = (data.items ?? []) as MarketplaceItem[] - const relevance = await this.relevance(items, roots) + const relevance = detectMarketplaceRelevance(items, data.filenames ?? []) const installed = project ? data.installed : { project: {}, global: { ...data.installed.global, ...data.installed.project } } @@ -35,16 +26,6 @@ export class MarketplaceService { } } - private relevance(items: MarketplaceItem[], roots: readonly vscode.Uri[]): Promise { - const key = `${roots.map((root) => root.toString()).join(",")}:${items.map((item) => `${item.type}:${item.id}`).join(",")}` - const current = this.scans.get(key) - if (current) return current - - const scan = detectMarketplaceRelevance(items, roots).finally(() => this.scans.delete(key)) - this.scans.set(key, scan) - return scan - } - async install( client: KiloClient, item: MarketplaceItem, @@ -79,10 +60,6 @@ export class MarketplaceService { // Notifications are owned by the caller (see install() above). return data as RemoveResult } - - dispose(): void { - this.scans.clear() - } } export type { diff --git a/packages/kilo-vscode/src/services/marketplace/notifier.ts b/packages/kilo-vscode/src/services/marketplace/notifier.ts index c60f24927ae..1af027aec4c 100644 --- a/packages/kilo-vscode/src/services/marketplace/notifier.ts +++ b/packages/kilo-vscode/src/services/marketplace/notifier.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode" import { MarketplaceService } from "." import { fetchMarketplaceData, type MarketplaceActionContext } from "./actions" import { selectSuggestions, showSuggestionNotification, suggestionSlug } from "./notify" +import { filenamePatterns, matchesPattern } from "./relevance" import type { KiloConnectionService } from "../cli-backend" import type { MarketplaceItem } from "./types" @@ -25,6 +26,8 @@ export class MarketplaceNotifier implements vscode.Disposable { private disposed = false /** Slugs already shown this session so a single scan burst doesn't re-toast. */ private shown = new Set() + /** Filename patterns from the last fetched catalog. */ + private patterns: string[] = [] constructor( private readonly connection: KiloConnectionService, @@ -35,7 +38,7 @@ export class MarketplaceNotifier implements vscode.Disposable { this.disposables.push( vscode.workspace.onDidChangeWorkspaceFolders(() => this.schedule()), vscode.extensions.onDidChange(() => this.schedule()), - vscode.workspace.onDidCreateFiles(() => this.schedule()), + vscode.workspace.onDidCreateFiles((event) => void this.created(event.files)), ) } @@ -51,10 +54,10 @@ export class MarketplaceNotifier implements vscode.Disposable { this.generation++ for (const disposable of this.disposables) disposable.dispose() this.disposables = [] - this.marketplace.dispose() } private schedule(): void { + if (this.disposed) return if (this.timer) clearTimeout(this.timer) this.timer = setTimeout(() => { this.timer = undefined @@ -80,8 +83,26 @@ export class MarketplaceNotifier implements vscode.Disposable { return this.project() ?? os.homedir() } - private roots(): vscode.Uri[] { - return vscode.workspace.workspaceFolders?.map((folder) => folder.uri) ?? [] + /** + * A rescan walks the workspace, so only rescan for entries that can change the + * result: a file that matches a known pattern, or a folder (for example a + * pasted one) that can contain such files. + */ + private async created(files: readonly vscode.Uri[]): Promise { + if (this.patterns.length === 0) return + const paths = files.map((uri) => vscode.workspace.asRelativePath(uri, false)) + if (paths.some((file) => this.patterns.some((pattern) => matchesPattern(file, pattern)))) return this.schedule() + const types = await Promise.all( + files.map((uri) => + Promise.resolve(vscode.workspace.fs.stat(uri)).then( + (stat) => stat.type, + // The entry can be gone again before the stat runs; it cannot match then. + () => vscode.FileType.Unknown, + ), + ), + ) + if (this.disposed) return + if (types.some((type) => type & vscode.FileType.Directory)) this.schedule() } private get ctx(): MarketplaceActionContext { @@ -90,13 +111,12 @@ export class MarketplaceNotifier implements vscode.Disposable { private async scan(): Promise { const generation = ++this.generation - const data = await fetchMarketplaceData(this.ctx, this.project(), this.directory(), this.roots()).catch( - (err: unknown) => { - console.warn("[Kilo New] Marketplace suggestion scan failed:", err) - return undefined - }, - ) + const data = await fetchMarketplaceData(this.ctx, this.project(), this.directory()).catch((err: unknown) => { + console.warn("[Kilo New] Marketplace suggestion scan failed:", err) + return undefined + }) if (!data || generation !== this.generation) return + this.patterns = filenamePatterns(data.marketplaceItems) const installed = new Set([ ...Object.keys(data.marketplaceInstalledMetadata.project), diff --git a/packages/kilo-vscode/src/services/marketplace/relevance.ts b/packages/kilo-vscode/src/services/marketplace/relevance.ts index 10bfe40d345..17516987a28 100644 --- a/packages/kilo-vscode/src/services/marketplace/relevance.ts +++ b/packages/kilo-vscode/src/services/marketplace/relevance.ts @@ -1,8 +1,7 @@ import * as vscode from "vscode" +import { minimatch } from "minimatch" import type { MarketplaceItem, MarketplaceRelevanceMetadata } from "./types" -const EXCLUDE = "**/{node_modules,.git,dist,build,out,.kilo,.opencode,.kilocode}/**" - function strings(value: unknown): string[] { if (!Array.isArray(value)) return [] return value.filter((item): item is string => typeof item === "string") @@ -21,52 +20,30 @@ function extensionIds(value: unknown): string[] { .filter((id): id is string => typeof id === "string") } -interface RelevanceHost { - extensions: readonly string[] - find: (root: vscode.Uri, pattern: string) => Promise +/** Unique `suggest_for.filename` patterns of the given marketplace items. */ +export function filenamePatterns(items: MarketplaceItem[]): string[] { + return Array.from(new Set(items.flatMap((item) => strings(item.suggest_for?.filename)))) } -function context(): RelevanceHost { - return { - extensions: vscode.extensions.all.map((extension) => extension.id), - find: async (root, pattern) => { - const glob = new vscode.RelativePattern(root, `**/${pattern}`) - return (await vscode.workspace.findFiles(glob, EXCLUDE, 1)).length > 0 - }, - } +/** Whether a workspace-relative path matches a `suggest_for.filename` pattern. */ +export function matchesPattern(file: string, pattern: string): boolean { + return minimatch(file.replaceAll("\\", "/"), `**/${pattern}`, { dot: true }) } -export async function detectMarketplaceRelevance( +/** + * Combines the filename patterns the backend found in the workspace (it honors + * .gitignore) with the installed VS Code extensions. + */ +export function detectMarketplaceRelevance( items: MarketplaceItem[], - roots: readonly vscode.Uri[], - source: RelevanceHost = context(), -): Promise { - const patterns = Array.from(new Set(items.flatMap((item) => strings(item.suggest_for?.filename)))) - const files = new Map() - - const batches = Array.from({ length: Math.ceil(patterns.length / 4) }, (_, index) => - patterns.slice(index * 4, index * 4 + 4), - ) - for (const batch of batches) { - await Promise.all( - batch.map(async (pattern) => { - const found = await Promise.all( - roots.map((root) => - source.find(root, pattern).catch((err: unknown) => { - console.warn(`[Kilo New] Marketplace relevance scan failed for ${pattern}:`, err) - return false - }), - ), - ) - files.set(pattern, found.some(Boolean)) - }), - ) - } - - const extensions = new Set(source.extensions.map((id) => id.toLowerCase())) + filenames: readonly string[], + installed: readonly string[] = vscode.extensions.all.map((extension) => extension.id), +): MarketplaceRelevanceMetadata { + const found = new Set(filenames) + const extensions = new Set(installed.map((id) => id.toLowerCase())) return Object.fromEntries( items.flatMap((item) => { - const filename = strings(item.suggest_for?.filename).filter((pattern) => files.get(pattern)) + const filename = strings(item.suggest_for?.filename).filter((pattern) => found.has(pattern)) const vscodeExtension = extensionIds(item.suggest_for?.vscode_extension).filter((id) => extensions.has(id.toLowerCase()), ) diff --git a/packages/kilo-vscode/src/services/mcp-auth/index.ts b/packages/kilo-vscode/src/services/mcp-auth/index.ts new file mode 100644 index 00000000000..6a4cd29a9c9 --- /dev/null +++ b/packages/kilo-vscode/src/services/mcp-auth/index.ts @@ -0,0 +1,16 @@ +import type { KiloConnectionService } from "../cli-backend/connection-service" +import { McpAuthService, type McpAuthOpts, type McpAuthResult, type McpAuthStatus } from "./service" + +export { McpAuthService, type McpAuthOpts, type McpAuthResult, type McpAuthStatus } +export { needsAuthNames } from "./status" + +const instances = new WeakMap() + +/** One `McpAuthService` per connection, shared by the sidebar, Kilo tabs, Settings panel, and the Marketplace panel. */ +export function mcpAuth(connection: KiloConnectionService, opts?: McpAuthOpts): McpAuthService { + const existing = instances.get(connection) + if (existing) return existing + const service = new McpAuthService(connection, opts) + instances.set(connection, service) + return service +} diff --git a/packages/kilo-vscode/src/services/mcp-auth/service.ts b/packages/kilo-vscode/src/services/mcp-auth/service.ts new file mode 100644 index 00000000000..b7da9ca1e0b --- /dev/null +++ b/packages/kilo-vscode/src/services/mcp-auth/service.ts @@ -0,0 +1,347 @@ +import type { KiloClient, McpStatus } from "@kilocode/sdk/v2/client" +import * as vscode from "vscode" +import type { SSEPayload } from "../cli-backend/sdk-sse-adapter" +import { needsAuthNames } from "./status" + +/** + * The subset of `KiloConnectionService` the auth service depends on. + * `KiloConnectionService` satisfies this structurally — kept narrow so + * tests can supply a fake without constructing a real connection. + */ +export interface McpAuthConnection { + getClientAsync(dir?: string): Promise + onEvent(listener: (event: SSEPayload, directory?: string) => void): () => void +} + +export type McpAuthStatus = + | "connected" + | "failed" + | "cancelled" + | "timeout" + | "unsupported" + | "not_found" + | "disabled" + | "needs_auth" + | "needs_client_registration" + +export interface McpAuthResult { + status: McpAuthStatus + error?: string +} + +export interface McpAuthOpts { + /** Milliseconds before a sign-in attempt is abandoned client-side. Default 6 minutes, kept above the CLI's 5-minute OAuth callback timeout so the CLI's own error reaches the user first. */ + timeout?: number + /** Milliseconds within which repeated browser URL events for the same URL are collapsed. Default 4 seconds. */ + dedupe?: number + /** Fallback used when VS Code cannot open an authorization URL. */ + onUrl?: (name: string, url: string) => void +} + +const DEFAULT_TIMEOUT_MS = 6 * 60 * 1000 +const DEFAULT_DEDUPE_MS = 4000 +const MAX_DIRECTORIES = 64 + +function busyKey(dir: string, name: string): string { + return `${dir}\0${name}` +} + +/** + * Owns MCP OAuth sign-in state for every directory on the shared connection. + * Settings, the chat prompt's session-issues indicator, and the Marketplace + * post-install prompt are all pure consumers of this service so they agree + * on which servers need sign-in and whether one is in progress. + * + * Mirrors `KiloMcpAuthService` from the JetBrains plugin (packages/kilo-jetbrains). + */ +export class McpAuthService { + private connection: McpAuthConnection + private timeout: number + private dedupeWindow: number + private onUrl?: (name: string, url: string) => void + + private needsAuthByDir = new Map>() + private busyKeys = new Set() + private active = new Map() + private refreshes = new Map | undefined>>() + private cancelled = new Set() + private listeners = new Set<(dir: string) => void>() + private lastUrl: string | undefined + private lastUrlAt = 0 + private unsubEvent: (() => void) | undefined + + constructor(connection: McpAuthConnection, opts: McpAuthOpts = {}) { + this.connection = connection + this.timeout = opts.timeout ?? DEFAULT_TIMEOUT_MS + this.dedupeWindow = opts.dedupe ?? DEFAULT_DEDUPE_MS + this.onUrl = opts.onUrl + this.start() + } + + /** Sorted names of servers currently reporting `needs_auth` for a directory. */ + needsAuth(dir: string): string[] { + return [...(this.needsAuthByDir.get(dir) ?? [])].sort() + } + + /** Sorted names of servers with an in-flight sign-in for a directory. */ + busy(dir: string): string[] { + const prefix = `${dir}\0` + return [...this.busyKeys] + .filter((key) => key.startsWith(prefix)) + .map((key) => key.slice(prefix.length)) + .sort() + } + + /** Subscribe to changes. Called with the affected directory. Returns an unsubscribe function. */ + onChange(listener: (dir: string) => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + /** Refresh `needsAuth` for a directory from the CLI. Returns the resulting sorted list. */ + async refresh(dir: string): Promise { + await this.refreshStatus(dir) + return this.needsAuth(dir) + } + + /** Refresh auth state and return the same MCP status snapshot used to derive it. */ + refreshStatus(dir: string): Promise | undefined> { + if (!dir) return Promise.resolve({}) + const active = this.refreshes.get(dir) + if (active) return active + const refresh = this.performStatusRefresh(dir).finally(() => { + if (this.refreshes.get(dir) === refresh) this.refreshes.delete(dir) + }) + this.refreshes.set(dir, refresh) + return refresh + } + + private async performStatusRefresh(dir: string): Promise | undefined> { + const status = await this.attempt | undefined>( + `mcp status for ${dir}`, + undefined, + async () => { + const client = await this.connection.getClientAsync(dir) + const { data } = await client.mcp.status({ directory: dir }) + return data ?? {} + }, + ) + if (!status) return undefined + this.updateNeedsAuth(dir, new Set(needsAuthNames(status))) + return status + } + + /** + * Start (or join) a sign-in attempt. Single-flight per (dir, name): a + * concurrent call for the same pair returns `failed` immediately without + * issuing a second request. Always refreshes `needsAuth` afterwards. + */ + async signIn(dir: string, name: string): Promise { + const key = busyKey(dir, name) + const token = {} + if (this.active.has(key)) return { status: "failed" } + this.active.set(key, token) + this.setBusy(key, true) + try { + const result = await this.race(dir, name) + if (result === "timeout") { + await this.cancel(dir, name) + return { status: "timeout" } + } + if (this.cancelled.has(token)) return { status: "cancelled" } + return result + } finally { + this.active.delete(key) + this.cancelled.delete(token) + this.setBusy(key, false) + await this.refresh(dir) + } + } + + /** Cancel an in-flight sign-in without deleting stored credentials. */ + async cancel(dir: string, name: string): Promise { + const token = this.active.get(busyKey(dir, name)) + if (token) this.cancelled.add(token) + const cancelled = await this.attempt(`mcp auth cancel for ${name}`, false, async () => { + const client = await this.connection.getClientAsync(dir) + const { response } = await client.mcp.auth.cancel({ name, directory: dir }) + return response?.ok === true + }) + if (!cancelled && token) this.cancelled.delete(token) + return cancelled + } + + /** Clear stored credentials and reconnect so the server reports `needs_auth` again immediately. */ + async reset(dir: string, name: string): Promise { + const token = this.active.get(busyKey(dir, name)) + if (token) this.cancelled.add(token) + const removed = await this.attempt(`mcp auth reset for ${name}`, false, async () => { + const client = await this.connection.getClientAsync(dir) + const { response } = await client.mcp.auth.remove({ name, directory: dir }) + return response?.ok === true + }) + if (!removed) { + if (token) this.cancelled.delete(token) + return false + } + let reconnected = false + try { + const disconnected = await this.attempt(`mcp disconnect for ${name}`, false, async () => { + const client = await this.connection.getClientAsync(dir) + const { data } = await client.mcp.disconnect({ name, directory: dir }) + return data === true + }) + const connected = await this.attempt(`mcp connect for ${name}`, false, async () => { + const client = await this.connection.getClientAsync(dir) + const { data } = await client.mcp.connect({ name, directory: dir }) + return data === true + }) + reconnected = disconnected && connected + return reconnected + } finally { + await this.refresh(dir) + if (!reconnected) this.markNeedsAuth(dir, name) + } + } + + dispose(): void { + this.unsubEvent?.() + this.unsubEvent = undefined + this.listeners.clear() + } + + /** Race `mcp.auth.authenticate` against the client-side timeout. */ + private async race(dir: string, name: string): Promise { + return new Promise((resolve) => { + let settled = false + const timer = setTimeout(() => { + if (settled) return + settled = true + resolve("timeout") + }, this.timeout) + + void this.authenticate(dir, name).then((result) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve(result) + }) + }) + } + + private async authenticate(dir: string, name: string): Promise { + return this.attempt(`mcp authenticate for ${name}`, { status: "failed" }, async (): Promise => { + const client = await this.connection.getClientAsync(dir) + const { data, error, response } = await client.mcp.auth.authenticate({ name, directory: dir, external: true }) + if (response?.ok && data) { + const status: McpAuthStatus = data.status + return { status, error: "error" in data ? data.error : undefined } + } + if (response?.status === 400) return { status: "unsupported", error: errorText(error) } + if (response?.status === 404) return { status: "not_found", error: errorText(error) } + return { status: "failed", error: errorText(error) ?? `HTTP ${response?.status ?? "unknown"}` } + }) + } + + private setBusy(key: string, value: boolean): void { + if (value === this.busyKeys.has(key)) return + if (value) this.busyKeys.add(key) + else this.busyKeys.delete(key) + const [dir] = key.split("\0") + if (dir) this.notify(dir) + } + + private updateNeedsAuth(dir: string, names: Set): void { + const existing = this.needsAuthByDir.get(dir) + if (existing && sameSet(existing, names)) return + this.needsAuthByDir.delete(dir) + this.needsAuthByDir.set(dir, names) + while (this.needsAuthByDir.size > MAX_DIRECTORIES) { + const oldest = this.needsAuthByDir.keys().next().value + if (oldest === undefined) break + this.needsAuthByDir.delete(oldest) + } + this.notify(dir) + } + + private markNeedsAuth(dir: string, name: string): void { + const existing = new Set(this.needsAuthByDir.get(dir) ?? []) + existing.add(name) + this.updateNeedsAuth(dir, existing) + } + + private notify(dir: string): void { + for (const listener of this.listeners) listener(dir) + } + + private start(): void { + // Defensive: some callers (notably test doubles for KiloConnectionService) + // implement only the client-fetching surface, not events. Skipping the + // subscription there is harmless — it only handles authorization URLs, + // which those callers don't exercise. + if (typeof this.connection.onEvent !== "function") return + this.unsubEvent = this.connection.onEvent((event, dir) => { + if (event.type === "mcp.browser.open.failed") { + this.onBrowserOpenFailed(event.properties.mcpName, event.properties.url) + return + } + if (event.type !== "mcp.auth.url" || !dir) return + this.onAuthUrl(dir, event.properties.mcpName, event.properties.url) + }) + } + + private onBrowserOpenFailed(name: string, url: string): void { + if (!this.acceptUrl(url)) return + this.onUrl?.(name, url) + } + + private onAuthUrl(dir: string, name: string, url: string): void { + if (!this.active.has(busyKey(dir, name))) return + if (!this.acceptUrl(url)) return + void Promise.resolve() + .then(() => vscode.env.openExternal(vscode.Uri.parse(url))) + .then( + (opened) => { + if (!opened) this.onUrl?.(name, url) + }, + () => this.onUrl?.(name, url), + ) + } + + private acceptUrl(url: string): boolean { + const now = Date.now() + if (url === this.lastUrl && now - this.lastUrlAt < this.dedupeWindow) return false + this.lastUrl = url + this.lastUrlAt = now + return true + } + + private async attempt(message: string, fallback: T, block: () => Promise): Promise { + try { + return await block() + } catch (error) { + console.error(`[Kilo New] McpAuthService: ${message} failed:`, error) + return fallback + } + } +} + +function sameSet(a: Set, b: Set): boolean { + if (a.size !== b.size) return false + for (const value of a) if (!b.has(value)) return false + return true +} + +function errorText(error: unknown): string | undefined { + if (!error) return undefined + if (typeof error === "string") return error + if (typeof error !== "object") return undefined + const obj = error as Record + if (typeof obj.error === "string") return obj.error + if (typeof obj.message === "string") return obj.message + return undefined +} + +export type { KiloClient } diff --git a/packages/kilo-vscode/src/services/mcp-auth/status.ts b/packages/kilo-vscode/src/services/mcp-auth/status.ts new file mode 100644 index 00000000000..b82c0ba0aab --- /dev/null +++ b/packages/kilo-vscode/src/services/mcp-auth/status.ts @@ -0,0 +1,9 @@ +import type { McpStatus } from "@kilocode/sdk/v2/client" + +/** Names of servers reporting `needs_auth`, sorted. Classification belongs to the CLI. */ +export function needsAuthNames(status: Record): string[] { + return Object.entries(status) + .filter(([, entry]) => entry.status === "needs_auth") + .map(([name]) => name) + .sort() +} diff --git a/packages/kilo-vscode/src/services/mcp-removal.ts b/packages/kilo-vscode/src/services/mcp-removal.ts new file mode 100644 index 00000000000..5a695f3ec3e --- /dev/null +++ b/packages/kilo-vscode/src/services/mcp-removal.ts @@ -0,0 +1,31 @@ +import type { KiloConnectionService } from "./cli-backend/connection-service" + +export type McpRemovalEvent = { + directory: string + name: string + phase: "removing" | "removed" | "installed" | "idle" +} + +class McpRemovalBus { + private listeners = new Set<(event: McpRemovalEvent) => void>() + + on(listener: (event: McpRemovalEvent) => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + emit(event: McpRemovalEvent): void { + for (const listener of this.listeners) listener(event) + } +} + +const buses = new WeakMap() + +/** Share MCP removal progress and completion across every webview using one CLI connection. */ +export function mcpRemoval(connection: KiloConnectionService): McpRemovalBus { + const existing = buses.get(connection) + if (existing) return existing + const bus = new McpRemovalBus() + buses.set(connection, bus) + return bus +} diff --git a/packages/kilo-vscode/src/shared/browser-stream.ts b/packages/kilo-vscode/src/shared/browser-stream.ts index b4d17fa65fd..235c89b75ad 100644 --- a/packages/kilo-vscode/src/shared/browser-stream.ts +++ b/packages/kilo-vscode/src/shared/browser-stream.ts @@ -24,6 +24,49 @@ export interface BrowserFrame extends BrowserViewIdentity { data: string } +export interface BrowserCursor extends BrowserViewIdentity { + cursor: string +} + +// Only CSS keywords can cross the browser boundary, never page-provided cursor images or URLs. +export const CURSORS = new Set([ + "default", + "none", + "context-menu", + "help", + "pointer", + "progress", + "wait", + "cell", + "crosshair", + "text", + "vertical-text", + "alias", + "copy", + "move", + "no-drop", + "not-allowed", + "grab", + "grabbing", + "all-scroll", + "col-resize", + "row-resize", + "n-resize", + "e-resize", + "s-resize", + "w-resize", + "ne-resize", + "nw-resize", + "se-resize", + "sw-resize", + "ew-resize", + "ns-resize", + "nesw-resize", + "nwse-resize", + "zoom-in", + "zoom-out", +]) + export type BrowserInteraction = | { kind: "pointer" @@ -50,3 +93,22 @@ export type BrowserInteraction = | { kind: "composition"; text: string; start: number; end: number } | { kind: "clipboard"; action: "copy" | "cut" | "paste" } | { kind: "release" } + +export type WheelInteraction = Extract + +// The host clamps the streamed page to these bounds. The viewport the webview publishes must use the same bounds, so +// the frame and the canvas size stay equal and the preview is never scaled back up. +export const VIEWPORT_LIMIT = { width: 4096, height: 2160 } as const + +// Coalesces compatible wheel input in place. Coordinates, modifiers, axis direction, and the 10000 cap are +// ordering barriers, so callers can keep batching while preserving scroll distance and reversals. +export function mergeWheel(current: WheelInteraction, next: WheelInteraction): boolean { + if (current.x !== next.x || current.y !== next.y || current.modifiers !== next.modifiers) return false + for (const axis of ["deltaX", "deltaY"] as const) { + if (Math.sign(current[axis]) !== Math.sign(next[axis])) return false + if (Math.abs(current[axis] + next[axis]) > 10000) return false + } + current.deltaX += next.deltaX + current.deltaY += next.deltaY + return true +} diff --git a/packages/kilo-vscode/src/utils.ts b/packages/kilo-vscode/src/utils.ts index 000262d87fb..1cd31f30931 100644 --- a/packages/kilo-vscode/src/utils.ts +++ b/packages/kilo-vscode/src/utils.ts @@ -70,10 +70,13 @@ export function buildWebviewHtml( introDismissed?: boolean frameSrc?: string sidebar?: "left" | "right" + settings?: { tab?: string; projectId?: string } + module?: boolean + preloads?: vscode.Uri[] }, ): string { const nonce = getNonce() - const csp = buildCspString(webview.cspSource, nonce, opts.port, opts.frameSrc) + const csp = buildCspString(webview.cspSource, nonce, opts.port, opts.frameSrc, opts.module) const markdownWorkerUri = opts.workerUri.toString().replace(/shiki-worker\.js$/, "markdown-shiki-worker.js") return ` @@ -82,6 +85,7 @@ export function buildWebviewHtml( + ${(opts.preloads ?? []).map((uri) => ``).join("\n ")} ${opts.title}
    + Link + +

    Explicit default text

    +

    Native text

    +
    Resize
    +
    Custom cursor fallback
    + +
    ` + const origin = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { "content-type": "text/html" } }) }) + await page.goto(origin.url.href).finally(() => origin.stop(true)) + const before = await page.locator("#cursors").evaluate((node) => node.outerHTML) + const probe = await page.context().newCDPSession(page) + const contexts: number[] = [] + probe.on("Runtime.executionContextCreated", ({ context }) => { + if (context.name === "kilo-cursor") contexts.push(context.id) + }) + await probe.send("Runtime.enable") + await stream.configure(view) + const check = async (cursor: string, action: () => Promise) => { + const result = Promise.withResolvers() + const receive = (value: BrowserCursor) => { + events.off("cursor", receive) + result.resolve(value) + } + events.on("cursor", receive) + await action() + const value = await Promise.race([ + result.promise, + Bun.sleep(1000).then(() => { + throw new Error(`Expected cursor ${cursor}, last report ${cursors.at(-1)?.cursor}`) + }), + ]) + expect(value).toMatchObject({ + browserId: scope.browserId, + navigation: scope.navigation, + revision: view.revision, + cursor, + }) + } + try { + await check("pointer", () => page.mouse.move(10, 20)) + await page.mouse.move(15, 20) + expect(cursors).toHaveLength(1) + await check("text", () => page.mouse.move(10, 60)) + await check("default", () => page.mouse.move(10, 100)) + await check("text", () => page.mouse.move(10, 140)) + await check("ew-resize", () => page.mouse.move(10, 180)) + await check("grab", () => page.mouse.move(10, 220)) + await check("grabbing", () => page.mouse.down()) + await check("grab", () => page.mouse.up()) + await check("default", () => page.mouse.move(130, 260)) + expect(await page.locator("#cursors").evaluate((node) => node.outerHTML)).toBe(before) + await page.evaluate(() => { + const frame = document.createElement("iframe") + frame.srcdoc = 'Child' + frame.style.cssText = "position:absolute;top:300px;left:0;width:300px;height:60px;border:0" + document.body.append(frame) + }) + await page.waitForFunction(() => document.querySelector("iframe")?.contentDocument?.body?.style.cursor === "grab") + await check("pointer", () => page.mouse.move(10, 20)) + await check("default", () => page.mouse.move(400, 20)) + await check("grab", () => page.mouse.move(10, 320)) + await check("default", async () => { + await page.locator("iframe").evaluate((node) => node.remove()) + await page.mouse.move(400, 20) + }) + expect(await page.evaluate(() => typeof (globalThis as unknown as Record).__kiloCursor)).toBe( + "undefined", + ) + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) scope.navigation++ + }) + await page.goto(`data:text/html,${encodeURIComponent(html)}`) + await stream.configure({ ...view, revision: 2 }) + await page.mouse.move(10, 20) + await page.evaluate(() => undefined) + expect(cursors.at(-1)).toMatchObject({ navigation: 2, revision: 2, cursor: "pointer" }) + const contextId = contexts.at(-1) + expect(contextId).toBeDefined() + const expression = "[typeof globalThis.__kiloCursorCleanup, typeof globalThis.__kiloCursor]" + const objectId = (await probe.send("Runtime.evaluate", { contextId, expression: "window" })).result.objectId! + expect( + (await probe.send("DOMDebugger.getEventListeners", { objectId })).listeners.filter( + (event) => event.type === "pointermove", + ), + ).toHaveLength(1) + expect( + (await probe.send("Runtime.evaluate", { contextId, expression, returnByValue: true })).result.value, + ).toEqual(["function", "function"]) + await stream.close() + expect( + (await probe.send("DOMDebugger.getEventListeners", { objectId })).listeners.filter( + (event) => event.type === "pointermove", + ), + ).toHaveLength(0) + expect( + (await probe.send("Runtime.evaluate", { contextId, expression, returnByValue: true })).result.value, + ).toEqual(["undefined", "undefined"]) + const count = cursors.length + await page.mouse.move(10, 180) + await page.evaluate(() => undefined) + expect(cursors).toHaveLength(count) + expect(page.isClosed()).toBe(false) + const reports: BrowserCursor[] = [] + const received = Promise.withResolvers() + const reopened = new BrowserStream( + page, + () => scope, + () => {}, + () => {}, + (value) => { + reports.push(value) + received.resolve(value) + }, + ) + streams.push(reopened) + await reopened.configure({ ...view, revision: 3 }) + await page.mouse.move(10, 20) + expect(await received.promise).toMatchObject({ navigation: 2, revision: 3, cursor: "pointer" }) + expect(reports).toHaveLength(1) + expect(cursors).toHaveLength(count) + } finally { + await probe.detach() + } + }, 10000) + test("streams bounded JPEGs from the existing page and honors monotonic viewport revisions", async () => { const { stream, page, next, frames } = await fixture() await stream.configure({ ...view, width: 1, height: 9999, revision: 0, active: false }) @@ -986,6 +1207,7 @@ describe.skipIf(!executable)("BrowserStream Chromium", () => { expect(await recorded()).toEqual([]) expect(page.viewportSize()).toEqual({ width: 640, height: 480 }) await stream.interact({ ...pointer, x: 1, y: 1 } as BrowserInteraction) + await page.waitForFunction(() => document.body.dataset.events?.includes("mousemove")) expect((await recorded()).at(-1)).toMatchObject({ type: "mousemove", x: 639, y: 479 }) await stream.close() await stream.interact({ kind: "text", text: "closed" }) diff --git a/packages/kilo-vscode/tests/unit/catalog-retry.test.ts b/packages/kilo-vscode/tests/unit/catalog-retry.test.ts new file mode 100644 index 00000000000..6db1478beb9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/catalog-retry.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "bun:test" +import { createCatalogRetry } from "../../src/kilo-provider/catalog-retry" + +function setup() { + const pending: Array<{ run: () => void; ms: number; cancelled: boolean }> = [] + let refreshes = 0 + const retry = createCatalogRetry({ + refresh: () => refreshes++, + delays: [10, 20, 40], + schedule: (run, ms) => { + const entry = { run, ms, cancelled: false } + pending.push(entry) + return () => { + entry.cancelled = true + } + }, + }) + const fire = () => { + const entry = pending.at(-1) + if (!entry || entry.cancelled) throw new Error("no retry scheduled") + entry.run() + } + return { retry, pending, fire, refreshes: () => refreshes } +} + +describe("createCatalogRetry", () => { + it("does nothing while the catalog is available", () => { + const state = setup() + state.retry.update(false) + expect(state.pending).toEqual([]) + }) + + it("backs off while the catalog stays unavailable and caps the delay", () => { + const state = setup() + for (const _ of [1, 2, 3, 4]) { + state.retry.update(true) + state.fire() + } + expect(state.pending.map((entry) => entry.ms)).toEqual([10, 20, 40, 40]) + expect(state.refreshes()).toBe(4) + }) + + it("arms only one retry at a time", () => { + const state = setup() + state.retry.update(true) + state.retry.update(true) + expect(state.pending).toHaveLength(1) + }) + + it("cancels and resets once the catalog recovers", () => { + const state = setup() + state.retry.update(true) + state.fire() + state.retry.update(true) + state.retry.update(false) + expect(state.pending.at(-1)?.cancelled).toBe(true) + state.retry.update(true) + expect(state.pending.at(-1)?.ms).toBe(10) + }) + + it("cancels a pending retry on dispose", () => { + const state = setup() + state.retry.update(true) + state.retry.dispose() + expect(state.pending.at(-1)?.cancelled).toBe(true) + expect(state.refreshes()).toBe(0) + }) + + it("ignores updates from fetches that settle after dispose", () => { + const state = setup() + state.retry.dispose() + state.retry.update(true) + expect(state.pending).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/chat-settings-message.test.ts b/packages/kilo-vscode/tests/unit/chat-settings-message.test.ts index 87c6c7e2e87..c41ec35480a 100644 --- a/packages/kilo-vscode/tests/unit/chat-settings-message.test.ts +++ b/packages/kilo-vscode/tests/unit/chat-settings-message.test.ts @@ -17,6 +17,7 @@ type Stub = { const original = { get: vscode.workspace.getConfiguration, watch: vscode.workspace.onDidChangeConfiguration, + trusted: Object.getOwnPropertyDescriptor(vscode.workspace, "isTrusted"), } function stubConfig(state: Map, scope = "kilo-code.new.chat") { @@ -34,6 +35,8 @@ afterEach(() => { const workspace = vscode.workspace as unknown as Stub workspace.getConfiguration = original.get as Stub["getConfiguration"] workspace.onDidChangeConfiguration = original.watch + if (original.trusted) Object.defineProperty(vscode.workspace, "isTrusted", original.trusted) + else delete (vscode.workspace as { isTrusted?: boolean }).isTrusted }) describe("buildChatSettingsMessage", () => { @@ -53,6 +56,45 @@ describe("buildChatSettingsMessage", () => { expect(buildChatSettingsMessage().settings.shiftTabCyclesVariant).toBe(false) }) + + it("broadcasts browser preference changes to all open chat viewers", () => { + Object.defineProperty(vscode.workspace, "isTrusted", { configurable: true, value: true }) + const workspace = vscode.workspace as unknown as Stub + const prefs = new Map([ + ["kilo-code.new.experimental.browserAutomation", true], + ["kilo-code.new.agentManager.browser.openLinksIn", "integrated"], + ]) + workspace.getConfiguration = (section) => ({ + get: (key: string, fallback?: T) => (prefs.get(`${section}.${key}`) as T | undefined) ?? fallback, + }) + const listeners = new Set<(event: vscode.ConfigurationChangeEvent) => void>() + workspace.onDidChangeConfiguration = (listener) => { + listeners.add(listener) + return new vscode.Disposable(() => listeners.delete(listener)) + } + const parent: unknown[] = [] + const child: unknown[] = [] + const main = watchChatConfig((message) => parent.push(message)) + const viewer = watchChatConfig((message) => child.push(message)) + const key = "kilo-code.new.agentManager.browser.openLinksIn" + prefs.set(key, "external") + for (const listener of listeners) listener({ affectsConfiguration: (name) => name === key }) + expect(parent).toEqual([ + { + type: "chatSettingsLoaded", + settings: { + shiftTabCyclesVariant: true, + browserAutomation: true, + agentManagerBrowserOpenLinksIn: "external", + workspaceTrusted: true, + }, + }, + ]) + expect(child).toEqual(parent) + main.dispose() + viewer.dispose() + expect(listeners.size).toBe(0) + }) }) describe("timeline settings", () => { diff --git a/packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts b/packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts index f5ea96ed08d..b9c88a70dc3 100644 --- a/packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts +++ b/packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts @@ -97,6 +97,47 @@ describe("cloud session preview handler", () => { } }) + it("sorts preview messages by time.created with id tiebreak and keeps parentID", async () => { + const sent: unknown[] = [] + const msg = (id: string, role: "user" | "assistant", created: number, parentID?: string) => ({ + info: { id, sessionID: "cloud-session", role, parentID, time: { created } }, + parts: [], + }) + const ctx: CloudSessionContext = { + ...context(sent), + client: { + kilo: { + cloud: { + session: { + get: async () => ({ + data: { + info: { id: "cloud-session", title: "Preview", time: { created: 1, updated: 1 } }, + // Assistant, User per turn with same-millisecond time ties — + // the shape the cloud export can return. + messages: [ + msg("m4", "assistant", 3000, "m3"), + msg("m3", "user", 3000), + msg("m2", "assistant", 2000, "m1"), + msg("m1", "user", 2000), + ], + }, + }), + }, + }, + }, + } as unknown as CloudSessionContext["client"], + } + + await handleRequestCloudSessionData(ctx, "cloud-session") + + const loaded = sent.find( + (m): m is { type: "cloudSessionDataLoaded"; messages: Array<{ id: string; parentID?: string }> } => + (m as { type?: string }).type === "cloudSessionDataLoaded", + ) + expect(loaded?.messages.map((m) => m.id)).toEqual(["m1", "m2", "m3", "m4"]) + expect(loaded?.messages.map((m) => m.parentID)).toEqual([undefined, "m1", undefined, "m3"]) + }) + it("reports a failure when the CLI import request stalls", async () => { const timeout = AbortSignal.timeout AbortSignal.timeout = () => { diff --git a/packages/kilo-vscode/tests/unit/config-utils.test.ts b/packages/kilo-vscode/tests/unit/config-utils.test.ts index ce6c385625d..ebc122db96a 100644 --- a/packages/kilo-vscode/tests/unit/config-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/config-utils.test.ts @@ -3,8 +3,11 @@ import { configUnsetPaths, ConfigState, deepMerge, + hideRemovedMcp, mergeScopedConfig, pruneConfigSet, + removeMcpConfig, + retainUnconfirmedMcpRemovals, stripNulls, } from "../../webview-ui/src/utils/config-utils" import type { Config } from "../../webview-ui/src/types/messages" @@ -98,11 +101,52 @@ describe("stripNulls", () => { }) }) +describe("removeMcpConfig", () => { + it("removes only the selected MCP server", () => { + const config = { + mcp: { + anaconda: { type: "remote", url: "https://anaconda.com/api/mcp" }, + docs: { type: "remote", url: "https://docs.example/mcp" }, + }, + } satisfies Config + + expect(removeMcpConfig(config, "anaconda")).toEqual({ mcp: { docs: config.mcp.docs } }) + }) + + it("removes the empty MCP collection", () => { + expect(removeMcpConfig({ mcp: { anaconda: { type: "remote" } } }, "anaconda")).toEqual({}) + }) + + it("keeps a removed MCP hidden from stale server snapshots", () => { + const config = { mcp: { anaconda: { type: "remote" as const }, docs: { type: "remote" as const } } } + + expect(hideRemovedMcp(config, new Set(["anaconda"]))).toEqual({ mcp: { docs: { type: "remote" } } }) + expect(retainUnconfirmedMcpRemovals(new Set(["anaconda"]), config)).toEqual(new Set(["anaconda"])) + expect(retainUnconfirmedMcpRemovals(new Set(["anaconda"]), {})).toEqual(new Set()) + }) +}) + // --------------------------------------------------------------------------- // Config state machine — reproduces the actual message-handler flow // --------------------------------------------------------------------------- describe("ConfigState", () => { + it("does not let a pending MCP draft resurrect a server removed outside Settings", () => { + const s = new ConfigState() + const anaconda = { type: "remote" as const, url: "https://anaconda.com/api/mcp" } + s.handleConfigLoaded({ mcp: { anaconda } }) + s.updateConfig({ mcp: { anaconda: { ...anaconda, enabled: true } } }) + + s.handleConfigLoaded({}) + expect(s.config.mcp?.anaconda).toBeDefined() + + s.removeMcp("anaconda") + expect(s.config.mcp).toBeUndefined() + expect(s.saved.mcp).toBeUndefined() + expect(s.draft.mcp).toBeUndefined() + expect(s.dirty).toBe(false) + }) + it("configLoaded sets config when no draft is pending", () => { const s = new ConfigState() s.handleConfigLoaded({ snapshot: true, username: "alice" }) diff --git a/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts b/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts index 83497225769..6f192504262 100644 --- a/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts +++ b/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, mock } from "bun:test" import * as fs from "node:fs/promises" import * as os from "node:os" import * as path from "node:path" -import simpleGit from "simple-git" +import { simpleGit } from "simple-git" import { abortSession, continueInWorktree, diff --git a/packages/kilo-vscode/tests/unit/document-reader.test.ts b/packages/kilo-vscode/tests/unit/document-reader.test.ts index db58481c2d5..05b76c758e3 100644 --- a/packages/kilo-vscode/tests/unit/document-reader.test.ts +++ b/packages/kilo-vscode/tests/unit/document-reader.test.ts @@ -2,12 +2,24 @@ import { describe, expect, it } from "bun:test" import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" -import { isInsideWorktree, readDocument } from "../../src/documents/document-reader" +import { isInsideWorktree, plansDir, readDocument } from "../../src/documents/document-reader" function workspace() { return fs.mkdtempSync(path.join(os.tmpdir(), "kilo-document-")) } +function withDataHome(run: () => T): T { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-data-")) + const previous = process.env.XDG_DATA_HOME + process.env.XDG_DATA_HOME = home + try { + return run() + } finally { + if (previous === undefined) delete process.env.XDG_DATA_HOME + else process.env.XDG_DATA_HOME = previous + } +} + describe("readDocument", () => { it("reads text files inside the worktree", () => { const root = workspace() @@ -30,6 +42,52 @@ describe("readDocument", () => { expect(readDocument(root, "data.bin")).toEqual({ error: "Binary files cannot be previewed." }) }) + + // Non-git projects store plans in the canonical plans dir, outside the + // worktree. The preview must still work there — see #14588. + it("reads plan files from the canonical plans dir outside the worktree", () => { + withDataHome(() => { + const root = workspace() + const dir = plansDir() + fs.mkdirSync(dir, { recursive: true }) + const plan = path.join(dir, "42-non-git.md") + fs.writeFileSync(plan, "# Global plan\n") + + expect(readDocument(root, plan)).toEqual({ + file: fs.realpathSync(plan), + kind: "text", + content: "# Global plan\n", + }) + }) + }) + + it("keeps rejecting files outside both the worktree and the plans dir", () => { + withDataHome(() => { + const root = workspace() + const outside = path.join(root, "..", "outside.md") + fs.writeFileSync(outside, "secret") + + expect(readDocument(root, outside)).toEqual({ error: "Document is outside the worktree." }) + }) + }) +}) + +describe("plansDir", () => { + it("honors XDG_DATA_HOME", () => { + withDataHome(() => { + expect(plansDir()).toEqual(path.join(process.env.XDG_DATA_HOME!, "kilo", "plans")) + }) + }) + + it("falls back to ~/.local/share", () => { + const previous = process.env.XDG_DATA_HOME + delete process.env.XDG_DATA_HOME + try { + expect(plansDir()).toEqual(path.join(os.homedir(), ".local", "share", "kilo", "plans")) + } finally { + if (previous !== undefined) process.env.XDG_DATA_HOME = previous + } + }) }) describe("isInsideWorktree", () => { diff --git a/packages/kilo-vscode/tests/unit/format-keybinding.test.ts b/packages/kilo-vscode/tests/unit/format-keybinding.test.ts index 36d9bf15b27..3ed8d90c74a 100644 --- a/packages/kilo-vscode/tests/unit/format-keybinding.test.ts +++ b/packages/kilo-vscode/tests/unit/format-keybinding.test.ts @@ -83,6 +83,15 @@ describe("buildKeybindingMap", () => { expect(buildKeybindingMap(bindings, false).search).toBe("Ctrl+F") }) + it("maps the shared session tab shortcuts", () => { + const bindings = [ + { command: "kilo-code.new.agentManager.previousTab", key: "ctrl+alt+left", mac: "cmd+alt+left" }, + { command: "kilo-code.new.agentManager.nextTab", key: "ctrl+alt+right", mac: "cmd+alt+right" }, + ] + expect(buildKeybindingMap(bindings, true)).toMatchObject({ previousTab: "⌘⌥←", nextTab: "⌘⌥→" }) + expect(buildKeybindingMap(bindings, false)).toMatchObject({ previousTab: "Ctrl+Alt+←", nextTab: "Ctrl+Alt+→" }) + }) + it("provides terminal navigation fallbacks", () => { expect(buildKeybindingMap([], true).previousTerminal).toBe("⌘⇧[") expect(buildKeybindingMap([], true).nextTerminal).toBe("⌘⇧]") diff --git a/packages/kilo-vscode/tests/unit/git-transfer.test.ts b/packages/kilo-vscode/tests/unit/git-transfer.test.ts index 11b29e6c0c4..4bc371cfabd 100644 --- a/packages/kilo-vscode/tests/unit/git-transfer.test.ts +++ b/packages/kilo-vscode/tests/unit/git-transfer.test.ts @@ -128,6 +128,98 @@ describe("git-transfer", () => { expect(status).toContain("M init.txt") }) + it("preserves both sides of a staged rename and later unstaged edits", async () => { + await git(["mv", "init.txt", "renamed.txt"], dir) + await fs.writeFile(path.join(dir, "renamed.txt"), "unstaged after rename\n") + const tree = await git(["write-tree"], dir) + const status = await git(["status", "--porcelain"], dir) + const snapshot = await capture(dir, noop) + + expect(await apply(snapshot, target, noop)).toEqual({ ok: true }) + + expect(await git(["write-tree"], target)).toBe(tree) + expect(await git(["ls-files"], target)).toBe("renamed.txt") + expect(await git(["show", ":renamed.txt"], target)).toBe("init") + expect(await fs.readFile(path.join(target, "renamed.txt"), "utf8")).toBe("unstaged after rename\n") + expect(await git(["status", "--porcelain"], target)).toBe(status) + expect(await git(["write-tree"], dir)).toBe(tree) + expect(await git(["status", "--porcelain"], dir)).toBe(status) + expect(await fs.readFile(path.join(dir, "renamed.txt"), "utf8")).toBe("unstaged after rename\n") + }) + + it("preserves a staged deletion", async () => { + await git(["rm", "init.txt"], dir) + const tree = await git(["write-tree"], dir) + const status = await git(["status", "--porcelain"], dir) + const snapshot = await capture(dir, noop) + + expect(await apply(snapshot, target, noop)).toEqual({ ok: true }) + + expect(await git(["write-tree"], target)).toBe(tree) + expect(await git(["diff"], target)).toBe("") + expect(await git(["status", "--porcelain"], target)).toBe(status) + await expect(fs.stat(path.join(target, "init.txt"))).rejects.toThrow() + expect(await git(["write-tree"], dir)).toBe(tree) + expect(await git(["status", "--porcelain"], dir)).toBe(status) + }) + + it.skipIf(process.platform === "win32")("preserves a staged executable mode change", async () => { + await git(["config", "core.fileMode", "true"], dir) + await fs.chmod(path.join(dir, "init.txt"), 0o755) + await git(["add", "init.txt"], dir) + const tree = await git(["write-tree"], dir) + const status = await git(["status", "--porcelain"], dir) + const snapshot = await capture(dir, noop) + + expect(await apply(snapshot, target, noop)).toEqual({ ok: true }) + + expect(await git(["write-tree"], target)).toBe(tree) + expect(await git(["ls-files", "--stage"], target)).toStartWith("100755 ") + expect((await fs.stat(path.join(target, "init.txt"))).mode & 0o111).toBe(0o111) + expect(await git(["diff"], target)).toBe("") + expect(await git(["write-tree"], dir)).toBe(tree) + expect(await git(["status", "--porcelain"], dir)).toBe(status) + }) + + it("preserves separate staged and unstaged binary contents", async () => { + const staged = Buffer.from([0, 1, 2, 3, 255]) + const unstaged = Buffer.from([0, 1, 4, 255]) + await fs.writeFile(path.join(dir, "binary.dat"), staged) + await git(["add", "binary.dat"], dir) + await fs.writeFile(path.join(dir, "binary.dat"), unstaged) + const tree = await git(["write-tree"], dir) + const status = await git(["status", "--porcelain"], dir) + const snapshot = await capture(dir, noop) + + expect(snapshot.staged).toContain("GIT binary patch") + expect(snapshot.unstaged).toContain("GIT binary patch") + expect(await apply(snapshot, target, noop)).toEqual({ ok: true }) + + expect(await git(["write-tree"], target)).toBe(tree) + expect(await fs.readFile(path.join(target, "binary.dat"))).toEqual(unstaged) + expect(await git(["status", "--porcelain"], target)).toBe(status) + expect(await git(["write-tree"], dir)).toBe(tree) + expect(await git(["status", "--porcelain"], dir)).toBe(status) + expect(await fs.readFile(path.join(dir, "binary.dat"))).toEqual(unstaged) + }) + + it("rejects a staged transfer when the destination index and working tree disagree", async () => { + await fs.writeFile(path.join(dir, "init.txt"), "staged\n") + await git(["add", "init.txt"], dir) + const snapshot = await capture(dir, noop) + await fs.writeFile(path.join(target, "init.txt"), "destination changes\n") + const tree = await git(["write-tree"], target) + + const result = await apply(snapshot, target, noop) + + expect(result.ok).toBe(false) + expect(result.error).toContain("Staged patch failed") + expect(await git(["write-tree"], target)).toBe(tree) + expect(await fs.readFile(path.join(target, "init.txt"), "utf8")).toBe("destination changes\n") + expect(await git(["show", ":init.txt"], dir)).toBe("staged") + expect(await fs.readFile(path.join(dir, "init.txt"), "utf8")).toBe("staged\n") + }) + it("writes untracked files", async () => { await fs.writeFile(path.join(dir, "new.txt"), "brand new\n") const snapshot = await capture(dir, noop) @@ -187,6 +279,8 @@ describe("git-transfer", () => { await fs.writeFile(path.join(dir, "init.txt"), "unstaged version\n") // Add an untracked file await fs.writeFile(path.join(dir, "extra.txt"), "extra\n") + const tree = await git(["write-tree"], dir) + const status = await git(["status", "--porcelain"], dir) const snapshot = await capture(dir, noop) const result = await apply(snapshot, target, noop) @@ -199,6 +293,13 @@ describe("git-transfer", () => { // Untracked file should exist const extra = await fs.readFile(path.join(target, "extra.txt"), "utf8") expect(extra).toBe("extra\n") + expect(await git(["show", ":init.txt"], target)).toBe("staged version") + expect(await git(["write-tree"], target)).toBe(tree) + expect(await git(["status", "--porcelain"], target)).toBe(status) + expect(await git(["write-tree"], dir)).toBe(tree) + expect(await git(["status", "--porcelain"], dir)).toBe(status) + expect(await fs.readFile(path.join(dir, "init.txt"), "utf8")).toBe("unstaged version\n") + expect(await fs.readFile(path.join(dir, "extra.txt"), "utf8")).toBe("extra\n") }) it("does not modify the source directory", async () => { diff --git a/packages/kilo-vscode/tests/unit/keybind-tokens.test.ts b/packages/kilo-vscode/tests/unit/keybind-tokens.test.ts new file mode 100644 index 00000000000..ba3e8bb7c82 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/keybind-tokens.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "bun:test" +import { parseBindingGroups } from "../../webview-ui/src/utils/keybind-tokens" + +describe("parseBindingGroups", () => { + it("splits a Mac binding into keys", () => { + expect(parseBindingGroups("⌘⇧R")).toEqual([["⌘", "⇧", "R"]]) + }) + + it("splits a Windows binding into keys", () => { + expect(parseBindingGroups("Ctrl+Shift+R")).toEqual([["Ctrl", "Shift", "R"]]) + }) + + it("gives one group per chord part or key pair", () => { + expect(parseBindingGroups("⌘K ⌘A")).toEqual([ + ["⌘", "K"], + ["⌘", "A"], + ]) + expect(parseBindingGroups("Ctrl+Alt+Up Ctrl+Alt+Down")).toEqual([ + ["Ctrl", "Alt", "Up"], + ["Ctrl", "Alt", "Down"], + ]) + }) + + it("keeps a single named key", () => { + expect(parseBindingGroups("Esc")).toEqual([["Esc"]]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-catalog.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-catalog.test.ts index 3f573cf6dab..b4abd0643fb 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-catalog.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-catalog.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test" import type { Config } from "@kilocode/sdk/v2/client" import type { AuthContext } from "../../src/kilo-provider/handlers/auth" +import { createCatalogRetry } from "../../src/kilo-provider/catalog-retry" const { KiloProvider } = await import("../../src/KiloProvider") @@ -25,6 +26,7 @@ type Internals = { cachedConfigMessage: unknown cachedProvidersMessage: unknown providersRefresh: Promise | null + catalogRetry: ReturnType authCtx: AuthContext fetchAndSendProviders(): Promise invalidateProviders(): void @@ -261,4 +263,103 @@ describe("KiloProvider catalog refresh", () => { expect(messages.filter((message) => message.type === "providersLoaded")).toHaveLength(1) expect(internal.cachedProvidersMessage).toBeNull() }) + + it("flags an organization's failed Kilo catalog and refetches until it recovers", async () => { + let failed = true + const { internal, messages } = setup( + async () => + failed + ? { data: { all: [external], connected: ["external"], default: { external: "model" }, failed: ["kilo"] } } + : catalog("org"), + () => "org", + ) + const scheduled: Array<() => void> = [] + internal.catalogRetry.dispose() + internal.catalogRetry = createCatalogRetry({ + refresh: () => void internal.fetchAndSendProviders(), + schedule: (run) => { + scheduled.push(run) + return () => {} + }, + }) + + await internal.fetchAndSendProviders() + expect(messages.at(-1)).toMatchObject({ type: "providersLoaded", kiloUnavailable: true }) + expect(scheduled).toHaveLength(1) + + failed = false + scheduled.at(0)?.() + await internal.providersRefresh + expect(messages.at(-1)).toMatchObject({ + type: "providersLoaded", + kiloUnavailable: false, + providers: { kilo: { models: { "org/model": { id: "org/model" } } } }, + }) + expect(scheduled).toHaveLength(1) + }) + + it("keeps retrying after a failed refetch while the Kilo catalog stays unavailable", async () => { + const steps = ["failed", "reject", "recovered"] + const { internal, messages } = setup( + async () => { + const step = steps.shift() + if (step === "reject") throw new Error("offline") + if (step === "failed") + return { + data: { all: [external], connected: ["external"], default: { external: "model" }, failed: ["kilo"] }, + } + return catalog("org") + }, + () => "org", + ) + const scheduled: Array<() => void> = [] + internal.catalogRetry.dispose() + internal.catalogRetry = createCatalogRetry({ + refresh: () => void internal.fetchAndSendProviders(), + schedule: (run) => { + scheduled.push(run) + return () => {} + }, + }) + + await internal.fetchAndSendProviders() + expect(messages.at(-1)).toMatchObject({ type: "providersLoaded", kiloUnavailable: true }) + scheduled.at(0)?.() + await internal.providersRefresh + expect(scheduled).toHaveLength(2) + + scheduled.at(1)?.() + await internal.providersRefresh + expect(messages.at(-1)).toMatchObject({ type: "providersLoaded", kiloUnavailable: false }) + expect(scheduled).toHaveLength(2) + }) + + it("does not flag a failed Kilo catalog outside an organization", async () => { + const { internal, messages } = setup( + async () => ({ + data: { all: [external], connected: ["external"], default: { external: "model" }, failed: ["kilo"] }, + }), + () => "", + ) + await internal.fetchAndSendProviders() + expect(messages.at(-1)).toMatchObject({ type: "providersLoaded", kiloUnavailable: false }) + }) + + it("refreshes providers when the model catalog is refreshed", async () => { + let org = "a" + const { internal, messages } = setup( + async () => catalog(org), + () => org, + ) + await internal.fetchAndSendProviders() + org = "b" + internal.handleEvent({ type: "models-dev.refreshed", properties: {} }, "global") + expect(internal.providersRefresh).not.toBeNull() + await internal.providersRefresh + expect(messages.at(-1)).toMatchObject({ + type: "providersLoaded", + organizationId: "b", + defaults: { kilo: "b/model" }, + }) + }) }) 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 485cfdbee84..9152afe678a 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 @@ -157,7 +157,6 @@ describe("KiloProvider indexing refresh", () => { const settings = () => ({ maxCost: 0, languageCommitMessage: "sync", - multiProject: false, claudeMigration: false, browserAutomation: false, "agentManager.autoBranchNaming": true, diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts index 271f0cf3eae..04524707434 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, spyOn } from "bun:test" import type { SessionStatus } from "@kilocode/sdk/v2/client" import * as vscode from "vscode" import type { PartUpdate } from "../../src/shared/stream-messages" -import type { AbortRequest } from "../../webview-ui/src/types/messages/webview-messages" +import type { AbortRequest, ClearSessionRequest } from "../../webview-ui/src/types/messages/webview-messages" import { REVERT_ERROR_CODE } from "../../src/shared/revert-error" // vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts) @@ -1144,6 +1144,46 @@ describe("KiloProvider revert failure reporting", () => { }) describe("KiloProvider.handleLoadMessages / focus mode freshness", () => { + it.each(["focus", "clear"])( + "cancels an old worktree transcript when %s changes the local selection", + async (action) => { + const pending = Promise.withResolvers>() + const requested = Promise.withResolvers() + const client = createClient() + client.session.messages = async (params: { sessionID?: string; before?: string; limit?: number }) => { + if (params.sessionID !== "s1") return mkResult([]) + requested.resolve() + return pending.promise + } + const { provider, internal, sent } = makeProvider(client) + provider.setSessionDirectory("s1", "/project-a/worktree") + provider.setSessionDirectory("s2", "/project-b") + const load = internal.handleLoadMessages("s1") + await requested.promise + const abort = internal.loadMessagesAbort + + if (action === "focus") await internal.handleLoadMessages("s2", { mode: "focus" }) + if (action === "clear") { + const listener = Promise.withResolvers<(message: ClearSessionRequest) => Promise>() + internal.setupWebviewMessageHandler({ + onDidReceiveMessage: (handler: (message: ClearSessionRequest) => Promise) => { + listener.resolve(handler) + return { dispose: () => {} } + }, + }) + const receive = await listener.promise + await receive({ type: "clearSession" }) + } + pending.resolve(mkResult([mkMessage("m1", "user", 1)])) + await load + + expect(abort?.signal.aborted).toBe(true) + expect(sent).not.toContainEqual(expect.objectContaining({ type: "messagesLoaded", sessionID: "s1" })) + expect(internal.contextSessionID).toBe(action === "focus" ? "s2" : undefined) + provider.dispose() + }, + ) + it("recovers the session Git directory from loaded tool history", async () => { const client = createClient({ messagesData: [ diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-mcp-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-mcp-messages.test.ts new file mode 100644 index 00000000000..05066f4964e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/kilo-provider-mcp-messages.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, mock } from "bun:test" +import { mcpAuth } from "../../src/services/mcp-auth" +import { KiloProvider } from "../../src/KiloProvider" + +type Internals = { + handleMcpMessage(message: unknown): Promise + fetchAndSendMcpStatus(): Promise + fetchAndSendMcpAuthState(): void + fetchAndSendMcpBundles(): Promise + refreshMcpAuthConsumers(): Promise + handleRemoveMcp(name: string): Promise + handleSignInMcp(name: string, notify: boolean): Promise + handleResetMcpAuth(name: string): Promise + marketplace: { remove: (...args: unknown[]) => Promise<{ success: boolean; slug: string }> } +} + +function actions() { + const connection = { + getClient: () => undefined, + getClientAsync: async () => ({}), + } as never + const provider = new KiloProvider({} as never, connection) + const internal = provider as unknown as Internals + const calls: string[] = [] + const auth = mcpAuth(connection) + auth.signIn = async (_dir, name) => { + calls.push(`signIn:${name}`) + return { status: "connected" } + } + auth.reset = async (_dir, name) => { + calls.push(`reset:${name}`) + return true + } + internal.refreshMcpAuthConsumers = async () => void calls.push("status") + // Overridden directly on the real MarketplaceService instance (not via + // mock.module) so remove-config-item.test.ts's own coverage of the real + // removeMcp()/removeMarketplaceItemFromAllScopes() wiring stays intact — + // mock.module replaces the module for every test file in the process. + internal.marketplace.remove = mock(async () => ({ success: true, slug: "anaconda" })) + provider.postMessage = (message) => calls.push(`post:${(message as { type: string }).type}`) + return { internal, calls } +} + +/** + * MCP messages are routed ahead of KiloProvider's main message switch (to keep + * that function inside its complexity budget), so the routing table itself is + * the contract worth locking down: every MCP message type must be claimed, and + * nothing else may be. + */ +function setup() { + const calls: string[] = [] + const provider = new KiloProvider( + {} as never, + { + getClient: () => undefined, + getClientAsync: async () => ({ + mcp: { auth: { cancel: async () => ({ response: { ok: true } }) } }, + }), + } as never, + ) + const internal = provider as unknown as Internals + Object.assign(internal, { + fetchAndSendMcpStatus: async () => void calls.push("status"), + fetchAndSendMcpAuthState: async () => void calls.push("authState"), + refreshMcpAuthConsumers: async () => void calls.push("authConsumers"), + fetchAndSendMcpBundles: async () => void calls.push("bundles"), + handleRemoveMcp: async (name: string) => void calls.push(`remove:${name}`), + handleSignInMcp: async (name: string, notify: boolean) => void calls.push(`signIn:${name}:${notify}`), + handleResetMcpAuth: async (name: string) => void calls.push(`reset:${name}`), + }) + return { internal, calls } +} + +describe("KiloProvider MCP message routing", () => { + it("claims every MCP message type", async () => { + const { internal } = setup() + for (const type of [ + "removeMcp", + "requestMcpStatus", + "connectMcp", + "disconnectMcp", + "requestMcpAuthState", + "signInMcp", + "cancelMcpSignIn", + "resetMcpAuth", + "requestMcpBundles", + ]) { + expect(await internal.handleMcpMessage({ type, name: "anaconda" }), type).toBe(true) + } + }) + + it("does not claim unrelated or malformed messages", async () => { + const { internal } = setup() + expect(await internal.handleMcpMessage({ type: "requestAgents" })).toBe(false) + expect(await internal.handleMcpMessage({ type: "sendMessage" })).toBe(false) + expect(await internal.handleMcpMessage({})).toBe(false) + expect(await internal.handleMcpMessage({ type: 42 })).toBe(false) + // Guards against a prefix-matching implementation claiming too much. + expect(await internal.handleMcpMessage({ type: "requestMcpSomethingElse" })).toBe(false) + }) + + it("routes each request message to its fetcher", async () => { + const { internal, calls } = setup() + await internal.handleMcpMessage({ type: "requestMcpStatus" }) + await internal.handleMcpMessage({ type: "requestMcpAuthState" }) + await internal.handleMcpMessage({ type: "requestMcpBundles" }) + expect(calls).toEqual(["status", "authConsumers", "bundles"]) + }) + + it("routes named actions and defaults signInMcp to notifying", async () => { + const { internal, calls } = setup() + await internal.handleMcpMessage({ type: "removeMcp", name: "anaconda" }) + await internal.handleMcpMessage({ type: "signInMcp", name: "anaconda" }) + await internal.handleMcpMessage({ type: "signInMcp", name: "anaconda", notify: false }) + await internal.handleMcpMessage({ type: "resetMcpAuth", name: "anaconda" }) + expect(calls).toEqual(["remove:anaconda", "signIn:anaconda:true", "signIn:anaconda:false", "reset:anaconda"]) + }) + + it("claims but ignores named actions with no server name", async () => { + const { internal, calls } = setup() + expect(await internal.handleMcpMessage({ type: "signInMcp" })).toBe(true) + expect(await internal.handleMcpMessage({ type: "removeMcp" })).toBe(true) + expect(calls).toEqual([]) + }) + + it("refreshes MCP status after sign-in", async () => { + const { internal, calls } = actions() + await internal.handleSignInMcp("anaconda", false) + expect(calls).toEqual(["signIn:anaconda", "post:mcpAuthResult", "status"]) + }) + + it("refreshes MCP status after resetting auth", async () => { + const { internal, calls } = actions() + await internal.handleResetMcpAuth("anaconda") + expect(calls).toEqual(["reset:anaconda", "status"]) + }) + + it("refreshes MCP auth consumers after removing a server, clearing its stale needs-auth state", async () => { + const { internal, calls } = actions() + await internal.handleRemoveMcp("anaconda") + expect(calls).toEqual(["post:mcpRemovalState", "post:mcpRemoved", "status", "post:mcpRemovalState"]) + }) + + it("does not refresh MCP auth consumers when removal fails", async () => { + const { internal, calls } = actions() + internal.marketplace.remove = mock(async () => ({ success: false, slug: "anaconda" })) + await internal.handleRemoveMcp("anaconda") + expect(calls).toEqual(["post:mcpRemovalState", "post:mcpRemovalState"]) + }) + + it("refreshes status and invalidates Agent Behaviour after shared auth changes", async () => { + const status = mock(async () => ({ data: { anaconda: { status: "connected" as const } } })) + const client = { mcp: { status } } + const connection = { + getClient: () => client, + getClientAsync: async () => client, + onEvent: () => () => {}, + } + const provider = new KiloProvider({} as never, connection as never) + const internal = provider as unknown as Internals + const messages: string[] = [] + ;(internal as unknown as { postMessage(message: unknown): void }).postMessage = (message) => + messages.push((message as { type: string }).type) + + await Promise.all([internal.refreshMcpAuthConsumers(), internal.refreshMcpAuthConsumers()]) + + expect(status).toHaveBeenCalledTimes(1) + expect(messages).toEqual(["mcpAuthState", "mcpStatusLoaded", "agentBehaviourInvalidated"]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts index ce29f0119b9..9ed70c03592 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts @@ -115,6 +115,8 @@ type ProviderInternals = { webview: { postMessage: (message: unknown) => Promise } | null startStatsPolling: () => void contextSessionID: string | undefined + resolveSession: (sessionID?: string, draftID?: string, context?: string, directory?: string) => Promise + handleCreateSession: () => Promise checkpoints: Map> sessionStatusMap: Map retryAbortControllers: Map @@ -151,6 +153,78 @@ function connect(internal: ProviderInternals): void { } describe("KiloProvider route integration", () => { + const session = { + id: "ses-background", + slug: "background", + projectID: "backend-b", + directory: "/repo/b", + title: "Background session", + version: "1", + time: { created: 1, updated: 1 }, + } + + it("registers a background source without replacing the foreground session", () => { + const { connection } = mockConnection() + const provider = new KiloProvider({} as never, connection, undefined, { + rootDirectory: () => "/repo/a", + projectQualifier: () => ({ projectId: "a" }), + }) + const internal = provider as unknown as ProviderInternals + const posted: unknown[] = [] + internal.webview = { postMessage: async (message) => posted.push(message) } + internal.isWebviewReady = true + internal.contextSessionID = "foreground" + provider.registerSession(session, false, "b") + expect(internal.contextSessionID).toBe("foreground") + expect(posted).toContainEqual( + expect.objectContaining({ + type: "sessionCreated", + projectId: "b", + session: expect.objectContaining({ id: session.id }), + }), + ) + }) + + it.each(["draft", "explicit"])("retains the source of a delayed %s local creation", async (mode) => { + const { connection } = mockConnection() + const started = Promise.withResolvers() + const created = Promise.withResolvers<{ data: typeof session }>() + const client = connection.getClient() + Object.assign(client.session, { + create: () => { + started.resolve() + return created.promise + }, + }) + let project = "b" + const provider = new KiloProvider({} as never, connection, undefined, { + rootDirectory: () => `/repo/${project}`, + projectQualifier: () => ({ projectId: project }), + }) + const internal = provider as unknown as ProviderInternals + const posted: unknown[] = [] + connect(internal) + internal.webview = { postMessage: async (message) => posted.push(message) } + internal.isWebviewReady = true + const pending = + mode === "draft" + ? internal.resolveSession(undefined, "pending-b", "local", "/repo/b") + : internal.handleCreateSession() + await started.promise + project = "a" + internal.contextSessionID = "foreground" + created.resolve({ data: session }) + await pending + expect(internal.contextSessionID).toBe("foreground") + expect(posted).toContainEqual( + expect.objectContaining({ + type: "sessionCreated", + projectId: "b", + session: expect.objectContaining({ id: session.id }), + }), + ) + }) + const comment: PRReviewCommentData = { id: "thread-one", origin: "pr", diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts index 1a45af3a156..e5676e8fdd1 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts @@ -204,6 +204,41 @@ describe("KiloProvider pending session refresh", () => { expect(internal.projectID).toBe("backend-b") }) + it("qualifies a session list with the project captured before the request", async () => { + const client = createClient() + const pending = Promise.withResolvers<{ data: unknown[]; response: { headers: Headers } }>() + client.experimental.session.list = async () => pending.promise as never + const connection = createConnection(client) + await connection.connect() + let active = "a" + const provider = new KiloProvider({} as never, connection as never, undefined, { + rootDirectory: () => `/repo/${active}`, + projectQualifier: () => ({ projectId: active }), + }) + const internal = provider as unknown as ProviderInternals + const sent: unknown[] = [] + internal.connectionState = "connected" + internal.isWebviewReady = true + internal.webview = { postMessage: async (message) => sent.push(message) } + + const request = internal.handleLoadSessions() + active = "b" + pending.resolve({ + data: [{ id: "ses-a", projectID: "backend-a", time: { created: 1, updated: 1 } }], + response: { headers: new Headers() }, + }) + await request + + expect(sent).toContainEqual( + expect.objectContaining({ + type: "sessionsLoaded", + projectId: "a", + sessions: [expect.objectContaining({ id: "ses-a" })], + }), + ) + expect(internal.projectID).toBeUndefined() + }) + it("keeps worktree sessions with legacy project ids", async () => { const sent: unknown[] = [] const ctx = createContext({ diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index 9a1426820ec..44e3d8715a3 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -156,6 +156,21 @@ describe("sessionToWebview", () => { expect(result.title).toBe("My Session") }) + it.each([ + ["high", { providerID: "kilo", modelID: "gpt", variant: "high" }], + ["default", { providerID: "kilo", modelID: "gpt" }], + ])("projects the agent and model the session last ran (variant %s)", (variant, model) => { + const result = sessionToWebview(makeSession({ agent: "plan", model: { id: "gpt", providerID: "kilo", variant } })) + expect(result.agent).toBe("plan") + expect(result.model).toEqual(model) + }) + + it("omits the agent and model before the session first runs", () => { + const result = JSON.parse(JSON.stringify(sessionToWebview(makeSession()))) + expect(result).not.toHaveProperty("agent") + expect(result).not.toHaveProperty("model") + }) + it("produces valid ISO format", () => { const result = sessionToWebview(makeSession()) expect(() => new Date(result.createdAt)).not.toThrow() @@ -791,6 +806,16 @@ describe("mapCloudSessionMessage", () => { const msg = mapCloudSessionMessageToWebviewMessage(makeCloudMessage({ role: "user" })) expect(msg.role).toBe("user") }) + + it("passes parentID through so turn grouping can link answers to prompts", () => { + const msg = mapCloudSessionMessageToWebviewMessage(makeCloudMessage({ parentID: "msg-0" })) + expect(msg.parentID).toBe("msg-0") + }) + + it("leaves parentID undefined when the cloud message has none", () => { + const msg = mapCloudSessionMessageToWebviewMessage(makeCloudMessage()) + expect(msg.parentID).toBeUndefined() + }) }) describe("getErrorMessage", () => { @@ -926,6 +951,20 @@ describe("getConfigErrorDetails", () => { expect(getConfigErrorDetails({ data: { path: "/cfg.json" } })).toBe("File: /cfg.json") }) + it("formats a shadowed-write error with the overriding file", () => { + const err = { + data: { + message: + "The setting was saved to /home/me/.config/kilo/kilo.json, but /home/me/.config/kilo/opencode.json still takes precedence over it.", + path: "/home/me/.config/kilo/kilo.json", + shadowedBy: "/home/me/.config/kilo/opencode.json", + }, + } + expect(getConfigErrorDetails(err)).toBe( + "File: /home/me/.config/kilo/kilo.json\nShadowed by: /home/me/.config/kilo/opencode.json", + ) + }) + it("returns undefined when issues array is empty and no path", () => { expect(getConfigErrorDetails({ data: { issues: [] } })).toBeUndefined() }) diff --git a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts index 25e01854735..90e7b43bed7 100644 --- a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts @@ -573,14 +573,26 @@ describe("Collapsed deferred tool details contract (source)", () => { }) describe("Deferred tool card remount contract (source)", () => { + const basic = fs.readFileSync(BASIC_TOOL_FILE, "utf-8") const wrapper = fs.readFileSync(path.join(MONOREPO_ROOT, "packages/kilo-ui/src/components/basic-tool.tsx"), "utf-8") + const css = fs.readFileSync(KILO_BASIC_TOOL_CSS_FILE, "utf-8") const scroll = fs.readFileSync(path.join(MONOREPO_ROOT, "packages/kilo-ui/src/hooks/create-auto-scroll.tsx"), "utf-8") - it("mounts a remembered-open deferred card with its body in the same frame", () => { - // Otherwise every virtualizer remount of an expanded diff paints a - // collapsed frame, then grows by the full diff height and the pinned - // transcript jumps (and can loop through the virtualizer's range). - expect(wrapper).toContain("const defer = () => props.defer && !(remount && initial())") + it("reserves remembered-open card height while remounting its body later", () => { + // Restoring the measured height prevents the collapsed frame and + // virtualizer feedback loop without rebuilding Pierre inside the switch. + expect(wrapper).toContain("const cached = remount && id ? heights.get(id) : undefined") + expect(wrapper).toContain("const defer = () => props.defer && !(remount && initial() && size == null)") + expect(wrapper).toContain("deferredSize={size}") + expect(wrapper).toContain("cached.revision === revision()") + expect(wrapper).toContain("const value = props.revision") + expect(wrapper).toContain('if (typeof value === "string") return checksum(value)') + expect(wrapper).toContain("!mounted.has(id)") + expect(wrapper).toContain("if (open && !props.forceOpen) remember(key())") + expect(basic).toContain("deferredSize?: { height: number; width: number; font: string }") + expect(basic).toContain('data-deferred-height={state.restored ? "" : undefined}') + expect(basic).toContain('style={{ "min-height": reserve() }}') + expect(css).toMatch(/\[data-slot="collapsible-content"\]\[data-deferred-height\]\s*\{[^}]*animation:\s*none;/) // The memory is separate from the user preference map: a display setting // or search forceOpen must not become a durable per-card open state. expect(wrapper).toContain("if (initial() && !props.forceOpen) remember(id)") diff --git a/packages/kilo-vscode/tests/unit/language-cache.test.ts b/packages/kilo-vscode/tests/unit/language-cache.test.ts new file mode 100644 index 00000000000..139eb909138 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/language-cache.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it } from "bun:test" +import path from "node:path" +import { Project, SyntaxKind } from "ts-morph" +import type { UiI18nParams, UiI18nPluralKey, UiPluralCategory } from "@kilocode/kilo-ui/context/i18n" +import type { LanguageContextValue, LanguageProvider } from "../../webview-ui/src/context/language" +import { + LOCALES, + RTL_LOCALES, + localeToBcp47, + normalizeLocale, + resolveTemplate, + type Locale, +} from "../../webview-ui/src/context/language-utils" + +const file = path.resolve(import.meta.dir, "../../webview-ui/src/context/language.tsx") +const project = new Project({ useInMemoryFileSystem: true }) +const source = project.createSourceFile("language.tsx", await Bun.file(file).text()) +const folder = path.join(path.dirname(file), "language-dictionaries") +const dictionaries = await Promise.all( + [...new Bun.Glob("**/*.ts").scanSync({ cwd: folder })].map(async (name) => + project.createSourceFile(name, await Bun.file(path.join(folder, name)).text()), + ), +) +const imports = source + .getImportDeclarations() + .filter((item) => item.getNamedImports().some((name) => name.getName() === "dict")) +const layers: Record> = Object.fromEntries( + await Promise.all( + imports.map(async (item) => { + const name = item.getNamedImports().at(0)?.getAliasNode()?.getText() + if (!name) throw new Error("Missing dictionary alias") + const specifier = item.getModuleSpecifierValue() + const target = specifier.startsWith(".") ? path.resolve(path.dirname(file), specifier) : specifier + return [name, (await import(target)).dict] + }), + ), +) +const solid: typeof import("solid-js") = await import(path.join(path.dirname(require.resolve("solid-js")), "solid.js")) +// The UI i18n module is JSX, so extract the real plural helpers instead of importing it. +const uiI18n = project.createSourceFile( + "ui-i18n.tsx", + await Bun.file(path.resolve(import.meta.dir, "../../../ui/src/context/i18n.tsx")).text(), +) +const helpers = new Function( + `${new Bun.Transpiler({ loader: "ts" }).transformSync( + [ + uiI18n.getVariableStatementOrThrow("rules").getText(), + uiI18n + .getFunctionOrThrow("pluralCategory") + .getText() + .replace(/^export /, ""), + uiI18n + .getFunctionOrThrow("pluralKey") + .getText() + .replace(/^export /, ""), + ].join("\n"), + )}; return { pluralCategory, pluralKey }`, +)() as { + pluralCategory: (locale: string, count: number) => UiPluralCategory + pluralKey: (key: UiI18nPluralKey, category: UiPluralCategory) => string +} +const { pluralCategory, pluralKey } = helpers +const body = source + .getVariableDeclarationOrThrow("LanguageProvider") + .getInitializerIfKindOrThrow(SyntaxKind.ArrowFunction) + .getBody() + .asKindOrThrow(SyntaxKind.Block) + +// Execute the actual cache and provider setup without compiling JSX or loading the renderer graph. +const code = new Bun.Transpiler({ loader: "ts" }).transformSync(` + ${["base", "english", "cache", "loads"].map((name) => source.getVariableStatementOrThrow(name).getText()).join("\n")} + ${source.getFunctionOrThrow("load").getText().replace("import(`./language-dictionaries/${locale}.ts`)", "importer(locale)")} + ${["normalizeLocale", "resolveTemplate"].map((name) => source.getFunctionOrThrow(name).getText()).join("\n")} + function provider(props) { + ${body + .getStatements() + .filter((item) => !item.isKind(SyntaxKind.ReturnStatement)) + .map((item) => item.getText()) + .join("\n")} + return { locale, setLocale, userOverride, t, plural } + } +`) +const setup = new Function( + ...Object.keys(layers), + "deps", + `const { createSignal, createMemo, createEffect, onCleanup, useVSCode, document, navigator, importer, console, + _normalizeLocale, _resolveTemplate, RTL_LOCALES, localeToBcp47, pluralKey, pluralCategory } = deps; + ${code}; return { cache, load, provider }`, +) + +function scene() { + const html = { lang: "", dir: "" } + const sent: unknown[] = [] + const errors: unknown[][] = [] + type Dictionary = typeof import("../../webview-ui/src/context/language-dictionaries/it") + const requests: (ReturnType> & { locale: Exclude })[] = [] + const state = setup(...Object.values(layers), { + ...solid, + useVSCode: () => ({ postMessage: (message: unknown) => sent.push(message) }), + document: { documentElement: html }, + navigator: { language: "fr-FR" }, + importer: (locale: Exclude) => { + const request = { ...Promise.withResolvers(), locale } + requests.push(request) + return request.promise + }, + console: { error: (...args: unknown[]) => errors.push(args) }, + _normalizeLocale: normalizeLocale, + _resolveTemplate: resolveTemplate, + RTL_LOCALES, + localeToBcp47, + pluralKey, + pluralCategory, + }) as { + cache: Partial>> + load: (locale: Locale) => Promise | undefined> + provider: (props: Parameters[0]) => LanguageContextValue & { + plural: (key: UiI18nPluralKey, count: number, params?: UiI18nParams) => string + } + } + return { + ...state, + html, + sent, + errors, + requests, + ready: async (locale = requests.at(-1)!.locale) => { + const request = requests.findLast((item) => item.locale === locale)! + request.resolve(await import(path.join(folder, `${locale}.ts`))) + }, + } +} + +describe("language dictionary cache", () => { + it("uses a relative source glob with exactly one factory per supported non-English locale", () => { + expect(dictionaries.map((item) => item.getBaseNameWithoutExtension()).sort()).toEqual( + LOCALES.filter((locale) => locale !== "en").sort(), + ) + expect( + source + .getDescendantsOfKind(SyntaxKind.CallExpression) + .filter((item) => item.getExpression().getText() === "import") + .map((item) => item.getArguments().at(0)?.getText()), + ).toEqual(["`./language-dictionaries/${locale}.ts`"]) + for (const module of dictionaries) { + const locale = module.getBaseNameWithoutExtension() + expect(module.getImportDeclarations().map((item) => item.getModuleSpecifierValue())).toEqual([ + `../../i18n/${locale}`, + `@kilocode/kilo-ui/i18n/${locale}`, + ...(locale === "fa" ? [] : [`@kilocode/kilo-i18n/${locale}`]), + "../../../agent-manager/i18n/en", + `../../../agent-manager/i18n/${locale}`, + ]) + } + }) + + it("keeps English startup synchronous and shares only each selected locale's load promise", async () => { + const state = scene() + const root = solid.createRoot((dispose) => ({ ...state.provider({ vscodeLanguage: () => "en" }), dispose })) + try { + expect( + source + .getImportDeclarations() + .filter((item) => item.getNamedImports().some((name) => name.getName() === "dict")) + .map((item) => item.getModuleSpecifierValue()), + ).toEqual(["@kilocode/kilo-ui/i18n/en", "../i18n/en", "../../agent-manager/i18n/en", "@kilocode/kilo-i18n/en"]) + expect(state.requests).toHaveLength(0) + expect(Object.keys(state.cache)).toEqual(["en"]) + expect(await state.load("en")).toBe(state.cache.en) + expect(root.locale()).toBe("en") + expect(root.t("common.save")).toBe(layers.appEn!["common.save"]) + root.setLocale("ar") + const ar = state.load("ar") + expect(state.load("ar")).toBe(ar) + root.setLocale("fa") + const fa = state.load("fa") + expect(state.load("fa")).toBe(fa) + expect(state.requests.map((item) => item.locale)).toEqual(["ar", "fa"]) + expect(Object.keys(state.cache)).toEqual(["en"]) + expect(root.userOverride()).toBe("fa") + expect(root.locale()).toBe("en") + expect(state.html).toEqual({ lang: "en", dir: "ltr" }) + expect(root.t("common.save")).toBe(layers.appEn!["common.save"]) + await state.ready("fa") + const persian = await fa + expect(root.locale()).toBe("fa") + expect(state.html).toEqual({ lang: "fa", dir: "rtl" }) + expect(Object.keys(state.cache)).toEqual(["en", "fa"]) + await state.ready("ar") + const arabic = await ar + expect(root.locale()).toBe("fa") + expect(state.html).toEqual({ lang: "fa", dir: "rtl" }) + expect(Object.keys(state.cache)).toEqual(["en", "fa", "ar"]) + expect(state.load("ar")).toBe(ar) + expect(state.cache.ar).toBe(arabic) + expect(state.cache.fa).toBe(persian) + expect(persian).not.toBe(arabic) + root.setLocale("ar") + expect(root.locale()).toBe("ar") + expect(root.t("settings.language.title")).toBe(arabic!["settings.language.title"]) + root.setLocale("en") + expect(root.locale()).toBe("en") + expect(state.html).toEqual({ lang: "en", dir: "ltr" }) + expect(state.requests).toHaveLength(2) + } finally { + root.dispose() + } + }) + + it("preserves every key and layer precedence for all 21 locales", async () => { + const state = scene() + const base = { ...layers.appEn, ...layers.uiEn, ...layers.kiloEn } + for (const locale of LOCALES) { + const [app, ui, kilo, am] = await Promise.all([ + import(path.resolve(path.dirname(file), `../i18n/${locale}.ts`)), + import(`@kilocode/kilo-ui/i18n/${locale}`), + locale === "fa" ? { dict: {} } : import(`@kilocode/kilo-i18n/${locale}`), + import(path.resolve(path.dirname(file), `../../agent-manager/i18n/${locale}.ts`)), + ]) + const expected = + locale === "en" + ? { ...base, ...am.dict } + : { + ...base, + ...app.dict, + ...ui.dict, + ...kilo.dict, + ...layers.amEn, + ...am.dict, + } + const pending = state.load(locale) + if (locale !== "en") await state.ready(locale) + const dict = await pending + expect(dict, locale).toEqual(expected) + expect(await state.load(locale)).toBe(dict) + } + expect(state.requests.map((item) => item.locale)).toEqual(LOCALES.filter((locale) => locale !== "en")) + }) + + it("ignores an obsolete non-English request after switching back to English", async () => { + const state = scene() + const root = solid.createRoot((dispose) => ({ ...state.provider({ vscodeLanguage: () => "en" }), dispose })) + try { + root.setLocale("ar") + root.setLocale("en") + await state.ready() + await state.load("ar") + expect(root.locale()).toBe("en") + expect(root.t("settings.language.title")).toBe(layers.appEn!["settings.language.title"]) + expect(state.html).toEqual({ lang: "en", dir: "ltr" }) + root.setLocale("ar") + expect(root.locale()).toBe("ar") + expect(state.html).toEqual({ lang: "ar", dir: "rtl" }) + } finally { + root.dispose() + } + }) + + it("ignores disposed callbacks while another provider shares the same pending load", async () => { + const state = scene() + const first = solid.createRoot((dispose) => ({ ...state.provider({ vscodeLanguage: () => "en" }), dispose })) + const second = solid.createRoot((dispose) => ({ ...state.provider({ vscodeLanguage: () => "en" }), dispose })) + first.setLocale("ar") + second.setLocale("ar") + first.dispose() + try { + expect(state.requests).toHaveLength(1) + await state.ready() + await state.load("ar") + expect(first.locale()).toBe("en") + expect(second.locale()).toBe("ar") + expect(state.html).toEqual({ lang: "ar", dir: "rtl" }) + } finally { + second.dispose() + } + }) + + it("keeps displayed locale, direction, plurals, and overrides coherent with English fallback", async () => { + const state = scene() + const [language, change] = solid.createSignal("de-DE") + const [override, update] = solid.createSignal("zh-Hant") + const root = solid.createRoot((dispose) => ({ + ...state.provider({ vscodeLanguage: language, languageOverride: override }), + dispose, + })) + try { + expect(root.locale()).toBe("en") + expect(root.userOverride()).toBe("zht") + expect(state.html).toEqual({ lang: "en", dir: "ltr" }) + await state.ready() + await state.load("zht") + expect(root.locale()).toBe("zht") + expect(state.html).toEqual({ lang: "zh-TW", dir: "ltr" }) + for (const locale of ["ar", "fa", "br", "en"] as const) { + const previous = root.locale() + const text = root.t("settings.language.title") + root.setLocale(locale) + if (!state.cache[locale]) { + expect(root.locale()).toBe(previous) + expect(root.t("settings.language.title")).toBe(text) + expect(state.html).toEqual({ lang: localeToBcp47(previous), dir: RTL_LOCALES.has(previous) ? "rtl" : "ltr" }) + await state.ready(locale) + } + await state.load(locale) + expect(root.locale()).toBe(locale) + expect(root.userOverride()).toBe(locale) + expect(root.t("settings.language.title")).toBe(state.cache[locale]!["settings.language.title"]) + expect(state.html).toEqual({ lang: localeToBcp47(locale), dir: RTL_LOCALES.has(locale) ? "rtl" : "ltr" }) + for (const count of [0, 1, 2, 3, 11, 100]) { + const key = pluralKey("ui.sessionTurn.diffs.changed", pluralCategory(localeToBcp47(locale), count)) + expect(root.plural("ui.sessionTurn.diffs.changed", count, { count: 99 })).toBe(root.t(key, { count })) + } + } + root.setLocale("") + await state.ready("de") + await state.load("de") + expect(root.locale()).toBe("de") + delete state.cache.de!["common.save"] + expect(root.t("common.save")).toBe(state.cache.en!["common.save"]) + expect(root.t("missing.key")).toBe("missing.key") + state.cache.de!["common.save"] = "" + expect(root.t("common.save")).toBe("") + change(undefined) + expect(root.locale()).toBe("de") + await state.ready("fr") + await state.load("fr") + expect(root.locale()).toBe("fr") + update("fa-IR") + expect(root.locale()).toBe("fa") + expect(state.html.dir).toBe("rtl") + update("") + expect(root.locale()).toBe("fa") + expect(state.sent).toEqual(["ar", "fa", "br", "en", ""].map((locale) => ({ type: "setLanguage", locale }))) + } finally { + root.dispose() + } + }) + + it("isolates load rejection and retries when the same language is selected again", async () => { + const state = scene() + const root = solid.createRoot((dispose) => ({ ...state.provider({ vscodeLanguage: () => "en" }), dispose })) + try { + root.setLocale("ar") + const pending = state.load("ar") + const request = state.requests.at(-1)! + const persian = state.load("fa") + const err = new Error("Dictionary chunk unavailable") + request.reject(err) + expect(await pending).toBeUndefined() + expect(state.load("fa")).toBe(persian) + expect(state.errors).toEqual([["[Kilo New] Failed to load language", { locale: "ar", err }]]) + expect(root.locale()).toBe("en") + expect(root.t("common.save")).toBe(state.cache.en!["common.save"]) + expect(state.html).toEqual({ lang: "en", dir: "ltr" }) + root.setLocale("ar") + expect(state.requests.map((item) => item.locale)).toEqual(["ar", "fa", "ar"]) + await state.ready("ar") + await state.load("ar") + expect(root.locale()).toBe("ar") + expect(state.html).toEqual({ lang: "ar", dir: "rtl" }) + expect(root.t("common.save")).toBe(state.cache.ar!["common.save"]) + await state.ready("fa") + expect(await persian).toBe(state.cache.fa) + expect(root.locale()).toBe("ar") + } finally { + root.dispose() + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/link-modifier.test.ts b/packages/kilo-vscode/tests/unit/link-modifier.test.ts new file mode 100644 index 00000000000..65263a29a68 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/link-modifier.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test" +import { forcesExternalBrowser } from "../../webview-ui/src/utils/link-modifier" + +describe("forcesExternalBrowser", () => { + test("forces the system browser on shift-click while the Integrated Browser handles links", () => { + expect(forcesExternalBrowser({ shiftKey: true }, true)).toBe(true) + }) + + test("leaves a plain click to the Integrated Browser", () => { + expect(forcesExternalBrowser({ shiftKey: false }, true)).toBe(false) + }) + + test("ignores shift when links do not route through the Integrated Browser", () => { + expect(forcesExternalBrowser({ shiftKey: true }, false)).toBe(false) + expect(forcesExternalBrowser({ shiftKey: true }, undefined)).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/local-tabs.test.ts b/packages/kilo-vscode/tests/unit/local-tabs.test.ts index 29d72f8c283..4d9b1e582ff 100644 --- a/packages/kilo-vscode/tests/unit/local-tabs.test.ts +++ b/packages/kilo-vscode/tests/unit/local-tabs.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "bun:test" import { + adjacentTab, + adjacentTabHint, addPendingTab, addSessionTab, closeAllTabs, @@ -295,6 +297,31 @@ describe("tracked tab restore", () => { }) describe("tracked tab reconcile", () => { + it("preserves an unmanaged local tab while the shared list still belongs to another project", () => { + expect(reconcileTrackedTabs(["ses-b"], ["ses-a"], inventory([]), trackedPending, false)).toBeUndefined() + expect(reconcileTrackedTabs(["ses-b"], ["ses-b"], inventory([]), trackedPending, true)).toBeUndefined() + expect(reconcileTrackedTabs(["ses-b"], [], inventory([]), trackedPending, true)).toEqual({ + ids: [], + forget: ["ses-b"], + }) + }) + + it("preserves an older open local tab while the selected project's list is incomplete", () => { + expect(reconcileTrackedTabs(["older"], ["newer"], inventory([]), trackedPending, false)).toBeUndefined() + }) + + it("still removes known external and child sessions before an authoritative list arrives", () => { + expect( + reconcileTrackedTabs( + ["local", "external", "child"], + [], + { local: [], external: new Set(["external"]), rejected: new Set(["child"]) }, + trackedPending, + false, + ), + ).toEqual({ ids: ["local"], forget: ["child"] }) + }) + it("evicts sparse sessions without forgetting them", () => { const data = trackedSessionInventory( [ @@ -370,3 +397,23 @@ describe("tabs for loaded sessions", () => { }) }) }) + +describe("adjacent session tabs", () => { + const ids = ["s1", "s2", "s3"] + const kb = { previousTab: "⌘⌥←", nextTab: "⌘⌥→" } + + it("selects the neighbour without wrapping at the ends", () => { + expect(adjacentTab(ids, "s2", -1)).toBe("s1") + expect(adjacentTab(ids, "s2", 1)).toBe("s3") + expect(adjacentTab(ids, "s1", -1)).toBeUndefined() + expect(adjacentTab(ids, "s3", 1)).toBeUndefined() + expect(adjacentTab(ids, undefined, 1)).toBeUndefined() + }) + + it("shows the shortcut only on direct neighbours of the active tab", () => { + expect(adjacentTabHint(ids, "s2", "s1", kb)).toBe("⌘⌥←") + expect(adjacentTabHint(ids, "s2", "s3", kb)).toBe("⌘⌥→") + expect(adjacentTabHint(ids, "s2", "s2", kb)).toBe("") + expect(adjacentTabHint(ids, "s1", "s3", kb)).toBe("") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts index 5a4ed565904..9815e685f5f 100644 --- a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts +++ b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts @@ -217,7 +217,7 @@ describe("Marketplace companion skill payloads", () => { const extensions = Object.getOwnPropertyDescriptor(vscode.extensions, "all") try { Object.defineProperty(vscode.extensions, "all", { configurable: true, value: [] }) - const data = await service.fetchData(client, project, project, []) + const data = await service.fetchData(client, project, project) expect(data.marketplaceItems).toEqual([mcp]) const loaded = data.marketplaceItems.at(0)! const options = { target: scope, parameters: { token: "test-value" } } @@ -241,7 +241,6 @@ describe("Marketplace companion skill payloads", () => { } finally { if (extensions) Object.defineProperty(vscode.extensions, "all", extensions) if (!extensions) Reflect.deleteProperty(vscode.extensions, "all") - service.dispose() server.stop(true) } }) @@ -277,6 +276,17 @@ describe("Marketplace removal actions", () => { expect(has(files, legacy)).toBe(false) expect(has(files, global)).toBe(false) }) + + it("does not report whole-bundle success when either scope fails", async () => { + const remove = mock(async (_client, _item, scope: "project" | "global") => ({ + success: scope === "project", + slug: item.id, + error: scope === "global" ? "Global config is still installed" : undefined, + })) + + expect(await removeMarketplaceItemFromAllScopes(ctx(remove), item, project, project)).toBe(false) + expect(remove.mock.calls.map((call) => call[2])).toEqual(["project", "global"]) + }) }) describe("Marketplace plugin removal", () => { diff --git a/packages/kilo-vscode/tests/unit/marketplace-bundles.test.ts b/packages/kilo-vscode/tests/unit/marketplace-bundles.test.ts new file mode 100644 index 00000000000..14ab77abb12 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/marketplace-bundles.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "bun:test" +import { createKiloClient } from "@kilocode/sdk/v2/client" +import * as vscode from "vscode" +import { marketplaceBundles } from "../../src/services/marketplace/bundles" + +const UUID_A = "11111111-1111-4111-8111-111111111111" +const UUID_B = "22222222-2222-4222-8222-222222222222" + +const fs = vscode.workspace.fs as unknown as { + readFile: (uri: vscode.Uri) => Promise + stat: (uri: vscode.Uri) => Promise<{ type: number; size: number; ctime: number; mtime: number }> +} +const original = { readFile: fs.readFile, stat: fs.stat } + +function setup(files: Map) { + fs.stat = async (uri) => { + const body = files.get(uri.fsPath) + if (body === undefined) throw new Error("ENOENT") + return { type: vscode.FileType.File, size: Buffer.byteLength(body, "utf8"), ctime: 0, mtime: 0 } + } + fs.readFile = async (uri) => { + const body = files.get(uri.fsPath) + if (body === undefined) throw new Error("ENOENT") + return Buffer.from(body) + } +} + +function restore() { + fs.readFile = original.readFile + fs.stat = original.stat +} + +function serve(skills: Array<{ name: string; location: string; content: string }>) { + return Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const url = new URL(request.url) + if (url.pathname === "/skill") return Response.json(skills) + return new Response(null, { status: 404 }) + }, + }) +} + +describe("marketplaceBundles", () => { + it("reads catalog independent companion ownership markers for project and global scope", async () => { + const projectSkill = "/repo/.kilo/skills/context7/SKILL.md" + const globalSkill = "/home/user/.kilo/skills/context7/SKILL.md" + const files = new Map([ + [ + "/repo/.kilo/skills/context7/.kilo-marketplace.json", + JSON.stringify({ version: 1, id: "context7", token: UUID_A }), + ], + [ + "/home/user/.kilo/skills/context7/.kilo-marketplace.json", + JSON.stringify({ version: 1, id: "context7", token: UUID_B }), + ], + ]) + setup(files) + const server = serve([ + { name: "context7-project", location: projectSkill, content: "" }, + { name: "context7-global", location: globalSkill, content: "" }, + ]) + try { + const client = createKiloClient({ baseUrl: server.url.href }) + const bundles = await marketplaceBundles(client, "/repo") + expect(bundles).toEqual([ + { id: "context7", scope: "global", skills: [globalSkill] }, + { id: "context7", scope: "project", skills: [projectSkill] }, + ]) + } finally { + restore() + server.stop(true) + } + }) + + it("rejects a marker file larger than 4 KiB", async () => { + const skill = "/repo/.kilo/skills/context7/SKILL.md" + const oversized = JSON.stringify({ version: 1, id: "context7", token: UUID_A, pad: "x".repeat(5000) }) + setup(new Map([["/repo/.kilo/skills/context7/.kilo-marketplace.json", oversized]])) + const server = serve([{ name: "context7", location: skill, content: "" }]) + try { + const client = createKiloClient({ baseUrl: server.url.href }) + expect(await marketplaceBundles(client, "/repo")).toEqual([]) + } finally { + restore() + server.stop(true) + } + }) + + it("rejects an unsupported marker version", async () => { + const skill = "/repo/.kilo/skills/context7/SKILL.md" + setup( + new Map([ + [ + "/repo/.kilo/skills/context7/.kilo-marketplace.json", + JSON.stringify({ version: 2, id: "context7", token: UUID_A }), + ], + ]), + ) + const server = serve([{ name: "context7", location: skill, content: "" }]) + try { + const client = createKiloClient({ baseUrl: server.url.href }) + expect(await marketplaceBundles(client, "/repo")).toEqual([]) + } finally { + restore() + server.stop(true) + } + }) + + it("rejects a non-UUID token", async () => { + const skill = "/repo/.kilo/skills/context7/SKILL.md" + setup( + new Map([ + [ + "/repo/.kilo/skills/context7/.kilo-marketplace.json", + JSON.stringify({ version: 1, id: "context7", token: "not-a-uuid" }), + ], + ]), + ) + const server = serve([{ name: "context7", location: skill, content: "" }]) + try { + const client = createKiloClient({ baseUrl: server.url.href }) + expect(await marketplaceBundles(client, "/repo")).toEqual([]) + } finally { + restore() + server.stop(true) + } + }) + + it("rejects an unsafe id", async () => { + const skill = "/repo/.kilo/skills/context7/SKILL.md" + setup( + new Map([ + [ + "/repo/.kilo/skills/context7/.kilo-marketplace.json", + JSON.stringify({ version: 1, id: "../escape", token: UUID_A }), + ], + ]), + ) + const server = serve([{ name: "context7", location: skill, content: "" }]) + try { + const client = createKiloClient({ baseUrl: server.url.href }) + expect(await marketplaceBundles(client, "/repo")).toEqual([]) + } finally { + restore() + server.stop(true) + } + }) + + it("skips builtin skills", async () => { + const server = serve([{ name: "builtin-skill", location: "builtin", content: "" }]) + try { + const client = createKiloClient({ baseUrl: server.url.href }) + expect(await marketplaceBundles(client, "/repo")).toEqual([]) + } finally { + server.stop(true) + } + }) + + it("groups multiple companion skills under the same bundle", async () => { + const a = "/repo/.kilo/skills/query-workflow/SKILL.md" + const b = "/repo/.kilo/skills/data-checks/SKILL.md" + setup( + new Map([ + [ + "/repo/.kilo/skills/query-workflow/.kilo-marketplace.json", + JSON.stringify({ version: 1, id: "anaconda", token: UUID_A }), + ], + [ + "/repo/.kilo/skills/data-checks/.kilo-marketplace.json", + JSON.stringify({ version: 1, id: "anaconda", token: UUID_A }), + ], + ]), + ) + const server = serve([ + { name: "query-workflow", location: a, content: "" }, + { name: "data-checks", location: b, content: "" }, + ]) + try { + const client = createKiloClient({ baseUrl: server.url.href }) + expect(await marketplaceBundles(client, "/repo")).toEqual([{ id: "anaconda", scope: "project", skills: [b, a] }]) + } finally { + restore() + server.stop(true) + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/marketplace-panel-arch.test.ts b/packages/kilo-vscode/tests/unit/marketplace-panel-arch.test.ts index 9db7db4ef10..7e51af502d4 100644 --- a/packages/kilo-vscode/tests/unit/marketplace-panel-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/marketplace-panel-arch.test.ts @@ -36,4 +36,19 @@ describe("standalone Marketplace architecture", () => { expect(remove).not.toContain("AgentMarketplaceItem") expect(remove).not.toContain("McpMarketplaceItem") }) + + it("broadcasts MCP removals from Marketplace to every Kilo provider", () => { + expect(panel).toContain("mcpRemoval(this.connection)") + expect(panel).toContain('phase: "installed"') + expect(panel).toContain('event.phase === "removed" || event.phase === "installed"') + expect(kilo).toContain("mcpRemoval(this.connectionService).on") + expect(kilo).toContain('type: "mcpRemoved"') + expect(kilo).toContain('type: "mcpRemovalState"') + }) + + it("invalidates Agent Behaviour tabs after cross-provider config changes", () => { + expect(kilo).toContain('type: "agentBehaviourInvalidated"') + expect(kilo).toContain('event.type === "server.instance.disposed"') + expect(kilo).toContain('event.type === "global.config.updated"') + }) }) diff --git a/packages/kilo-vscode/tests/unit/marketplace-panel-mcp-auth-refresh.test.ts b/packages/kilo-vscode/tests/unit/marketplace-panel-mcp-auth-refresh.test.ts new file mode 100644 index 00000000000..03ab49c84e4 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/marketplace-panel-mcp-auth-refresh.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, mock } from "bun:test" +import type { MarketplaceItem } from "../../src/services/marketplace/types" +import { mcpAuth } from "../../src/services/mcp-auth" + +const { MarketplacePanelProvider } = await import("../../src/MarketplacePanelProvider") + +interface Internals { + remove(item: MarketplaceItem, scope: "project" | "global"): Promise + marketplace: { remove: (...args: unknown[]) => Promise<{ success: boolean; slug: string; error?: string }> } +} + +function panel() { + const connection = { + getClient: () => undefined, + getClientAsync: async () => ({}), + onStateChange: () => () => {}, + onLanguageChanged: () => () => {}, + onEventFiltered: () => () => {}, + } as never + const provider = new MarketplacePanelProvider({} as never, connection, {} as never) + const internal = provider as unknown as Internals + internal.marketplace.remove = mock(async () => ({ success: true, slug: "anaconda" })) + return { internal, connection } +} + +/** + * install() already refreshes the shared McpAuthService so a newly installed + * server's needs-auth state reaches every provider via its onChange + * broadcast. remove() must mirror that so a removed server's stale + * needs-auth state clears the same way — see the bug where uninstalling an + * MCP server left its session-issues warning icon stuck. + */ +describe("MarketplacePanelProvider MCP removal auth refresh", () => { + it("refreshes the shared MCP auth service after removing an MCP server", async () => { + const { internal, connection } = panel() + const refresh = mock(async () => [] as string[]) + mcpAuth(connection).refresh = refresh + const item = { id: "anaconda", type: "mcp" } as MarketplaceItem + + await internal.remove(item, "global") + + expect(refresh).toHaveBeenCalledWith("/repo") + }) + + it("does not refresh MCP auth when removal fails", async () => { + const { internal, connection } = panel() + internal.marketplace.remove = mock(async () => ({ success: false, slug: "anaconda", error: "boom" })) + const refresh = mock(async () => [] as string[]) + mcpAuth(connection).refresh = refresh + const item = { id: "anaconda", type: "mcp" } as MarketplaceItem + + await internal.remove(item, "global") + + expect(refresh).not.toHaveBeenCalled() + }) + + it("does not refresh MCP auth for non-MCP item types", async () => { + const { internal, connection } = panel() + const refresh = mock(async () => [] as string[]) + mcpAuth(connection).refresh = refresh + const item = { id: "my-skill", type: "skill" } as MarketplaceItem + + await internal.remove(item, "global") + + expect(refresh).not.toHaveBeenCalled() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts b/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts index c0ef1168683..805681121ff 100644 --- a/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts +++ b/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, it, mock } from "bun:test" -import * as vscode from "vscode" -import { detectMarketplaceRelevance } from "../../src/services/marketplace/relevance" +import { describe, expect, it } from "bun:test" +import { detectMarketplaceRelevance, filenamePatterns, matchesPattern } from "../../src/services/marketplace/relevance" import type { MarketplaceItem } from "../../src/services/marketplace/types" const items: MarketplaceItem[] = [ @@ -41,14 +40,8 @@ const items: MarketplaceItem[] = [ ] describe("Marketplace relevance", () => { - it("matches workspace files and installed extensions with deduplicated bounded searches", async () => { - const root = vscode.Uri.file("/repo") - const find = mock(async (_root: vscode.Uri, pattern: string) => pattern === "*.component.ts") - - const relevance = await detectMarketplaceRelevance(items, [root], { - extensions: ["MS-ToolsAI.Jupyter"], - find, - }) + it("combines backend filename matches with installed extensions", () => { + const relevance = detectMarketplaceRelevance(items, ["*.component.ts"], ["MS-ToolsAI.Jupyter"]) expect(relevance).toEqual({ "agent:angular": { filename: ["*.component.ts"] }, @@ -57,48 +50,21 @@ describe("Marketplace relevance", () => { vscodeExtension: ["ms-toolsai.jupyter"], }, }) - expect(find).toHaveBeenCalledTimes(3) - expect(find.mock.calls).toContainEqual([root, "*.component.ts"]) - expect(find.mock.calls).toContainEqual([root, "*.ipynb"]) - expect(find.mock.calls).toContainEqual([root, "*.rs"]) }) - it("searches every workspace root and preserves remote URIs", async () => { - const local = vscode.Uri.file("/repo") - const remote = vscode.Uri.parse("vscode-remote://ssh-remote+host/workspace") - const find = mock(async (root: vscode.Uri, pattern: string) => root === remote && pattern === "*.ipynb") - - const relevance = await detectMarketplaceRelevance(items, [local, remote], { extensions: [], find }) - - expect(relevance).toEqual({ "mcp:jupyter": { filename: ["*.ipynb"] } }) - expect(find.mock.calls).toContainEqual([remote, "*.ipynb"]) - }) - - it("ignores malformed suggestion metadata", async () => { + it("ignores malformed suggestion metadata", () => { const malformed = { ...items[0], suggest_for: { filename: "*.component.ts", vscode_extension: [42] }, } as unknown as MarketplaceItem - const find = mock(async () => true) - const relevance = await detectMarketplaceRelevance([malformed], [vscode.Uri.file("/repo")], { - extensions: ["test.extension"], - find, - }) - - expect(relevance).toEqual({}) - expect(find).not.toHaveBeenCalled() + expect(detectMarketplaceRelevance([malformed], ["*.component.ts"], ["test.extension"])).toEqual({}) }) - it("still matches installed extensions without a workspace", async () => { - const find = mock(async () => true) - - const relevance = await detectMarketplaceRelevance(items, [], { - extensions: ["ms-toolsai.jupyter"], - find, - }) - - expect(relevance).toEqual({ "mcp:jupyter": { vscodeExtension: ["ms-toolsai.jupyter"] } }) - expect(find).not.toHaveBeenCalled() + it("matches created paths against filename patterns", () => { + expect(filenamePatterns(items)).toEqual(["*.component.ts", "*.ipynb", "*.rs"]) + expect(matchesPattern("notebooks/analysis.ipynb", "*.ipynb")).toBe(true) + expect(matchesPattern("src\\app\\app.component.ts", "*.component.ts")).toBe(true) + expect(matchesPattern("src/main.ts", "*.component.ts")).toBe(false) }) }) diff --git a/packages/kilo-vscode/tests/unit/mcp-auth-service.test.ts b/packages/kilo-vscode/tests/unit/mcp-auth-service.test.ts new file mode 100644 index 00000000000..457702904af --- /dev/null +++ b/packages/kilo-vscode/tests/unit/mcp-auth-service.test.ts @@ -0,0 +1,393 @@ +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test" +import * as vscode from "vscode" +import { McpAuthService, type McpAuthConnection } from "../../src/services/mcp-auth/service" + +interface FakeClient { + mcp: { + status(params: { directory?: string }): Promise<{ data: Record }> + connect(params: { name: string; directory?: string }): Promise<{ data: boolean }> + disconnect(params: { name: string; directory?: string }): Promise<{ data: boolean }> + auth: { + authenticate(params: { name: string; directory?: string; external?: boolean }): Promise<{ + data?: { status: string; error?: string } + error?: unknown + response?: { ok: boolean; status: number } + }> + cancel(params: { name: string; directory?: string }): Promise<{ response?: { ok: boolean; status: number } }> + remove(params: { name: string; directory?: string }): Promise<{ response?: { ok: boolean; status: number } }> + } + } +} + +/** Minimal fake satisfying McpAuthConnection, with per-call hooks for assertions and controllable behaviour. */ +function fakeConnection(opts: { + status?: + | Record + | (() => Promise>) + authenticate?: (name: string) => Promise<{ + data?: { status: string; error?: string } + error?: unknown + response?: { ok: boolean; status: number } + }> + onAuthCancel?: (name: string) => void + onAuthRemove?: (name: string) => void + onConnect?: (name: string) => void + onDisconnect?: (name: string) => void + connectResult?: boolean + disconnectResult?: boolean + authCancelOk?: boolean + authRemoveOk?: boolean +}) { + const calls = { + status: 0, + authenticate: [] as Array<{ name: string; external?: boolean }>, + authCancel: [] as string[], + authRemove: [] as string[], + connect: [] as string[], + disconnect: [] as string[], + } + + const client: FakeClient = { + mcp: { + status: async () => { + calls.status++ + return { data: typeof opts.status === "function" ? await opts.status() : (opts.status ?? {}) } + }, + connect: async ({ name }) => { + calls.connect.push(name) + opts.onConnect?.(name) + return { data: opts.connectResult ?? true } + }, + disconnect: async ({ name }) => { + calls.disconnect.push(name) + opts.onDisconnect?.(name) + return { data: opts.disconnectResult ?? true } + }, + auth: { + authenticate: async ({ name, external }) => { + calls.authenticate.push({ name, external }) + if (opts.authenticate) return opts.authenticate(name) + return { data: { status: "connected" }, response: { ok: true, status: 200 } } + }, + cancel: async ({ name }) => { + calls.authCancel.push(name) + opts.onAuthCancel?.(name) + return { response: { ok: opts.authCancelOk ?? true, status: opts.authCancelOk === false ? 404 : 200 } } + }, + remove: async ({ name }) => { + calls.authRemove.push(name) + opts.onAuthRemove?.(name) + return { response: { ok: opts.authRemoveOk ?? true, status: opts.authRemoveOk === false ? 404 : 200 } } + }, + }, + }, + } + + const listeners = new Set<(event: unknown, directory?: string) => void>() + const connection: McpAuthConnection = { + getClientAsync: async () => client as unknown as never, + onEvent: (listener) => { + listeners.add(listener as (event: unknown, directory?: string) => void) + return () => listeners.delete(listener as (event: unknown, directory?: string) => void) + }, + } + + return { + connection, + calls, + emit: (event: unknown, directory?: string) => { + for (const listener of listeners) listener(event, directory) + }, + } +} + +function neverResolves(): Promise { + return new Promise(() => {}) +} + +afterEach(() => mock.restore()) + +describe("McpAuthService.refresh", () => { + it("collects only needs_auth servers for a directory", async () => { + const { connection } = fakeConnection({ + status: { + anaconda: { status: "needs_auth" }, + ok: { status: "connected" }, + broken: { status: "failed", error: "Connection refused" }, + }, + }) + const service = new McpAuthService(connection) + const names = await service.refresh("/test") + expect(names).toEqual(["anaconda"]) + expect(service.needsAuth("/test")).toEqual(["anaconda"]) + }) + + it("shares one status request across concurrent auth refreshes", async () => { + const { connection, calls } = fakeConnection({ status: { anaconda: { status: "needs_auth" } } }) + const service = new McpAuthService(connection) + + const [first, second] = await Promise.all([service.refreshStatus("/test"), service.refreshStatus("/test")]) + + expect(first).toEqual({ anaconda: { status: "needs_auth" } }) + expect(second).toEqual(first) + expect(calls.status).toBe(1) + }) + + it("returns empty and does not call the client for a blank directory", async () => { + const { connection, calls } = fakeConnection({ status: { anaconda: { status: "needs_auth" } } }) + const service = new McpAuthService(connection) + const names = await service.refresh("") + expect(names).toEqual([]) + expect(calls.status).toBe(0) + }) + + it("bounds directory state to 64 entries, evicting the oldest", async () => { + const { connection } = fakeConnection({ status: { anaconda: { status: "needs_auth" } } }) + const service = new McpAuthService(connection) + for (let i = 0; i < 65; i++) await service.refresh(`/test/${i}`) + expect(service.needsAuth("/test/0")).toEqual([]) + expect(service.needsAuth("/test/1")).toEqual(["anaconda"]) + expect(service.needsAuth("/test/64")).toEqual(["anaconda"]) + }) + + it("preserves prior needs-auth state when status refresh fails", async () => { + let fail = false + const { connection } = fakeConnection({ + status: async () => { + if (fail) throw new Error("temporary failure") + return { anaconda: { status: "needs_auth" } } + }, + }) + const service = new McpAuthService(connection) + await service.refresh("/test") + fail = true + + expect(await service.refresh("/test")).toEqual(["anaconda"]) + expect(service.needsAuth("/test")).toEqual(["anaconda"]) + }) +}) + +describe("McpAuthService.signIn", () => { + it("returns connected and clears needs_auth after a trailing refresh", async () => { + const { connection, calls } = fakeConnection({ + status: { anaconda: { status: "connected" } }, + authenticate: async () => ({ data: { status: "connected" }, response: { ok: true, status: 200 } }), + }) + const service = new McpAuthService(connection) + const result = await service.signIn("/test", "anaconda") + expect(result).toEqual({ status: "connected", error: undefined }) + expect(calls.authenticate).toEqual([{ name: "anaconda", external: true }]) + expect(service.needsAuth("/test")).toEqual([]) + }) + + it("is single-flight per directory and name", async () => { + const { connection, calls } = fakeConnection({ + status: {}, + authenticate: () => neverResolves(), + }) + const service = new McpAuthService(connection, { timeout: 50_000 }) + const first = service.signIn("/test", "anaconda") + const second = await service.signIn("/test", "anaconda") + expect(second).toEqual({ status: "failed" }) + expect(calls.authenticate.length).toBe(1) + // Don't await `first` to completion (it would need the 50s timeout); just + // confirm the single-flight guard, not the eventual outcome. + void first + }) + + it("cancels auth without removing credentials and reports timeout when authenticate never returns", async () => { + const { connection, calls } = fakeConnection({ + status: {}, + authenticate: () => neverResolves(), + }) + const service = new McpAuthService(connection, { timeout: 20 }) + const result = await service.signIn("/test", "anaconda") + expect(result).toEqual({ status: "timeout" }) + expect(calls.authCancel).toEqual(["anaconda"]) + expect(calls.authRemove).toEqual([]) + }) + + it("rewrites a quietly cancelled sign-in to cancelled, and a subsequent sign-in still succeeds", async () => { + const pendingResolvers: Array< + (value: { data: { status: string }; response: { ok: boolean; status: number } }) => void + > = [] + const { connection } = fakeConnection({ + status: {}, + authenticate: () => + new Promise((resolve) => { + pendingResolvers.push(resolve) + }), + }) + const service = new McpAuthService(connection, { timeout: 50_000 }) + const pending = service.signIn("/test", "anaconda") + await service.cancel("/test", "anaconda") + pendingResolvers[0]?.({ data: { status: "connected" }, response: { ok: true, status: 200 } }) + const result = await pending + expect(result).toEqual({ status: "cancelled" }) + + const second = service.signIn("/test", "anaconda") + await Promise.resolve() + pendingResolvers[1]?.({ data: { status: "connected" }, response: { ok: true, status: 200 } }) + expect((await second).status).toBe("connected") + }) + + it("maps a 400 response to unsupported and a 404 to not_found", async () => { + const unsupported = fakeConnection({ + status: {}, + authenticate: async () => ({ error: { error: "no oauth" }, response: { ok: false, status: 400 } }), + }) + const serviceA = new McpAuthService(unsupported.connection) + expect(await serviceA.signIn("/test", "a")).toEqual({ status: "unsupported", error: "no oauth" }) + + const notFound = fakeConnection({ + status: {}, + authenticate: async () => ({ error: { error: "missing" }, response: { ok: false, status: 404 } }), + }) + const serviceB = new McpAuthService(notFound.connection) + expect(await serviceB.signIn("/test", "b")).toEqual({ status: "not_found", error: "missing" }) + }) +}) + +describe("McpAuthService.cancel", () => { + it("cancels the flow without removing stored credentials", async () => { + const { connection, calls } = fakeConnection({ status: {} }) + const service = new McpAuthService(connection) + const cancelled = await service.cancel("/test", "anaconda") + expect(cancelled).toBe(true) + expect(calls.authCancel).toEqual(["anaconda"]) + expect(calls.authRemove).toEqual([]) + }) +}) + +describe("McpAuthService.reset", () => { + it("removes credentials, disconnects, reconnects, and refreshes needs-auth state", async () => { + const { connection, calls } = fakeConnection({ status: { anaconda: { status: "needs_auth" } } }) + const service = new McpAuthService(connection) + const reconnected = await service.reset("/test", "anaconda") + expect(reconnected).toBe(true) + expect(calls.authRemove).toEqual(["anaconda"]) + expect(calls.disconnect).toEqual(["anaconda"]) + expect(calls.connect).toEqual(["anaconda"]) + expect(service.needsAuth("/test")).toEqual(["anaconda"]) + }) + + it("keeps needs-auth state when the reconnect fails", async () => { + const { connection } = fakeConnection({ status: {}, connectResult: false }) + const service = new McpAuthService(connection) + const reconnected = await service.reset("/test", "anaconda") + expect(reconnected).toBe(false) + expect(service.needsAuth("/test")).toEqual(["anaconda"]) + }) + + it("quietly cancels an active sign-in", async () => { + let resolveAuth: + | ((value: { data: { status: string }; response: { ok: boolean; status: number } }) => void) + | undefined + const { connection } = fakeConnection({ + status: {}, + authenticate: () => + new Promise((resolve) => { + resolveAuth = resolve + }), + }) + const service = new McpAuthService(connection, { timeout: 50_000 }) + const pending = service.signIn("/test", "anaconda") + await service.reset("/test", "anaconda") + resolveAuth?.({ data: { status: "connected" }, response: { ok: true, status: 200 } }) + expect(await pending).toEqual({ status: "cancelled" }) + }) +}) + +describe("McpAuthService browser-open-failed dedupe", () => { + it("collapses duplicate events for the same URL within the dedupe window", () => { + const urls: Array<{ name: string; url: string }> = [] + const { connection, emit } = fakeConnection({ status: {} }) + new McpAuthService(connection, { dedupe: 60_000, onUrl: (name, url) => urls.push({ name, url }) }) + const event = { type: "mcp.browser.open.failed", properties: { mcpName: "anaconda", url: "https://x/auth" } } + emit(event) + emit(event) + expect(urls.length).toBe(1) + }) + + it("opens for each distinct URL when the dedupe window is zero", () => { + const urls: Array<{ name: string; url: string }> = [] + const { connection, emit } = fakeConnection({ status: {} }) + new McpAuthService(connection, { dedupe: 0, onUrl: (name, url) => urls.push({ name, url }) }) + emit({ type: "mcp.browser.open.failed", properties: { mcpName: "anaconda", url: "https://x/auth?a=1" } }) + emit({ type: "mcp.browser.open.failed", properties: { mcpName: "anaconda", url: "https://x/auth?a=2" } }) + expect(urls.length).toBe(2) + }) +}) + +describe("McpAuthService auth URL handling", () => { + it("opens an owned auth URL through VS Code and ignores other directories", async () => { + const opened = spyOn(vscode.env, "openExternal").mockResolvedValue(true) + const urls: Array<{ name: string; url: string }> = [] + const auth = Promise.withResolvers<{ + data: { status: string } + response: { ok: boolean; status: number } + }>() + const { connection, emit } = fakeConnection({ status: {}, authenticate: () => auth.promise }) + const service = new McpAuthService(connection, { onUrl: (name, url) => urls.push({ name, url }) }) + const pending = service.signIn("/owned", "anaconda") + + emit({ type: "mcp.auth.url", properties: { mcpName: "anaconda", url: "https://x/ignored" } }, "/other") + emit({ type: "mcp.auth.url", properties: { mcpName: "other", url: "https://x/ignored" } }, "/owned") + emit({ type: "mcp.auth.url", properties: { mcpName: "anaconda", url: "https://x/auth" } }, "/owned") + await Bun.sleep(0) + + expect(opened).toHaveBeenCalledTimes(1) + expect(opened.mock.calls[0]?.[0]).toMatchObject({ fsPath: "https://x/auth" }) + expect(urls).toEqual([]) + auth.resolve({ data: { status: "connected" }, response: { ok: true, status: 200 } }) + await pending + }) + + it("uses onUrl only when VS Code declines or fails to open the URL", async () => { + const opened = spyOn(vscode.env, "openExternal") + .mockResolvedValueOnce(false) + .mockRejectedValueOnce(new Error("no browser")) + const urls: Array<{ name: string; url: string }> = [] + const auth = Promise.withResolvers<{ + data: { status: string } + response: { ok: boolean; status: number } + }>() + const { connection, emit } = fakeConnection({ status: {}, authenticate: () => auth.promise }) + const service = new McpAuthService(connection, { + dedupe: 0, + onUrl: (name, url) => urls.push({ name, url }), + }) + const pending = service.signIn("/test", "anaconda") + + emit({ type: "mcp.auth.url", properties: { mcpName: "anaconda", url: "https://x/declined" } }, "/test") + emit({ type: "mcp.auth.url", properties: { mcpName: "anaconda", url: "https://x/failed" } }, "/test") + await Bun.sleep(0) + + expect(urls).toEqual([ + { name: "anaconda", url: "https://x/declined" }, + { name: "anaconda", url: "https://x/failed" }, + ]) + auth.resolve({ data: { status: "connected" }, response: { ok: true, status: 200 } }) + await pending + }) + + it("deduplicates repeated owned auth URL events", async () => { + const opened = spyOn(vscode.env, "openExternal").mockResolvedValue(true) + const auth = Promise.withResolvers<{ + data: { status: string } + response: { ok: boolean; status: number } + }>() + const { connection, emit } = fakeConnection({ status: {}, authenticate: () => auth.promise }) + const service = new McpAuthService(connection, { dedupe: 60_000 }) + const pending = service.signIn("/test", "anaconda") + const event = { type: "mcp.auth.url", properties: { mcpName: "anaconda", url: "https://x/auth" } } + + emit(event, "/test") + emit(event, "/test") + await Bun.sleep(0) + + expect(opened).toHaveBeenCalledTimes(1) + auth.resolve({ data: { status: "connected" }, response: { ok: true, status: 200 } }) + await pending + }) +}) diff --git a/packages/kilo-vscode/tests/unit/mcp-auth-status.test.ts b/packages/kilo-vscode/tests/unit/mcp-auth-status.test.ts new file mode 100644 index 00000000000..f9585ba1b63 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/mcp-auth-status.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "bun:test" +import { needsAuthNames } from "../../src/services/mcp-auth/status" + +describe("needsAuthNames", () => { + it("returns only needs_auth servers, sorted", () => { + const names = needsAuthNames({ + zebra: { status: "needs_auth" }, + anaconda: { status: "failed", error: "Unauthorized: authentication required" }, + connected: { status: "connected" }, + disabled: { status: "disabled" }, + }) + expect(names).toEqual(["zebra"]) + }) + + it("returns an empty array when nothing needs auth", () => { + expect(needsAuthNames({ ok: { status: "connected" } })).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/mcp-oauth-config.test.ts b/packages/kilo-vscode/tests/unit/mcp-oauth-config.test.ts new file mode 100644 index 00000000000..0b184d5a681 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/mcp-oauth-config.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "bun:test" +import { + oauthMode, + oauthPatch, + validateOauth, + type OauthFields, +} from "../../webview-ui/src/components/settings/mcp-oauth-config" + +const EMPTY: OauthFields = { clientId: "", clientSecret: "", scope: "", callbackPort: "", redirectUri: "" } + +describe("oauthMode", () => { + it("maps undefined/null oauth to automatic", () => { + expect(oauthMode(undefined)).toBe("automatic") + expect(oauthMode(null)).toBe("automatic") + }) + + it("maps oauth: false to disabled", () => { + expect(oauthMode(false)).toBe("disabled") + }) + + it("maps any oauth object to custom", () => { + expect(oauthMode({})).toBe("custom") + expect(oauthMode({ clientId: "abc" })).toBe("custom") + }) +}) + +describe("oauthPatch", () => { + it("automatic produces null (explicit removal)", () => { + expect(oauthPatch("automatic", EMPTY)).toBeNull() + }) + + it("disabled produces false", () => { + expect(oauthPatch("disabled", EMPTY)).toBe(false) + }) + + it("custom produces an object with only non-blank fields, port coerced to a number", () => { + const patch = oauthPatch("custom", { + clientId: "abc", + clientSecret: "shh", + scope: "read", + callbackPort: "19999", + redirectUri: "", + }) + expect(patch).toEqual({ + clientId: "abc", + clientSecret: "shh", + scope: "read", + callbackPort: 19999, + redirectUri: undefined, + }) + }) + + it("custom with every field blank still produces an object (not null/false)", () => { + const patch = oauthPatch("custom", EMPTY) + expect(patch).toEqual({ + clientId: undefined, + clientSecret: undefined, + scope: undefined, + callbackPort: undefined, + redirectUri: undefined, + }) + }) +}) + +describe("validateOauth", () => { + it("skips validation outside custom mode", () => { + expect(validateOauth("automatic", { ...EMPTY, callbackPort: "999999" })).toEqual({}) + expect(validateOauth("disabled", { ...EMPTY, clientSecret: "shh" })).toEqual({}) + }) + + it("rejects an out-of-range callback port", () => { + expect(validateOauth("custom", { ...EMPTY, callbackPort: "0" }).callbackPort).toBeDefined() + expect(validateOauth("custom", { ...EMPTY, callbackPort: "65536" }).callbackPort).toBeDefined() + expect(validateOauth("custom", { ...EMPTY, callbackPort: "19999" }).callbackPort).toBeUndefined() + }) + + it("rejects a non-numeric callback port", () => { + expect(validateOauth("custom", { ...EMPTY, callbackPort: "abc" }).callbackPort).toBeDefined() + }) + + it("requires a client ID when a client secret is set", () => { + expect(validateOauth("custom", { ...EMPTY, clientSecret: "shh" }).secret).toBeDefined() + expect(validateOauth("custom", { ...EMPTY, clientId: "abc", clientSecret: "shh" }).secret).toBeUndefined() + }) + + it("rejects a non-absolute redirect URI", () => { + expect(validateOauth("custom", { ...EMPTY, redirectUri: "/callback" }).redirectUri).toBeDefined() + expect( + validateOauth("custom", { ...EMPTY, redirectUri: "http://127.0.0.1:19876/mcp/oauth/callback" }).redirectUri, + ).toBeUndefined() + }) + + it("passes with every field blank", () => { + expect(validateOauth("custom", EMPTY)).toEqual({}) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/mcp-removal-state.test.ts b/packages/kilo-vscode/tests/unit/mcp-removal-state.test.ts new file mode 100644 index 00000000000..8afa3a591ff --- /dev/null +++ b/packages/kilo-vscode/tests/unit/mcp-removal-state.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test" +import { createMcpAuth } from "../../webview-ui/src/context/mcp-auth" + +describe("createMcpAuth removal state", () => { + it("tracks removal progress and clears it on completion", () => { + const mcp = createMcpAuth({ post: () => {}, connected: () => true }) + + expect(mcp.accept({ type: "mcpRemovalState", name: "anaconda", removing: true })).toBe(true) + expect(mcp.removing()).toEqual(["anaconda"]) + + expect(mcp.accept({ type: "mcpRemoved", name: "anaconda" })).toBe(true) + expect(mcp.removing()).toEqual([]) + }) + + it("does not duplicate removal state", () => { + const mcp = createMcpAuth({ post: () => {}, connected: () => true }) + const message = { type: "mcpRemovalState" as const, name: "anaconda", removing: true } + + mcp.accept(message) + mcp.accept(message) + + expect(mcp.removing()).toEqual(["anaconda"]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/mcp-removal.test.ts b/packages/kilo-vscode/tests/unit/mcp-removal.test.ts new file mode 100644 index 00000000000..ed970877ec8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/mcp-removal.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test" +import type { KiloConnectionService } from "../../src/services/cli-backend/connection-service" +import { mcpRemoval } from "../../src/services/mcp-removal" + +function connection(): KiloConnectionService { + return {} as KiloConnectionService +} + +describe("mcpRemoval", () => { + it("shares removal events across consumers of one connection", () => { + const conn = connection() + const events: string[] = [] + const unsubscribe = mcpRemoval(conn).on((event) => events.push(`${event.phase}:${event.name}`)) + + mcpRemoval(conn).emit({ directory: "/workspace", name: "anaconda", phase: "removing" }) + mcpRemoval(conn).emit({ directory: "/workspace", name: "anaconda", phase: "removed" }) + mcpRemoval(conn).emit({ directory: "/workspace", name: "anaconda", phase: "idle" }) + + expect(events).toEqual(["removing:anaconda", "removed:anaconda", "idle:anaconda"]) + unsubscribe() + }) + + it("isolates connections and honors unsubscribe", () => { + const first = connection() + const second = connection() + const events: string[] = [] + const unsubscribe = mcpRemoval(first).on((event) => events.push(event.name)) + + mcpRemoval(second).emit({ directory: "/workspace", name: "other", phase: "removed" }) + unsubscribe() + mcpRemoval(first).emit({ directory: "/workspace", name: "anaconda", phase: "removed" }) + + expect(events).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/model-selection.test.ts b/packages/kilo-vscode/tests/unit/model-selection.test.ts index 3fd9fe8d6e7..bdc9ca2ddb8 100644 --- a/packages/kilo-vscode/tests/unit/model-selection.test.ts +++ b/packages/kilo-vscode/tests/unit/model-selection.test.ts @@ -1,208 +1,110 @@ import { describe, expect, it } from "bun:test" import { resolveModelSelection } from "../../webview-ui/src/context/model-selection" -import { KILO_AUTO, parseModelString } from "../../src/shared/provider-model" import type { ModelSelection, Provider } from "../../webview-ui/src/types/messages" -function makeProvider(id: string, name: string, modelIds: string[]): Provider { - const models: Provider["models"] = {} - for (const modelID of modelIds) { - models[modelID] = { id: modelID, name: modelID } +function makeProvider(id: string, models: string[]): Provider { + const result: Provider = { id, name: id, models: {} } + for (const m of models) { + result.models[m] = { id: m, name: m } } - return { id, name, models } + return result } -const providers = { - kilo: makeProvider("kilo", "Kilo Gateway", ["kilo-auto/free"]), - anthropic: makeProvider("anthropic", "Anthropic", ["claude-sonnet-4"]), - openai: makeProvider("openai", "OpenAI", ["gpt-4.1"]), +const KILO_AUTO: ModelSelection = { providerID: "kilo", modelID: "kilo-auto/free" } + +const providers: Record = { + kilo: makeProvider("kilo", ["kilo-auto/free", "z-first", "z-recommended"]), + anthropic: makeProvider("anthropic", ["claude-sonnet-4"]), + openai: makeProvider("openai", ["gpt-4.1"]), } -describe("parseModelString", () => { - it("parses provider/model pairs", () => { - expect(parseModelString("anthropic/claude-sonnet-4")).toEqual({ - providerID: "anthropic", - modelID: "claude-sonnet-4", - }) - }) +const claude: ModelSelection = { providerID: "anthropic", modelID: "claude-sonnet-4" } +const gpt: ModelSelection = { providerID: "openai", modelID: "gpt-4.1" } +const first: ModelSelection = { providerID: "kilo", modelID: "z-first" } +const recommended: ModelSelection = { providerID: "kilo", modelID: "z-recommended" } - it("keeps slashes inside kilo model ids", () => { - expect(parseModelString("kilo/kilo-auto/free")).toEqual({ - providerID: "kilo", - modelID: "kilo-auto/free", - }) - }) - - it("returns null for invalid values", () => { - expect(parseModelString(undefined)).toBeNull() - expect(parseModelString("claude-sonnet-4")).toBeNull() - }) -}) +function base() { + return { + providers, + connected: ["kilo", "anthropic", "openai"], + ready: true, + organizationId: null, + fallback: KILO_AUTO, + } +} describe("resolveModelSelection", () => { - it("prefers a valid override", () => { - const result = resolveModelSelection({ - providers, - connected: ["anthropic", "openai"], - override: { providerID: "openai", modelID: "gpt-4.1" }, - mode: { providerID: "anthropic", modelID: "claude-sonnet-4" }, - fallback: KILO_AUTO, - }) - expect(result).toEqual({ providerID: "openai", modelID: "gpt-4.1" }) + it("returns null when providers are pending", () => { + const result = resolveModelSelection({ ...base(), ready: false, session: first }) + expect(result).toBeNull() }) - it("falls back from an invalid override to the mode model", () => { - const result = resolveModelSelection({ - providers, - connected: ["anthropic"], - override: { providerID: "openai", modelID: "gpt-4.1" }, - mode: { providerID: "anthropic", modelID: "claude-sonnet-4" }, - fallback: KILO_AUTO, - }) - expect(result).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4" }) + it("returns null when organization is pending", () => { + const result = resolveModelSelection({ ...base(), ready: true, organizationId: undefined, session: first }) + expect(result).toBeNull() }) - it("falls back from invalid config to the first valid recent model", () => { - const result = resolveModelSelection({ - providers, - connected: ["openai"], - mode: { providerID: "anthropic", modelID: "claude-sonnet-4" }, - recent: [ - { providerID: "anthropic", modelID: "claude-sonnet-4" }, - { providerID: "openai", modelID: "gpt-4.1" }, - ], - fallback: KILO_AUTO, - }) - expect(result).toEqual({ providerID: "openai", modelID: "gpt-4.1" }) + it("returns the session pick when set", () => { + const result = resolveModelSelection({ ...base(), session: claude, mode: gpt, global: first }) + expect(result).toEqual(claude) }) - it("uses kilo auto as the explicit final fallback", () => { + it("returns the mode model over the global model", () => { + const result = resolveModelSelection({ ...base(), mode: gpt, global: first }) + expect(result).toEqual(gpt) + }) + + it("returns the global model when no session or mode is set", () => { + const result = resolveModelSelection({ ...base(), global: gpt }) + expect(result).toEqual(gpt) + }) + + it("returns the org recommendation for org users", () => { + const result = resolveModelSelection({ ...base(), organizationId: "org-a", defaults: { kilo: "z-recommended" } }) + expect(result).toEqual(recommended) + }) + + it("skips recents for org users", () => { const result = resolveModelSelection({ - providers, - connected: [], - fallback: KILO_AUTO, + ...base(), + organizationId: "org-a", + defaults: { kilo: "z-recommended" }, + recent: [claude], }) + expect(result).toEqual(recommended) + }) + + it("returns the first kilo model for org users when no recommendation exists", () => { + const result = resolveModelSelection({ ...base(), organizationId: "org-a", recent: [claude] }) expect(result).toEqual(KILO_AUTO) }) - it("rejects a fallback missing from the loaded catalog", () => { + it("returns the first valid recent model for non-org users", () => { const result = resolveModelSelection({ - providers: { openai: providers.openai }, - connected: [], - fallback: KILO_AUTO, + ...base(), + recent: [{ providerID: "anthropic", modelID: "missing" }, claude, gpt], + }) + expect(result).toEqual(claude) + }) + + it("returns the fallback when nothing else matches", () => { + const result = resolveModelSelection({ ...base() }) + expect(result).toEqual(KILO_AUTO) + }) + + it("pending kilo models are hidden until the catalog is ready", () => { + const result = resolveModelSelection({ + ...base(), + ready: false, + organizationId: "org-a", + defaults: { kilo: "z-recommended" }, + session: recommended, }) expect(result).toBeNull() }) - it("does not treat an empty catalog as unvalidated preferences", () => { - const result = resolveModelSelection({ - providers: {}, - connected: [], - override: { providerID: "openai", modelID: "gpt-4.1" }, - mode: { providerID: "anthropic", modelID: "claude-sonnet-4" }, - fallback: KILO_AUTO, - }) - expect(result).toBeNull() - }) -}) - -describe("organization model selection", () => { - const first = { providerID: "kilo", modelID: "z-first" } - const recommendation = { providerID: "kilo", modelID: "a-default" } - const recent = { providerID: "kilo", modelID: "older-recent" } - const external = { providerID: "openai", modelID: "gpt-4.1" } - const input = { - providers: { - ...providers, - kilo: makeProvider("kilo", "Kilo Gateway", [ - first.modelID, - recommendation.modelID, - recent.modelID, - "kilo-auto/free", - ]), - }, - connected: ["openai"], - ready: true, - organizationId: "org-a", - defaults: { kilo: recommendation.modelID }, - recent: [{ providerID: "kilo", modelID: "missing-recent" }, recent, external], - fallback: KILO_AUTO, - } - - it("uses the recommendation for fresh Org login instead of recents or the generic fallback", () => { - expect(resolveModelSelection(input)).toEqual(recommendation) - }) - - it.each([undefined, "", "unavailable"])("uses catalog order for an absent or invalid default %s", (model) => { - expect(resolveModelSelection({ ...input, defaults: model === undefined ? {} : { kilo: model } })).toEqual(first) - }) - - it.each(["session", "override", "mode", "global"] as const)( - "preserves a valid %s before the recommendation", - (key) => { - expect(resolveModelSelection({ ...input, [key]: KILO_AUTO })).toEqual(KILO_AUTO) - }, - ) - - it("validates session, manual, mode, and global preferences in order", () => { - const missing = { providerID: "kilo", modelID: "missing" } - const choices = { session: KILO_AUTO, override: recent, mode: first, global: external } - expect(resolveModelSelection({ ...input, ...choices })).toEqual(KILO_AUTO) - expect(resolveModelSelection({ ...input, ...choices, session: missing })).toEqual(recent) - expect(resolveModelSelection({ ...input, ...choices, session: missing, override: missing })).toEqual(first) - expect(resolveModelSelection({ ...input, ...choices, session: missing, override: missing, mode: missing })).toEqual( - external, - ) - expect( - resolveModelSelection({ ...input, session: missing, override: missing, mode: missing, global: missing }), - ).toEqual(recommendation) - }) - - it("preserves explicitly configured external providers only while connected", () => { - expect(resolveModelSelection({ ...input, override: external })).toEqual(external) - expect(resolveModelSelection({ ...input, connected: [], override: external })).toEqual(recommendation) - }) - - it.each([{}, { kilo: makeProvider("kilo", "Kilo Gateway", []) }, { openai: providers.openai }])( - "does not fall back to free models or external recents for an empty Org catalog", - (catalog) => { - expect(resolveModelSelection({ ...input, providers: catalog, override: KILO_AUTO })).toBeNull() - }, - ) - - it("keeps explicit external models available with an empty Org catalog", () => { - expect(resolveModelSelection({ ...input, providers: { openai: providers.openai }, override: external })).toEqual( - external, - ) - }) - - it("does not trust a retained Kilo catalog while refresh or auth context is pending", () => { - for (const pending of [{ ready: false }, { organizationId: undefined }]) { - expect(resolveModelSelection({ ...input, ...pending, override: KILO_AUTO })).toBeNull() - expect(resolveModelSelection({ ...input, ...pending, override: external })).toEqual(external) - } - }) - - it("keeps Personal recents ahead of defaults and validates its final fallback", () => { - expect(resolveModelSelection({ ...input, organizationId: null })).toEqual(recent) - expect(resolveModelSelection({ ...input, organizationId: null, recent: [] })).toEqual(KILO_AUTO) - expect( - resolveModelSelection({ ...input, organizationId: null, recent: [], fallback: external, connected: [] }), - ).toBeNull() - }) - - it("restores the same explicit choice through Personal, Org A, Org B, and Personal", () => { - const override: ModelSelection = { providerID: "kilo", modelID: "personal" } - const personal = { - ...input, - organizationId: null, - providers: { kilo: makeProvider("kilo", "Kilo", [override.modelID]) }, - } - expect(resolveModelSelection({ ...personal, override })).toEqual(override) - expect(resolveModelSelection({ ...input, override })).toEqual(recommendation) - expect( - resolveModelSelection({ ...input, organizationId: "org-b", defaults: { kilo: first.modelID }, override }), - ).toEqual(first) - expect(resolveModelSelection({ ...personal, override })).toEqual(override) - expect(override).toEqual({ providerID: "kilo", modelID: "personal" }) + it("falls through invalid candidates to the fallback", () => { + const resolved = resolveModelSelection({ ...base(), session: { providerID: "unknown", modelID: "m" } }) + expect(resolved).toEqual(KILO_AUTO) }) }) diff --git a/packages/kilo-vscode/tests/unit/model-selector-lifecycle.test.ts b/packages/kilo-vscode/tests/unit/model-selector-lifecycle.test.ts new file mode 100644 index 00000000000..2202eb22261 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/model-selector-lifecycle.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "bun:test" +import path from "node:path" +import { Project, SyntaxKind } from "ts-morph" + +const root = path.resolve(import.meta.dir, "../..") +const project = new Project({ useInMemoryFileSystem: true }) +const source = project.createSourceFile( + "ModelSelector.tsx", + await Bun.file(path.join(root, "webview-ui/src/components/shared/ModelSelector.tsx")).text(), +) +const body = source + .getVariableDeclarationOrThrow("ModelSelectorBase") + .getInitializerIfKindOrThrow(SyntaxKind.ArrowFunction) + .getBody() + .asKindOrThrow(SyntaxKind.Block) +const helpers = source + .getStatements() + .filter((statement) => statement.getStart() < source.getVariableStatementOrThrow("ModelSelectorBase").getStart()) + .filter( + (statement) => statement.isKind(SyntaxKind.VariableStatement) || statement.isKind(SyntaxKind.FunctionDeclaration), + ) + .map((statement) => statement.getText()) + .join("\n") +// Run the component's real reactive setup and handlers, without compiling its JSX or loading the renderer graph. +const setup = new Bun.Transpiler({ loader: "ts" }).transformSync(` + ${helpers} + function selector(props) { + ${body + .getStatements() + .filter((statement) => !statement.isKind(SyntaxKind.ReturnStatement)) + .map((statement) => statement.getText()) + .join("\n")} + return { open, setOpen, search, setSearch, activeModel, hasProviders, canOpen, triggerLabel, + visibleModels, filtered, favoriteKeys, favoriteModels, groups, rows, nodes, + nodeMap, nodeIndex, rowMap, mounted, selectedKey, previewModel, handleKeyDown, toggleGroup } + } +`) + +function check(code: string) { + const script = ` + import assert from "node:assert/strict" + import { dirname, join } from "node:path" + const solid = join(dirname(require.resolve("solid-js")), "solid.js") + const { createRoot, createSignal, createMemo, createEffect, createUniqueId, createSelector, onCleanup, untrack } = await import(solid) + const { KILO_GATEWAY_ID, isSmall, providerSortKey, isFree, isDataCollectedModel, hasByok, isAuto, + freeDataLabel, autoSummary, buildTriggerLabel, sanitizeName, mostUsedModels, rankModelSearch } = await import("./webview-ui/src/components/shared/model-selector-utils.ts") + const { isEnterKeyCommitNotIme } = await import("./webview-ui/src/utils/ime-enter.ts") + const window = new EventTarget() + const requestAnimationFrame = () => 1 + const cancelAnimationFrame = () => {} + const SessionContext = {} + const language = { t: (key) => key } + const vscode = { getModelSelectorExpanded: () => true, setModelSelectorExpanded: () => {} } + let provider + let session + const useProvider = () => provider + const useLanguage = () => language + const useVSCode = () => vscode + const useContext = () => session + ${setup} + const model = (id, providerID = "kilo") => ({ id, name: id[0].toUpperCase() + id.slice(1), providerID, providerName: providerID }) + function scene(opts = {}) { + const stats = { filters: 0, favorites: 0, usage: 0, recent: 0 } + function catalog(items) { + Object.defineProperty(items, "filter", { value(...args) { + stats.filters++ + return Array.prototype.filter.apply(this, args) + } }) + return items + } + const [models, setModels] = createSignal(catalog([model("alpha"), model("bravo"), model("charlie", "anthropic"), model("hidden", "offline"), model("kilo-auto/small")])) + const [connected, setConnected] = createSignal(["anthropic"]) + const [value, setValue] = createSignal({ providerID: "kilo", modelID: "alpha" }) + const [override, setOverride] = createSignal() + const [favorites, setFavorites] = createSignal([{ providerID: "kilo", modelID: "alpha" }]) + const [usage, setUsage] = createSignal({ "kilo/bravo": { count: 7, lastUsed: 10 } }) + const [recent, setRecent] = createSignal([]) + provider = { models, connected, findModel: (selection) => models().find((item) => item.providerID === selection?.providerID && item.id === selection?.modelID), kiloUnavailable: () => false } + session = { + favoriteModels: () => { stats.favorites++; return favorites() }, + modelUsageHistory: () => { stats.usage++; return usage() }, + recentModels: () => { stats.recent++; return recent() }, + } + const props = { get value() { return value() }, get models() { return override() }, allowClear: true, ...opts, + onSelect: (providerID, modelID) => setValue({ providerID, modelID }) } + const state = createRoot((dispose) => ({ ...selector(props), dispose })) + return { ...state, stats, value, setValue, setConnected, setOverride, setFavorites, setUsage, setRecent, + refresh: (items) => setModels(catalog(items)) } + } + const flush = async () => { await Promise.resolve(); await Promise.resolve() } + const key = (state, value) => state.handleKeyDown({ key: value, preventDefault() {} }) + const empty = (state) => { + for (const name of ["visibleModels", "filtered", "favoriteModels", "groups", "rows", "nodes", "mounted"]) assert.deepEqual(state[name](), [], name) + for (const name of ["favoriteKeys", "nodeMap", "nodeIndex", "rowMap"]) assert.equal(state[name]().size, 0, name) + assert.equal(state.previewModel(), null) + } + ${code} + ` + const child = Bun.spawnSync([process.execPath, "--conditions=browser", "-e", script], { + cwd: root, + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }) + expect(child.exitCode, child.stdout.toString() + child.stderr.toString()).toBe(0) +} + +describe("ModelSelector closed popup work", () => { + it("keeps trigger state reactive without catalog filtering or personalization work while closed", () => { + check(` + const state = scene() + try { + await flush() + empty(state) + assert.equal(state.triggerLabel(), "Alpha") + assert.equal(state.canOpen(), true) + assert.deepEqual(state.stats, { filters: 0, favorites: 0, usage: 0, recent: 0 }) + state.setValue({ providerID: "kilo", modelID: "bravo" }) + state.setFavorites([{ providerID: "kilo", modelID: "bravo" }]) + state.setUsage({ "kilo/alpha": { count: 9, lastUsed: 20 } }) + state.setRecent([{ providerID: "kilo", modelID: "bravo" }]) + state.refresh([model("bravo"), model("delta")]) + assert.equal(state.triggerLabel(), "Bravo") + empty(state) + assert.deepEqual(state.stats, { filters: 0, favorites: 0, usage: 0, recent: 0 }) + } finally { state.dispose() } + `) + }) + + it("preserves availability rules, constrained catalogs, and clearing an unavailable selection", () => { + check(` + const state = scene({ allowClear: false }) + try { + await flush() + state.setValue(null) + state.refresh([model("hidden", "offline"), model("kilo-auto/small")]) + assert.equal(state.hasProviders(), false) + assert.equal(state.canOpen(), false) + assert.equal(state.triggerLabel(), "dialog.model.noProviders") + state.setConnected(["offline"]) + assert.equal(state.hasProviders(), true) + assert.equal(state.canOpen(), true) + state.setOverride([]) + assert.equal(state.canOpen(), false) + state.setOverride([model("hidden", "offline")]) + assert.equal(state.canOpen(), true) + empty(state) + assert.equal(state.stats.filters, 0) + const small = scene({ includeAutoSmall: true }) + small.refresh([model("kilo-auto/small")]) + assert.equal(small.canOpen(), true) + small.dispose() + const clear = scene() + clear.refresh([]) + assert.equal(clear.hasProviders(), false) + assert.equal(clear.canOpen(), true) + clear.setValue(null) + assert.equal(clear.canOpen(), false) + clear.dispose() + } finally { state.dispose() } + `) + }) + + it("restores favorites, usage, search, preview, and keyboard selection on open and reopen", () => { + check(` + const state = scene() + try { + await flush() + state.setOpen(true) + await flush() + assert.ok(state.stats.filters > 0) + assert.deepEqual(state.groups().map((group) => group.key), ["favorites", "most-used", "kilo", "anthropic"]) + assert.equal(state.selectedKey(), "favorite:kilo/alpha") + assert.equal(state.previewModel()?.id, "alpha") + state.toggleGroup("favorites") + assert.equal(state.nodeMap().has("favorite:kilo/alpha"), false) + key(state, "ArrowRight") + assert.equal(state.nodeMap().has("favorite:kilo/alpha"), true) + key(state, "ArrowRight") + assert.equal(state.selectedKey(), "favorite:kilo/alpha") + state.setSearch("bravo") + assert.equal(state.selectedKey(), "model:kilo/bravo") + assert.equal(state.previewModel()?.id, "bravo") + assert.equal(state.value().modelID, "alpha") + key(state, "Enter") + assert.equal(state.value().modelID, "bravo") + assert.equal(state.open(), false) + empty(state) + const stats = { ...state.stats } + state.setFavorites([{ providerID: "kilo", modelID: "bravo" }]) + state.setUsage({ "kilo/alpha": { count: 9, lastUsed: 20 } }) + state.refresh([model("alpha"), model("bravo"), model("delta")]) + assert.deepEqual(state.stats, stats) + state.setOpen(true) + await flush() + assert.equal(state.search(), "") + assert.equal(state.selectedKey(), "favorite:kilo/bravo") + assert.equal(state.previewModel()?.id, "bravo") + assert.equal(state.nodeMap().has("model:kilo/delta"), true) + assert.deepEqual(state.groups().find((group) => group.key === "most-used").rows.map((row) => row.model.id), ["alpha"]) + key(state, "Escape") + assert.equal(state.value().modelID, "bravo") + empty(state) + } finally { state.dispose() } + `) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/model-state.test.ts b/packages/kilo-vscode/tests/unit/model-state.test.ts deleted file mode 100644 index f238dd06d2c..00000000000 --- a/packages/kilo-vscode/tests/unit/model-state.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { afterEach, describe, expect, it } from "bun:test" -import { link, mkdtemp, rm } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join } from "node:path" -import type { KiloClient } from "@kilocode/sdk/v2/client" -import type { PersistModelSelectionRequest } from "../../webview-ui/src/types/messages/webview-messages" - -const dirs: string[] = [] -const model = { providerID: "kilo", modelID: "model-a" } -const other = { providerID: "other", modelID: "model-b" } -const unrelated = { - recent: [other], - favorite: [model], - variant: { "kilo/model-a": "low" }, - custom: { nested: [1, "keep", null] }, -} - -function load(): Promise { - // Reload module-level path caching without replacing the persistence implementation. - return import(`../../src/kilo-provider/model-state.ts?${crypto.randomUUID()}`) -} - -async function fixture(data?: unknown, prefs?: unknown) { - const dir = await mkdtemp(join(tmpdir(), "kilo-model-state-")) - dirs.push(dir) - const file = join(dir, "model.json") - const preference = join(dir, "vscode-model.json") - if (data !== undefined) await Bun.write(file, JSON.stringify(data)) - if (prefs !== undefined) await Bun.write(preference, JSON.stringify(prefs)) - const client = { path: { get: async () => ({ data: { state: dir } }) } } as unknown as KiloClient - const host = await load() - const messages: unknown[] = [] - const post = (message: unknown) => { - messages.push(message) - } - return { - dir, - get file() { - return Bun.file(file) - }, - get preference() { - return Bun.file(preference) - }, - client, - host, - post, - messages, - save: (selection: Omit) => - host.handleMessage("persistModelSelection", selection, client, post), - request: async () => { - expect(await host.handleMessage("requestModelSelections", {}, client, post)).toBe(true) - return messages.at(-1) - }, - } -} - -afterEach(async () => { - await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) -}) - -describe("model-state", () => { - it("persists a preferred model and effort together while preserving other modes and unrelated fields", async () => { - const state = await fixture({ ...unrelated, model: { code: other, plan: other } }) - - expect(await state.save({ agent: "code", ...model, variant: "high" })).toBe(true) - - expect(await state.file.json()).toEqual({ - ...unrelated, - model: { code: model, plan: other }, - }) - expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "high" } }) - expect(await state.request()).toEqual({ - type: "modelSelectionsLoaded", - selections: { code: model, plan: other }, - preferred: { ...model, variant: "high" }, - }) - }) - - it.each([undefined, ""])("saves an explicit default effort for variant %j", async (variant) => { - const state = await fixture({ model: { plan: other } }, { preferred: { ...other, variant: "high" } }) - - await state.save({ agent: "code", ...model, ...(variant === undefined ? {} : { variant }) }) - - expect(await state.file.json()).toEqual({ - model: { plan: other, code: model }, - }) - expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "" } }) - }) - - it("loads the saved combo after the host module reloads", async () => { - const state = await fixture() - await state.save({ agent: "code", ...model, variant: "high" }) - const host = await load() - - expect(await host.handleMessage("requestModelSelections", {}, state.client, state.post)).toBe(true) - - expect(state.messages).toEqual([ - { type: "modelSelectionsLoaded", selections: { code: model }, preferred: { ...model, variant: "high" } }, - ]) - }) - - it("atomically replaces the complete preferred combo without modifying the previous file", async () => { - const prefs = { preferred: { ...other, variant: "low" } } - const state = await fixture({ model: { plan: other } }, prefs) - const previous = join(state.dir, "previous.json") - await link(join(state.dir, "vscode-model.json"), previous) - - await state.save({ agent: "code", ...model, variant: "high" }) - - expect(await Bun.file(previous).json()).toEqual(prefs) - expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "high" } }) - }) - - it("loads legacy model memory without inventing a preferred combo", async () => { - const state = await fixture({ model: { code: model, bad: { providerID: 42, modelID: "invalid" } } }) - - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model } }) - }) - - it.each([undefined, "", "high"])( - "validates and sanitizes a loaded preferred combo with variant %j", - async (variant) => { - const preferred = { ...model, ...(variant === undefined ? {} : { variant }) } - const data = { model: { plan: other } } - const prefs = { preferred: { ...preferred, extra: "discard" } } - const state = await fixture(data, prefs) - - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { plan: other }, preferred }) - expect(await state.file.json()).toEqual(data) - expect(await state.preference.json()).toEqual(prefs) - }, - ) - - it.each( - [ - null, - true, - 42, - "invalid", - [], - {}, - { providerID: "kilo" }, - { modelID: "model-a" }, - { ...model, providerID: 42 }, - { ...model, providerID: "" }, - { ...model, modelID: null }, - { ...model, modelID: "" }, - { ...model, variant: null }, - { ...model, variant: 42 }, - { ...model, variant: false }, - { ...model, variant: {} }, - { ...model, variant: [] }, - ].map((preferred) => [preferred] as const), - )("omits invalid preferred data %j without discarding model memory", async (preferred) => { - const state = await fixture({ model: { code: model } }, { preferred }) - - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model } }) - }) - - it.each(["{", "null", "[]", '"invalid"'])("recovers from invalid model.json contents %s", async (raw) => { - const preferred = { ...model, variant: "high" } - const state = await fixture(undefined, { preferred }) - await Bun.write(state.file, raw) - - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: {}, preferred }) - await state.save({ agent: "code", ...model, variant: "high" }) - expect(await state.file.json()).toEqual({ model: { code: model } }) - expect(await state.preference.json()).toEqual({ preferred }) - }) - - it.each(["{", "null", "[]", '"invalid"'])("ignores invalid vscode-model.json contents %s", async (raw) => { - const state = await fixture({ model: { code: model } }) - await Bun.write(state.preference, raw) - - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model } }) - await state.save({ agent: "code", ...model, variant: "high" }) - expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "high" } }) - }) - - it.each([ - { agent: "", ...model }, - { agent: 42, ...model }, - { agent: "code", modelID: "model-a" }, - { agent: "code", ...model, modelID: null }, - { agent: "code", ...model, variant: null }, - { agent: "code", ...model, variant: 42 }, - ])("ignores invalid explicit selections %j without overwriting saved preferences", async (message) => { - const data = { ...unrelated, model: { plan: other } } - const prefs = { preferred: { ...other, variant: "high" } } - const state = await fixture(data, prefs) - - expect(await state.host.handleMessage("persistModelSelection", message, state.client, state.post)).toBe(true) - expect(await state.file.json()).toEqual(data) - expect(await state.preference.json()).toEqual(prefs) - }) - - it("returns empty memory for a missing file and creates it on an explicit save", async () => { - const state = await fixture() - - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: {} }) - expect(await state.file.exists()).toBe(false) - expect(await state.preference.exists()).toBe(false) - await state.save({ agent: "code", ...model }) - expect(await state.file.json()).toEqual({ model: { code: model } }) - expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "" } }) - }) - - it("clears model memory and preferred together while retaining unrelated fields", async () => { - const state = await fixture({ ...unrelated, model: { code: model } }, { preferred: { ...model, variant: "high" } }) - - await state.host.reset(state.client, state.post) - - expect(await state.file.json()).toEqual({ ...unrelated, model: {} }) - expect(await state.preference.json()).toEqual({}) - expect(state.messages).toEqual([{ type: "modelSelectionsLoaded", selections: {} }]) - const host = await load() - await host.handleMessage("requestModelSelections", {}, state.client, state.post) - expect(state.messages.at(-1)).toEqual({ type: "modelSelectionsLoaded", selections: {} }) - }) - - it("preserves every model entry during concurrent saves and keeps the latest preferred combo", async () => { - const state = await fixture({ ...unrelated, model: { existing: other } }) - const choices = Array.from({ length: 12 }, (_, index) => ({ - agent: `mode-${index}`, - providerID: "kilo", - modelID: `model-${index}`, - variant: `effort-${index}`, - })) - - await Promise.all([ - ...choices.map((choice) => state.save(choice)), - state.save({ agent: "mode-0", ...other, variant: "" }), - ]) - - expect(await state.file.json()).toEqual({ - ...unrelated, - model: { - existing: other, - ...Object.fromEntries( - choices.map((choice) => [choice.agent, { providerID: choice.providerID, modelID: choice.modelID }]), - ), - "mode-0": other, - }, - }) - expect(await state.preference.json()).toEqual({ preferred: { ...other, variant: "" } }) - }) - - it("waits for pending persistence before returning model selections", async () => { - const state = await fixture({ model: { plan: other } }) - - const saving = state.save({ agent: "code", ...model, variant: "high" }) - const loaded = await state.request() - await saving - - expect(loaded).toEqual({ - type: "modelSelectionsLoaded", - selections: { plan: other, code: model }, - preferred: { ...model, variant: "high" }, - }) - }) - - it("orders reset with pending saves without resurrecting cleared model memory", async () => { - const state = await fixture({ ...unrelated, model: { existing: other } }) - - await Promise.all([ - state.save({ agent: "code", ...model, variant: "high" }), - state.host.reset(state.client, state.post), - state.save({ agent: "plan", ...other }), - ]) - - expect(await state.file.json()).toEqual({ - ...unrelated, - model: { plan: other }, - }) - expect(await state.preference.json()).toEqual({ preferred: { ...other, variant: "" } }) - }) - - it.each(["high", ""])("retains preferred effort %j after a TUI rewrite of shared model state", async (variant) => { - const state = await fixture() - const preferred = { ...model, variant } - await state.save({ agent: "code", ...preferred }) - const before = await state.preference.text() - const shared = { model: { plan: other }, recent: [other], favorite: [], variant: { "other/model-b": "low" } } - - await Bun.write(state.file, JSON.stringify(shared)) - const host = await load() - await host.handleMessage("requestModelSelections", {}, state.client, state.post) - - expect(state.messages).toEqual([{ type: "modelSelectionsLoaded", selections: { plan: other }, preferred }]) - expect(await state.preference.text()).toBe(before) - expect(await state.file.json()).toEqual(shared) - }) - - it("does not read preferred metadata from the shared TUI file", async () => { - const state = await fixture({ model: { code: model }, preferred: { ...other, variant: "high" } }) - - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model } }) - }) - - it("keeps saving to the resolved state directory during a temporary disconnect", async () => { - const state = await fixture() - await state.request() - await state.host.handleMessage( - "persistModelSelection", - { agent: "code", ...model, variant: "high" }, - null, - state.post, - ) - expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "high" } }) - await state.host.handleMessage("requestModelSelections", {}, null, state.post) - expect(state.messages.at(-1)).toEqual({ - type: "modelSelectionsLoaded", - selections: { code: model }, - preferred: { ...model, variant: "high" }, - }) - }) - - it("defers a cold-start request without a client until a ready-client retry", async () => { - const preferred = { ...model, variant: "high" } - const state = await fixture({ model: { code: model } }, { preferred }) - - expect(await state.host.handleMessage("requestModelSelections", {}, null, state.post)).toBe(true) - expect(state.messages).toEqual([]) - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model }, preferred }) - }) - - it.each(["missing", "error"])("defers loading when the CLI state path is unavailable (%s)", async (reason) => { - const preferred = { ...model, variant: "high" } - const state = await fixture({ model: { code: model } }, { preferred }) - const client = { - path: { - get: async () => { - if (reason === "error") throw new Error("CLI is not ready") - return { data: {} } - }, - }, - } as unknown as KiloClient - - expect(await state.host.handleMessage("requestModelSelections", {}, client, state.post)).toBe(true) - expect(state.messages).toEqual([]) - expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model }, preferred }) - }) -}) diff --git a/packages/kilo-vscode/tests/unit/model-usage.test.ts b/packages/kilo-vscode/tests/unit/model-usage.test.ts index 4bdb344a4eb..e4d427f49af 100644 --- a/packages/kilo-vscode/tests/unit/model-usage.test.ts +++ b/packages/kilo-vscode/tests/unit/model-usage.test.ts @@ -1,11 +1,12 @@ import { describe, expect, test } from "bun:test" -import type { Provider, SessionModelUsage } from "../../webview-ui/src/types/messages" +import type { Message, Provider, SessionModelUsage } from "../../webview-ui/src/types/messages" import { cacheRate, groupModelUsage, hasModelUsage, isSameSessionTree, modelUsageName, + sessionModel, tokenSummary, } from "../../webview-ui/src/context/model-usage" @@ -68,4 +69,28 @@ describe("model usage", () => { expect(isSameSessionTree("child", "new", get, "sibling")).toBeTrue() expect(isSameSessionTree("child", "other", get)).toBeFalse() }) + + test("reads the model and variant of the latest message", () => { + const user = { + id: "u1", + sessionID: "child", + role: "user", + createdAt: "2026-01-01T00:00:00.000Z", + model: { providerID: "kilo", modelID: "openai/gpt-5", variant: "high" }, + } satisfies Message + const assistant = { + id: "a1", + sessionID: "child", + role: "assistant", + createdAt: "2026-01-01T00:00:01.000Z", + providerID: "minimax", + modelID: "minimax-m3", + variant: "low", + } satisfies Message + + expect(sessionModel([])).toBeUndefined() + expect(sessionModel([user])).toEqual({ providerID: "kilo", modelID: "openai/gpt-5", variant: "high" }) + expect(sessionModel([user, assistant])).toEqual({ providerID: "minimax", modelID: "minimax-m3", variant: "low" }) + expect(sessionModel([user, { ...assistant, providerID: undefined }])).toEqual(user.model) + }) }) diff --git a/packages/kilo-vscode/tests/unit/navigate.test.ts b/packages/kilo-vscode/tests/unit/navigate.test.ts index 6d0a29cee33..0d8b0b5f859 100644 --- a/packages/kilo-vscode/tests/unit/navigate.test.ts +++ b/packages/kilo-vscode/tests/unit/navigate.test.ts @@ -13,8 +13,8 @@ import { type ProjectNavInput, LOCAL, } from "../../webview-ui/agent-manager/navigate" -import { createProjectNav, type NavTarget } from "../../webview-ui/agent-manager/project-nav" -import type { SidebarItem } from "../../webview-ui/agent-manager/section-helpers" +import { createProjectNav } from "../../webview-ui/agent-manager/project-nav" +import type { NavTarget } from "../../webview-ui/agent-manager/navigate" import type { AgentManagerStateMessage, AgentProjectSnapshot, @@ -257,6 +257,24 @@ describe("buildProjectNavOrder", () => { ]) }) + it("puts pinned worktrees before ungrouped and section worktrees", () => { + const order = buildProjectNavOrder([ + { + id: "A", + expanded: true, + worktrees: [{ id: "w1" }, { id: "w2", sectionId: "s1" }, { id: "w3", sectionId: "s1", pinned: true }], + worktreeOrder: ["w1", "w3", "s1", "w2"], + sections: [{ id: "s1", collapsed: false }], + }, + ]) + expect(order.map((e) => e.id)).toEqual([ + localNavId("A"), + worktreeNavId("A", "w3"), + worktreeNavId("A", "w1"), + worktreeNavId("A", "w2"), + ]) + }) + it("uses project-qualified composite ids, never raw worktree ids", () => { const order = buildProjectNavOrder([{ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [] }]) const ids = order.map((e) => e.id) @@ -505,17 +523,11 @@ describe("createProjectNav", () => { activeProjectId: string | undefined, currentSessionID: string | undefined, post: (t: NavTarget) => void = () => {}, - focus: (item: SidebarItem) => void = () => {}, - multiProject = true, - sidebarOrder: () => SidebarItem[] = () => [], ) => createRoot((dispose) => { fn( createProjectNav( { - multiProject: () => multiProject, - sidebarOrder, - focus, projects, states, activeProjectId: () => activeProjectId, @@ -586,45 +598,34 @@ describe("createProjectNav", () => { // Past the end — and C's collapsed worktree is never reachable by any index. expect(jumpOnce(4)).toBeUndefined() expect(jumpOnce(99)).toBeUndefined() + expect(jumpOnce(-1)).toBeUndefined() }) it("multi-project jump targets the global order by index (⌘3 = B Local)", () => { expect(jumpOnce(2)).toEqual({ projectId: "B", kind: "local" }) }) - it("single-project mode keeps the legacy in-process traversal and never posts activateSelection", () => { - const order: SidebarItem[] = [ - { type: "local", id: "local" }, - { type: "wt", id: "w1" }, - { type: "session", id: "s1" }, - ] - const focused: SidebarItem[] = [] - const posted: NavTarget[] = [] - const focus = (item: SidebarItem) => focused.push(item) - const post = (t: NavTarget) => posted.push(t) - run( - (nav) => nav.step("down"), - LOCAL, - "A", - undefined, - post, - focus, - false, - () => order, - ) - expect(focused.pop()).toEqual({ type: "wt", id: "w1" }) - run( - (nav) => nav.jump(2), - "w1", - "A", - undefined, - post, - focus, - false, - () => order, - ) - expect(focused.pop()).toEqual({ type: "session", id: "s1" }) - // Legacy path never dispatches the multi-project activation message. - expect(posted).toEqual([]) + it("uses project activation even with only the default project", () => { + createRoot((dispose) => { + const posted: NavTarget[] = [] + const nav = createProjectNav( + { + projects: () => [project("A", true)], + states, + activeProjectId: () => "A", + selection: () => LOCAL, + currentSessionID: () => undefined, + }, + (target) => posted.push(target), + () => {}, + ) + nav.step("down") + nav.jump(0) + expect(posted).toEqual([ + { projectId: "A", kind: "worktree", worktreeId: "aw1" }, + { projectId: "A", kind: "local" }, + ]) + dispose() + }) }) }) diff --git a/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts b/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts index c4abe00da7a..8ed3e6969b9 100644 --- a/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts +++ b/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts @@ -93,8 +93,6 @@ function check(code: string) { function dialog(saved = {}, initial = { providers: catalog(x, y), fallback: y, alternate: y, ready: true, connected: [] }) { const result = createRoot((dispose) => { const [snapshot, refresh] = createSignal(initial) - const [compareMode, setCompareMode] = createSignal(false) - const preferences = [] const state = createDialogPreferences({ saved, agent: saved.agent ?? "code", @@ -103,16 +101,13 @@ function check(code: string) { variants: (value) => Object.keys(snapshot().providers[value.providerID]?.models[value.modelID]?.variants ?? {}), fallback: (name) => name === "code" ? snapshot().fallback : snapshot().alternate ?? null, effort: (name, model) => model ? snapshot().efforts?.[name + "/" + model.modelID] ?? snapshot().efforts?.[name] : undefined, - preferred: () => snapshot().preferred, - hydrated: () => snapshot().hydrated ?? true, - compare: compareMode, - remember: (...args) => preferences.push(args), }) return { - ...state, setCompareMode, preferences, + ...state, pick: state.selectModel, choose: state.selectVariant, clear: () => state.selectVariant(DEFAULT_VARIANT), + reset: state.clear, cached: state.saved, refresh: (update) => refresh((current) => ({ ...current, ...update })), dispose, } @@ -153,7 +148,6 @@ describe("NewWorktreeDialog models", () => { assert.deepEqual(state.model(), x) assert.deepEqual(state.cached(), { agent: "code", model: x, variant: "" }) assert.equal(state.selection.canSubmit(), true) - assert.deepEqual(state.preferences, [["code", x, ""]]) `) }) @@ -171,19 +165,37 @@ describe("NewWorktreeDialog models", () => { `) }) - it("keeps an explicit model and effort through real dialog mode switches", () => { + it("keeps an explicit model and effort per agent through mode switches", () => { check(` - const state = dialog({ model: x, variant: "high" }) + const state = dialog({ model: x, variant: "high" }, { + providers: catalog(x, y), fallback: x, alternate: y, ready: true, connected: [], + }) await Promise.resolve() + assert.deepEqual(state.model(), x) + assert.equal(state.effectiveVariant(), "high") state.selectAgent("plan") + assert.deepEqual(state.model(), y) + assert.equal(state.effectiveVariant(), undefined) + state.selectAgent("code") assert.deepEqual(state.model(), x) assert.equal(state.effectiveVariant(), "high") assert.deepEqual(state.cached().model, x) assert.equal(state.cached().variant, "high") - state.selectAgent("code") + `) + }) + + it("keeps picks per agent so switching modes and back restores the choice", () => { + check(` + const state = dialog({}, { + providers: catalog(x, y, z), fallback: x, alternate: z, ready: true, connected: [], + }) assert.deepEqual(state.model(), x) - assert.equal(state.effectiveVariant(), "high") - assert.deepEqual(state.preferences, []) + state.pick("kilo", "y") + state.selectAgent("plan") + assert.deepEqual(state.model(), z) + state.selectAgent("code") + assert.deepEqual(state.model(), y) + assert.deepEqual(state.cached().model, y) `) }) @@ -197,67 +209,60 @@ describe("NewWorktreeDialog models", () => { }) state.pick("kilo", "y") assert.equal(state.variant(), ${JSON.stringify(expected)}) - assert.deepEqual(state.preferences, [["code", y, ${JSON.stringify(expected)}]]) `) }) - it("pins the displayed inherited model and effort on mode switch without saving a model preference", () => { + it("switches to the target agent's default when it has no dialog pick", () => { check(` const state = dialog({}, { providers: catalog(x, y), fallback: x, alternate: y, ready: true, connected: [], - efforts: { code: "high", plan: undefined }, + efforts: { code: "high" }, }) await Promise.resolve() - state.selectAgent("plan") assert.deepEqual(state.model(), x) assert.equal(state.effectiveVariant(), "high") - state.refresh({ providers: catalog(y) }) + state.selectAgent("plan") assert.deepEqual(state.model(), y) + assert.equal(state.effectiveVariant(), undefined) state.selectAgent("code") - state.refresh({ providers: catalog(x, y) }) assert.deepEqual(state.model(), x) + assert.equal(state.effectiveVariant(), "high") assert.equal(state.selection.choice(), undefined) assert.equal(state.cached().model, undefined) - assert.deepEqual(state.preferences, []) `) }) - it.each([ - [undefined, "low"], - ["", undefined], - ["high", "high"], - ])("keeps outgoing effort %s distinct from an unset choice on mode switch", (value, expected) => { + it("resolves the target agent's configured effort when it has no pick", () => { check(` const providers = catalog(x) providers.kilo.models.x.variants = { low: {}, high: {} } - const state = dialog(${value === "" ? '{ variant: "" }' : "{}"}, { + const state = dialog({}, { providers, fallback: x, alternate: x, ready: true, connected: [], - efforts: { code: ${JSON.stringify(value)}, plan: "low" }, + efforts: { code: "high", plan: "low" }, }) await Promise.resolve() state.selectAgent("plan") - assert.equal(state.effectiveVariant(), ${JSON.stringify(expected)}) - assert.equal(state.variant(), ${JSON.stringify(value)}) - assert.deepEqual(state.preferences, []) + assert.equal(state.effectiveVariant(), "low") + assert.equal(state.variant(), undefined) + state.selectAgent("code") + assert.equal(state.effectiveVariant(), "high") + assert.equal(state.variant(), undefined) `) }) - it.each(["", "high"])("keeps inherited raw effort %s through catalog changes and a mode switch", (value) => { + it.each(["", "high"])("keeps inherited raw effort %s through catalog changes", (value) => { check(` const providers = catalog(x) providers.kilo.models.x.variants = { low: {} } const state = dialog({}, { providers, fallback: x, alternate: x, ready: true, connected: [], - efforts: { code: ${JSON.stringify(value)}, plan: "low" }, + efforts: { code: ${JSON.stringify(value)} }, }) await Promise.resolve() assert.equal(state.variant(), undefined) assert.equal(state.effectiveVariant(), ${value === "" ? "undefined" : '"low"'}) - state.selectAgent("plan") - assert.equal(state.variant(), ${JSON.stringify(value)}) state.refresh({ providers: catalog(x) }) assert.equal(state.effectiveVariant(), ${value === "" ? "undefined" : '"high"'}) - assert.deepEqual(state.preferences, []) `) }) @@ -275,7 +280,6 @@ describe("NewWorktreeDialog models", () => { reopened.refresh({ providers: catalog(x, y), ready: true }) assert.deepEqual(reopened.model(), x) assert.equal(reopened.effectiveVariant(), "high") - assert.deepEqual(reopened.preferences, []) `) }) @@ -291,9 +295,10 @@ describe("NewWorktreeDialog models", () => { assert.equal(state.cached().variant, "high") state.selectAgent("plan") state.refresh({ providers: catalog(x, y) }) + assert.deepEqual(state.model(), y) + state.selectAgent("code") assert.deepEqual(state.model(), x) assert.equal(state.effectiveVariant(), "high") - assert.deepEqual(state.preferences, []) `) }) @@ -309,139 +314,75 @@ describe("NewWorktreeDialog models", () => { state.pick("kilo", "z") assert.deepEqual(state.model(), z) assert.equal(state.effectiveVariant(), "high") - assert.deepEqual(state.preferences, [["code", z, "high"]]) `) }) - it("shares only explicit single-model picks and keeps explicit Default across mode switches", () => { + it("keeps an explicit Default per agent across mode switches", () => { check(` const state = dialog({}, { providers: catalog(x, y), fallback: x, alternate: y, ready: true, connected: [], efforts: { code: "high", plan: "high" }, }) await Promise.resolve() - assert.deepEqual(state.preferences, []) - state.pick("kilo", "y") - assert.deepEqual(state.preferences, [["code", y, "high"]]) state.clear() - assert.deepEqual(state.preferences.at(-1), ["code", y, ""]) + assert.equal(state.variant(), "") + assert.deepEqual(state.selection.choice(), x) state.selectAgent("plan") + assert.equal(state.effectiveVariant(), "high") + state.selectAgent("code") assert.equal(state.effectiveVariant(), undefined) assert.equal(state.variant(), "") - assert.equal(state.preferences.length, 2) - state.choose("high") - assert.deepEqual(state.preferences.at(-1), ["plan", y, "high"]) const reopened = dialog(state.cached()) await Promise.resolve() - assert.deepEqual(reopened.model(), y) - assert.equal(reopened.effectiveVariant(), "high") - assert.deepEqual(reopened.preferences, []) + assert.deepEqual(reopened.model(), x) + assert.equal(reopened.effectiveVariant(), undefined) + assert.equal(reopened.variant(), "") `) }) - it("starts with the latest shared preference rather than stale dialog choices without following later updates", () => { + it("uses the agent default and variant preference when no pick is saved", () => { check(` - const state = dialog({ model: x, variant: "high" }, { - providers: catalog(x, y), fallback: x, ready: true, connected: [], preferred: { ...y, variant: "" }, + const state = dialog({}, { + providers: catalog(x, y), fallback: x, ready: true, connected: [], efforts: { "code/y": "high" }, }) - await Promise.resolve() - assert.deepEqual(state.model(), y) + assert.deepEqual(state.model(), x) assert.equal(state.effectiveVariant(), undefined) - assert.equal(state.variant(), "") - state.refresh({ preferred: { ...x, variant: "high" } }) - assert.deepEqual(state.model(), y) - assert.equal(state.variant(), "") - assert.deepEqual(state.preferences, []) + const other = dialog({ agent: "plan" }, { + providers: catalog(x, y), fallback: x, alternate: y, ready: true, connected: [], efforts: { "plan/y": "high" }, + }) + assert.deepEqual(other.model(), y) + assert.equal(other.effectiveVariant(), "high") `) }) - it("adopts a delayed first hydrated preference once without following later shared updates", () => { + it("clears the saved model and effort after submit so reopening starts from config", () => { check(` - const providers = catalog(x, y, z) - providers.kilo.models.y.variants = { low: {}, high: {} } - const state = dialog({ model: x, variant: "high" }, { - providers, fallback: x, ready: true, connected: [], hydrated: false, - }) + const state = dialog() await Promise.resolve() - state.refresh({ preferred: { ...y, variant: "low" } }) - assert.deepEqual(state.model(), x) - assert.equal(state.effectiveVariant(), "high") - state.refresh({ hydrated: true }) - assert.deepEqual(state.model(), y) - assert.equal(state.effectiveVariant(), "low") - assert.deepEqual(state.cached().model, y) - assert.equal(state.cached().variant, "low") - state.refresh({ preferred: { ...z, variant: "high" } }) - assert.deepEqual(state.model(), y) - assert.equal(state.effectiveVariant(), "low") - assert.deepEqual(state.preferences, []) - `) - }) - - it("retains cached choices when first hydration has no preference and ignores a later preference", () => { - check(` - const state = dialog({ model: x, variant: "high" }, { - providers: catalog(x, y), fallback: y, ready: true, connected: [], hydrated: false, - }) - await Promise.resolve() - state.refresh({ hydrated: true }) - assert.deepEqual(state.model(), x) - assert.equal(state.effectiveVariant(), "high") - state.refresh({ preferred: { ...y, variant: "" } }) - assert.deepEqual(state.model(), x) - assert.equal(state.effectiveVariant(), "high") + state.pick("kilo", "x") assert.deepEqual(state.cached().model, x) - assert.equal(state.cached().variant, "high") - assert.deepEqual(state.preferences, []) + state.reset() + assert.deepEqual(state.cached(), { agent: "code", model: undefined, variant: undefined }) + assert.deepEqual(state.model(), y) `) }) - it.each(["model", "variant", "default", "mode"])( - "preserves a %s interaction before initial preferences arrive", - (action) => { - check(` - const state = dialog({ model: x, variant: "high" }, { - providers: catalog(x, y, z), fallback: x, alternate: y, ready: true, connected: [], hydrated: false, - }) - const actions = { - model: () => state.pick("kilo", "z"), - variant: () => state.choose("high"), - default: state.clear, - mode: () => state.selectAgent("plan"), - } - actions[${JSON.stringify(action)}]() - await Promise.resolve() - const expected = state.model() - const effort = state.variant() - const writes = state.preferences.length - state.refresh({ hydrated: true, preferred: { ...y, variant: "" } }) - assert.deepEqual(state.model(), expected) - assert.equal(state.variant(), effort) - assert.deepEqual(state.cached().model, expected) - assert.equal(state.cached().variant, effort) - assert.equal(state.preferences.length, writes) - `) - }, - ) - - it("pins a model on explicit effort selection but never shares comparison choices", () => { + it("pins a model on explicit effort selection and keeps the choice through refreshes", () => { check(` const state = dialog() await Promise.resolve() state.choose("high") - assert.deepEqual(state.preferences, [["code", y, "high"]]) + assert.deepEqual(state.selection.choice(), y) state.refresh({ fallback: x }) assert.deepEqual(state.model(), y) - state.setCompareMode(true) const allocations = setAllocationVariant(toggleModel(new Map(), "kilo", "x", "X"), "kilo", "x", "high") assert.equal(state.selection.canSubmit(allocations), true) - state.choose(undefined) - assert.equal(state.preferences.length, 1) state.selectAgent("plan") state.refresh({ ready: false }) state.refresh({ ready: true }) - assert.deepEqual(state.preferences, [["code", y, "high"]]) + state.selectAgent("code") assert.deepEqual(state.selection.choice(), y) + assert.equal(state.variant(), "high") `) }) @@ -461,20 +402,17 @@ describe("NewWorktreeDialog models", () => { `) }) - it("never saves automatic initial, agent, or refreshed organization defaults", () => { + it("follows the target agent's default on mode switch without saving a choice", () => { check(` const { state, refresh, switchAgent, seen } = scene(undefined) assert.deepEqual(state.model(), y) assert.equal(state.choice(), undefined) refresh({ providers: catalog(y, z), alternate: z }) - batch(() => { - state.retain() - switchAgent("plan") - }) - assert.deepEqual(state.model(), y) + switchAgent("plan") + assert.deepEqual(state.model(), z) assert.equal(state.choice(), undefined) refresh({ providers: catalog(x), alternate: x }) - assert.deepEqual(seen, [y, x]) + assert.deepEqual(seen, [y, z, x]) assert.equal(state.choice(), undefined) `) }) diff --git a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts index 9a25c280874..38f8b076df8 100644 --- a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts +++ b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts @@ -135,15 +135,21 @@ describe("recoveryDirs", () => { }) describe("handlePermissionResponse", () => { - it("rejects an unknown route without using a workspace fallback", async () => { - const { fake, messages, replies } = ctx({ tracked: ["s1"] }) - const log = spyOn(console, "error").mockImplementation(() => {}) + it("falls back to the session directory when no permission directory is recorded", async () => { + const { fake, messages, replies } = ctx({ tracked: ["child"] }) + const routed: PermissionContext = { + ...fake, + getWorkspaceDirectory: (id) => (id === "child" ? "/workspace/.kilo/worktrees/feature" : "/workspace"), + } - await handlePermissionResponse(fake, "missing", "s1", "once", [], []) - log.mockRestore() + await handlePermissionResponse(routed, "missing", "child", "once", [], []) - expect(replies).toEqual([]) - expect(messages).toEqual([{ type: "permissionError", permissionID: "missing" }]) + expect(replies).toEqual([ + { requestID: "missing", reply: "once", directory: "/workspace/.kilo/worktrees/feature", interactive: true }, + ]) + expect(messages).toEqual([ + { type: "permissionResolved", permissionID: "missing", sessionID: "child", response: "once" }, + ]) }) it("shares one save/reply sequence across concurrent callers", async () => { diff --git a/packages/kilo-vscode/tests/unit/project-avatar.test.ts b/packages/kilo-vscode/tests/unit/project-avatar.test.ts new file mode 100644 index 00000000000..c90586716ae --- /dev/null +++ b/packages/kilo-vscode/tests/unit/project-avatar.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test" +import * as fs from "fs" +import * as os from "os" +import * as path from "path" +import { avatar, origin, owner } from "../../src/agent-manager/project/avatar" + +function repo(url: string) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "avatar-")) + fs.mkdirSync(path.join(root, ".git")) + fs.writeFileSync( + path.join(root, ".git", "config"), + `[remote "upstream"]\n\turl = git@github.com:other/x.git\n[remote "origin"]\n\turl = ${url}\n`, + ) + return root +} + +describe("project avatar", () => { + it("parses GitHub owners from remote URLs", () => { + expect(owner("git@github.com:Kilo-Org/kilocode.git")).toBe("Kilo-Org") + expect(owner("git@github.com-work:Kilo-Org/kilocode.git")).toBe("Kilo-Org") + expect(owner("https://github.com/Kilo-Org/kilocode")).toBe("Kilo-Org") + expect(owner("https://gitlab.com/foo/bar.git")).toBeUndefined() + }) + + it("reads the origin remote from git config", () => { + expect(origin(repo("git@github.com:Kilo-Org/kilocode.git"))).toBe("Kilo-Org") + expect(origin(repo("git@gitlab.com:team/private.git"))).toBeUndefined() + expect(origin(fs.mkdtempSync(path.join(os.tmpdir(), "avatar-")))).toBeUndefined() + }) + + it("serves a cached avatar from disk as a data URL", () => { + const cache = fs.mkdtempSync(path.join(os.tmpdir(), "avatar-cache-")) + process.env.XDG_CACHE_HOME = cache + fs.mkdirSync(path.join(cache, "kilo", "project-avatars"), { recursive: true }) + fs.writeFileSync(path.join(cache, "kilo", "project-avatars", "cached-org.png"), "png") + expect(avatar(repo("git@github.com:Cached-Org/x.git"))).toBe( + `data:image/png;base64,${Buffer.from("png").toString("base64")}`, + ) + expect(avatar(repo("git@gitlab.com:team/private.git"))).toBeUndefined() + }) + + it("returns undefined instead of throwing when git config cannot be read", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "avatar-")) + // A directory named config makes readFileSync throw (EISDIR). + fs.mkdirSync(path.join(root, ".git", "config"), { recursive: true }) + expect(() => avatar(root)).not.toThrow() + expect(avatar(root)).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/project-order.test.ts b/packages/kilo-vscode/tests/unit/project-order.test.ts new file mode 100644 index 00000000000..43e137f3206 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/project-order.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "bun:test" +import { applyProjectOrder, moveProject, projectDrop } from "../../webview-ui/agent-manager/project-order" + +const rows = [ + { id: "a", top: 0, bottom: 30 }, + // An expanded project is much taller than a collapsed one. + { id: "b", top: 30, bottom: 330 }, + { id: "c", top: 330, bottom: 360 }, +] + +describe("projectDrop", () => { + it("uses the row half under the pointer", () => { + expect(projectDrop(rows, 10)).toEqual({ id: "a", after: false }) + expect(projectDrop(rows, 100)).toEqual({ id: "b", after: false }) + expect(projectDrop(rows, 200)).toEqual({ id: "b", after: true }) + }) + + it("clamps positions outside the rows", () => { + expect(projectDrop(rows, -50)).toEqual({ id: "a", after: false }) + expect(projectDrop(rows, 900)).toEqual({ id: "c", after: true }) + expect(projectDrop([], 10)).toBeUndefined() + }) +}) + +describe("moveProject", () => { + it("moves a project before or after the target", () => { + expect(moveProject(["a", "b", "c"], "a", { id: "c", after: true })).toEqual(["b", "c", "a"]) + expect(moveProject(["a", "b", "c"], "c", { id: "a", after: false })).toEqual(["c", "a", "b"]) + expect(moveProject(["a", "b", "c"], "a", { id: "b", after: true })).toEqual(["b", "a", "c"]) + }) + + it("returns undefined when the order does not change", () => { + expect(moveProject(["a", "b", "c"], "a", { id: "b", after: false })).toBeUndefined() + expect(moveProject(["a", "b", "c"], "b", { id: "a", after: true })).toBeUndefined() + expect(moveProject(["a", "b", "c"], "b", { id: "b", after: true })).toBeUndefined() + expect(moveProject(["a", "b"], "x", { id: "a", after: false })).toBeUndefined() + }) +}) + +describe("applyProjectOrder", () => { + const projects = [ + { id: "p", pinned: true }, + { id: "a", pinned: false }, + { id: "b", pinned: false }, + { id: "c", pinned: false }, + ] + + it("keeps the pinned project first and unlisted projects last", () => { + expect(applyProjectOrder(projects, ["c", "p", "a"]).map((p) => p.id)).toEqual(["p", "c", "a", "b"]) + }) + + it("returns the snapshots without a local order", () => { + expect(applyProjectOrder(projects)).toBe(projects) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/project-sessions-live.test.ts b/packages/kilo-vscode/tests/unit/project-sessions-live.test.ts index 2303cf49d16..cd7057aa5d1 100644 --- a/packages/kilo-vscode/tests/unit/project-sessions-live.test.ts +++ b/packages/kilo-vscode/tests/unit/project-sessions-live.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "bun:test" import { createRoot, createSignal } from "solid-js" import { createProjectSessionsLive } from "../../webview-ui/agent-manager/project/sessions-live" +import { recentSessions } from "../../webview-ui/src/context/session-utils" +import { fixture } from "../fixtures/run" import type { ProjectSessionInfo, SessionInfo } from "../../webview-ui/src/types/messages" const session = (worktreeId: string | null): ProjectSessionInfo => ({ @@ -13,6 +15,43 @@ const session = (worktreeId: string | null): ProjectSessionInfo => ({ }) describe("project session live state", () => { + it("keeps recent sessions scoped across project switches and empty projects", () => { + createRoot((dispose) => { + const first = [1, 2, 3, 4].map((day) => ({ + ...session(null), + id: `first-${day}`, + updatedAt: `2026-08-0${day}T10:00:00.000Z`, + })) + const second = [{ ...session(null), id: "second" }] + const stale = { ...session(null), id: "unscoped", updatedAt: "2026-09-01T10:00:00.000Z" } + const child = { ...session(null), id: "child", parentID: "second" } + const store = [...first, ...second, stale, child] + const state = { pid: "first" } + const live = createProjectSessionsLive({ + base: () => ({ first, second, empty: [] }), + pid: () => state.pid, + store: () => store, + managed: () => [], + locals: () => new Set(), + }) + const recent = () => recentSessions(live.current()).map((item) => item.id) + + expect(recent()).toEqual(["first-4", "first-3", "first-2"]) + state.pid = "second" + expect(recent()).toEqual(["second"]) + state.pid = "empty" + expect(recent()).toEqual([]) + expect(recentSessions(store).map((item) => item.id)).toEqual(["unscoped", "second", "first-4"]) + dispose() + }) + }) + + it( + "consumes a project-scoped sessions accessor and keeps the shared store as the default", + () => fixture("welcome-recent-sessions"), + 30_000, + ) + it("uses managed placement while the project session cache is stale", () => { createRoot((dispose) => { const [base] = createSignal>({ project: [session(null)] }) @@ -20,7 +59,6 @@ describe("project session live state", () => { const live = createProjectSessionsLive({ base, pid: () => "project", - enabled: () => true, store, managed: () => [{ id: "session-1", worktreeId: "worktree-1", createdAt: "2026-08-26T10:00:00.000Z" }], locals: () => new Set(), @@ -36,15 +74,14 @@ describe("project session live state", () => { }) }) - it("returns only the active project and keeps the legacy source when disabled", () => { + it("returns only the active project and does not expose unscoped sessions", () => { createRoot((dispose) => { - const state = { pid: "second" as string | undefined, enabled: true, store: [] as SessionInfo[] } + const state = { pid: "second" as string | undefined, store: [] as SessionInfo[] } const first = { ...session("same"), title: "First project" } const second = { ...session("same"), title: "Second project" } const live = createProjectSessionsLive({ base: () => ({ first: [first], second: [second] }), pid: () => state.pid, - enabled: () => state.enabled, store: () => state.store, managed: () => [], locals: () => new Set(), @@ -54,9 +91,8 @@ describe("project session live state", () => { expect(live.current()).toEqual([first]) state.pid = undefined expect(live.current()).toEqual([]) - state.enabled = false state.store = [first] - expect(live.current()).toEqual([first]) + expect(live.current()).toEqual([]) dispose() }) }) diff --git a/packages/kilo-vscode/tests/unit/project-state-handlers.test.ts b/packages/kilo-vscode/tests/unit/project-state-handlers.test.ts index 7600b769358..5fb87c4a20f 100644 --- a/packages/kilo-vscode/tests/unit/project-state-handlers.test.ts +++ b/packages/kilo-vscode/tests/unit/project-state-handlers.test.ts @@ -19,8 +19,8 @@ describe("createProjectStateHandlers", () => { const applied: AgentManagerStateMessage[] = [] const routed: AgentManagerStateMessage[] = [] const handler = createProjectStateHandlers({ - setMulti: () => {}, setProjects: () => {}, + migrate: () => {}, setStates: (update) => Object.assign(stored, update(stored)), prune: () => {}, ensure: () => ({ sections: () => [], applyState: (value) => applied.push(value) }), @@ -28,9 +28,6 @@ describe("createProjectStateHandlers", () => { routeCatalog: () => {}, routeState: (value) => routed.push(value), isActive: () => true, - pending: () => false, - setPending: () => {}, - rename: () => {}, font: () => {}, browser: () => {}, current: () => "session-a", @@ -47,13 +44,49 @@ describe("createProjectStateHandlers", () => { expect(value.browserAutomation).toBe(true) }) + it("migrates the pinned project, or the first catalog entry, when projects arrive", () => { + const migrated: string[] = [] + const handler = createProjectStateHandlers({ + setProjects: () => {}, + migrate: (id) => migrated.push(id), + setStates: () => {}, + prune: () => {}, + ensure: () => ({ sections: () => [], applyState: () => {} }), + active: () => ({ sections: () => [], applyState: () => {} }), + routeCatalog: () => {}, + routeState: () => {}, + isActive: () => true, + font: () => {}, + browser: () => {}, + current: () => "session-a", + closeBrowser: () => {}, + openBrowser: () => {}, + }) + const project = (id: string, pinned = false) => ({ + id, + root: `/repo/${id}`, + label: id, + pinned, + active: pinned, + expanded: false, + initialized: true, + missing: false, + }) + + handler.projects({ type: "agentManager.projects", projects: [project("a"), project("b", true)] }) + handler.projects({ type: "agentManager.projects", projects: [project("a"), project("b")] }) + handler.projects({ type: "agentManager.projects", projects: [] }) + + expect(migrated).toEqual(["b", "a"]) + }) + it("opens browser previews only for the active project and selected session", () => { let opened = 0 let closed = 0 let enabled = false const handler = createProjectStateHandlers({ - setMulti: () => {}, setProjects: () => {}, + migrate: () => {}, setStates: () => {}, prune: () => {}, ensure: () => ({ sections: () => [], applyState: () => {} }), @@ -61,9 +94,6 @@ describe("createProjectStateHandlers", () => { routeCatalog: () => {}, routeState: () => {}, isActive: (project) => project === "project-a", - pending: () => false, - setPending: () => {}, - rename: () => {}, font: () => {}, browser: (value) => { enabled = value @@ -142,8 +172,8 @@ describe("createProjectStateHandlers", () => { ) const binding = browser.bind(current) const handler = createProjectStateHandlers({ - setMulti: () => {}, setProjects: () => {}, + migrate: () => {}, setStates: () => {}, prune: () => {}, ensure: () => ({ sections: () => [], applyState: () => {} }), @@ -151,9 +181,6 @@ describe("createProjectStateHandlers", () => { routeCatalog: () => {}, routeState: () => {}, isActive: (project) => project === "project-a", - pending: () => false, - setPending: () => {}, - rename: () => {}, font: () => {}, ...binding, }) diff --git a/packages/kilo-vscode/tests/unit/prompt-fold.test.ts b/packages/kilo-vscode/tests/unit/prompt-fold.test.ts new file mode 100644 index 00000000000..308ffd9ad9c --- /dev/null +++ b/packages/kilo-vscode/tests/unit/prompt-fold.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test" +import { fold } from "../../webview-ui/src/components/chat/prompt-fold" + +const base = { pinned: 48, reserve: 210, slot: 26 } +const edge = base.reserve + base.pinned + +describe("fold", () => { + it("keeps every action when they fit", () => { + expect(fold({ ...base, width: edge + 4 * base.slot, count: 4 })).toBe(4) + }) + + it("folds progressively and reserves a slot for the overflow button", () => { + expect(fold({ ...base, width: edge + 4 * base.slot - 1, count: 4 })).toBe(2) + expect(fold({ ...base, width: edge + 3 * base.slot, count: 4 })).toBe(2) + expect(fold({ ...base, width: edge + 2 * base.slot, count: 4 })).toBe(1) + expect(fold({ ...base, width: edge + base.slot, count: 4 })).toBe(0) + }) + + it("never goes below zero or above the count", () => { + expect(fold({ ...base, width: 100, count: 4 })).toBe(0) + expect(fold({ ...base, width: 2000, count: 3 })).toBe(3) + }) + + it("counts wider pinned actions, such as the goal send button", () => { + expect(fold({ ...base, width: edge + 4 * base.slot, pinned: base.pinned + 60, count: 4 })).toBeLessThan(4) + }) + + it("scales with a larger font", () => { + const big = { pinned: 64, reserve: 280, slot: 34 } + expect(fold({ ...big, width: big.reserve + big.pinned + 4 * big.slot, count: 4 })).toBe(4) + expect(fold({ ...big, width: big.reserve + big.pinned + 4 * big.slot - 1, count: 4 })).toBe(2) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index a05e4f4daba..90e0e442da3 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -86,12 +86,14 @@ describe("sendCommand dismisses pending tool requests", () => { }) it("applies model, agent, and variant overrides when provided by a command", () => { - expect(body).toContain("if (overrides?.agent)") - expect(body).toContain("selectAgent(overrides.agent, scope)") - expect(body).toContain("if (overrides?.model)") - expect(body).toContain("selectModel(effectiveSelection.providerID, effectiveSelection.modelID, scope)") - expect(body).toContain("if (overrides?.variant !== undefined)") - expect(body).toContain("selectVariant(overrides.variant, scope)") + const overrides = extractFunctionBody(source, "applyOverrides") + expect(overrides).toContain("if (overrides?.agent)") + expect(overrides).toContain("selectAgent(overrides.agent, scope)") + expect(overrides).toContain("if (overrides?.model)") + expect(overrides).toContain("selectModel(selection.providerID, selection.modelID, scope)") + expect(overrides).toContain("if (overrides?.variant !== undefined)") + expect(overrides).toContain("selectVariant(overrides.variant, scope)") + expect(body).toContain("if (effectiveSelection) applyOverrides(overrides, scope, effectiveSelection)") }) }) @@ -300,12 +302,13 @@ describe("sendMessage / sendCommand draft id contract", () => { // from ":pending:" to ":session:". The user loses the // typed message and the new session starts empty. const body = extractFunctionBody(source, "sendMessage") - expect(body).toMatch(/const effectiveDraftID = !sid && !draftID \? crypto\.randomUUID\(\) : draftID/) + expect(body).toContain("const effectiveDraftID = mintDraft(sid, draftID, false)") }) it("sendCommand mints a draftID when there is no current session and none was supplied", () => { const body = extractFunctionBody(source, "sendCommand") - expect(body).toMatch(/const effectiveDraftID = !sid && !draftID \? crypto\.randomUUID\(\) : draftID/) + expect(body).toContain("const effectiveDraftID = mintDraft(sid, draftID, true)") + expect(extractFunctionBody(source, "mintDraft")).toMatch(/if \(sid \|\| draftID\) return draftID/) }) it("sendMessage seeds the pending agent before resolving draft-scoped settings", () => { @@ -315,14 +318,15 @@ describe("sendMessage / sendCommand draft id contract", () => { // model with the default agent's system prompt. const body = extractFunctionBody(source, "sendMessage") expect(body).toMatch( - /if \(!sid && !draftID && effectiveDraftID\) agentDrafts\.seed\(effectiveDraftID\)[\s\S]*const settings = submission\(scope, selection\)/, + /const effectiveDraftID = mintDraft\(sid, draftID, false\)[\s\S]*const settings = submission\(scope, selection\)/, ) + expect(extractFunctionBody(source, "mintDraft")).toMatch(/agentDrafts\.seed\(id\)/) }) it("sendCommand seeds the pending agent before resolving draft-scoped settings", () => { const body = extractFunctionBody(source, "sendCommand") expect(body).toMatch( - /if \(!sid && !draftID && effectiveDraftID\) \{\s*agentDrafts\.seed\(effectiveDraftID\)[\s\S]*submission\(scope, effectiveSelection\)/, + /const effectiveDraftID = mintDraft\(sid, draftID, true\)[\s\S]*submission\(scope, effectiveSelection\)/, ) }) diff --git a/packages/kilo-vscode/tests/unit/roo-migration-ui.fixture.tsx b/packages/kilo-vscode/tests/unit/roo-migration-ui.fixture.tsx index ba27037a073..8eb991eef1f 100644 --- a/packages/kilo-vscode/tests/unit/roo-migration-ui.fixture.tsx +++ b/packages/kilo-vscode/tests/unit/roo-migration-ui.fixture.tsx @@ -19,6 +19,7 @@ Object.assign(globalThis, { SVGElement: window.SVGElement, MutationObserver: window.MutationObserver, ResizeObserver: window.ResizeObserver, + IntersectionObserver: window.IntersectionObserver, CustomEvent: window.CustomEvent, Event: window.Event, MouseEvent: window.MouseEvent, diff --git a/packages/kilo-vscode/tests/unit/sandbox-bootstrap.test.ts b/packages/kilo-vscode/tests/unit/sandbox-bootstrap.test.ts index 0b8b376baad..ec4db3e22cb 100644 --- a/packages/kilo-vscode/tests/unit/sandbox-bootstrap.test.ts +++ b/packages/kilo-vscode/tests/unit/sandbox-bootstrap.test.ts @@ -148,7 +148,7 @@ describe("Agent Manager sandbox startup", () => { }) test("places the sandbox toggle with prompt actions instead of model selectors", () => { - const selectors = dialog.indexOf('
    ') + const selectors = dialog.indexOf("', selectors) const sandbox = dialog.indexOf(" { ]) }) + it("puts pinned worktrees first, even from a collapsed section", () => { + const s1 = sec("s1", 0, { collapsed: true }) + const w1 = wt("w1", { sectionId: "s1", pinned: true }) + const w2 = wt("w2", { sectionId: "s1" }) + const w3 = wt("w3") + const w4 = wt("w4", { pinned: true }) + const sorted = [w1, w4, w2, w3] + const items = buildTopLevelItems([s1], [w4, w3], sorted, ["w1", "w4", "w3", "s1", "w2"]) + const members = (id: string) => (id === "s1" ? [w1, w2] : []) + expect(items.map((item) => (item.kind === "worktree" ? item.wt.id : item.section.id))).toEqual(["w3", "s1"]) + expect(buildSidebarOrder(items, sorted, [s1], members).map((item) => item.id)).toEqual(["local", "w1", "w4", "w3"]) + }) + + it("puts pinned worktrees first when no sections exist", () => { + const sorted = [wt("a"), wt("b", { pinned: true }), wt("c")] + const items = buildTopLevelItems([], [], sorted, []) + expect(buildSidebarOrder(items, sorted, [], () => []).map((item) => item.id)).toEqual(["local", "b", "a", "c"]) + }) + it("skips worktrees in collapsed sections", () => { const s1 = sec("s1", 0, { collapsed: true }) const w1 = wt("w1", { sectionId: "s1" }) diff --git a/packages/kilo-vscode/tests/unit/server-cwd.test.ts b/packages/kilo-vscode/tests/unit/server-cwd.test.ts new file mode 100644 index 00000000000..cec18cf3850 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/server-cwd.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, spyOn } from "bun:test" +import * as fs from "fs" +import * as os from "os" +import * as path from "path" +import { ensureServerCwd } from "../../src/services/cli-backend/server-utils" + +describe("ensureServerCwd", () => { + it("accepts an existing filesystem root", () => { + const root = path.parse(process.cwd()).root + expect(() => ensureServerCwd(root)).not.toThrow() + }) + + it("does not recreate an existing directory", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-server-cwd-")) + const mkdir = spyOn(fs, "mkdirSync") + try { + ensureServerCwd(dir) + expect(mkdir).not.toHaveBeenCalled() + } finally { + mkdir.mockRestore() + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + it("creates a missing storage directory and its parents", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-server-cwd-")) + try { + const dir = path.join(root, "storage", "kilo-code") + ensureServerCwd(dir) + expect(fs.statSync(dir).isDirectory()).toBe(true) + expect(() => ensureServerCwd(dir)).not.toThrow() + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it("propagates failures to create a missing directory", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-server-cwd-")) + try { + const file = path.join(root, "file") + fs.writeFileSync(file, "") + expect(() => ensureServerCwd(path.join(file, "storage"))).toThrow() + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-agent.test.ts b/packages/kilo-vscode/tests/unit/session-agent.test.ts index 2949fb55ac6..8eacb40f88c 100644 --- a/packages/kilo-vscode/tests/unit/session-agent.test.ts +++ b/packages/kilo-vscode/tests/unit/session-agent.test.ts @@ -4,6 +4,7 @@ import { createDraftAgentSeed, draftAgentSelection, resolvePromptAgent, + resolveScopeAgent, resolveSessionAgent, } from "../../webview-ui/src/context/session-agent" import type { Message } from "../../webview-ui/src/types/messages" @@ -160,6 +161,41 @@ describe("resolvePromptAgent", () => { }) }) +describe("resolveScopeAgent", () => { + const fallback = "code" + + it("uses the scope's own selection first", () => { + expect(resolveScopeAgent({ id: "ses_1", selections: { ses_1: "plan" }, pending: "ask", fallback })).toBe("plan") + expect( + resolveScopeAgent({ id: "pending:tab", selections: { "pending:tab": "plan" }, pending: "ask", fallback }), + ).toBe("plan") + }) + + it("resolves drafts and the composer to the pending agent, then the default", () => { + for (const id of ["composer", "pending:tab", "sidebar-pending:tab", "4f1c-draft"]) { + expect(resolveScopeAgent({ id, selections: {}, pending: "ask", fallback })).toBe("ask") + expect(resolveScopeAgent({ id, selections: {}, pending: null, fallback })).toBe(fallback) + } + }) + + it("keeps a real session without a selection on the default agent, never the pending one", () => { + expect(resolveScopeAgent({ id: "ses_1", selections: {}, pending: "ask", fallback })).toBe(fallback) + }) + + it("matches the agent sent with the prompt whenever one is sent", () => { + const cases = [ + { id: "ses_1", selections: { ses_1: "plan" }, pending: "ask" }, + { id: "pending:tab", selections: {}, pending: "ask" }, + { id: "composer", selections: {}, pending: "ask" }, + { id: "ses_2", selections: {}, pending: "ask" }, + ] + for (const input of cases) { + const sent = resolvePromptAgent({ sessionID: input.id, selections: input.selections, pending: input.pending }) + if (sent) expect(resolveScopeAgent({ ...input, fallback })).toBe(sent) + } + }) +}) + describe("draftAgentSelection", () => { it("carries a pending agent into a new draft scope", () => { const result = draftAgentSelection({}, "draft-1", "plan") diff --git a/packages/kilo-vscode/tests/unit/session-dock.test.ts b/packages/kilo-vscode/tests/unit/session-dock.test.ts index 872b59d99b8..2cc3d1d50f5 100644 --- a/packages/kilo-vscode/tests/unit/session-dock.test.ts +++ b/packages/kilo-vscode/tests/unit/session-dock.test.ts @@ -91,7 +91,7 @@ describe("session dock layout", () => { it("keeps Goal inside the shared session actions", () => { const view = read("webview-ui/src/components/chat/ChatView.tsx") expect(view).toContain("hasActions={() => !props.readonly && (hasActions(hasMessages()) || !!goal())}") - expect(view).toContain("actions={(control, agents) => renderActions(hasMessages(), control, agents)}") + expect(view).toContain("actions={(control, agents, todos) => renderActions(hasMessages(), control, agents, todos)}") expect(view).toContain("{control()}") }) @@ -105,7 +105,7 @@ describe("session dock layout", () => { expect(goal).toContain('variant="ghost"') expect(goal).toContain("disabled={props.readonly || !actions()}") expect(goal).toContain("if (!actions() || !goal()) setOpen(false)") - expect(dock).toContain("props.actions?.(goal.control, idleStack)") + expect(dock).toContain("props.actions?.(goal.control, idleStack, idleTodo)") expect(dock).toContain("const goal = useGoalDock({") expect(goal).toContain('session.sendCommand("goal", goal().active ? "pause" : "resume")') expect(goal).toContain('session.sendCommand("goal", "clear")') diff --git a/packages/kilo-vscode/tests/unit/session-issues-render.test.ts b/packages/kilo-vscode/tests/unit/session-issues-render.test.ts new file mode 100644 index 00000000000..e6ebcd4f63a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-issues-render.test.ts @@ -0,0 +1,8 @@ +import { it } from "bun:test" +import { fixture } from "../fixtures/run" + +it( + "hides the warning trigger with no issues and shows it once an MCP server needs sign-in", + () => fixture("session-issues"), + 30_000, +) diff --git a/packages/kilo-vscode/tests/unit/session-issues.test.ts b/packages/kilo-vscode/tests/unit/session-issues.test.ts new file mode 100644 index 00000000000..aa6bb0ed46d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-issues.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "bun:test" +import { mcpAuthIssues } from "../../webview-ui/src/components/chat/session-issues" + +const strings: Record = { + "prompt.mcp.provider": "{{name}} MCP", + "prompt.mcp.signIn.busy": "Signing in…", + "prompt.mcp.openSettings": "Open in Settings", + "common.signIn": "Sign in", +} + +function t(key: string, params?: Record): string { + const value = strings[key] ?? key + if (!params) return value + return Object.entries(params).reduce((acc, [k, v]) => acc.replaceAll(`{{${k}}}`, v), value) +} + +describe("mcpAuthIssues", () => { + it("returns an empty array when nothing needs auth", () => { + expect(mcpAuthIssues([], [], t, { signIn: () => {}, openSettings: () => {} })).toEqual([]) + }) + + it("builds one issue per server with Sign in and Open in Settings actions", () => { + const issues = mcpAuthIssues(["anaconda"], [], t, { signIn: () => {}, openSettings: () => {} }) + expect(issues).toHaveLength(1) + expect(issues[0]!.id).toBe("mcp-auth:anaconda") + expect(issues[0]!.title).toBe("Anaconda MCP") + expect(issues[0]!.actions.map((a) => a.title)).toEqual(["Sign in", "Open in Settings"]) + expect(issues[0]!.actions[0]!.enabled).not.toBe(false) + }) + + it("disables sign-in and shows a busy label while a sign-in is in progress", () => { + const issues = mcpAuthIssues(["anaconda"], ["anaconda"], t, { signIn: () => {}, openSettings: () => {} }) + expect(issues[0]!.actions[0]!.title).toBe("Signing in…") + expect(issues[0]!.actions[0]!.enabled).toBe(false) + }) + + it("sorts multiple issues by server name", () => { + const issues = mcpAuthIssues(["zebra", "anaconda"], [], t, { signIn: () => {}, openSettings: () => {} }) + expect(issues.map((i) => i.id)).toEqual(["mcp-auth:anaconda", "mcp-auth:zebra"]) + }) + + it("invokes the signIn handler with the server name when the action runs", () => { + const seen: string[] = [] + const issues = mcpAuthIssues(["anaconda"], [], t, { + signIn: (name) => seen.push(`signIn:${name}`), + openSettings: (name) => seen.push(`settings:${name}`), + }) + issues[0]!.actions[0]!.run() + issues[0]!.actions[1]!.run() + expect(seen).toEqual(["signIn:anaconda", "settings:anaconda"]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-model-selector.test.ts b/packages/kilo-vscode/tests/unit/session-model-selector.test.ts index fb19dcc2c75..953511027df 100644 --- a/packages/kilo-vscode/tests/unit/session-model-selector.test.ts +++ b/packages/kilo-vscode/tests/unit/session-model-selector.test.ts @@ -4,7 +4,7 @@ import { createModelSelector } from "../../webview-ui/src/context/session-model- describe("model selector", () => { it("carries the active session variant for the selected model", () => { const selected = { providerID: "kilo", modelID: "old" } - const next: Array<{ id: string; selection: typeof selected }> = [] + const next: Array<{ id: string; agent: string; selection: typeof selected }> = [] const variants: Array<{ value: string | undefined; session: string | undefined }> = [] const hidden: string[] = [] const selector = createModelSelector({ @@ -12,7 +12,7 @@ describe("model selector", () => { agent: () => "code", selected: () => selected, variant: () => "high", - apply: (_agent, selection, id) => next.push({ id: id!, selection }), + apply: (agent, selection, id) => next.push({ id, agent, selection }), set: () => undefined, carry: (_selection, value, _agent, session) => variants.push({ value, session }), hide: (id) => hidden.push(id), @@ -21,22 +21,22 @@ describe("model selector", () => { selector.select("kilo", "new") const model = { providerID: "kilo", modelID: "new" } - expect(next).toEqual([{ id: "session", selection: model }]) + expect(next).toEqual([{ id: "session", agent: "code", selection: model }]) expect(variants).toEqual([{ value: "high", session: "session" }]) expect(hidden).toEqual(["session"]) }) - it("retains a session variant without persisting a global model selection", () => { + it("allocates a session model without persisting a shared pick", () => { const selected = { providerID: "kilo", modelID: "old" } - const models: Array<{ id: string; selection: typeof selected }> = [] + const models: Array<{ id: string; agent: string; selection: typeof selected }> = [] const variants: Array<{ value: string | undefined; session: string | undefined }> = [] const selector = createModelSelector({ - current: () => undefined, + current: () => "session", agent: () => "code", selected: () => selected, variant: () => "high", apply: () => undefined, - set: (id, selection) => models.push({ id, selection }), + set: (id, agent, selection) => models.push({ id, agent, selection }), carry: (_selection, value, _agent, session) => variants.push({ value, session }), hide: () => undefined, }) @@ -44,7 +44,7 @@ describe("model selector", () => { selector.session("session", "kilo", "new") const model = { providerID: "kilo", modelID: "new" } - expect(models).toEqual([{ id: "session", selection: model }]) + expect(models).toEqual([{ id: "session", agent: "code", selection: model }]) expect(variants).toEqual([{ value: "high", session: "session" }]) }) }) diff --git a/packages/kilo-vscode/tests/unit/session-model-store.test.ts b/packages/kilo-vscode/tests/unit/session-model-store.test.ts index d8329d74dd7..d1d960abf79 100644 --- a/packages/kilo-vscode/tests/unit/session-model-store.test.ts +++ b/packages/kilo-vscode/tests/unit/session-model-store.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "bun:test" import { + COMPOSER, type ModelStore, type ResolveEnv, applyModel, getAgentModel, - getSessionModel, + getPick, getSelected, + getSessionModel, } from "../../webview-ui/src/context/session-model-store" import type { ModelSelection, Provider } from "../../webview-ui/src/types/messages" @@ -20,352 +22,181 @@ function makeProvider(id: string, models: string[]): Provider { const KILO_AUTO: ModelSelection = { providerID: "kilo", modelID: "kilo-auto/free" } const providers: Record = { - kilo: makeProvider("kilo", ["kilo-auto/free"]), + kilo: makeProvider("kilo", ["kilo-auto/free", "z-first", "z-personal", "z-recommended"]), anthropic: makeProvider("anthropic", ["claude-sonnet-4"]), openai: makeProvider("openai", ["gpt-4.1"]), } -function env(): ResolveEnv { +const claude: ModelSelection = { providerID: "anthropic", modelID: "claude-sonnet-4" } +const gpt: ModelSelection = { providerID: "openai", modelID: "gpt-4.1" } +const first: ModelSelection = { providerID: "kilo", modelID: "z-first" } +const personal: ModelSelection = { providerID: "kilo", modelID: "z-personal" } +const recommended: ModelSelection = { providerID: "kilo", modelID: "z-recommended" } + +function env(overrides?: Partial): ResolveEnv { return { providers, connected: ["kilo", "anthropic", "openai"], + ready: true, + organizationId: null, fallback: KILO_AUTO, getModeModel: () => null, getGlobalModel: () => null, + ...overrides, } } function emptyStore(): ModelStore { return { - modelSelections: {}, sessionOverrides: {}, agentSelections: {}, recentModels: [], } } -const claude: ModelSelection = { providerID: "anthropic", modelID: "claude-sonnet-4" } -const gpt: ModelSelection = { providerID: "openai", modelID: "gpt-4.1" } - -describe("per-session model selection", () => { - it("selecting a model in session A does not write per-mode globally", () => { +describe("per-scope per-agent model selection", () => { + it("picking a model in one session does not leak into other sessions", () => { const store = emptyStore() const e = env() - // User picks claude in session A - const after = applyModel(store, "code", claude, "session-a") - const updated: ModelStore = { ...store, ...after } + const updated: ModelStore = { ...store, ...applyModel(store, "code", claude, "session-a") } - // Session A should see claude (via session override) expect(getSessionModel(updated, e, "session-a", "code")).toEqual(claude) - - // Session B (no override) keeps the default model. - const sessionB = getSessionModel(updated, e, "session-b", "code") - expect(sessionB).toEqual(KILO_AUTO) + expect(getSessionModel(updated, e, "session-b", "code")).toEqual(KILO_AUTO) }) it("each session preserves its own model independently", () => { let store = emptyStore() const e = env() - // User picks claude in session A - const a = applyModel(store, "code", claude, "session-a") - store = { ...store, ...a } + store = { ...store, ...applyModel(store, "code", claude, "session-a") } + store = { ...store, ...applyModel(store, "code", gpt, "session-b") } - // User picks gpt in session B - const b = applyModel(store, "code", gpt, "session-b") - store = { ...store, ...b } - - // Both sessions should keep their own model expect(getSessionModel(store, e, "session-a", "code")).toEqual(claude) expect(getSessionModel(store, e, "session-b", "code")).toEqual(gpt) }) - it("getSelected returns per-session override when session is active", () => { + it("picks are per agent within a scope, so switching agents re-resolves", () => { let store = emptyStore() - const e = env() + const e = env({ getModeModel: (agent) => (agent === "plan" ? gpt : null) }) - const a = applyModel(store, "code", claude, "session-a") - store = { ...store, ...a } + store = { ...store, ...applyModel(store, "code", claude, "session-a") } expect(getSelected(store, e, "session-a", "code")).toEqual(claude) + // No plan pick exists: the plan config applies, not the code pick. + expect(getSelected(store, e, "session-a", "plan")).toEqual(gpt) + // Switching back restores the code pick. + expect(getSelected(store, e, "session-a", "code")).toEqual(claude) }) - it("getSelected returns global model when no session is active", () => { + it("applyModel preserves the other agents' picks in the scope", () => { let store = emptyStore() - const e = env() + store = { ...store, ...applyModel(store, "code", claude, "session-a") } + store = { ...store, ...applyModel(store, "plan", gpt, "session-a") } - // Sidebar mode (no session) — writes globally - const result = applyModel(store, "code", claude, undefined) - store = { ...store, ...result } + expect(getPick(store, "session-a", "code")).toEqual(claude) + expect(getPick(store, "session-a", "plan")).toEqual(gpt) - expect(getSelected(store, e, undefined, "code")).toEqual(claude) + const updated: ModelStore = { ...store, ...applyModel(store, "code", personal, "session-a") } + expect(getPick(updated, "session-a", "code")).toEqual(personal) + expect(getPick(updated, "session-a", "plan")).toEqual(gpt) }) - it("sidebar model selection writes globally and is visible to new sessions without overrides", () => { - let store = emptyStore() - const e = env() + it("mode config beats stale remembered recents for scopes without picks", () => { + const store: ModelStore = { ...emptyStore(), recentModels: [personal] } + const e = env({ getModeModel: () => first }) - // User picks claude in sidebar (no session) - const result = applyModel(store, "code", claude, undefined) - store = { ...store, ...result } - - // A new session without an override should see the global model - expect(getSessionModel(store, e, "session-new", "code")).toEqual(claude) + expect(getSelected(store, e, "new-session", "code")).toEqual(first) + expect(getSessionModel(store, e, "new-session", "code")).toEqual(first) }) - it("setSessionModel (compare mode) only writes per-session override", () => { + it("recents apply when no config exists", () => { + const store: ModelStore = { ...emptyStore(), recentModels: [claude] } + + expect(getSelected(store, env(), "new-session", "code")).toEqual(claude) + }) + + it("global config applies when mode config is missing", () => { + const store: ModelStore = { ...emptyStore(), recentModels: [claude] } + const e = env({ getGlobalModel: () => gpt }) + + expect(getSelected(store, e, "new-session", "code")).toEqual(gpt) + }) + + it("invalid configured models fall through to the next step", () => { + const store: ModelStore = { ...emptyStore(), recentModels: [claude] } + const e = env({ + getModeModel: () => ({ providerID: "anthropic", modelID: "missing-model" }), + getGlobalModel: () => gpt, + }) + + expect(getSelected(store, e, "new-session", "code")).toEqual(gpt) + }) + + it("pending catalogs hide kilo models but keep explicit picks", () => { const store = emptyStore() + const e = env({ ready: false, organizationId: undefined }) - // Simulate setSessionModel — writes only to sessionOverrides - store.sessionOverrides["session-a"] = claude - store.sessionOverrides["session-b"] = gpt - - const e = env() - expect(getSessionModel(store, e, "session-a", "code")).toEqual(claude) - expect(getSessionModel(store, e, "session-b", "code")).toEqual(gpt) + expect(getSelected(store, e, "session-a", "code")).toEqual(null) + expect(getAgentModel(store, e, "code")).toEqual(null) }) - it("switching sessions preserves model selection after multiple changes", () => { + it("org users skip recents and use the recommendation", () => { + const store: ModelStore = { ...emptyStore(), recentModels: [claude] } + const e = env({ organizationId: "org-a", defaults: { kilo: "z-recommended" } }) + + expect(getSelected(store, e, "new-session", "code")).toEqual(recommended) + }) + + it("org users fall back to the first kilo model without a recommendation", () => { + const store: ModelStore = { ...emptyStore(), recentModels: [claude] } + const e = env({ organizationId: "org-a" }) + + expect(getSelected(store, e, "new-session", "code")).toEqual(KILO_AUTO) + }) + + it("org users keep in-scope picks", () => { let store = emptyStore() - const e = env() + store = { ...store, ...applyModel(store, "code", claude, COMPOSER) } + const e = env({ organizationId: "org-a", defaults: { kilo: "z-recommended" } }) - // Simulate: user in session A picks claude - let result = applyModel(store, "code", claude, "session-a") - store = { ...store, ...result } + expect(getSelected(store, e, COMPOSER, "code")).toEqual(claude) + }) - // Switch to session B — picks gpt - result = applyModel(store, "code", gpt, "session-b") - store = { ...store, ...result } + it("getAgentModel resolves mode config, then global, then recents, then fallback", () => { + const store: ModelStore = { ...emptyStore(), recentModels: [claude] } - // Switch back to session A — picks gpt this time - result = applyModel(store, "code", gpt, "session-a") - store = { ...store, ...result } + expect(getAgentModel(store, env({ getModeModel: () => first }), "code")).toEqual(first) + expect(getAgentModel(store, env({ getGlobalModel: () => gpt }), "code")).toEqual(gpt) + expect(getAgentModel(store, env(), "code")).toEqual(claude) + expect(getAgentModel(emptyStore(), env(), "code")).toEqual(KILO_AUTO) + }) + + it("getSessionModel resolves for the session's selected agent", () => { + let store = emptyStore() + store = { ...store, ...applyModel(store, "plan", gpt, "session-a") } + store = { ...store, agentSelections: { ...store.agentSelections, "session-a": "plan" } } + const e = env({ getModeModel: (agent) => (agent === "plan" ? personal : claude) }) - // Switch back to session B — should still have gpt - expect(getSessionModel(store, e, "session-b", "code")).toEqual(gpt) - // Session A was updated to gpt expect(getSessionModel(store, e, "session-a", "code")).toEqual(gpt) + expect(getSessionModel({ ...store, agentSelections: {} }, e, "session-a", "code")).toEqual(claude) }) -}) -describe("per-mode model memory", () => { - it("uses remembered model selections for modes without configured models", () => { - const store = { ...emptyStore(), modelSelections: { ask: gpt } } - - expect(getAgentModel(store, env(), "ask")).toEqual(gpt) - }) - - it("keeps an explicit remembered model ahead of the configured mode default", () => { - const configured: ResolveEnv = { - ...env(), - getModeModel: (name) => (name === "code" ? claude : null), - } - const store = { ...emptyStore(), modelSelections: { code: gpt } } - - expect(getAgentModel(store, configured, "code", true)).toEqual(gpt) - }) - - it("applyModel in a session writes only to sessionOverrides", () => { - const store = emptyStore() - const result = applyModel(store, "code", claude, "session-a") - - expect(result.sessionOverrides["session-a"]).toEqual(claude) - expect(result.modelSelections["code"]).toBeUndefined() - }) - - it("keeps a session override when its selected mode changes", () => { - let store = emptyStore() - const e = env() - - // User picks claude for "code" mode in session A - const result = applyModel(store, "code", claude, "session-a") - store = { ...store, ...result } - - const switched = { ...store, agentSelections: { "session-a": "ask" } } - expect(getSessionModel(switched, e, "session-a", "code")).toEqual(claude) - }) - - it("different modes remember their own model independently", () => { - let store = emptyStore() - const e = env() - - // User picks claude for "code" globally - let result = applyModel(store, "code", claude, undefined) - store = { ...store, ...result } - - // User switches to "ask" mode and picks gpt globally - result = applyModel(store, "ask", gpt, undefined) - store = { ...store, ...result } - - // Clear session overrides (simulating mode switch) - const cleared: ModelStore = { ...store, sessionOverrides: {} } - - // Each mode should have its own saved model - expect(getSelected(cleared, e, undefined, "code")).toEqual(claude) - expect(getSelected(cleared, e, undefined, "ask")).toEqual(gpt) - }) - - it("per-session override still takes priority over global modelSelections", () => { - let store = emptyStore() - const e = env() - - // User picks claude globally for "code" - let result = applyModel(store, "code", claude, undefined) - store = { ...store, ...result } - - // Session A overrides with gpt - result = applyModel(store, "code", gpt, "session-a") - store = { ...store, ...result } - - // Session A sees gpt (its override), not the global claude - expect(getSelected(store, e, "session-a", "code")).toEqual(gpt) - // Global modelSelections stays at the sidebar/default choice. - expect(store.modelSelections["code"]).toEqual(claude) - }) - - it("applyModel without session only writes to modelSelections, not sessionOverrides", () => { - const store = emptyStore() - const result = applyModel(store, "code", claude, undefined) - - expect(result.modelSelections["code"]).toEqual(claude) - expect(Object.keys(result.sessionOverrides)).toHaveLength(0) - }) - - it("keeps a plan session's explicit model when switching to configured implementation mode", () => { + it("agent manager allocations keep the manual choice without touching other agents", () => { let store = emptyStore() - const configured: ResolveEnv = { - ...env(), - getModeModel: (name) => (name === "code" ? gpt : name === "plan" ? claude : null), - } - - // Old manual memory says implementation/code should use claude. - let result = applyModel(store, "code", claude, undefined) - store = { ...store, ...result } - - // Current plan session is using its own model. - result = applyModel(store, "plan", claude, "session-a") - store = { ...store, ...result, agentSelections: { "session-a": "plan" } } - - const switched: ModelStore = { - ...store, - agentSelections: { "session-a": "code" }, - } - - expect(getSelected(switched, configured, "session-a", "code")).toEqual(claude) - }) -}) - -describe("organization model store", () => { - const first = { providerID: "kilo", modelID: "first" } - const recommendation = { providerID: "kilo", modelID: "org-default" } - const organization: ResolveEnv = { - ...env(), - ready: true, - organizationId: "org-a", - providers: { ...providers, kilo: makeProvider("kilo", [first.modelID, recommendation.modelID, KILO_AUTO.modelID]) }, - defaults: { kilo: recommendation.modelID }, - } - - it("ignores implicit mode memory and generic recents across every accessor", () => { - const store: ModelStore = { - ...emptyStore(), - modelSelections: { code: KILO_AUTO, ask: first }, - recentModels: [KILO_AUTO, gpt], - } - const before = structuredClone(store) - expect(getSelected(store, organization, undefined, "code")).toEqual(recommendation) - expect(getSelected(store, organization, "session-a", "code")).toEqual(recommendation) - expect(getSessionModel(store, organization, "session-a", "code")).toEqual(recommendation) - expect(getAgentModel(store, organization, "ask")).toEqual(recommendation) - expect(store).toEqual(before) - }) - - it.each([undefined, "session-a"])("preserves explicit free selections in scope %s", (scope) => { - const store = emptyStore() - const updated = { ...store, ...applyModel(store, "code", KILO_AUTO, scope) } - expect(getSelected(updated, organization, scope, "code")).toEqual(KILO_AUTO) - expect(getSessionModel(updated, organization, "session-a", "code")).toEqual(KILO_AUTO) - if (!scope) expect(getAgentModel(updated, organization, "code")).toEqual(KILO_AUTO) - }) - - it.each([undefined, "session-a"])("restores explicit X through X to Y to X in scope %s without writes", (scope) => { - const store = emptyStore() - const updated = { ...store, ...applyModel(store, "code", KILO_AUTO, scope) } - const before = structuredClone(updated) - const restricted = { ...organization, providers: { kilo: makeProvider("kilo", [recommendation.modelID]) } } - expect(getSelected(updated, env(), scope, "code")).toEqual(KILO_AUTO) - expect(getSelected(updated, restricted, scope, "code")).toEqual(recommendation) - expect(getSessionModel(updated, restricted, "session-a", "code")).toEqual(recommendation) - expect(getSelected(updated, env(), scope, "code")).toEqual(KILO_AUTO) - expect(getSessionModel(updated, env(), "session-a", "code")).toEqual(KILO_AUTO) - if (!scope) { - expect(getAgentModel(updated, restricted, "code")).toEqual(recommendation) - expect(getAgentModel(updated, env(), "code")).toEqual(KILO_AUTO) - } - expect(updated).toEqual(before) - }) - - it("falls through an unavailable session override to a valid explicit manual choice", () => { - const store = { - ...emptyStore(), - modelSelections: { code: gpt }, - userSetAgents: { code: true }, - sessionOverrides: { "session-a": { providerID: "kilo", modelID: "missing" } }, - } - expect(getSelected(store, organization, "session-a", "code")).toEqual(gpt) - expect(getSessionModel(store, organization, "session-a", "code")).toEqual(gpt) - }) - - it("validates history overrides without deleting them when the catalog is empty or pending", () => { - const store = { ...emptyStore(), sessionOverrides: { "session-a": KILO_AUTO } } - const before = structuredClone(store) - for (const pending of [{ ready: false }, { providers: {} }, { organizationId: undefined }]) { - expect(getSelected(store, { ...organization, ...pending }, "session-a", "code")).toBeNull() - expect(getSessionModel(store, { ...organization, ...pending }, "session-a", "code")).toBeNull() - } - expect(getSessionModel(store, organization, "session-a", "code")).toEqual(KILO_AUTO) - expect(store).toEqual(before) - }) - - it("preserves connected external session choices while Kilo refreshes", () => { - const store = { ...emptyStore(), sessionOverrides: { "session-a": gpt } } - expect(getSessionModel(store, { ...organization, ready: false }, "session-a", "code")).toEqual(gpt) - expect(getSessionModel(store, { ...organization, connected: [] }, "session-a", "code")).toEqual(recommendation) - }) - - it("honors the same explicit choice in Agent Manager and the chat picker", () => { - const store = { ...emptyStore(), modelSelections: { code: KILO_AUTO }, userSetAgents: { code: true } } - const configured = { ...organization, getModeModel: () => first, getGlobalModel: () => gpt } - expect(getAgentModel(store, configured, "code")).toEqual(KILO_AUTO) - expect(getSelected(store, configured, undefined, "code")).toEqual(KILO_AUTO) - expect(store.modelSelections.code).toEqual(KILO_AUTO) - expect(getAgentModel(store, organization, "code")).toEqual(KILO_AUTO) - }) + store = { ...store, ...applyModel(store, "code", claude, "allocated") } + store = { ...store, agentSelections: { ...store.agentSelections, allocated: "code" } } + const e = env({ getModeModel: (agent) => (agent === "code" ? first : null) }) - it("uses valid mode and global config before the recommendation when implicit memory is stale", () => { - const store = { ...emptyStore(), modelSelections: { code: KILO_AUTO } } - expect(getAgentModel(store, { ...organization, getModeModel: () => first }, "code")).toEqual(first) - expect(getSelected(store, { ...organization, getGlobalModel: () => gpt }, undefined, "code")).toEqual(gpt) + // The allocated pick beats the mode config for that session. + expect(getSessionModel(store, e, "allocated", "code")).toEqual(claude) + // New sessions use the mode config. + expect(getSessionModel(store, e, "fresh", "code")).toEqual(first) }) - it("uses the latest explicit default across modes without changing a session override", () => { - const store: ModelStore = { - ...emptyStore(), - preferred: gpt, - modelSelections: { code: KILO_AUTO, ask: first }, - userSetAgents: { code: true, ask: true }, - sessionOverrides: { active: claude }, - } - const configured = { ...organization, getModeModel: () => first, getGlobalModel: () => KILO_AUTO } - for (const agent of ["code", "ask"]) { - expect(getAgentModel(store, configured, agent)).toEqual(gpt) - expect(getSelected(store, configured, undefined, agent)).toEqual(gpt) - expect(getSelected(store, configured, "active", agent)).toEqual(claude) - } - const unavailable = { ...configured, connected: ["kilo"] } - expect(getAgentModel(store, unavailable, "code")).toEqual(KILO_AUTO) - expect(getAgentModel(store, configured, "code")).toEqual(gpt) - expect(store.preferred).toEqual(gpt) + it("the composer scope key cannot collide with sessions, drafts, or pending tabs", () => { + expect(COMPOSER).not.toMatch(/^(?:sidebar-)?pending:/) + expect(COMPOSER).not.toMatch(/pending/) + expect(COMPOSER).not.toMatch(/^[0-9a-f]{8}-/) }) }) diff --git a/packages/kilo-vscode/tests/unit/session-outcome.test.ts b/packages/kilo-vscode/tests/unit/session-outcome.test.ts index 80c68b3f7ce..55e74bcbc23 100644 --- a/packages/kilo-vscode/tests/unit/session-outcome.test.ts +++ b/packages/kilo-vscode/tests/unit/session-outcome.test.ts @@ -14,7 +14,7 @@ function message(finish?: string, error?: Message["error"]): Message { } function todo(status: TodoItem["status"]): TodoItem { - return { id: status, content: status, status } + return { content: status, status } } describe("terminal", () => { diff --git a/packages/kilo-vscode/tests/unit/session-preference-loader.test.ts b/packages/kilo-vscode/tests/unit/session-preference-loader.test.ts deleted file mode 100644 index 755fb605ba7..00000000000 --- a/packages/kilo-vscode/tests/unit/session-preference-loader.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "bun:test" -import path from "node:path" - -describe("session preference loader", () => { - it.each(["exhaustion", "offline", "reconnect", "retry", "ready", "cleanup", "loaded", "synchronous"])( - "%s uses the production helper with browser Solid reactivity", - (name) => { - const child = Bun.spawnSync( - [ - process.execPath, - "--conditions=browser", - path.join(import.meta.dir, "../fixtures/session-preference-loader.ts"), - name, - ], - { cwd: path.join(import.meta.dir, "../../webview-ui"), stdout: "pipe", stderr: "pipe" }, - ) - expect(child.exitCode, child.stdout.toString() + child.stderr.toString()).toBe(0) - }, - ) -}) diff --git a/packages/kilo-vscode/tests/unit/session-preferences.test.ts b/packages/kilo-vscode/tests/unit/session-preferences.test.ts index 4cdb88e5c3c..e62a1b80e29 100644 --- a/packages/kilo-vscode/tests/unit/session-preferences.test.ts +++ b/packages/kilo-vscode/tests/unit/session-preferences.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { resolveMessagePrefs } from "../../webview-ui/src/context/session-preferences" +import { resolveInfoPrefs, resolveMessagePrefs } from "../../webview-ui/src/context/session-preferences" import type { Message } from "../../webview-ui/src/types/messages" function msg(input: Partial): Message { @@ -15,7 +15,7 @@ function msg(input: Partial): Message { const agents = new Set(["code", "ask"]) describe("session preference recovery", () => { - it("recovers model, variant, and agent from the latest user message", () => { + it("recovers each agent's latest user message", () => { const prefs = resolveMessagePrefs( [ msg({ @@ -32,23 +32,23 @@ describe("session preference recovery", () => { agents, ) - expect(prefs).toEqual({ - agent: "code", - model: { providerID: "openai", modelID: "gpt-5.5" }, - variant: "medium", + expect(prefs.agent).toBe("code") + expect(prefs.picks).toEqual({ + ask: { model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, variant: "low", seq: 1 }, + code: { model: { providerID: "openai", modelID: "gpt-5.5" }, variant: "medium", seq: 0 }, }) }) it.each([undefined, ""])("restores model default %s instead of an older effort", (variant) => { const prefs = resolveMessagePrefs( [ - msg({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } }), - msg({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant } }), + msg({ agent: "code", model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } }), + msg({ agent: "code", model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant } }), ], agents, ) - expect(prefs.variant).toBe("") + expect(prefs.picks.code?.variant).toBe("") }) it("ignores assistant-only model data and invalid agents", () => { @@ -63,7 +63,22 @@ describe("session preference recovery", () => { agents, ) - expect(prefs).toEqual({}) + expect(prefs.agent).toBeUndefined() + expect(prefs.picks).toEqual({}) + expect(prefs.unattributed).toBeUndefined() + }) + + it("keeps agentless user messages for the session's current agent", () => { + const prefs = resolveMessagePrefs( + [msg({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } })], + agents, + ) + + expect(prefs.unattributed).toEqual({ + model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + variant: "high", + seq: 0, + }) }) it("can recover the latest valid agent separately from the latest user model", () => { @@ -75,10 +90,49 @@ describe("session preference recovery", () => { agents, ) - expect(prefs).toEqual({ + expect(prefs.agent).toBe("code") + expect(prefs.picks).toEqual({ + ask: { model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, variant: "", seq: 1 }, + }) + }) + + it("prefers the newer agentless message over an older attributed one", () => { + const prefs = resolveMessagePrefs( + [ + msg({ agent: "code", model: { providerID: "openai", modelID: "gpt-5.5" } }), + msg({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } }), + ], + agents, + ) + + expect(prefs.agent).toBe("code") + expect(prefs.picks.code?.seq).toBe(1) + expect(prefs.unattributed?.seq).toBe(0) + }) +}) + +describe("session info recovery", () => { + it("recovers the agent, model and effort a server session last ran with", () => { + expect( + resolveInfoPrefs({ agent: "ask", model: { providerID: "openai", modelID: "gpt-5.5", variant: "high" } }, agents), + ).toEqual({ agent: "ask", model: { providerID: "openai", modelID: "gpt-5.5" }, variant: "high" }) + }) + + it("treats a missing variant as an explicit Default", () => { + expect(resolveInfoPrefs({ agent: "code", model: { providerID: "openai", modelID: "gpt-5.5" } }, agents)).toEqual({ agent: "code", - model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + model: { providerID: "openai", modelID: "gpt-5.5" }, variant: "", }) }) + + it("keeps the agent alone when the session has no model yet", () => { + expect(resolveInfoPrefs({ agent: "code" }, agents)).toEqual({ agent: "code" }) + }) + + it("ignores unknown or missing agents so a model is never attributed to the wrong agent", () => { + const model = { providerID: "openai", modelID: "gpt-5.5" } + expect(resolveInfoPrefs({ agent: "task", model }, agents)).toBeUndefined() + expect(resolveInfoPrefs({ model }, agents)).toBeUndefined() + }) }) diff --git a/packages/kilo-vscode/tests/unit/session-scroll-bottom.test.ts b/packages/kilo-vscode/tests/unit/session-scroll-bottom.test.ts index 14f094bb1fc..eae4002ac32 100644 --- a/packages/kilo-vscode/tests/unit/session-scroll-bottom.test.ts +++ b/packages/kilo-vscode/tests/unit/session-scroll-bottom.test.ts @@ -110,6 +110,16 @@ describe("the Storybook session mock satisfies what MessageList reads", () => { expect(providers).toMatch(/scrollBottomID: \(\) =>/) expect(providers).toMatch(/consumeScrollBottom: \(\) =>/) }) + + it("provides the MCP state and actions read by PromptInput", () => { + expect(providers).toMatch(/mcpAuth: \(\) =>/) + expect(providers).toMatch(/mcpRemoving: \(\) =>/) + expect(providers).toMatch(/mcpAuthResult: \(\) =>/) + expect(providers).toMatch(/mcpBundles: \(\) =>/) + expect(providers).toMatch(/signInMcp: noop/) + expect(providers).toMatch(/cancelMcpSignIn: noop/) + expect(providers).toMatch(/resetMcpAuth: noop/) + }) }) describe("MessageList arms a restore pass for the already-selected session", () => { diff --git a/packages/kilo-vscode/tests/unit/session-tab-pin.test.ts b/packages/kilo-vscode/tests/unit/session-tab-pin.test.ts new file mode 100644 index 00000000000..9bf2bc78462 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-tab-pin.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "bun:test" +import { unlinkSync } from "node:fs" +import path from "node:path" +import { build } from "esbuild" +import { solidPlugin } from "esbuild-plugin-solid" + +const ROOT = path.resolve(import.meta.dir, "../..") +const WEBVIEW = path.join(ROOT, "webview-ui") +const FIXTURE = path.join(ROOT, "tests/fixtures/session-tab-pin.tsx") + +describe("SessionTab shift-click pinning", () => { + it("toggles the pin on shift-click and selects on a plain click", async () => { + const solid = path.dirname(Bun.resolveSync("solid-js/package.json", WEBVIEW)) + const aliases: Record = { + "solid-js": path.join(solid, "dist/solid.js"), + "solid-js/web": path.join(solid, "web/dist/web.js"), + "solid-js/store": path.join(solid, "store/dist/store.js"), + } + const dedupe = { + name: "solid-dedupe", + setup(ctx: Parameters[0]["plugins"]>[number]["setup"]>[0]) { + ctx.onResolve({ filter: /^solid-js(\/web|\/store)?$/ }, (args) => ({ path: aliases[args.path] })) + }, + } + const result = await build({ + entryPoints: [FIXTURE], + bundle: true, + conditions: ["browser"], + external: ["happy-dom"], + format: "esm", + logLevel: "silent", + platform: "node", + plugins: [dedupe, solidPlugin()], + target: "es2022", + write: false, + }) + const file = path.join(ROOT, `.session-tab-pin-${crypto.randomUUID()}.mjs`) + await Bun.write(file, result.outputFiles[0]!.contents) + const child = Bun.spawnSync(["bun", file], { cwd: WEBVIEW, stdout: "pipe", stderr: "pipe" }) + unlinkSync(file) + + const output = child.stdout.toString() + child.stderr.toString() + expect(child.exitCode, output).toBe(0) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-variant-store.test.ts b/packages/kilo-vscode/tests/unit/session-variant-store.test.ts index dfa6afa7f3e..07c1c60b531 100644 --- a/packages/kilo-vscode/tests/unit/session-variant-store.test.ts +++ b/packages/kilo-vscode/tests/unit/session-variant-store.test.ts @@ -57,11 +57,11 @@ describe("per-session variant selection", () => { }) it.each(["anthropic/claude-sonnet-4", variantKey(model, "code")])( - "preserves the remembered preference %s above the configured variant", + "keeps the configured variant %s above the remembered preference", (key) => { const store = { [key]: "high" } - expect(getVariant(store, model, ["high", "max"], "code", "pending-new", "max")).toBe("high") - expect(getAgentVariant(store, model, { variants: { high: {}, max: {} } }, "code", "max")).toBe("high") + expect(getVariant(store, model, ["high", "max"], "code", "pending-new", "max")).toBe("max") + expect(getAgentVariant(store, model, { variants: { high: {}, max: {} } }, "code", "max")).toBe("max") }, ) diff --git a/packages/kilo-vscode/tests/unit/session-variants.test.ts b/packages/kilo-vscode/tests/unit/session-variants.test.ts index d83b5ba6b46..a21fabbfb97 100644 --- a/packages/kilo-vscode/tests/unit/session-variants.test.ts +++ b/packages/kilo-vscode/tests/unit/session-variants.test.ts @@ -4,7 +4,7 @@ import type { ExtensionMessage, ModelSelection } from "../../webview-ui/src/type const model: ModelSelection = { providerID: "anthropic", modelID: "claude-sonnet-4" } -function setup(session?: string, configured?: string) { +function setup(session = "composer", configured?: string) { const config = { model: "anthropic/claude-sonnet-4", variant: configured } const selections: Record = {} const messages: Array<{ type: string; key?: string; value?: string }> = [] @@ -21,6 +21,7 @@ function setup(session?: string, configured?: string) { agent: () => "code", config: () => config, find: () => ({ variants: { low: {}, high: {}, max: {} } }), + draft: (id) => id === "composer" || /^(?:sidebar-)?pending:/.test(id), remember: (agent, model, variant) => remembered.push({ agent, model, variant }), post: (message) => { order.push("post") @@ -46,16 +47,16 @@ function setup(session?: string, configured?: string) { describe("session variants", () => { it("distinguishes an unset effort from an explicit Default selection", () => { const state = setup() - expect(state.variants.saved(model, "code")).toBeUndefined() + expect(state.variants.saved(model, "code", "composer")).toBeUndefined() expect(state.variants.choice()).toBeUndefined() expect(state.variants.request()).toBe("") state.variants.select("") - expect(state.variants.saved(model, "code")).toBe("") + expect(state.variants.saved(model, "code", "composer")).toBe("") expect(state.variants.choice()).toBe("") expect(state.variants.request()).toBe("") }) - it.each([undefined, "session-a"])("carries explicit Default rather than the target preference for %s", (id) => { + it.each(["composer", "session-a"])("carries explicit Default rather than the target preference for %s", (id) => { const state = setup(id, "max") state.selections["agent/code/anthropic/claude-sonnet-4"] = "high" state.variants.carry(model, "", "code", id) @@ -89,14 +90,32 @@ describe("session variants", () => { expect(state.variants.request()).toBe("max") }) - it("keeps remembered effort when configuration changes for new tabs", () => { + it("uses updated configuration ahead of remembered defaults for new tabs", () => { const state = setup("pending-new", "high") state.selections["agent/code/anthropic/claude-sonnet-4"] = "low" - expect(state.variants.current()).toBe("low") + expect(state.variants.current()).toBe("high") state.config.variant = "max" + expect(state.variants.current()).toBe("max") + expect(state.variants.request()).toBe("max") + expect(state.variants.agent("code", model)).toBe("max") + }) + + it("resolves the raw saved choice in the same order as the displayed effort", () => { + const state = setup("pending-new", "high") + state.selections["agent/code/anthropic/claude-sonnet-4"] = "low" + expect(state.variants.current()).toBe("high") + expect(state.variants.saved(model, "code")).toBe("high") + expect(state.variants.choice()).toBe("high") + state.variants.select("low") + expect(state.variants.saved(model, "code", "pending-new")).toBe("low") expect(state.variants.current()).toBe("low") - expect(state.variants.request()).toBe("low") - expect(state.variants.agent("code", model)).toBe("low") + }) + + it("skips a configured variant the model does not offer, as the displayed effort does", () => { + const state = setup("pending-new", "ultra") + state.selections["agent/code/anthropic/claude-sonnet-4"] = "low" + expect(state.variants.current()).toBe("low") + expect(state.variants.saved(model, "code")).toBe("low") }) it("does not apply a configured variant to another model", () => { @@ -124,10 +143,11 @@ describe("session variants", () => { expect(state.messages).toEqual([]) }) - it("persists global selections but keeps session selections local", () => { - const global = setup() - global.variants.select("high") - expect(global.remembered).toEqual([{ agent: "code", model, variant: "high" }]) + it("keeps session selections local while drafts remember their effort", () => { + const draft = setup() + draft.variants.select("high") + expect(draft.selections["session/composer/anthropic/claude-sonnet-4"]).toBe("high") + expect(draft.remembered).toEqual([{ agent: "code", model, variant: "high" }]) const scoped = setup("session-a") scoped.variants.select("low") @@ -140,7 +160,7 @@ describe("session variants", () => { const state = setup() state.selections["agent/code/anthropic/claude-sonnet-4"] = "high" state.variants.select(undefined) - expect(state.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "" }) + expect(state.selections["session/composer/anthropic/claude-sonnet-4"]).toBe("") expect(state.variants.current()).toBeUndefined() expect(state.remembered).toEqual([{ agent: "code", model, variant: "" }]) }) @@ -148,7 +168,7 @@ describe("session variants", () => { it("does not shadow a cached variant when carrying the model default", () => { const global = setup() global.selections["agent/code/anthropic/claude-sonnet-4"] = "high" - global.variants.carry(model, undefined, "code") + global.variants.carry(model, undefined, "code", "composer") expect(global.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "high" }) expect(global.messages).toEqual([]) diff --git a/packages/kilo-vscode/tests/unit/settings-io.test.ts b/packages/kilo-vscode/tests/unit/settings-io.test.ts index 6ec90b6a60e..fdace8de319 100644 --- a/packages/kilo-vscode/tests/unit/settings-io.test.ts +++ b/packages/kilo-vscode/tests/unit/settings-io.test.ts @@ -187,6 +187,13 @@ describe("parseImport", () => { } }) + it("preserves the memory model setting", () => { + const json = JSON.stringify({ memory_model: "kilo/kilo-auto/small" }) + const result = parseImport(json) + expect(result.ok).toBe(true) + if (result.ok) expect(result.config.memory_model).toBe("kilo/kilo-auto/small") + }) + it("strips _meta before returning config", () => { const json = JSON.stringify({ _meta: { version: 1, exportedAt: "2026-01-01", secretsStripped: true }, @@ -300,6 +307,13 @@ describe("round-trip", () => { expect(result.config.instructions).toEqual(["rules.md"]) } }) + + it("export then import preserves the memory model setting", () => { + const original: Config = { memory_model: "kilo/kilo-auto/small" } + const result = parseImport(JSON.stringify(buildExport(original))) + expect(result.ok).toBe(true) + if (result.ok) expect(result.config.memory_model).toBe("kilo/kilo-auto/small") + }) }) // --------------------------------------------------------------------------- diff --git a/packages/kilo-vscode/tests/unit/settings-search.test.ts b/packages/kilo-vscode/tests/unit/settings-search.test.ts new file mode 100644 index 00000000000..5e4fbbd2e26 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/settings-search.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "bun:test" +import { readdirSync, readFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { + SETTINGS_ENTRIES, + SETTINGS_TABS, + highlightSegments, + searchSettings, +} from "../../webview-ui/src/components/settings/settings-search" +import { dict as en } from "../../webview-ui/src/i18n/en" +import { dict as agentManagerEn } from "../../webview-ui/agent-manager/i18n/en" + +const labels = { ...en, ...agentManagerEn } as Record +const translate = (key: string) => labels[key] ?? key + +describe("settings search index", () => { + it("maps every entry to a known tab", () => { + const tabs = new Set(SETTINGS_TABS.map((tab) => tab.id)) + for (const entry of SETTINGS_ENTRIES) { + expect(tabs.has(entry.tab)).toBe(true) + } + }) + + it("references existing English title keys", () => { + for (const entry of SETTINGS_ENTRIES) { + expect(labels[entry.titleKey]).toBeDefined() + } + for (const tab of SETTINGS_TABS) { + expect(labels[tab.titleKey]).toBeDefined() + } + }) + + it("references existing explicit description keys", () => { + for (const entry of SETTINGS_ENTRIES) { + if (!entry.descriptionKey) continue + expect(labels[entry.descriptionKey]).toBeDefined() + } + }) +}) + +// The index duplicates settings the tabs render inline, so guard against it +// rotting: every listed key must still appear in a settings component. +describe("settings search index drift guard", () => { + const settingsDir = join(dirname(fileURLToPath(import.meta.url)), "../../webview-ui/src/components/settings") + const skip = new Set([ + "settings-search.ts", + "settings-search-index.ts", + "SettingsSearch.tsx", + "SettingsSearchResults.tsx", + ]) + const sources = () => + readdirSync(settingsDir) + .filter((name) => /\.(ts|tsx)$/.test(name) && !skip.has(name)) + .map((name) => readFileSync(join(settingsDir, name), "utf8")) + .join("\n") + + it("lists only title keys the settings UI still renders", () => { + const source = sources() + const missing = SETTINGS_ENTRIES.filter((entry) => !source.includes(`"${entry.titleKey}"`)).map( + (entry) => entry.titleKey, + ) + expect(missing).toEqual([]) + }) + + it("lists only tab keys the settings UI still renders", () => { + const source = sources() + const missing = SETTINGS_TABS.filter((tab) => !source.includes(`"${tab.titleKey}"`)).map((tab) => tab.titleKey) + expect(missing).toEqual([]) + }) +}) + +describe("searchSettings", () => { + it("returns nothing for an empty query", () => { + expect(searchSettings("", translate)).toEqual([]) + expect(searchSettings(" ", translate)).toEqual([]) + }) + + it("returns nothing when no setting matches", () => { + expect(searchSettings("zzzzzznotasetting", translate)).toEqual([]) + }) + + it("ranks a title match above a tab-only match", () => { + const results = searchSettings("font", translate) + expect(results.length).toBeGreaterThan(0) + expect(results[0].titleKey).toBe("settings.display.fontSize.title") + expect(results[0].tab).toBe("display") + expect(results[0].tabOnly).toBe(false) + }) + + it("matches description text", () => { + const results = searchSettings("username", translate) + expect(results.some((result) => result.titleKey === "settings.display.username.title")).toBe(true) + }) + + it("matches keywords", () => { + const results = searchSettings("playwright", translate) + expect(results.some((result) => result.tab === "browser")).toBe(true) + }) + + it("requires every token to match", () => { + expect(searchSettings("display font", translate).some((result) => result.tab === "display")).toBe(true) + expect(searchSettings("display zzzz", translate)).toEqual([]) + }) + + it("falls back to a tab-only result when only the category matches", () => { + const results = searchSettings("providers", translate) + const tab = results.find((result) => result.tab === "providers") + expect(tab?.tabOnly).toBe(true) + }) + + it("restricts results to the supplied visible tabs", () => { + const tabs = SETTINGS_TABS.filter((tab) => tab.id !== "indexing") + const results = searchSettings("embeddings", translate, SETTINGS_ENTRIES, tabs) + expect(results.some((result) => result.tab === "indexing")).toBe(false) + }) + + it("resolves display titles and descriptions", () => { + const result = searchSettings("font", translate)[0] + expect(result.title).toBe("Font Size") + expect(typeof result.description).toBe("string") + }) + + // The results pane groups matches by tab and indexes the flat list for + // keyboard selection, so a tab must not reappear after another tab's results. + it("keeps each tab's matches contiguous", () => { + for (const query of ["browser", "model", "provider", "api", "mode", "font"]) { + const seen = new Set() + let previous = "" + for (const result of searchSettings(query, translate)) { + if (result.tab === previous) continue + expect(seen.has(result.tab)).toBe(false) + seen.add(result.tab) + previous = result.tab + } + } + }) +}) + +describe("highlightSegments", () => { + it("returns the whole text when the query is empty", () => { + expect(highlightSegments("Font Size", "")).toEqual([{ text: "Font Size", match: false }]) + }) + + it("marks matched tokens only", () => { + const parts = highlightSegments("Default Model", "model") + expect(parts).toEqual([ + { text: "Default ", match: false }, + { text: "Model", match: true }, + ]) + }) + + it("marks every token and is case-insensitive", () => { + const parts = highlightSegments("Image Generation Model", "image model") + expect(parts.filter((part) => part.match).map((part) => part.text.toLowerCase())).toEqual(["image", "model"]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/shortcut-hint.test.ts b/packages/kilo-vscode/tests/unit/shortcut-hint.test.ts new file mode 100644 index 00000000000..914a6c36098 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/shortcut-hint.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "bun:test" +import { recommend, type HintContext, type ManagerContext } from "../../webview-ui/src/utils/shortcut-hint" + +const bindings = { + addToContext: "⌘K ⌘A", + focusChatInput: "⌘⇧A", + agentManagerOpen: "⌘⇧M", + previousSession: "⌘⌥↑", + nextSession: "⌘⌥↓", + previousTab: "⌘⌥←", + nextTab: "⌘⌥→", + toggleDiff: "⌘D", + openPR: "⌘⇧R", + jumpTo3: "⌘3", +} + +const idle: HintContext = { bindings, focused: true, away: false, draft: false, busy: false, selection: false } +const manager: ManagerContext = { changes: false, pr: false, worktrees: 0 } +const am = (state: Partial, ctx: Partial = {}) => + recommend({ ...idle, ...ctx, manager: { ...manager, ...state } }) + +describe("shortcut hint recommendation", () => { + describe("sidebar chat", () => { + it("user selected code in the editor: add it to the chat", () => { + const hint = { binding: "⌘K ⌘A", label: "addSelection" } + expect(recommend({ ...idle, focused: false, away: true, selection: true })).toEqual(hint) + }) + + it("the hint lives in the placeholder, so a draft always hides it", () => { + expect(recommend({ ...idle, focused: false, away: true, selection: true, draft: true })).toBeUndefined() + }) + + it("user clicked back into the prompt after selecting: the editor shortcut no longer works", () => { + expect(recommend({ ...idle, selection: true })).toBeUndefined() + }) + + it("user works in the editor: show how to get back to the prompt", () => { + expect(recommend({ ...idle, focused: false, away: true })).toEqual({ binding: "⌘⇧A", label: "type" }) + }) + + it("user types or the prompt is idle: show nothing", () => { + expect(recommend({ ...idle, focused: false, draft: true })).toBeUndefined() + expect(recommend(idle)).toBeUndefined() + }) + + it("agent runs while the user waits in the prompt: Esc stops it", () => { + expect(recommend({ ...idle, busy: true })).toEqual({ binding: "Esc", label: "stop" }) + }) + + it("another session tab is open: show how to switch session tabs", () => { + const hint = { binding: "⌘⌥← ⌘⌥→", label: "sessions" } + expect(recommend({ ...idle, tabs: true })).toEqual(hint) + expect(recommend({ ...idle, tabs: true, busy: true })).toEqual(hint) + expect(recommend({ ...idle, tabs: true, draft: true })).toBeUndefined() + }) + }) + + describe("agent manager", () => { + it("another session waits for an answer: jump to it, before anything in this session", () => { + const hint = { binding: "⌘3", label: "waiting" } + expect(am({ waiting: 3, changes: true, worktrees: 2 })).toEqual(hint) + expect(am({ waiting: 3 }, { focused: false })).toEqual(hint) + expect(am({ waiting: 3 }, { draft: true })).toBeUndefined() + }) + + it("a selection in the editor still wins, because the user just made it", () => { + expect(am({ waiting: 3 }, { focused: false, away: true, selection: true })?.label).toBe("addSelection") + }) + + it("focus is in the sidebar, a panel, or a terminal: Cmd+Shift+M goes back to the prompt", () => { + expect(am({ worktrees: 2 }, { focused: false })).toEqual({ binding: "⌘⇧M", label: "type" }) + }) + + it("agent runs: suggest working on another session, or stop with one session", () => { + expect(am({ worktrees: 2, changes: true }, { busy: true })).toEqual({ binding: "⌘⌥↑ ⌘⌥↓", label: "sessions" }) + expect(am({}, { busy: true })).toEqual({ binding: "Esc", label: "stop" }) + }) + + it("agent finished with changes: review them, unless the diff is already open", () => { + expect(am({ changes: true, pr: true, worktrees: 2 })).toEqual({ binding: "⌘D", label: "changes" }) + expect(am({ changes: true, pr: true, panel: "diff" })).toEqual({ binding: "⌘⇧R", label: "pr" }) + }) + + it("pull request exists and its panel is closed: open it", () => { + expect(am({ pr: true })).toEqual({ binding: "⌘⇧R", label: "pr" }) + expect(am({ pr: true, panel: "pr" })).toBeUndefined() + }) + + it("nothing to review: switch session when there is more than one", () => { + expect(am({ worktrees: 1 })?.label).toBe("sessions") + expect(am({})).toBeUndefined() + }) + + it("a shortcut the user removed is never suggested", () => { + const rest = { ...bindings, toggleDiff: "" } + expect(recommend({ ...idle, bindings: rest, manager: { ...manager, changes: true } })).toBeUndefined() + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/shortcut-hints-settings.test.ts b/packages/kilo-vscode/tests/unit/shortcut-hints-settings.test.ts new file mode 100644 index 00000000000..9ce52cab58c --- /dev/null +++ b/packages/kilo-vscode/tests/unit/shortcut-hints-settings.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import * as vscode from "vscode" +import { + buildShortcutHintsSettingMessage, + shortcutHints, + watchShortcutHintsConfig, +} from "../../src/kilo-provider/shortcut-hints-settings" + +type Stub = { + getConfiguration: (section?: string) => { + get: (key: string, fallback?: T) => T | undefined + } + onDidChangeConfiguration: (listener: (event: vscode.ConfigurationChangeEvent) => void) => vscode.Disposable +} + +const original = { + get: vscode.workspace.getConfiguration, + watch: vscode.workspace.onDidChangeConfiguration, +} + +function stubConfig(state: Map) { + ;(vscode.workspace as unknown as Stub).getConfiguration = (section?: string) => { + if (section !== "kilo-code.new") { + return { get: (_key: string, fallback?: T) => fallback } + } + return { + get: (key: string, fallback?: T) => (state.has(key) ? (state.get(key) as T) : fallback), + } + } +} + +afterEach(() => { + const workspace = vscode.workspace as unknown as Stub + workspace.getConfiguration = original.get as Stub["getConfiguration"] + workspace.onDidChangeConfiguration = original.watch +}) + +describe("shortcut hints settings", () => { + let state: Map + + beforeEach(() => { + state = new Map() + stubConfig(state) + }) + + it("defaults to enabled and reports the persisted value", () => { + expect(shortcutHints()).toBe(true) + expect(buildShortcutHintsSettingMessage()).toEqual({ type: "shortcutHintsSettingLoaded", visible: true }) + + state.set("showShortcutHints", false) + expect(buildShortcutHintsSettingMessage()).toEqual({ type: "shortcutHintsSettingLoaded", visible: false }) + }) + + it("synchronizes open viewers and stops after disposal", () => { + const listeners = new Set<(event: vscode.ConfigurationChangeEvent) => void>() + const workspace = vscode.workspace as unknown as Stub + workspace.onDidChangeConfiguration = (listener) => { + listeners.add(listener) + return new vscode.Disposable(() => listeners.delete(listener)) + } + const parent: unknown[] = [] + const child: unknown[] = [] + const main = watchShortcutHintsConfig((msg) => parent.push(msg)) + const viewer = watchShortcutHintsConfig((msg) => child.push(msg)) + const emit = (key: string) => { + for (const listener of listeners) listener({ affectsConfiguration: (name) => name === key }) + } + + emit("kilo-code.new.agentManager.autoBranchNaming") + expect(parent).toEqual([]) + expect(child).toEqual([]) + + state.set("showShortcutHints", false) + emit("kilo-code.new.showShortcutHints") + expect(parent).toEqual([{ type: "shortcutHintsSettingLoaded", visible: false }]) + expect(child).toEqual(parent) + + viewer.dispose() + state.set("showShortcutHints", true) + emit("kilo-code.new.showShortcutHints") + expect(parent).toHaveLength(2) + expect(child).toHaveLength(1) + + main.dispose() + expect(listeners.size).toBe(0) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/sidebar-search.test.ts b/packages/kilo-vscode/tests/unit/sidebar-search.test.ts deleted file mode 100644 index b7f8502cf0a..00000000000 --- a/packages/kilo-vscode/tests/unit/sidebar-search.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, expect, it } from "bun:test" -import type { SectionState, SessionInfo, WorktreeState } from "../../webview-ui/src/types/messages" -import { buildSidebarSearch } from "../../webview-ui/agent-manager/sidebar-search" -import { buildShortcutCategories } from "../../webview-ui/agent-manager/shortcuts" - -const session = (id: string, title: string, updatedAt: string, parentID?: string): SessionInfo => ({ - id, - title, - parentID, - createdAt: "2026-06-01T00:00:00.000Z", - updatedAt, -}) - -const worktree = (id: string, branch: string, sectionId?: string): WorktreeState => ({ - id, - branch, - path: `/tmp/${id}`, - parentBranch: "main", - createdAt: "2026-06-01T00:00:00.000Z", - sectionId, -}) - -const section: SectionState = { - id: "section-polish", - name: "Polish", - color: "Blue", - order: 0, - collapsed: true, -} - -const build = (overrides?: Partial[0]>) => - buildSidebarSearch({ - worktrees: [ - { - worktree: worktree("wt-search", "feat/sidebar-search", section.id), - label: "Agent Manager search", - sessions: [ - session("busy-session", "Build grouped search", "2026-06-02T00:00:00.000Z"), - session("recent-session", "Review search UI", "2026-06-03T00:00:00.000Z"), - ], - }, - { - worktree: worktree("wt-other", "fix/other"), - label: "Other worktree", - sessions: [session("other-session", "Other session", "2026-06-04T00:00:00.000Z")], - }, - ], - sections: [section], - local: [session("local-session", "Local investigation", "2026-06-05T00:00:00.000Z")], - localLabel: "local", - localBranch: "main", - untitled: "Untitled", - pending: (id) => id.startsWith("pending:"), - activityFor: (id) => (id === "busy-session" ? "busy" : "idle"), - busy: () => false, - ...overrides, - }) - -describe("buildSidebarSearch", () => { - it("indexes worktree sessions with section context and excludes non-root tabs", () => { - const items = build({ - worktrees: [ - { - worktree: worktree("wt-search", "feat/sidebar-search", section.id), - label: "Agent Manager search", - sessions: [ - session("session", "Build grouped search", "2026-06-02T00:00:00.000Z"), - session("pending:1", "New Session", "2026-06-03T00:00:00.000Z"), - session("child", "Subagent", "2026-06-04T00:00:00.000Z", "session"), - ], - }, - ], - local: [], - }) - - expect(items).toHaveLength(3) - expect(items[0]).toMatchObject({ - kind: "session", - sessionId: "session", - worktreeId: "wt-search", - meta: ["Polish", "Agent Manager search", "feat/sidebar-search"], - section: { id: section.id, color: "Blue", collapsed: true }, - }) - expect(items[0]?.search).toContain("Build grouped search Agent Manager search feat/sidebar-search Polish") - expect(items[1]).toMatchObject({ kind: "local", title: "local", meta: ["main"], count: 0 }) - expect(items[2]).toMatchObject({ kind: "worktree", worktreeId: "wt-search", count: 1, visible: false }) - }) - - it("ranks attention and progress before recency within each result group", () => { - const items = build() - - expect(items.map((item) => item.key)).toEqual([ - "session:busy-session", - "session:local-session", - "session:other-session", - "session:recent-session", - "worktree:wt-search", - "local", - "worktree:wt-other", - ]) - expect(items[0]).toMatchObject({ state: "busy", updatedAt: "2026-06-02T00:00:00.000Z" }) - expect(items[1]).toMatchObject({ location: "local", meta: ["local"] }) - expect(items[4]).toMatchObject({ state: "busy", updatedAt: "2026-06-03T00:00:00.000Z" }) - }) - - it("uses the strongest session activity for context rows", () => { - const items = build({ - activityFor: (id) => (id === "busy-session" ? "busy" : id === "recent-session" ? "waiting" : "idle"), - }) - - expect(items.find((item) => item.key === "session:recent-session")).toMatchObject({ state: "waiting" }) - expect(items.find((item) => item.key === "worktree:wt-search")).toMatchObject({ state: "waiting" }) - }) - - it("keeps operation busy separate from session activity", () => { - const items = build({ - activityFor: () => "idle", - busy: () => true, - }) - - expect(items.find((item) => item.key === "worktree:wt-search")).toMatchObject({ state: "idle", busy: true }) - }) - - it("uses expanded sidebar visibility before recency as a tie-breaker", () => { - const items = build({ - worktrees: [ - { - worktree: worktree("wt-hidden", "feat/hidden", section.id), - label: "Same task hidden", - sessions: [session("hidden", "Same task", "2026-06-05T00:00:00.000Z")], - }, - { - worktree: worktree("wt-visible", "feat/visible"), - label: "Same task visible", - sessions: [session("visible", "Same task", "2026-06-01T00:00:00.000Z")], - }, - ], - local: [], - activityFor: () => "idle", - }) - - expect(items.filter((item) => item.kind === "session").map((item) => item.sessionId)).toEqual(["visible", "hidden"]) - expect(items.find((item) => item.key === "session:hidden")).toMatchObject({ visible: false }) - expect(items.find((item) => item.key === "session:visible")).toMatchObject({ visible: true }) - }) - - it("avoids repeating a worktree label that matches the session title", () => { - const items = build({ - worktrees: [ - { - worktree: worktree("wt-owned", "feat/owned", section.id), - label: "Owned context", - sessions: [session("owned", "Owned context", "2026-06-02T00:00:00.000Z")], - }, - ], - local: [], - }) - - expect(items[0]).toMatchObject({ kind: "session", meta: ["Polish", "feat/owned"] }) - }) -}) - -describe("Agent Manager shortcut map", () => { - it("includes sidebar search in the quick-switch section", () => { - const categories = buildShortcutCategories({ search: "⌘F", jumpTo1: "⌘1" }, (key) => key) - expect(categories[0]?.shortcuts[0]).toEqual({ - label: "agentManager.sidebarSearch.label", - binding: "⌘F", - }) - }) - - it("includes open pull request in the sidebar section", () => { - const categories = buildShortcutCategories({ openPR: "⌘⇧R" }, (key) => key) - expect(categories[1]?.shortcuts).toContainEqual({ - label: "agentManager.shortcuts.openPR", - binding: "⌘⇧R", - }) - }) -}) diff --git a/packages/kilo-vscode/tests/unit/todo-dock.test.ts b/packages/kilo-vscode/tests/unit/todo-dock.test.ts new file mode 100644 index 00000000000..51bc26f0cf3 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/todo-dock.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "bun:test" +import { + TODO_TITLE_MAX, + todoFinished, + todoFit, + todoStats, + todoVisible, +} from "../../webview-ui/src/components/chat/todo/todo-dock" +import type { TodoItem } from "../../webview-ui/src/types/messages" + +// Same shape as the backend sends: no ID, the text is the identity. +const item = (name: string, status: TodoItem["status"]): TodoItem => ({ content: `Item ${name}`, status }) + +describe("todoStats", () => { + it("leaves cancelled items out of the total", () => { + const stats = todoStats([item("1", "completed"), item("2", "cancelled"), item("3", "pending")]) + expect(stats.done).toBe(1) + expect(stats.total).toBe(2) + expect(stats.all).toBe(false) + expect(stats.active?.content).toBe("Item 3") + }) + + it("prefers the item in progress over the next pending one", () => { + expect(todoStats([item("1", "pending"), item("2", "in_progress")]).active?.content).toBe("Item 2") + }) + + it("treats a list of only cancelled items as empty", () => { + const stats = todoStats([item("1", "cancelled")]) + expect(stats.total).toBe(0) + expect(stats.all).toBe(false) + }) +}) + +describe("todoVisible", () => { + const open = todoStats([item("1", "completed"), item("2", "pending")]) + const done = todoStats([item("1", "completed"), item("2", "completed")]) + + it("always shows a list with open items", () => { + expect(todoVisible(open, "msg_1", "msg_9")).toBe(true) + }) + + it("keeps a finished list until a newer user message starts another run", () => { + expect(todoVisible(done, "msg_5", "msg_3")).toBe(true) + expect(todoVisible(done, "msg_5", "msg_7")).toBe(false) + }) + + it("hides an empty list", () => { + expect(todoVisible(todoStats([]), undefined, undefined)).toBe(false) + }) +}) + +describe("todoFinished", () => { + it("fires when the last open item of the same list finishes", () => { + const prev = todoStats([item("1", "completed"), item("2", "in_progress")]) + const next = todoStats([item("1", "completed"), item("2", "completed")]) + expect(todoFinished(prev, next)).toBe(true) + }) + + it("does not fire for a list that arrives already done", () => { + const next = todoStats([item("1", "completed")]) + expect(todoFinished(todoStats([]), next)).toBe(false) + expect(todoFinished(next, next)).toBe(false) + }) + + it("does not fire when a done list replaces another list", () => { + const prev = todoStats([item("1", "completed"), item("2", "pending")]) + const next = todoStats([item("a", "completed"), item("b", "completed")]) + expect(todoFinished(prev, next)).toBe(false) + }) + + it("does not fire when the replaced list only shares items that were already done", () => { + const prev = todoStats([item("1", "completed"), item("2", "pending")]) + const next = todoStats([item("1", "completed"), item("b", "completed")]) + expect(todoFinished(prev, next)).toBe(false) + }) +}) + +describe("todoFit", () => { + const base = { chip: 26, count: 28, gap: 2, goal: { full: 90, compact: 26 } } + + it("shows everything when there is room", () => { + expect(todoFit({ ...base, space: 600, title: 140 })).toEqual({ title: 140, count: true, compact: false }) + }) + + it("caps a long title", () => { + expect(todoFit({ ...base, space: 900, title: 500 }).title).toBe(TODO_TITLE_MAX) + }) + + it("truncates the title before it hides the goal label", () => { + const fit = todoFit({ ...base, space: 240, title: 200 }) + expect(fit.compact).toBe(false) + expect(fit.title).toBe(240 - 26 - 28 - 92) + }) + + it("hides the goal label, then the title, then the count", () => { + expect(todoFit({ ...base, space: 150, title: 200 })).toEqual({ title: 68, count: true, compact: true }) + expect(todoFit({ ...base, space: 100, title: 200 })).toEqual({ title: 0, count: true, compact: true }) + expect(todoFit({ ...base, space: 60, title: 200 })).toEqual({ title: 0, count: false, compact: true }) + }) + + it("keeps the goal label when there is no title to show", () => { + expect(todoFit({ ...base, space: 150, title: 0 })).toEqual({ title: 0, count: true, compact: false }) + }) + + it("never compacts a goal that is not there", () => { + expect(todoFit({ ...base, goal: undefined, space: 40, title: 200 })).toEqual({ + title: 0, + count: false, + compact: false, + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/todo-revert.test.ts b/packages/kilo-vscode/tests/unit/todo-revert.test.ts index 790e5663780..43b7e033b2a 100644 --- a/packages/kilo-vscode/tests/unit/todo-revert.test.ts +++ b/packages/kilo-vscode/tests/unit/todo-revert.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { state, target } from "../../webview-ui/src/context/todo-revert" +import { state } from "../../webview-ui/src/context/todo-revert" import type { Message, Part, TodoItem } from "../../webview-ui/src/types/messages" const base = { @@ -13,7 +13,7 @@ const user = (id: string): Message => ({ ...base, id, role: "user" }) const assistant = (id: string, parentID: string): Message => ({ ...base, id, parentID, role: "assistant" }) const todos = (...status: TodoItem["status"][]): TodoItem[] => - status.map((status, index) => ({ id: String(index + 1), content: `todo ${index + 1}`, status })) + status.map((status, index) => ({ content: `todo ${index + 1}`, status })) const part = (id: string, messageID: string, list: TodoItem[]): Part => ({ id, @@ -50,12 +50,6 @@ describe("todo revert helpers", () => { expect(state({ messages, parts })).toEqual(todos("completed", "completed", "pending")) }) - it("targets the todowrite part that first completed the clicked todo", () => { - expect(target({ messages, parts }, 0)?.id).toBe("part_2") - expect(target({ messages, parts }, 1)?.id).toBe("part_3") - expect(target({ messages, parts }, 2)).toBeUndefined() - }) - it("uses normal part ordering inside the revert boundary message", () => { expect(state({ messages, parts, revert: { messageID: "message_2", partID: "part_2" } })).toEqual( todos("pending", "pending", "pending"), diff --git a/packages/kilo-vscode/tests/unit/user-keybindings.test.ts b/packages/kilo-vscode/tests/unit/user-keybindings.test.ts new file mode 100644 index 00000000000..82bfe9be5d9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/user-keybindings.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "bun:test" +import * as path from "node:path" +import { buildKeybindingMap } from "../../src/agent-manager/format-keybinding" +import { mergeUserKeybindings, parseJsonc, userKeybindingFiles } from "../../src/agent-manager/user-keybindings" + +const defaults = [ + { command: "kilo-code.new.agentManagerOpen", key: "ctrl+shift+m", mac: "cmd+shift+m" }, + { command: "kilo-code.new.agentManager.toggleDiff", key: "ctrl+d", mac: "cmd+d" }, + { command: "kilo-code.new.agentManager.showShortcuts", key: "ctrl+shift+/", mac: "cmd+shift+/" }, +] + +const map = (user: unknown, mac: boolean) => buildKeybindingMap(mergeUserKeybindings(defaults, user, mac), mac) + +describe("user keybindings", () => { + it("parses keybindings.json with comments and trailing commas", () => { + const text = `// Place your key bindings in this file +[ + { "key": "cmd+j", "command": "kilo-code.new.agentManagerOpen" }, /* block */ + { "key": "cmd+//", "command": "x", "when": "a // not a comment" }, +]` + expect(parseJsonc(text)).toEqual([ + { key: "cmd+j", command: "kilo-code.new.agentManagerOpen" }, + { key: "cmd+//", command: "x", when: "a // not a comment" }, + ]) + expect(parseJsonc("[ { ")).toBeUndefined() + }) + + it("shows the user's shortcut instead of the default", () => { + const user = [{ key: "cmd+j", command: "kilo-code.new.agentManagerOpen" }] + expect(map(user, true).agentManagerOpen).toBe("⌘J") + expect(map([{ key: "ctrl+alt+j", command: "kilo-code.new.agentManagerOpen" }], false).agentManagerOpen).toBe( + "Ctrl+Alt+J", + ) + expect(map(undefined, false).agentManagerOpen).toBe("Ctrl+Shift+M") + }) + + it("formats chords", () => { + expect(map([{ key: "cmd+k cmd+d", command: "kilo-code.new.agentManager.toggleDiff" }], true).toggleDiff).toBe( + "⌘K ⌘D", + ) + }) + + it("hides a removed shortcut instead of showing its fallback", () => { + const user = [{ key: "cmd+d", command: "-kilo-code.new.agentManager.toggleDiff" }] + expect(map(user, true).toggleDiff).toBeUndefined() + const other = [{ key: "cmd+x", command: "-kilo-code.new.agentManager.toggleDiff" }] + expect(map(other, true).toggleDiff).toBe("⌘D") + }) + + it("finds the file for the default profile and falls back from a profile", () => { + const user = path.join("/data", "User") + expect(userKeybindingFiles(path.join(user, "globalStorage", "kilocode.kilo-code"))).toEqual([ + path.join(user, "keybindings.json"), + ]) + expect(userKeybindingFiles(path.join(user, "profiles", "abc", "globalStorage", "kilocode.kilo-code"))).toEqual([ + path.join(user, "profiles", "abc", "keybindings.json"), + path.join(user, "keybindings.json"), + ]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/webview-html.test.ts b/packages/kilo-vscode/tests/unit/webview-html.test.ts index 0191bc64a18..280cfd18454 100644 --- a/packages/kilo-vscode/tests/unit/webview-html.test.ts +++ b/packages/kilo-vscode/tests/unit/webview-html.test.ts @@ -1,5 +1,44 @@ import { describe, it, expect } from "bun:test" import { buildConnectSrc, buildCspString } from "../../src/webview-html-utils" +import { KiloProvider } from "../../src/KiloProvider" + +describe("Settings webview entry", () => { + function html(route?: () => { tab?: string; projectId?: string }) { + const provider = new KiloProvider( + { fsPath: "/extension" } as never, + { getServerInfo: () => ({ port: 3000 }) } as never, + undefined, + { settingsPanel: route }, + ) as unknown as { _getHtmlForWebview: (webview: unknown) => string } + return () => + provider._getHtmlForWebview({ + cspSource: "vscode-resource://test", + asWebviewUri: (uri: { fsPath: string }) => ({ toString: () => `vscode-resource://${uri.fsPath}` }), + }) + } + + it("starts the current Settings route directly, including after a reload", () => { + let tab = "indexing" + const render = html(() => ({ tab, projectId: "" })) + const first = render() + expect(first).toContain("/dist/settings.js") + expect(first).toContain("/dist/settings.css") + expect(first).toContain('type="module"') + expect(first).toContain('window.KILO_SETTINGS = {"tab":"indexing"') + expect(first).toContain('"projectId":"\\u003c/script>') + expect(first).not.toContain("