From 6e2515c232c217e9ebae4b23209249edd416b898 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:59:44 +0000 Subject: [PATCH 01/96] docs(kilo-docs): add Setting Up Other Tools page for KiloClaw --- packages/kilo-docs/lib/nav/kiloclaw.ts | 1 + .../kilo-docs/pages/kiloclaw/tools/index.md | 1 + .../kiloclaw/tools/setting-up-other-tools.md | 40 +++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md diff --git a/packages/kilo-docs/lib/nav/kiloclaw.ts b/packages/kilo-docs/lib/nav/kiloclaw.ts index 5b232a18f4e..f491518a7cb 100644 --- a/packages/kilo-docs/lib/nav/kiloclaw.ts +++ b/packages/kilo-docs/lib/nav/kiloclaw.ts @@ -54,6 +54,7 @@ export const KiloClawNav: NavSection[] = [ { href: "/kiloclaw/tools/1password", children: "1Password" }, { href: "/kiloclaw/tools/brave-search", children: "Brave Search" }, { href: "/kiloclaw/tools/agentcard", children: "AgentCard" }, + { href: "/kiloclaw/tools/setting-up-other-tools", children: "Setting Up Other Tools" }, ], }, { diff --git a/packages/kilo-docs/pages/kiloclaw/tools/index.md b/packages/kilo-docs/pages/kiloclaw/tools/index.md index 4a17e3289e7..9e1dd8830f3 100644 --- a/packages/kilo-docs/pages/kiloclaw/tools/index.md +++ b/packages/kilo-docs/pages/kiloclaw/tools/index.md @@ -12,3 +12,4 @@ KiloClaw supports integrations with third-party tools that extend your agent's c - [**1Password**](/docs/kiloclaw/tools/1password) — Securely manage credentials and let your agent fetch API keys or passwords without ever seeing them in plain text. - [**Brave Search**](/docs/kiloclaw/tools/brave-search) — Equip your agent with real-time web browsing via the Brave Search API. - [**AgentCard**](/docs/kiloclaw/tools/agentcard) — Enable your agent to perform financial transactions using virtual debit cards. +- [**Setting Up Other Tools**](/docs/kiloclaw/tools/setting-up-other-tools) — Configure your agent to use any third-party tool with a CLI or API. diff --git a/packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md b/packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md new file mode 100644 index 00000000000..bad70fd24a7 --- /dev/null +++ b/packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md @@ -0,0 +1,40 @@ +--- +title: "Setting Up Other Tools" +description: "Configure your KiloClaw agent to use third-party tools that aren't pre-installed" +--- + +# Setting Up Other Tools + +While KiloClaw comes with a set of [pre-configured tool integrations](/docs/kiloclaw/tools), your agent isn't limited to just those. KiloClaw can be configured to use virtually any third-party tool — as long as it has a CLI or an API, you can teach your agent to work with it. + +## If There Is a CLI + +When the tool you want to integrate provides a command-line interface, follow these steps: + +1. **Install the CLI.** Install the tool's CLI on your KiloClaw instance. You can do this by prompting your agent to run the appropriate install command, or by using the KiloClaw Dashboard to execute it directly. + +2. **Tell KiloClaw to install the CLI.** Prompt the agent to install the CLI so it's available in the agent's environment. For example: _"Install the Fly.io CLI."_ + +3. **Get a key, PAT, or token for the CLI and add it to the agent's 1Password vault.** Generate the necessary credentials from the tool's dashboard or settings, then store them in [1Password](/docs/kiloclaw/tools/1password) so the agent can access them securely. + +4. **Add the tool to `TOOLS.md`.** Navigate to the KiloClaw Dashboard (`app.kilo.ai/claw/settings`) > **Danger Zone** > **Edit Files** > workspace folder > `TOOLS.md`, and add the following to the bottom of the file: + + ``` + is <1 sentence description>. You have access to it via the CLI. The username and password are in the 1Password vault under . + ``` + +5. **Prompt the agent to use the CLI.** Ask the agent to perform a task using the tool. As it learns how to use the CLI, instruct it to save any usage details, flags, or workflow notes back to `TOOLS.md` for future reference. + +## If There Is No CLI, but There Is an API + +When the tool only provides an API (no CLI), follow these steps: + +1. **Get an API key with proper scopes and add it to the agent's 1Password vault.** Generate an API key from the tool's developer settings with the appropriate permissions. Store the key along with any other API details (e.g. base URL, username) in [1Password](/docs/kiloclaw/tools/1password). + +2. **Add the tool to `TOOLS.md`.** Navigate to the KiloClaw Dashboard (`app.kilo.ai/claw/settings`) > **Danger Zone** > **Edit Files** > workspace folder > `TOOLS.md`, and add the following to the bottom of the file: + + ``` + is <1 sentence description>. You have access to it via the API. API documentation is at . Credentials are in 1Password under . + ``` + +3. **Prompt the agent to use the API.** Ask the agent to perform a task using the tool's API. It will read the documentation and credentials from the information you provided. From ba4b581294e9eb2ad090ce855d5eb14adbdce528 Mon Sep 17 00:00:00 2001 From: Alex Gold Date: Fri, 17 Apr 2026 12:16:31 -0400 Subject: [PATCH 02/96] Manual AKG edits Updated the document to reflect changes in terminology and added instructions for configuring 1Password with the KiloClaw agent. --- .../kiloclaw/tools/setting-up-other-tools.md | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md b/packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md index bad70fd24a7..193b47ec59a 100644 --- a/packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md +++ b/packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md @@ -1,29 +1,27 @@ --- -title: "Setting Up Other Tools" -description: "Configure your KiloClaw agent to use third-party tools that aren't pre-installed" +title: "Setting Up Other Services" +description: "Configure your KiloClaw agent to use third-party tools and services that aren't pre-installed" --- -# Setting Up Other Tools +# Setting Up Other Services -While KiloClaw comes with a set of [pre-configured tool integrations](/docs/kiloclaw/tools), your agent isn't limited to just those. KiloClaw can be configured to use virtually any third-party tool — as long as it has a CLI or an API, you can teach your agent to work with it. +While KiloClaw comes with a set of [pre-configured tool integrations](/docs/kiloclaw/tools), your agent isn't limited to just those. KiloClaw can be configured to use virtually any third-party integration as a tool — as long as it has a CLI or an API, you can teach your agent to work with it. ## If There Is a CLI When the tool you want to integrate provides a command-line interface, follow these steps: -1. **Install the CLI.** Install the tool's CLI on your KiloClaw instance. You can do this by prompting your agent to run the appropriate install command, or by using the KiloClaw Dashboard to execute it directly. +1. **Tell KiloClaw to install the CLI.** Prompt the agent to install the CLI so it's available in the agent's environment. For example: _"Install the Fly.io CLI."_ -2. **Tell KiloClaw to install the CLI.** Prompt the agent to install the CLI so it's available in the agent's environment. For example: _"Install the Fly.io CLI."_ +2. **Get a key, PAT, or token for the CLI and add it to the agent's 1Password vault.** Generate the necessary credentials from the tool's dashboard or settings, then store them in [1Password](/docs/kiloclaw/tools/1password) so the agent can access them securely. -3. **Get a key, PAT, or token for the CLI and add it to the agent's 1Password vault.** Generate the necessary credentials from the tool's dashboard or settings, then store them in [1Password](/docs/kiloclaw/tools/1password) so the agent can access them securely. - -4. **Add the tool to `TOOLS.md`.** Navigate to the KiloClaw Dashboard (`app.kilo.ai/claw/settings`) > **Danger Zone** > **Edit Files** > workspace folder > `TOOLS.md`, and add the following to the bottom of the file: +3. **Add the tool to `TOOLS.md`.** Navigate to the KiloClaw Dashboard (`app.kilo.ai/claw/settings`) > **Danger Zone** > **Edit Files** > `workspace` folder > `TOOLS.md`, and add the following to the bottom of the file: ``` is <1 sentence description>. You have access to it via the CLI. The username and password are in the 1Password vault under . ``` -5. **Prompt the agent to use the CLI.** Ask the agent to perform a task using the tool. As it learns how to use the CLI, instruct it to save any usage details, flags, or workflow notes back to `TOOLS.md` for future reference. +4. **Prompt the agent to use the CLI.** Ask the agent to perform a task using the tool. ## If There Is No CLI, but There Is an API @@ -38,3 +36,20 @@ When the tool only provides an API (no CLI), follow these steps: ``` 3. **Prompt the agent to use the API.** Ask the agent to perform a task using the tool's API. It will read the documentation and credentials from the information you provided. + +{% callout type="note" %} +If you have not configured your KiloClaw with the 1Password CLI, you can add the username in `TOOLS.md` and the key in the [KiloClaw Dashboard](app.kilo.ai/claw/settings) under **Additional Secrets** with the config path `skills.entries..apiKey` and environment variable name `_API_KEY`. + +Change the TOOLS.md instruction to reference the correct Environment Variable. +{% /callout %} + +## Improving performance + +The instructions above will get your KiloClaw started with using the tool, but it will have to read the documentation every time and may fumble to use the CLI or API in question. + +As you use the CLI or API, instruct KiloClaw to do the following to make usage more reliable and less token-intensive: + +* Save usage patterns to `TOOLS.md` +* Extract usage patterns into a skill +* Write a python or javascript wrapper for the CLI or API to encompass the ways you tend to use it + From 934f1409c324c35c6ed4eeb97b6b07c8ed3145a3 Mon Sep 17 00:00:00 2001 From: Alex Gold Date: Fri, 17 Apr 2026 12:19:36 -0400 Subject: [PATCH 03/96] Update packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md --- .../kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md b/packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md index 193b47ec59a..ad838d06bfa 100644 --- a/packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md +++ b/packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md @@ -7,6 +7,8 @@ description: "Configure your KiloClaw agent to use third-party tools and service While KiloClaw comes with a set of [pre-configured tool integrations](/docs/kiloclaw/tools), your agent isn't limited to just those. KiloClaw can be configured to use virtually any third-party integration as a tool — as long as it has a CLI or an API, you can teach your agent to work with it. +We have seen this pattern work well with outside services like ZenDesk, Todoist, GitLab, and more. + ## If There Is a CLI When the tool you want to integrate provides a command-line interface, follow these steps: From fa2180bbfc5b61bd3e0708ac67feab8578bc9895 Mon Sep 17 00:00:00 2001 From: Alex Gold Date: Fri, 17 Apr 2026 12:24:39 -0400 Subject: [PATCH 04/96] Update packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> --- .../kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md b/packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md index ad838d06bfa..16558f195be 100644 --- a/packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md +++ b/packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md @@ -40,7 +40,7 @@ When the tool only provides an API (no CLI), follow these steps: 3. **Prompt the agent to use the API.** Ask the agent to perform a task using the tool's API. It will read the documentation and credentials from the information you provided. {% callout type="note" %} -If you have not configured your KiloClaw with the 1Password CLI, you can add the username in `TOOLS.md` and the key in the [KiloClaw Dashboard](app.kilo.ai/claw/settings) under **Additional Secrets** with the config path `skills.entries..apiKey` and environment variable name `_API_KEY`. +If you have not configured your KiloClaw with the 1Password CLI, you can add the username in `TOOLS.md` and the key in the [KiloClaw Dashboard](https://app.kilo.ai/claw/settings) under **Additional Secrets** with the config path `skills.entries..apiKey` and environment variable name `_API_KEY`. Change the TOOLS.md instruction to reference the correct Environment Variable. {% /callout %} From acd294e0976ed7808119ba116b8dbfe73d46f04e Mon Sep 17 00:00:00 2001 From: Alex Gold Date: Fri, 17 Apr 2026 13:47:20 -0400 Subject: [PATCH 05/96] Apply suggestion from @alexkgold --- packages/kilo-docs/lib/nav/kiloclaw.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-docs/lib/nav/kiloclaw.ts b/packages/kilo-docs/lib/nav/kiloclaw.ts index f491518a7cb..5d051fb7255 100644 --- a/packages/kilo-docs/lib/nav/kiloclaw.ts +++ b/packages/kilo-docs/lib/nav/kiloclaw.ts @@ -54,7 +54,7 @@ export const KiloClawNav: NavSection[] = [ { href: "/kiloclaw/tools/1password", children: "1Password" }, { href: "/kiloclaw/tools/brave-search", children: "Brave Search" }, { href: "/kiloclaw/tools/agentcard", children: "AgentCard" }, - { href: "/kiloclaw/tools/setting-up-other-tools", children: "Setting Up Other Tools" }, + { href: "/kiloclaw/tools/other-tools", children: "Other Tools" }, ], }, { From 0f7b16e51b614066a8796bbaef485a60c639fa53 Mon Sep 17 00:00:00 2001 From: Alex Gold Date: Fri, 17 Apr 2026 13:47:37 -0400 Subject: [PATCH 06/96] Apply suggestion from @alexkgold --- packages/kilo-docs/pages/kiloclaw/tools/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-docs/pages/kiloclaw/tools/index.md b/packages/kilo-docs/pages/kiloclaw/tools/index.md index 9e1dd8830f3..c9ad0125f04 100644 --- a/packages/kilo-docs/pages/kiloclaw/tools/index.md +++ b/packages/kilo-docs/pages/kiloclaw/tools/index.md @@ -12,4 +12,4 @@ KiloClaw supports integrations with third-party tools that extend your agent's c - [**1Password**](/docs/kiloclaw/tools/1password) — Securely manage credentials and let your agent fetch API keys or passwords without ever seeing them in plain text. - [**Brave Search**](/docs/kiloclaw/tools/brave-search) — Equip your agent with real-time web browsing via the Brave Search API. - [**AgentCard**](/docs/kiloclaw/tools/agentcard) — Enable your agent to perform financial transactions using virtual debit cards. -- [**Setting Up Other Tools**](/docs/kiloclaw/tools/setting-up-other-tools) — Configure your agent to use any third-party tool with a CLI or API. +- [**Setting Up Other Tools**](/docs/kiloclaw/tools/other-tools) — Configure your agent to use any third-party tool with a CLI or API. From 5e7b7dfbddd475a378479c0d814a94770f35a470 Mon Sep 17 00:00:00 2001 From: Alex Gold Date: Fri, 17 Apr 2026 13:47:49 -0400 Subject: [PATCH 07/96] Rename setting-up-other-tools.md to other-tools.md --- .../kiloclaw/tools/{setting-up-other-tools.md => other-tools.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/kilo-docs/pages/kiloclaw/tools/{setting-up-other-tools.md => other-tools.md} (100%) diff --git a/packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md b/packages/kilo-docs/pages/kiloclaw/tools/other-tools.md similarity index 100% rename from packages/kilo-docs/pages/kiloclaw/tools/setting-up-other-tools.md rename to packages/kilo-docs/pages/kiloclaw/tools/other-tools.md From 21baf778360380aeb26f56ff6daada2730a73d20 Mon Sep 17 00:00:00 2001 From: Alex Gold Date: Fri, 17 Apr 2026 14:15:40 -0400 Subject: [PATCH 08/96] Apply suggestions from code review Co-authored-by: Alex Gold --- packages/kilo-docs/pages/kiloclaw/tools/other-tools.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/kilo-docs/pages/kiloclaw/tools/other-tools.md b/packages/kilo-docs/pages/kiloclaw/tools/other-tools.md index 16558f195be..8dd1e06a984 100644 --- a/packages/kilo-docs/pages/kiloclaw/tools/other-tools.md +++ b/packages/kilo-docs/pages/kiloclaw/tools/other-tools.md @@ -41,8 +41,6 @@ When the tool only provides an API (no CLI), follow these steps: {% callout type="note" %} If you have not configured your KiloClaw with the 1Password CLI, you can add the username in `TOOLS.md` and the key in the [KiloClaw Dashboard](https://app.kilo.ai/claw/settings) under **Additional Secrets** with the config path `skills.entries..apiKey` and environment variable name `_API_KEY`. - -Change the TOOLS.md instruction to reference the correct Environment Variable. {% /callout %} ## Improving performance From 338eecf2427825e0e74ac04352f9e8928132efc7 Mon Sep 17 00:00:00 2001 From: Alex Gold Date: Mon, 20 Apr 2026 16:52:19 -0400 Subject: [PATCH 09/96] Updates --- .../pages/kiloclaw/tools/other-tools.md | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/kilo-docs/pages/kiloclaw/tools/other-tools.md b/packages/kilo-docs/pages/kiloclaw/tools/other-tools.md index 8dd1e06a984..00402eb9299 100644 --- a/packages/kilo-docs/pages/kiloclaw/tools/other-tools.md +++ b/packages/kilo-docs/pages/kiloclaw/tools/other-tools.md @@ -13,34 +13,30 @@ We have seen this pattern work well with outside services like ZenDesk, Todoist, When the tool you want to integrate provides a command-line interface, follow these steps: -1. **Tell KiloClaw to install the CLI.** Prompt the agent to install the CLI so it's available in the agent's environment. For example: _"Install the Fly.io CLI."_ +1. Tell KiloClaw to install the CLI. -2. **Get a key, PAT, or token for the CLI and add it to the agent's 1Password vault.** Generate the necessary credentials from the tool's dashboard or settings, then store them in [1Password](/docs/kiloclaw/tools/1password) so the agent can access them securely. +2. Add a key, PAT, or token to the KiloClaw's [1Password](/docs/kiloclaw/tools/1password). -3. **Add the tool to `TOOLS.md`.** Navigate to the KiloClaw Dashboard (`app.kilo.ai/claw/settings`) > **Danger Zone** > **Edit Files** > `workspace` folder > `TOOLS.md`, and add the following to the bottom of the file: +3. Navigate to the KiloClaw Dashboard (`app.kilo.ai/claw/settings`) > *Danger Zone* > *Edit Files* > `workspace` folder > `TOOLS.md`, and add the following to the bottom of the file: - ``` - is <1 sentence description>. You have access to it via the CLI. The username and password are in the 1Password vault under . - ``` +> is <1 sentence description>. You have access to it via the CLI. The username and password are in the 1Password vault under . -4. **Prompt the agent to use the CLI.** Ask the agent to perform a task using the tool. +4. Ask the agent to perform a task using the tool. ## If There Is No CLI, but There Is an API When the tool only provides an API (no CLI), follow these steps: -1. **Get an API key with proper scopes and add it to the agent's 1Password vault.** Generate an API key from the tool's developer settings with the appropriate permissions. Store the key along with any other API details (e.g. base URL, username) in [1Password](/docs/kiloclaw/tools/1password). +1. Add a key, PAT, or token to the KiloClaw's [1Password](/docs/kiloclaw/tools/1password). -2. **Add the tool to `TOOLS.md`.** Navigate to the KiloClaw Dashboard (`app.kilo.ai/claw/settings`) > **Danger Zone** > **Edit Files** > workspace folder > `TOOLS.md`, and add the following to the bottom of the file: +2. Navigate to the KiloClaw Dashboard (`app.kilo.ai/claw/settings`) > *Danger Zone* > *Edit Files* > `workspace` folder > `TOOLS.md`, and add the following to the bottom of the file: - ``` - is <1 sentence description>. You have access to it via the API. API documentation is at . Credentials are in 1Password under . - ``` +> is <1 sentence description>. You have access to it via the API. API documentation is at . Credentials are in 1Password under . -3. **Prompt the agent to use the API.** Ask the agent to perform a task using the tool's API. It will read the documentation and credentials from the information you provided. +3. Ask the agent to use the API. {% callout type="note" %} -If you have not configured your KiloClaw with the 1Password CLI, you can add the username in `TOOLS.md` and the key in the [KiloClaw Dashboard](https://app.kilo.ai/claw/settings) under **Additional Secrets** with the config path `skills.entries..apiKey` and environment variable name `_API_KEY`. +If you have not configured your KiloClaw with the 1Password CLI, you can add the username in `TOOLS.md` and the key as an *Additional Secret* in the [KiloClaw Dashboard](https://app.kilo.ai/claw/settings) with the config path `skills.entries..apiKey` and environment variable name `_API_KEY`. {% /callout %} ## Improving performance From 0353f6b8221e6a62949583c14dc039b4ea4bef71 Mon Sep 17 00:00:00 2001 From: Alex Gold Date: Mon, 20 Apr 2026 17:04:03 -0400 Subject: [PATCH 10/96] Fix brackets --- packages/kilo-docs/pages/kiloclaw/tools/other-tools.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kilo-docs/pages/kiloclaw/tools/other-tools.md b/packages/kilo-docs/pages/kiloclaw/tools/other-tools.md index 00402eb9299..4b5df2eb1a6 100644 --- a/packages/kilo-docs/pages/kiloclaw/tools/other-tools.md +++ b/packages/kilo-docs/pages/kiloclaw/tools/other-tools.md @@ -19,7 +19,7 @@ When the tool you want to integrate provides a command-line interface, follow th 3. Navigate to the KiloClaw Dashboard (`app.kilo.ai/claw/settings`) > *Danger Zone* > *Edit Files* > `workspace` folder > `TOOLS.md`, and add the following to the bottom of the file: -> is <1 sentence description>. You have access to it via the CLI. The username and password are in the 1Password vault under . +> TOOL is 1 SENTENCE DESCRIPTION. You have access to it via the CLI NAME CLI. The username and password are in the 1Password vault under TOOL. 4. Ask the agent to perform a task using the tool. @@ -31,9 +31,9 @@ When the tool only provides an API (no CLI), follow these steps: 2. Navigate to the KiloClaw Dashboard (`app.kilo.ai/claw/settings`) > *Danger Zone* > *Edit Files* > `workspace` folder > `TOOLS.md`, and add the following to the bottom of the file: -> is <1 sentence description>. You have access to it via the API. API documentation is at . Credentials are in 1Password under . +> TOOL is 1 SENTENCE DESCRIPTION. You have access to it via the API. API documentation is at URL OF API DOCUMENTATION. Credentials are in 1Password under TOOL NAME. -3. Ask the agent to use the API. +1. Ask the agent to use the API. {% callout type="note" %} If you have not configured your KiloClaw with the 1Password CLI, you can add the username in `TOOLS.md` and the key as an *Additional Secret* in the [KiloClaw Dashboard](https://app.kilo.ai/claw/settings) with the config path `skills.entries..apiKey` and environment variable name `_API_KEY`. From ce4a595cdcba5074b0ebdf74f24213a8322f2cc7 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:50:02 +0000 Subject: [PATCH 11/96] fix(vscode): dispose autocomplete context listener --- .changeset/autocomplete-listener-dispose.md | 5 +++++ .../AutocompleteInlineCompletionProvider.ts | 7 +++++-- .../autocomplete/context/ContextRetrievalService.ts | 4 ++++ .../context/ImportDefinitionsService.test.ts | 10 ++++++++++ .../autocomplete/context/ImportDefinitionsService.ts | 9 +++++++-- .../services/autocomplete/continuedev/core/index.d.ts | 6 +++++- .../core/vscode-test-harness/src/VSCodeIde.ts | 4 ++-- 7 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 .changeset/autocomplete-listener-dispose.md diff --git a/.changeset/autocomplete-listener-dispose.md b/.changeset/autocomplete-listener-dispose.md new file mode 100644 index 00000000000..2b8b830d3a0 --- /dev/null +++ b/.changeset/autocomplete-listener-dispose.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Dispose autocomplete editor listeners when inline completions are disabled. diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts index 93ef16b749e..ae05485b55c 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts @@ -133,6 +133,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple /** Abort controller for the current in-flight FIM request */ private fimAbortController: AbortController | null = null private acceptedCommand: vscode.Disposable | null = null + private contextService: ContextRetrievalService | null = null private debounceDelayMs: number = INITIAL_DEBOUNCE_DELAY_MS private latencyHistory: number[] = [] private telemetry: AutocompleteTelemetry | null @@ -167,10 +168,10 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple })() const ide = new VsCodeIde(context) - const contextService = new ContextRetrievalService(ide) + this.contextService = new ContextRetrievalService(ide) const contextProvider: AutocompleteContextProvider = { ide, - contextService, + contextService: this.contextService, model, ignoreController: this.ignoreController, } @@ -304,6 +305,8 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple this.fimAbortController?.abort() this.fimAbortController = null this.telemetry?.dispose() + this.contextService?.dispose() + this.contextService = null this.recentlyVisitedRangesService.dispose() this.recentlyEditedTracker.dispose() void this.disposeIgnoreController() diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ContextRetrievalService.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ContextRetrievalService.ts index 8b6e8ae5a75..fe4a917f961 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ContextRetrievalService.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ContextRetrievalService.ts @@ -64,6 +64,10 @@ export class ContextRetrievalService { return this.staticContextService.getContext(helper) } + public dispose(): void { + this.importDefinitionsService.dispose() + } + /** * Initialize the import definitions cache for a file. * This is normally done automatically when the active text editor changes, diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ImportDefinitionsService.test.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ImportDefinitionsService.test.ts index 5e24cfe2713..71fafe82e72 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ImportDefinitionsService.test.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ImportDefinitionsService.test.ts @@ -25,6 +25,16 @@ describe("ImportDefinitionsService", () => { service = new ImportDefinitionsService(mockIde) }) + it("should dispose active editor listener", () => { + const disposable = { dispose: vi.fn() } + mockIde.onDidChangeActiveTextEditor = vi.fn().mockReturnValue(disposable) + service = new ImportDefinitionsService(mockIde) + + service.dispose() + + expect(disposable.dispose).toHaveBeenCalledTimes(1) + }) + describe("Python Imports - Real Tree-sitter Parsing", () => { it("should extract named imports from 'from module import' statements", async () => { const pythonCode = `from os.path import join, exists diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ImportDefinitionsService.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ImportDefinitionsService.ts index 538d79d218f..523af450f6c 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ImportDefinitionsService.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ImportDefinitionsService.ts @@ -1,4 +1,4 @@ -import { IDE, RangeInFileWithContents } from "../.." +import { Disposable, IDE, RangeInFileWithContents } from "../.." import { PrecalculatedLruCache } from "../../util/LruCache" import { getFullLanguageName, getParserForFile, getQueryForFile } from "../../util/treeSitter" import { findUriInDirs } from "../../util/uri" @@ -14,15 +14,20 @@ export class ImportDefinitionsService { this._getFileInfo.bind(this), ImportDefinitionsService.N, ) + private readonly disposable: Disposable | void constructor(private readonly ide: IDE) { - ide.onDidChangeActiveTextEditor((filepath) => { + this.disposable = ide.onDidChangeActiveTextEditor((filepath) => { this.cache .initKey(filepath) .catch((e) => console.warn(`Failed to initialize ImportDefinitionService: ${e.message}`)) }) } + dispose(): void { + this.disposable?.dispose() + } + get(filepath: string): FileInfo | undefined { return this.cache.get(filepath) } diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/index.d.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/index.d.ts index 014efae8599..5585fe8a087 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/index.d.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/index.d.ts @@ -383,6 +383,10 @@ export type FileStatsMap = { [path: string]: FileStats } +export interface Disposable { + dispose(): void +} + export interface IDE { getIdeInfo(): Promise @@ -423,7 +427,7 @@ export interface IDE { getDocumentSymbols(textDocumentIdentifier: string): Promise // Callbacks - onDidChangeActiveTextEditor(callback: (fileUri: string) => void): void + onDidChangeActiveTextEditor(callback: (fileUri: string) => void): Disposable | void } export type ContextProviderName = | "diff" diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/vscode-test-harness/src/VSCodeIde.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/vscode-test-harness/src/VSCodeIde.ts index 894136e8aab..d3cb779748b 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/vscode-test-harness/src/VSCodeIde.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/vscode-test-harness/src/VSCodeIde.ts @@ -237,8 +237,8 @@ export class VsCodeIde implements IDE { return result } - onDidChangeActiveTextEditor(callback: (fileUri: string) => void): void { - vscode.window.onDidChangeActiveTextEditor((editor) => { + onDidChangeActiveTextEditor(callback: (fileUri: string) => void): vscode.Disposable { + return vscode.window.onDidChangeActiveTextEditor((editor) => { if (editor) { callback(editor.document.uri.toString()) } From f95d67bab4276f4628b94ed4a2a022d4d36722f8 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 23 Apr 2026 16:35:33 -0400 Subject: [PATCH 12/96] fix(jetbrains): simplify session loading panels --- .changeset/soft-garlic-care.md | 5 + .../client/actions/KiloSettingsAction.kt | 2 +- .../client/actions/ReinstallKiloAction.kt | 3 +- .../client/actions/RestartKiloAction.kt | 3 +- .../client/actions/StatusInfoAction.kt | 32 -- .../ai/kilocode/client/session/SessionUi.kt | 10 +- .../client/session/ui/ConnectionPanel.kt | 100 ++++++ .../client/session/ui/EmptySessionPanel.kt | 41 +++ .../kilocode/client/session/ui/StatusPanel.kt | 334 ------------------ .../resources/kilo.jetbrains.frontend.xml | 5 - .../resources/messages/KiloBundle.properties | 33 +- 11 files changed, 160 insertions(+), 408 deletions(-) create mode 100644 .changeset/soft-garlic-care.md delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/StatusInfoAction.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt diff --git a/.changeset/soft-garlic-care.md b/.changeset/soft-garlic-care.md new file mode 100644 index 00000000000..ee6f55a6d61 --- /dev/null +++ b/.changeset/soft-garlic-care.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Refine JetBrains session loading UI and settings actions. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt index 974bd38458f..b7f6ba5d12f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt @@ -29,7 +29,7 @@ class KiloSettingsAction : AnAction() { group, e.dataContext, JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, - true, // showDisabledActions — StatusInfoAction is always disabled + true, ) .showUnderneathOf(component) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt index 242498d9b59..7eab2842c67 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt @@ -5,8 +5,9 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service +import com.intellij.openapi.project.DumbAware -class ReinstallKiloAction : AnAction() { +class ReinstallKiloAction : AnAction(), DumbAware { override fun actionPerformed(e: AnActionEvent) { service().reinstallAsync() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt index f69c8c9668d..335cf611f0f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt @@ -5,8 +5,9 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service +import com.intellij.openapi.project.DumbAware -class RestartKiloAction : AnAction() { +class RestartKiloAction : AnAction(), DumbAware { override fun actionPerformed(e: AnActionEvent) { service().restartAsync() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/StatusInfoAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/StatusInfoAction.kt deleted file mode 100644 index 7f808f158ae..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/StatusInfoAction.kt +++ /dev/null @@ -1,32 +0,0 @@ -package ai.kilocode.client.actions - -import ai.kilocode.client.app.KiloAppService -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.rpc.dto.KiloAppStatusDto -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.components.service - -/** - * Non-interactive info row at the bottom of the settings popup showing - * connection status and CLI version (from the last health check). - */ -class StatusInfoAction : AnAction() { - override fun actionPerformed(e: AnActionEvent) { - // intentionally non-actionable - } - - override fun update(e: AnActionEvent) { - val svc = service() - val status = when (svc.state.value.status) { - KiloAppStatusDto.READY -> KiloBundle.message("toolwindow.status.connected.short") - KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting.short") - KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading.short") - KiloAppStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected.short") - KiloAppStatusDto.ERROR -> KiloBundle.message("toolwindow.status.error.short") - } - val ver = svc.version?.let { " · $it" } ?: "" - e.presentation.text = "$status$ver" - e.presentation.isEnabled = 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 b9b4856c4d2..9212f9816ce 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 @@ -5,12 +5,13 @@ import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.ui.ConnectionPanel +import ai.kilocode.client.session.ui.EmptySessionPanel import ai.kilocode.client.session.ui.LabelPicker import ai.kilocode.client.session.ui.PermissionPanel import ai.kilocode.client.session.ui.PromptPanel import ai.kilocode.client.session.ui.QuestionPanel import ai.kilocode.client.session.ui.SessionPanel -import ai.kilocode.client.session.ui.StatusPanel import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project import com.intellij.openapi.util.registry.Registry @@ -28,7 +29,8 @@ import javax.swing.JPanel * Top-level session UI — a thin composition root. * * Responsibilities: - * - Creates and wires [SessionController], [SessionPanel], [StatusPanel], + * - Creates and wires [SessionController], [SessionPanel], [EmptySessionPanel], + * [ConnectionPanel], * [PromptPanel], [QuestionPanel], [PermissionPanel]. * - Switches between the status (loading) card and the transcript card via * [SessionControllerEvent.ViewChanged]. @@ -71,7 +73,7 @@ class SessionUi( // ------ status (loading) panel ------ - private val status = StatusPanel(this, controller) + private val status = EmptySessionPanel(this) // ------ transcript ------ @@ -87,6 +89,7 @@ class SessionUi( private val question = QuestionPanel(controller) private val permission = PermissionPanel(controller) + private val connection = ConnectionPanel(this, controller) // ------ prompt ------ @@ -105,6 +108,7 @@ class SessionUi( isOpaque = false add(question) add(permission) + add(connection) add(prompt) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt new file mode 100644 index 00000000000..34c925e9e4d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -0,0 +1,100 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionController +import ai.kilocode.client.session.SessionControllerEvent +import ai.kilocode.client.session.SessionControllerListener +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import javax.swing.JPanel + +class ConnectionPanel( + parent: Disposable, + private val controller: SessionController, +) : JPanel(BorderLayout()), SessionControllerListener, Disposable { + + private val label = JBLabel().apply { + border = JBUI.Borders.empty(4, 8) + foreground = UIUtil.getContextHelpForeground() + } + + init { + Disposer.register(parent, this) + isOpaque = false + add(label, BorderLayout.CENTER) + controller.addListener(this, this) + render() + } + + override fun onEvent(event: SessionControllerEvent) { + when (event) { + is SessionControllerEvent.AppChanged, + is SessionControllerEvent.WorkspaceChanged -> render() + + else -> Unit + } + } + + private fun render() { + val app = controller.model.app + val workspace = controller.model.workspace + + if (app.status == KiloAppStatusDto.ERROR) { + label.foreground = UIUtil.getErrorForeground() + label.text = app.error ?: KiloBundle.message("session.connection.error.unknown") + showPanel() + return + } + + if (workspace.status == KiloWorkspaceStatusDto.ERROR) { + label.foreground = UIUtil.getErrorForeground() + label.text = workspace.error ?: KiloBundle.message("session.connection.error.unknown") + showPanel() + return + } + + if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY) { + hidePanel() + return + } + + label.foreground = UIUtil.getContextHelpForeground() + label.text = KiloBundle.message("session.connection.connecting") + showPanel() + } + + private fun showPanel() { + if (!isVisible) { + isVisible = true + refresh() + return + } + refresh() + } + + private fun hidePanel() { + if (isVisible) { + isVisible = false + refresh() + return + } + refresh() + } + + private fun refresh() { + parent?.revalidate() + parent?.repaint() + revalidate() + repaint() + } + + override fun dispose() { + // no-op + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt new file mode 100644 index 00000000000..8aa2c1208ee --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt @@ -0,0 +1,41 @@ +package ai.kilocode.client.session.ui + +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.IconLoader +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import javax.swing.JPanel + +/** + * Centered empty-session panel. + */ +class EmptySessionPanel( + parent: Disposable, +) : JPanel(GridBagLayout()), Disposable { + + init { + Disposer.register(parent, this) + } + + private val logo = JBLabel( + IconLoader.getIcon("/icons/kilo-content.svg", EmptySessionPanel::class.java), + ).apply { + alignmentX = CENTER_ALIGNMENT + } + + init { + isOpaque = false + + add(logo, GridBagConstraints().apply { + anchor = GridBagConstraints.CENTER + insets = JBUI.insets(12) + }) + } + + override fun dispose() { + // no-op + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt deleted file mode 100644 index a27b5471129..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt +++ /dev/null @@ -1,334 +0,0 @@ -package ai.kilocode.client.session.ui - -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.session.SessionController -import ai.kilocode.client.session.SessionControllerEvent -import ai.kilocode.client.session.SessionControllerListener -import ai.kilocode.rpc.dto.KiloAppStateDto -import ai.kilocode.rpc.dto.KiloAppStatusDto -import ai.kilocode.rpc.dto.KiloWorkspaceStateDto -import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto -import ai.kilocode.rpc.dto.ProfileStatusDto -import com.intellij.icons.AllIcons -import com.intellij.openapi.Disposable -import com.intellij.openapi.util.Disposer -import com.intellij.openapi.util.IconLoader -import com.intellij.ui.AnimatedIcon -import com.intellij.ui.components.JBLabel -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import java.awt.Font -import java.awt.GridBagConstraints -import java.awt.GridBagLayout -import javax.swing.Box -import javax.swing.BoxLayout -import javax.swing.Icon -import javax.swing.JPanel -import javax.swing.SwingConstants - -/** - * Welcome panel showing app + workspace initialization progress. - * - * Pure view — listens to [SessionController] events and reads - * [SessionModel][ai.kilocode.client.session.model.SessionModel] for data. - * No coroutines, no service references. - * - * Uses icon+label rows for each resource being loaded. Icons act as - * status indicators: animated spinner for loading, green check for - * success, red circle for error, grey circle for idle. - */ -class StatusPanel( - parent: Disposable, - private val controller: SessionController, -) : JPanel(GridBagLayout()), SessionControllerListener, Disposable { - - init { - Disposer.register(parent, this) - } - - // ------ status icons ------ - - private val iconLoading: Icon = AnimatedIcon.Default() - private val iconOk: Icon = AllIcons.RunConfigurations.TestPassed - private val iconError: Icon = AllIcons.RunConfigurations.TestFailed - private val iconWarn: Icon = AllIcons.General.Warning - private val iconIdle: Icon = AllIcons.RunConfigurations.TestNotRan - - // ------ header ------ - - private val logo = JBLabel( - IconLoader.getIcon("/icons/kilo-content.svg", StatusPanel::class.java), - ).apply { - alignmentX = CENTER_ALIGNMENT - } - - private val status = JBLabel().apply { - alignmentX = CENTER_ALIGNMENT - horizontalAlignment = SwingConstants.CENTER - font = JBUI.Fonts.label(13f) - foreground = UIUtil.getLabelForeground() - } - - // ------ app rows ------ - - private val configRow = row(KiloBundle.message("toolwindow.row.config")) - private val notifRow = row(KiloBundle.message("toolwindow.row.notifications")) - private val profileRow = row(KiloBundle.message("toolwindow.row.profile")) - - // ------ workspace rows ------ - - private val providersRow = row(KiloBundle.message("toolwindow.row.providers")) - private val agentsRow = row(KiloBundle.message("toolwindow.row.agents")) - private val commandsRow = row(KiloBundle.message("toolwindow.row.commands")) - private val skillsRow = row(KiloBundle.message("toolwindow.row.skills")) - - // ------ section headers ------ - - private val appHeader = header(KiloBundle.message("toolwindow.section.app")) - private val wsHeader = header(KiloBundle.message("toolwindow.section.workspace")) - - private val appSection = section(appHeader, configRow, notifRow, profileRow) - private val wsSection = section(wsHeader, providersRow, agentsRow, commandsRow, skillsRow) - - init { - isOpaque = false - - val body = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - border = JBUI.Borders.empty(12, 16) - - add(logo) - add(Box.createVerticalStrut(JBUI.scale(12))) - add(status) - add(Box.createVerticalStrut(JBUI.scale(12))) - add(appSection) - add(Box.createVerticalStrut(JBUI.scale(12))) - add(wsSection) - } - - add(body, GridBagConstraints()) - - resetAll() - controller.addListener(this, this) - } - - override fun onEvent(event: SessionControllerEvent) { - when (event) { - is SessionControllerEvent.AppChanged -> { - renderApp(controller.model.app) - revalidate() - repaint() - } - - is SessionControllerEvent.WorkspaceChanged -> { - renderWorkspace(controller.model.workspace) - revalidate() - repaint() - } - - else -> {} - } - } - - // ------ rendering ------ - - private fun renderApp(state: KiloAppStateDto) { - status.text = title(state) - - when (state.status) { - KiloAppStatusDto.DISCONNECTED -> { - resetAll() - } - KiloAppStatusDto.CONNECTING -> { - configRow.loading() - notifRow.loading() - profileRow.loading() - } - KiloAppStatusDto.LOADING -> { - val p = state.progress - if (p != null) { - if (p.config) configRow.ok(KiloBundle.message("toolwindow.row.config")) else configRow.loading() - if (p.notifications) notifRow.ok(KiloBundle.message("toolwindow.row.notifications")) else notifRow.loading() - renderProfile(p.profile) - } - } - KiloAppStatusDto.READY -> { - val p = state.progress - if (p != null) { - configRow.ok(KiloBundle.message("toolwindow.row.config")) - notifRow.ok(KiloBundle.message("toolwindow.row.notifications")) - renderProfile(p.profile) - } else { - configRow.ok(KiloBundle.message("toolwindow.row.config")) - notifRow.ok(KiloBundle.message("toolwindow.row.notifications")) - profileRow.ok(KiloBundle.message("toolwindow.profile.loggedin")) - } - } - KiloAppStatusDto.ERROR -> { - val errors = state.errors.associate { it.resource to it } - configRow.apply { - val detail = errors["config"]?.detail ?: KiloBundle.message("toolwindow.error.failed") - if ("config" in errors) error(KiloBundle.message("toolwindow.error.config", detail)) - else ok(KiloBundle.message("toolwindow.row.config")) - } - notifRow.apply { - val detail = errors["notifications"]?.detail ?: KiloBundle.message("toolwindow.error.failed") - if ("notifications" in errors) error(KiloBundle.message("toolwindow.error.notifications", detail)) - else ok(KiloBundle.message("toolwindow.row.notifications")) - } - profileRow.apply { - val detail = errors["profile"]?.detail ?: KiloBundle.message("toolwindow.error.failed") - if ("profile" in errors) error(KiloBundle.message("toolwindow.error.profile", detail)) - else ok(KiloBundle.message("toolwindow.profile.loggedin")) - } - } - } - } - - private fun renderWorkspace(state: KiloWorkspaceStateDto) { - val appReady = controller.model.app.status == KiloAppStatusDto.READY - val visible = appReady || state.status != KiloWorkspaceStatusDto.PENDING - wsSection.isVisible = visible - if (!visible) return - - when (state.status) { - KiloWorkspaceStatusDto.PENDING -> { - providersRow.idle(KiloBundle.message("toolwindow.row.providers")) - agentsRow.idle(KiloBundle.message("toolwindow.row.agents")) - commandsRow.idle(KiloBundle.message("toolwindow.row.commands")) - skillsRow.idle(KiloBundle.message("toolwindow.row.skills")) - } - KiloWorkspaceStatusDto.LOADING -> { - val p = state.progress - if (p != null) { - if (p.providers) providersRow.ok(KiloBundle.message("toolwindow.row.providers")) else providersRow.loading() - if (p.agents) agentsRow.ok(KiloBundle.message("toolwindow.row.agents")) else agentsRow.loading() - if (p.commands) commandsRow.ok(KiloBundle.message("toolwindow.row.commands")) else commandsRow.loading() - if (p.skills) skillsRow.ok(KiloBundle.message("toolwindow.row.skills")) else skillsRow.loading() - } else { - providersRow.loading() - agentsRow.loading() - commandsRow.loading() - skillsRow.loading() - } - } - KiloWorkspaceStatusDto.READY -> { - val prov = state.providers?.providers?.size ?: 0 - val ag = state.agents?.all?.size ?: 0 - val cmd = state.commands.size - val sk = state.skills.size - providersRow.ok(KiloBundle.message("toolwindow.row.providers.count", prov)) - agentsRow.ok(KiloBundle.message("toolwindow.row.agents.count", ag)) - commandsRow.ok(KiloBundle.message("toolwindow.row.commands.count", cmd)) - skillsRow.ok(KiloBundle.message("toolwindow.row.skills.count", sk)) - } - KiloWorkspaceStatusDto.ERROR -> { - val msg = state.error ?: KiloBundle.message("toolwindow.error.unknown") - providersRow.error(msg) - agentsRow.idle(KiloBundle.message("toolwindow.row.agents")) - commandsRow.idle(KiloBundle.message("toolwindow.row.commands")) - skillsRow.idle(KiloBundle.message("toolwindow.row.skills")) - } - } - } - - // ------ helpers ------ - - private fun title(state: KiloAppStateDto): String = - when (state.status) { - KiloAppStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected") - KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting") - KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading") - KiloAppStatusDto.READY -> { - val ver = controller.model.version - if (ver != null) KiloBundle.message("toolwindow.status.connected.version", ver) - else KiloBundle.message("toolwindow.status.connected") - } - KiloAppStatusDto.ERROR -> KiloBundle.message( - "toolwindow.status.error", - state.error ?: KiloBundle.message("toolwindow.error.unknown"), - ) - } - - private fun renderProfile(profile: ProfileStatusDto) { - when (profile) { - ProfileStatusDto.LOADED -> profileRow.ok(KiloBundle.message("toolwindow.profile.loggedin")) - ProfileStatusDto.NOT_LOGGED_IN -> profileRow.warn(KiloBundle.message("toolwindow.profile.notloggedin")) - ProfileStatusDto.PENDING -> profileRow.loading(KiloBundle.message("toolwindow.row.profile")) - } - } - - private fun resetAll() { - configRow.idle(KiloBundle.message("toolwindow.row.config")) - notifRow.idle(KiloBundle.message("toolwindow.row.notifications")) - profileRow.idle(KiloBundle.message("toolwindow.row.profile")) - providersRow.idle(KiloBundle.message("toolwindow.row.providers")) - agentsRow.idle(KiloBundle.message("toolwindow.row.agents")) - commandsRow.idle(KiloBundle.message("toolwindow.row.commands")) - skillsRow.idle(KiloBundle.message("toolwindow.row.skills")) - } - - // ------ row factory ------ - - private fun row(text: String): StatusRow = StatusRow(text, iconIdle) - - private fun header(text: String): JBLabel = JBLabel(text).apply { - alignmentX = LEFT_ALIGNMENT - font = JBUI.Fonts.label().deriveFont(JBUI.Fonts.label().style or Font.BOLD) - foreground = UIUtil.getLabelForeground() - border = JBUI.Borders.empty(0, 0, 4, 0) - } - - private fun section(hdr: JBLabel, vararg rows: StatusRow): JPanel = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - alignmentX = CENTER_ALIGNMENT - add(hdr) - for (r in rows) add(r.label) - } - - inner class StatusRow(text: String, icon: Icon) { - val label = JBLabel(text, icon, SwingConstants.LEFT).apply { - font = JBUI.Fonts.label() - foreground = UIUtil.getContextHelpForeground() - iconTextGap = JBUI.scale(6) - border = JBUI.Borders.empty(2, 0) - alignmentX = LEFT_ALIGNMENT - } - - fun ok(msg: String, ic: Icon = iconOk) { - label.icon = ic - label.text = msg - label.foreground = UIUtil.getContextHelpForeground() - } - - fun loading(msg: String = label.text) { - label.icon = iconLoading - label.text = msg - label.foreground = UIUtil.getContextHelpForeground() - } - - fun warn(msg: String) { - label.icon = iconWarn - label.text = msg - label.foreground = UIUtil.getContextHelpForeground() - } - - fun error(msg: String) { - label.icon = iconError - label.text = msg - label.foreground = UIUtil.getErrorForeground() - } - - fun idle(msg: String) { - label.icon = iconIdle - label.text = msg - label.foreground = UIUtil.getContextHelpForeground() - } - } - - override fun dispose() { - // Listener auto-removed by Disposer (registered in init via addListener) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index 5922e16ebd1..d865deb63fe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -31,14 +31,9 @@ - - - - Date: Fri, 24 Apr 2026 12:49:16 -0400 Subject: [PATCH 13/96] fix(jetbrains): overlay connection status above prompt --- .changeset/slow-terms-dance.md | 5 + .../ai/kilocode/client/session/SessionUi.kt | 63 +++++- .../client/session/ui/ConnectionPanel.kt | 3 +- .../client/session/SessionUiLayoutTest.kt | 191 ++++++++++++++++++ 4 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 .changeset/slow-terms-dance.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt diff --git a/.changeset/slow-terms-dance.md b/.changeset/slow-terms-dance.md new file mode 100644 index 00000000000..777d5bec8e7 --- /dev/null +++ b/.changeset/slow-terms-dance.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep the JetBrains session connection status above the prompt without shifting the chat layout. 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 9212f9816ce..aeaff4c2fca 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 @@ -23,7 +23,9 @@ import kotlinx.coroutines.CoroutineScope import java.awt.BorderLayout import java.awt.CardLayout import javax.swing.BoxLayout +import javax.swing.JLayeredPane import javax.swing.JPanel +import javax.swing.SwingUtilities /** * Top-level session UI — a thin composition root. @@ -34,6 +36,8 @@ import javax.swing.JPanel * [PromptPanel], [QuestionPanel], [PermissionPanel]. * - Switches between the status (loading) card and the transcript card via * [SessionControllerEvent.ViewChanged]. + * - Keeps [ConnectionPanel] on a transparent overlay layer directly above the + * prompt. * - Delegates all transcript and dock updates to the panels themselves via * [SessionModelEvent] listeners (no inline rendering logic here). * - Scrolls to the bottom on new content. @@ -99,6 +103,36 @@ class SessionUi( onAbort = { controller.abort() }, ) + private val content = JPanel(BorderLayout()) + + private val overlay = object : JPanel(null) { + override fun contains(x: Int, y: Int): Boolean { + for (child in components) { + if (child.isVisible && child.bounds.contains(x, y)) return true + } + return false + } + }.apply { + isOpaque = false + } + + private val root: JLayeredPane = object : JLayeredPane() { + override fun doLayout() { + content.setBounds(0, 0, width, height) + overlay.setBounds(0, 0, width, height) + + content.doLayout() + prompt.parent?.doLayout() + + val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, overlay) + val h = connection.preferredSize.height + connection.setBounds(box.x, maxOf(0, box.y - h), box.width, h) + connection.doLayout() + } + + override fun getPreferredSize() = content.preferredSize + } + init { // South area: question dock, permission dock, and prompt stacked vertically. // BoxLayout(Y_AXIS) collapses invisible panels to zero height automatically, @@ -108,7 +142,6 @@ class SessionUi( isOpaque = false add(question) add(permission) - add(connection) add(prompt) } @@ -116,8 +149,17 @@ class SessionUi( center.add(scroll, MESSAGES) cards.show(center, STATUS) - add(center, BorderLayout.CENTER) - add(south, BorderLayout.SOUTH) + content.add(center, BorderLayout.CENTER) + content.add(south, BorderLayout.SOUTH) + + overlay.add(connection) + + root.add(content) + root.setLayer(content, JLayeredPane.DEFAULT_LAYER) + root.add(overlay) + root.setLayer(overlay, JLayeredPane.PALETTE_LAYER) + + add(root, BorderLayout.CENTER) // ------ picker wiring ------ @@ -202,6 +244,7 @@ class SessionUi( permission.hidePanel() } } + refresh() scrollToBottom() } @@ -210,6 +253,20 @@ class SessionUi( bar.value = bar.maximum } + private fun refresh() { + center.revalidate() + center.repaint() + content.revalidate() + content.repaint() + root.revalidate() + root.repaint() + } + + override fun doLayout() { + super.doLayout() + root.doLayout() + } + override fun dispose() {} } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index 34c925e9e4d..eeba3d47831 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -26,7 +26,8 @@ class ConnectionPanel( init { Disposer.register(parent, this) - isOpaque = false + isOpaque = true + background = UIUtil.getPanelBackground() add(label, BorderLayout.CENTER) controller.addListener(this, this) render() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt new file mode 100644 index 00000000000..73a2b15b1f1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -0,0 +1,191 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.model.Permission +import ai.kilocode.client.session.model.PermissionMeta +import ai.kilocode.client.session.model.Question +import ai.kilocode.client.session.model.QuestionItem +import ai.kilocode.client.session.model.QuestionOption +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.ui.ConnectionPanel +import ai.kilocode.client.session.ui.PermissionPanel +import ai.kilocode.client.session.ui.PromptPanel +import ai.kilocode.client.session.ui.QuestionPanel +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import javax.swing.JLayeredPane +import javax.swing.JPanel +import javax.swing.SwingUtilities + +@Suppress("UnstableApiUsage") +class SessionUiLayoutTest : BasePlatformTestCase() { + + private lateinit var scope: CoroutineScope + private lateinit var sessions: KiloSessionService + private lateinit var app: KiloAppService + private lateinit var workspaces: KiloWorkspaceService + private lateinit var workspace: Workspace + private lateinit var ui: SessionUi + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + + val rpc = FakeSessionRpcApi() + val appRpc = FakeAppRpcApi().also { + it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + } + val workspaceRpc = FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY) + } + + sessions = KiloSessionService(project, scope, rpc) + app = KiloAppService(scope, appRpc) + workspaces = KiloWorkspaceService(scope, workspaceRpc) + workspace = workspaces.workspace("/test") + + ui = SessionUi(project, workspace, sessions, app, scope).apply { + setSize(800, 600) + } + layout() + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test root contains content and overlay layers`() { + val root = find(ui) + + assertEquals(2, root.componentCount) + assertTrue(root.components.all { it is JPanel }) + val panels = root.components.map { it as JPanel } + val overlay = panels.first { it.components.any { child -> child is ConnectionPanel } } + val content = panels.first { it !== overlay } + assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(content)) + assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(overlay)) + } + + fun `test overlay panel matches prompt width and sits above prompt`() { + val prompt = find(ui) + val connection = find(ui) + val overlay = connection.parent + + layout() + + val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, overlay) + val h = connection.preferredSize.height + + assertEquals(box.x, connection.x) + assertEquals(box.width, connection.width) + assertEquals(maxOf(0, box.y - h), connection.y) + assertEquals(h, connection.height) + } + + fun `test overlay follows prompt when question panel changes visibility`() { + val prompt = find(ui) + val connection = find(ui) + val overlay = connection.parent + val question = find(ui) + + layout() + assertFalse(question.isVisible) + + controller().model.setState(questionState()) + layout() + + assertTrue(question.isVisible) + val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, overlay) + assertEquals(box.width, connection.width) + assertEquals(maxOf(0, box.y - connection.preferredSize.height), connection.y) + assertEquals(connection.preferredSize.height, connection.height) + } + + fun `test overlay follows prompt when permission panel changes visibility`() { + val prompt = find(ui) + val connection = find(ui) + val overlay = connection.parent + val permission = find(ui) + + layout() + assertFalse(permission.isVisible) + + controller().model.setState(permissionState()) + layout() + + assertTrue(permission.isVisible) + val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, overlay) + assertEquals(box.width, connection.width) + assertEquals(maxOf(0, box.y - connection.preferredSize.height), connection.y) + assertEquals(connection.preferredSize.height, connection.height) + } + + private fun layout() { + ui.doLayout() + find(ui).doLayout() + } + + private inline fun find(root: java.awt.Container): T { + return find(root, T::class.java) ?: error("missing ${T::class.java.simpleName}") + } + + private fun find(root: java.awt.Container, cls: Class): T? { + if (cls.isInstance(root)) return cls.cast(root) + for (child in root.components) { + if (cls.isInstance(child)) return cls.cast(child) + if (child is java.awt.Container) { + val item = find(child, cls) + if (item != null) return item + } + } + return null + } + + private fun controller(): SessionController { + val field = SessionUi::class.java.getDeclaredField("controller") + field.isAccessible = true + return field.get(ui) as SessionController + } + + private fun questionState() = SessionState.AwaitingQuestion( + Question( + id = "q1", + items = listOf( + QuestionItem( + question = "Proceed?", + header = "Confirm", + options = listOf(QuestionOption("Yes", "Continue")), + multiple = false, + custom = true, + ) + ), + ) + ) + + private fun permissionState() = SessionState.AwaitingPermission( + Permission( + id = "p1", + sessionId = "ses", + name = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + meta = PermissionMeta(raw = emptyMap()), + ) + ) +} From 16c93d2d1adcaec21ed5f0dfb9af72e95fdaf265 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 24 Apr 2026 12:57:15 -0400 Subject: [PATCH 14/96] refactor(jetbrains): move busy state logic into model --- .../src/main/kotlin/ai/kilocode/client/session/SessionUi.kt | 5 ----- .../kotlin/ai/kilocode/client/session/model/SessionState.kt | 5 +++++ 2 files changed, 5 insertions(+), 5 deletions(-) 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 aeaff4c2fca..ae489352450 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 @@ -269,8 +269,3 @@ class SessionUi( override fun dispose() {} } - -private fun SessionState.isBusy(): Boolean = when (this) { - is SessionState.Idle, is SessionState.Error -> false - else -> true -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt index 6226dde8a55..7d0f5f1b67b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt @@ -15,4 +15,9 @@ sealed class SessionState { data class Offline(val message: String, val requestId: String) : SessionState() data class Error(val message: String, val kind: String? = null) : SessionState() + + fun isBusy(): Boolean = when (this) { + is Idle, is Error -> false + else -> true + } } From bb5ddee22127c53654c74b71058eed16cd00c36b Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 24 Apr 2026 14:13:05 -0400 Subject: [PATCH 15/96] refactor(jetbrains): isolate session update and layout helpers Move controller queue types into a dedicated update package and extract the generic layered root so SessionUi can stay focused on composition. Rename the message list panel to match its role and keep the queue condenser with the update flow. --- .../kilocode/client/app/KiloSessionService.kt | 2 +- .../ai/kilocode/client/session/SessionUi.kt | 69 ++--- .../client/session/model/SessionModel.kt | 2 +- .../client/session/ui/ConnectionPanel.kt | 6 +- .../client/session/ui/MessageListUi.kt | 282 ------------------ .../client/session/ui/PermissionPanel.kt | 2 +- .../client/session/ui/ProgressPanel.kt | 2 +- .../client/session/ui/QuestionPanel.kt | 2 +- ...ionPanel.kt => SessionMessageListPanel.kt} | 2 +- .../client/session/ui/SessionRootPanel.kt | 76 +++++ .../session/{ => update}/SessionController.kt | 30 +- .../{ => update}/SessionControllerEvent.kt | 2 +- .../{ => update}/SessionQueueCondenser.kt | 4 +- .../{ => update}/SessionUpdateQueue.kt | 2 +- .../client/session/ListenerLifecycleTest.kt | 2 +- .../session/SessionControllerTestBase.kt | 16 +- .../session/SessionQueueCondenserTest.kt | 1 + .../client/session/SessionRecoveryTest.kt | 3 +- .../client/session/SessionUiLayoutTest.kt | 17 +- .../client/session/SessionUiUpdateTest.kt | 8 +- .../client/session/SessionUpdateQueueTest.kt | 1 + .../client/session/ui/QuestionPanelTest.kt | 2 +- ...Test.kt => SessionMessageListPanelTest.kt} | 8 +- .../client/session/ui/SessionRootPanelTest.kt | 70 +++++ 24 files changed, 235 insertions(+), 376 deletions(-) delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/{SessionPanel.kt => SessionMessageListPanel.kt} (99%) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/{ => update}/SessionController.kt (97%) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/{ => update}/SessionControllerEvent.kt (96%) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/{ => update}/SessionQueueCondenser.kt (96%) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/{ => update}/SessionUpdateQueue.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/{SessionPanelTest.kt => SessionMessageListPanelTest.kt} (96%) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 063922fcdb4..5905fd0fa52 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -34,7 +34,7 @@ import kotlinx.coroutines.launch * Project-level frontend service for session management. * * Stateless with respect to "active session" — callers pass explicit - * session IDs. [ai.kilocode.client.session.SessionController] owns the + * session IDs. [ai.kilocode.client.session.update.SessionController] owns the * active session concept. */ @Service(Service.Level.PROJECT) 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 ae489352450..81fafa5a64a 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 @@ -11,7 +11,11 @@ import ai.kilocode.client.session.ui.LabelPicker import ai.kilocode.client.session.ui.PermissionPanel import ai.kilocode.client.session.ui.PromptPanel import ai.kilocode.client.session.ui.QuestionPanel -import ai.kilocode.client.session.ui.SessionPanel +import ai.kilocode.client.session.ui.SessionRootPanel +import ai.kilocode.client.session.ui.SessionMessageListPanel +import ai.kilocode.client.session.update.EVENT_FLUSH_MS +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project import com.intellij.openapi.util.registry.Registry @@ -23,7 +27,6 @@ import kotlinx.coroutines.CoroutineScope import java.awt.BorderLayout import java.awt.CardLayout import javax.swing.BoxLayout -import javax.swing.JLayeredPane import javax.swing.JPanel import javax.swing.SwingUtilities @@ -31,11 +34,11 @@ import javax.swing.SwingUtilities * Top-level session UI — a thin composition root. * * Responsibilities: - * - Creates and wires [SessionController], [SessionPanel], [EmptySessionPanel], + * - Creates and wires [ai.kilocode.client.session.update.SessionController], [SessionMessageListPanel], [EmptySessionPanel], * [ConnectionPanel], * [PromptPanel], [QuestionPanel], [PermissionPanel]. * - Switches between the status (loading) card and the transcript card via - * [SessionControllerEvent.ViewChanged]. + * [ai.kilocode.client.session.update.SessionControllerEvent.ViewChanged]. * - Keeps [ConnectionPanel] on a transparent overlay layer directly above the * prompt. * - Delegates all transcript and dock updates to the panels themselves via @@ -65,9 +68,9 @@ class SessionUi( ?: EVENT_FLUSH_MS private val controller = SessionController( - this, null, sessions, workspace, app, cs, this, - flushMs = flushMs, - condense = Registry.`is`("kilo.session.condense", true), + this, null, sessions, workspace, app, cs, this, + flushMs = flushMs, + condense = Registry.`is`("kilo.session.condense", true), ) // ------ card switch ------ @@ -81,7 +84,7 @@ class SessionUi( // ------ transcript ------ - private val transcript = SessionPanel(controller.model, this) + private val transcript = SessionMessageListPanel(controller.model, this) private val scroll = JBScrollPane(transcript).apply { border = JBUI.Borders.empty() @@ -103,34 +106,8 @@ class SessionUi( onAbort = { controller.abort() }, ) - private val content = JPanel(BorderLayout()) - - private val overlay = object : JPanel(null) { - override fun contains(x: Int, y: Int): Boolean { - for (child in components) { - if (child.isVisible && child.bounds.contains(x, y)) return true - } - return false - } - }.apply { - isOpaque = false - } - - private val root: JLayeredPane = object : JLayeredPane() { - override fun doLayout() { - content.setBounds(0, 0, width, height) - overlay.setBounds(0, 0, width, height) - - content.doLayout() - prompt.parent?.doLayout() - - val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, overlay) - val h = connection.preferredSize.height - connection.setBounds(box.x, maxOf(0, box.y - h), box.width, h) - connection.doLayout() - } - - override fun getPreferredSize() = content.preferredSize + private val root = SessionRootPanel().apply { + content.layout = BorderLayout() } init { @@ -149,15 +126,13 @@ class SessionUi( center.add(scroll, MESSAGES) cards.show(center, STATUS) - content.add(center, BorderLayout.CENTER) - content.add(south, BorderLayout.SOUTH) - - overlay.add(connection) - - root.add(content) - root.setLayer(content, JLayeredPane.DEFAULT_LAYER) - root.add(overlay) - root.setLayer(overlay, JLayeredPane.PALETTE_LAYER) + root.content.add(center, BorderLayout.CENTER) + root.content.add(south, BorderLayout.SOUTH) + root.addOverlay(connection) { panel, child -> + val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, panel) + val h = child.preferredSize.height + java.awt.Rectangle(box.x, maxOf(0, box.y - h), box.width, h) + } add(root, BorderLayout.CENTER) @@ -256,8 +231,8 @@ class SessionUi( private fun refresh() { center.revalidate() center.repaint() - content.revalidate() - content.repaint() + root.content.revalidate() + root.content.repaint() root.revalidate() root.repaint() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 4fec5b07bb1..0c93461c5e8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -15,7 +15,7 @@ import com.intellij.openapi.util.Disposer /** * Pure session model — single source of truth for session content and runtime state. * - * **EDT-only access** — no synchronization. [ai.kilocode.client.session.SessionController] guarantees all + * **EDT-only access** — no synchronization. [ai.kilocode.client.session.update.SessionController] guarantees all * reads and writes happen on the EDT. * * In addition to the flat message list, the model maintains a derived diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index eeba3d47831..5fd14f8f8a5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -1,9 +1,9 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.session.SessionController -import ai.kilocode.client.session.SessionControllerEvent -import ai.kilocode.client.session.SessionControllerListener +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent +import ai.kilocode.client.session.update.SessionControllerListener import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import com.intellij.openapi.Disposable diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt deleted file mode 100644 index b7e98135703..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt +++ /dev/null @@ -1,282 +0,0 @@ -package ai.kilocode.client.session.ui - -import ai.kilocode.client.session.model.SessionModel -import ai.kilocode.client.session.model.SessionModelEvent -import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.model.Compaction -import ai.kilocode.client.session.model.Content -import ai.kilocode.client.session.model.Generic -import ai.kilocode.client.session.model.Message -import ai.kilocode.client.session.model.Reasoning -import ai.kilocode.client.session.model.Text -import ai.kilocode.client.session.model.Tool -import ai.kilocode.client.session.model.ToolExecState -import com.intellij.openapi.Disposable -import com.intellij.ui.AnimatedIcon -import com.intellij.ui.JBColor -import com.intellij.ui.components.JBLabel -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import java.awt.BorderLayout -import java.awt.FlowLayout -import javax.swing.BoxLayout -import javax.swing.JPanel -import javax.swing.JTextArea -import javax.swing.border.MatteBorder - -/** - * Scrollable panel displaying session messages aligned to the top, - * with an optional animated status indicator at the bottom. - * - * Passive view — all rendering is driven by [SessionModelEvent]s - * from the [SessionModel]. No public mutation methods. - */ -class MessageListUi( - parent: Disposable, - private val model: SessionModel, -) : JPanel(BorderLayout()) { - - private val blocks = LinkedHashMap() - private var errorLabel: JBLabel? = null - - private val inner = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - border = JBUI.Borders.empty(4, 8) - } - - private val label = JBLabel().apply { - foreground = UIUtil.getContextHelpForeground() - } - - private val spinner = JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(4), 0)).apply { - isOpaque = false - isVisible = false - border = JBUI.Borders.empty(6, 0) - alignmentX = LEFT_ALIGNMENT - add(JBLabel(AnimatedIcon.Default())) - add(label) - } - - init { - isOpaque = true - background = UIUtil.getPanelBackground() - inner.add(spinner) - add(inner, BorderLayout.NORTH) - - model.addListener(parent) { event -> - when (event) { - is SessionModelEvent.MessageAdded -> onAdded(event.info) - is SessionModelEvent.MessageUpdated -> onAdded(event.info) // refresh/upsert - is SessionModelEvent.MessageRemoved -> onRemoved(event.id) - is SessionModelEvent.ContentAdded -> onContentAdded(event.messageId, event.content) - is SessionModelEvent.ContentUpdated -> onContentUpdated(event.messageId, event.content) - is SessionModelEvent.ContentRemoved -> onContentRemoved(event.messageId, event.contentId) - is SessionModelEvent.ContentDelta -> onContentDelta(event.messageId, event.contentId, event.delta) - is SessionModelEvent.StateChanged -> onState(event.state) - is SessionModelEvent.HistoryLoaded -> onHistory() - is SessionModelEvent.Cleared -> onCleared() - is SessionModelEvent.DiffUpdated, - is SessionModelEvent.TodosUpdated, - is SessionModelEvent.Compacted, - is SessionModelEvent.TurnAdded, - is SessionModelEvent.TurnUpdated, - is SessionModelEvent.TurnRemoved -> Unit - } - } - } - - private fun onAdded(info: Message) { - if (blocks.containsKey(info.info.id)) return - val block = MessageBlock(info) - blocks[info.info.id] = block - inner.add(block, inner.componentCount - 1) - refresh() - } - - private fun onRemoved(id: String) { - val block = blocks.remove(id) ?: return - inner.remove(block) - refresh() - } - - private fun onContentAdded(messageId: String, content: Content) { - blocks[messageId]?.addContent(content) - refresh() - } - - private fun onContentUpdated(messageId: String, content: Content) { - blocks[messageId]?.updateContent(content) - refresh() - } - - private fun onContentRemoved(messageId: String, contentId: String) { - blocks[messageId]?.removeContent(contentId) - refresh() - } - - private fun onContentDelta(messageId: String, contentId: String, delta: String) { - blocks[messageId]?.appendDelta(contentId, delta) - refresh() - } - - private fun onState(state: SessionState) { - errorLabel?.let { inner.remove(it); errorLabel = null } - when (state) { - is SessionState.Busy -> { - label.text = state.text - spinner.isVisible = true - } - is SessionState.Error -> { - spinner.isVisible = false - val err = JBLabel(state.message).apply { - foreground = JBColor.RED - font = JBUI.Fonts.label() - border = JBUI.Borders.empty(4, 0) - alignmentX = LEFT_ALIGNMENT - } - errorLabel = err - inner.add(err, inner.componentCount - 1) - } - else -> { - spinner.isVisible = false - } - } - refresh() - } - - private fun onHistory() { - clear() - for (entry in model.messages()) { - val block = MessageBlock(entry) - blocks[entry.info.id] = block - inner.add(block, inner.componentCount - 1) - for ((_, content) in entry.parts) block.addContent(content) - } - refresh() - } - - private fun onCleared() { - clear() - refresh() - } - - private fun clear() { - blocks.clear() - errorLabel = null - inner.removeAll() - inner.add(spinner) - spinner.isVisible = false - } - - private fun refresh() { - revalidate() - repaint() - } -} - -private class MessageBlock(info: Message) : JPanel() { - private val areas = LinkedHashMap() - private val labels = LinkedHashMap() - - init { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - alignmentX = LEFT_ALIGNMENT - - border = if (info.info.role == "user") { - JBUI.Borders.compound( - MatteBorder(1, 0, 0, 0, JBColor.border()), - JBUI.Borders.empty(8, 0, 4, 0), - ) - } else { - JBUI.Borders.empty(4, 0) - } - } - - fun addContent(content: Content) { - when (content) { - is Text -> { - val area = createArea() - if (content.content.isNotEmpty()) area.text = content.content.toString() - areas[content.id] = area - add(area) - } - is Reasoning -> { - val area = createArea().apply { - foreground = UIUtil.getContextHelpForeground() - } - if (content.content.isNotEmpty()) area.text = content.content.toString() - areas[content.id] = area - add(area) - } - is Tool -> { - val lbl = createToolLabel(content) - labels[content.id] = lbl - add(lbl) - } - is Compaction -> { - val lbl = JBLabel("Context compacted").apply { - foreground = UIUtil.getContextHelpForeground() - font = JBUI.Fonts.smallFont() - border = JBUI.Borders.empty(4, 0) - alignmentX = LEFT_ALIGNMENT - } - labels[content.id] = lbl - add(lbl) - } - is Generic -> {} // unknown part type — not rendered in this pass - } - revalidate() - } - - fun removeContent(contentId: String) { - areas.remove(contentId)?.let { remove(it) } - labels.remove(contentId)?.let { remove(it) } - revalidate() - } - - fun updateContent(content: Content) { - when (content) { - is Text -> areas[content.id]?.text = content.content.toString() - is Reasoning -> areas[content.id]?.text = content.content.toString() - is Tool -> labels[content.id]?.text = toolText(content) - is Compaction -> {} - is Generic -> {} - } - revalidate() - } - - fun appendDelta(contentId: String, delta: String) { - areas[contentId]?.append(delta) - revalidate() - } - - private fun createArea() = JTextArea().apply { - isEditable = false - lineWrap = true - wrapStyleWord = true - isOpaque = false - font = JBUI.Fonts.label() - foreground = UIUtil.getLabelForeground() - border = JBUI.Borders.empty() - alignmentX = LEFT_ALIGNMENT - } - - private fun createToolLabel(content: Tool) = JBLabel(toolText(content)).apply { - foreground = UIUtil.getContextHelpForeground() - font = JBUI.Fonts.smallFont() - border = JBUI.Borders.empty(2, 0) - alignmentX = LEFT_ALIGNMENT - } - - private fun toolText(content: Tool): String { - val icon = when (content.state) { - ToolExecState.PENDING -> "\u23F3" - ToolExecState.RUNNING -> "\u25B6" - ToolExecState.COMPLETED -> "\u2713" - ToolExecState.ERROR -> "\u2717" - } - return "$icon ${content.title ?: content.name}" - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt index 0c105e5da22..64319963508 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.session.ui -import ai.kilocode.client.session.SessionController +import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.session.model.Permission import ai.kilocode.rpc.dto.PermissionReplyDto import com.intellij.icons.AllIcons diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt index 75619241f77..3dea6a3beaf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt @@ -18,7 +18,7 @@ import java.awt.FlowLayout * - [SessionState.Busy] → shows an animated spinner and [SessionState.Busy.text] * - Any other state → hidden * - * Owned by [SessionPanel], which always re-anchors it as the last child so it + * Owned by [SessionMessageListPanel], which always re-anchors it as the last child so it * appears below all turn views inside the scroll pane. */ class ProgressPanel( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt index fcd83a223c1..709f5b08448 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.session.ui -import ai.kilocode.client.session.SessionController +import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.session.model.Question import ai.kilocode.rpc.dto.QuestionReplyDto import com.intellij.ui.JBColor diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionPanel.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 7a44c4531ef..4708c5a4d49 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -28,7 +28,7 @@ import com.intellij.util.ui.JBUI * * All method calls must happen on the EDT. */ -class SessionPanel( +class SessionMessageListPanel( private val model: SessionModel, parent: Disposable, ) : SessionLayoutPanel() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt new file mode 100644 index 00000000000..0c13740eb17 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt @@ -0,0 +1,76 @@ +package ai.kilocode.client.session.ui + +import java.awt.Dimension +import java.awt.Rectangle +import javax.swing.JComponent +import javax.swing.JLayeredPane +import javax.swing.JPanel + +class SessionRootPanel : JLayeredPane() { + + val content = JPanel() + + val overlay = Overlay() + + init { + layout = null + add(content) + setLayer(content, DEFAULT_LAYER) + add(overlay) + setLayer(overlay, PALETTE_LAYER) + } + + fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { + overlay.addOverlay(child, bounds) + } + + override fun doLayout() { + components + .sortedBy { getLayer(it) } + .forEach { child -> + child.setBounds(0, 0, width, height) + child.doLayout() + } + } + + override fun getPreferredSize(): Dimension { + val w = components.maxOfOrNull { it.preferredSize.width } ?: 0 + val h = components.maxOfOrNull { it.preferredSize.height } ?: 0 + return Dimension(w, h) + } + + class Overlay : JPanel(null) { + + private val items = linkedMapOf Rectangle>() + + init { + isOpaque = false + } + + fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { + items[child] = bounds + add(child) + } + + override fun contains(x: Int, y: Int): Boolean { + for (child in components) { + if (child.isVisible && child.bounds.contains(x, y)) return true + } + return false + } + + override fun doLayout() { + items.forEach { (child, bounds) -> + child.bounds = bounds(this, child) + child.doLayout() + } + } + + override fun getPreferredSize(): Dimension { + val pref = super.getPreferredSize() + val w = maxOf(pref.width, components.maxOfOrNull { it.preferredSize.width } ?: 0) + val h = maxOf(pref.height, components.maxOfOrNull { it.preferredSize.height } ?: 0) + return Dimension(w, h) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt similarity index 97% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index bdc6303385f..edacb192472 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService @@ -35,6 +35,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import java.awt.Component /** * Session lifecycle orchestrator for a single session. @@ -49,15 +50,15 @@ import kotlinx.coroutines.launch * via [SessionControllerEvent] to registered listeners. */ class SessionController( - parent: Disposable, - id: String?, - private val sessions: KiloSessionService, - private val workspace: Workspace, - private val app: KiloAppService, - private val cs: CoroutineScope, - comp: java.awt.Component? = null, - private val flushMs: Long = EVENT_FLUSH_MS, - private val condense: Boolean = true, + parent: Disposable, + id: String?, + private val sessions: KiloSessionService, + private val workspace: Workspace, + private val app: KiloAppService, + private val cs: CoroutineScope, + comp: Component? = null, + private val flushMs: Long = EVENT_FLUSH_MS, + private val condense: Boolean = true, ) : Disposable { companion object { @@ -73,7 +74,14 @@ class SessionController( private val listeners = mutableListOf() private var sessionId: String? = id private val directory: String get() = workspace.directory - private val updates = SessionUpdateQueue(parent, comp, flushMs, ::handle, condense, id != null) { sessionId ?: "pending" } + private val updates = SessionUpdateQueue( + parent, + comp, + flushMs, + ::handle, + condense, + id != null + ) { sessionId ?: "pending" } private var partType: String? = null private var tool: String? = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt index 6026aed5e28..b872969180f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionQueueCondenser.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionQueueCondenser.kt index cb6b85d7667..dcb06f4e6ca 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionQueueCondenser.kt @@ -1,9 +1,9 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.rpc.dto.ChatEventDto /** - * Reduces a batch of queued [ChatEventDto] events before they are flushed to + * Reduces a batch of queued [ai.kilocode.rpc.dto.ChatEventDto] events before they are flushed to * the model, by merging consecutive same-key snapshot and text-delta events. * * ## Algorithm diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionUpdateQueue.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionUpdateQueue.kt index 2c02e7b3349..5881e1bff08 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionUpdateQueue.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt index a9a7de5c9df..f45af5a2ebe 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.session -import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.util.Disposer diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt index 8e79cec7dc9..83688adb057 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt @@ -10,6 +10,8 @@ import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.AgentsDto import ai.kilocode.rpc.dto.ChatEventDto @@ -36,7 +38,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking /** - * Base class for [SessionController] tests. + * Base class for [ai.kilocode.client.session.update.SessionController] tests. * * Provides real IntelliJ Application/EDT/Disposer via [BasePlatformTestCase], * real frontend services wired to fake RPC backends, and shared helpers. @@ -128,7 +130,17 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { protected fun controller(id: String? = null, flushMs: Long, condense: Boolean): SessionController { val root = Root() - val m = SessionController(parent, id, sessions, workspace, app, scope, root, flushMs, condense) + val m = SessionController( + parent, + id, + sessions, + workspace, + app, + scope, + root, + flushMs, + condense + ) controllers.add(m) roots[m] = root return m diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt index 0e5cf03e171..f114099717f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session +import ai.kilocode.client.session.update.SessionQueueCondenser import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt index 87d68d4ff32..05af93de749 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt @@ -5,13 +5,12 @@ import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionStatusDto -import ai.kilocode.rpc.dto.SessionTimeDto /** * Tests for pending permission/question recovery after history load. * * VS Code rehydrates pending prompts by calling list endpoints after - * reconnect. JetBrains now does the same in [SessionController.recoverPending]. + * reconnect. JetBrains now does the same in [ai.kilocode.client.session.update.SessionController.recoverPending]. */ class SessionRecoveryTest : SessionControllerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 73a2b15b1f1..7522c71aa94 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -14,6 +14,8 @@ import ai.kilocode.client.session.ui.ConnectionPanel import ai.kilocode.client.session.ui.PermissionPanel import ai.kilocode.client.session.ui.PromptPanel import ai.kilocode.client.session.ui.QuestionPanel +import ai.kilocode.client.session.ui.SessionRootPanel +import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi @@ -26,7 +28,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import javax.swing.JLayeredPane -import javax.swing.JPanel import javax.swing.SwingUtilities @Suppress("UnstableApiUsage") @@ -71,15 +72,13 @@ class SessionUiLayoutTest : BasePlatformTestCase() { } fun `test root contains content and overlay layers`() { - val root = find(ui) + val root = find(ui) assertEquals(2, root.componentCount) - assertTrue(root.components.all { it is JPanel }) - val panels = root.components.map { it as JPanel } - val overlay = panels.first { it.components.any { child -> child is ConnectionPanel } } - val content = panels.first { it !== overlay } - assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(content)) - assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(overlay)) + assertSame(root.content, root.components.first { it === root.content }) + assertSame(root.overlay, root.components.first { it === root.overlay }) + assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content)) + assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay)) } fun `test overlay panel matches prompt width and sits above prompt`() { @@ -138,7 +137,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { private fun layout() { ui.doLayout() - find(ui).doLayout() + find(ui).doLayout() } private inline fun find(root: java.awt.Container): T { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt index 343271cdf7f..c5044e1b342 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt @@ -2,7 +2,7 @@ package ai.kilocode.client.session import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.ui.SessionPanel +import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.views.TextView import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto @@ -14,7 +14,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase /** * Integration test: mutate [SessionModel] directly on the EDT and verify - * that [SessionPanel] reflects the changes without any end-to-end RPC flow. + * that [SessionMessageListPanel] reflects the changes without any end-to-end RPC flow. * * This tests the full model → event → view update pipeline in isolation. */ @@ -23,13 +23,13 @@ class SessionUiUpdateTest : BasePlatformTestCase() { private lateinit var model: SessionModel private lateinit var parent: Disposable - private lateinit var panel: SessionPanel + private lateinit var panel: SessionMessageListPanel override fun setUp() { super.setUp() parent = Disposer.newDisposable("test") model = SessionModel() - panel = SessionPanel(model, parent) + panel = SessionMessageListPanel(model, parent) } override fun tearDown() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt index 04dc2ad4f3e..ed084a1584e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.update.SessionController import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.SessionStatusDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt index 9f16b995293..769b5dbbe08 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt @@ -4,7 +4,7 @@ import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace -import ai.kilocode.client.session.SessionController +import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionPanelTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index a0df7614d7a..c2f2a68858e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -11,24 +11,24 @@ import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase /** - * Tests for [SessionPanel] — structural and index integrity. + * Tests for [SessionMessageListPanel] — structural and index integrity. * * Uses [BasePlatformTestCase] for a real IntelliJ Application; layout * is not measured (no screen), but the structural / index state is fully * testable. */ @Suppress("UnstableApiUsage") -class SessionPanelTest : BasePlatformTestCase() { +class SessionMessageListPanelTest : BasePlatformTestCase() { private lateinit var model: SessionModel private lateinit var parent: Disposable - private lateinit var panel: SessionPanel + private lateinit var panel: SessionMessageListPanel override fun setUp() { super.setUp() parent = Disposer.newDisposable("test") model = SessionModel() - panel = SessionPanel(model, parent) + panel = SessionMessageListPanel(model, parent) } override fun tearDown() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt new file mode 100644 index 00000000000..7d66c8604f1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt @@ -0,0 +1,70 @@ +package ai.kilocode.client.session.ui + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Dimension +import java.awt.Rectangle +import javax.swing.JLayeredPane +import javax.swing.JPanel + +@Suppress("UnstableApiUsage") +class SessionRootPanelTest : BasePlatformTestCase() { + + fun `test root owns content and overlay layers`() { + val root = SessionRootPanel() + + assertEquals(2, root.componentCount) + assertSame(root.content, root.components.first { it === root.content }) + assertSame(root.overlay, root.components.first { it === root.overlay }) + assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content)) + assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay)) + } + + fun `test root layout fills immediate children`() { + val root = SessionRootPanel().apply { + setSize(320, 180) + } + + root.doLayout() + + assertEquals(Rectangle(0, 0, 320, 180), root.content.bounds) + assertEquals(Rectangle(0, 0, 320, 180), root.overlay.bounds) + } + + fun `test root preferred size is max of immediate children`() { + val root = SessionRootPanel().apply { + content.preferredSize = Dimension(300, 120) + overlay.preferredSize = Dimension(180, 220) + } + + assertEquals(Dimension(300, 220), root.preferredSize) + } + + fun `test addOverlay applies callback bounds and delegates child layout`() { + val root = SessionRootPanel().apply { + setSize(400, 260) + } + val child = Probe() + + root.addOverlay(child) { _, item -> + Rectangle(12, 34, item.preferredSize.width, item.preferredSize.height) + } + + root.doLayout() + + assertEquals(Rectangle(12, 34, 80, 24), child.bounds) + assertTrue(child.laid) + } + + private class Probe : JPanel() { + var laid = false + + init { + preferredSize = Dimension(80, 24) + } + + override fun doLayout() { + laid = true + super.doLayout() + } + } +} From a63d77a4f7da83f4df9eb569bd744a9e888b7513 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 24 Apr 2026 15:04:54 -0400 Subject: [PATCH 16/96] refactor: streamline jetbrains session UI and tests Keep SessionUi construction phased and align the session test tree with the production package layout so the UI and update flow are easier to navigate. Also pin Kotlin editorconfig defaults so IDE formatting matches repo expectations. --- .editorconfig | 5 + .../ai/kilocode/client/session/SessionUi.kt | 189 +++++++++--------- .../client/session/ui/SessionRootPanel.kt | 3 +- .../client/session/SessionUiLayoutTest.kt | 8 +- .../session/{ => ui}/ProgressPanelTest.kt | 3 +- .../session/{ => ui}/SessionUiUpdateTest.kt | 3 +- .../session/{ => update}/AppWatchingTest.kt | 2 +- .../{ => update}/ChatLoggingFlowTest.kt | 2 +- .../{ => update}/ConfigSelectionTest.kt | 2 +- .../{ => update}/HistoryLoadingTest.kt | 2 +- .../{ => update}/ListenerLifecycleTest.kt | 3 +- .../session/{ => update}/MessageListTest.kt | 2 +- .../{ => update}/ProgressTrackingTest.kt | 2 +- .../{ => update}/PromptLifecycleTest.kt | 2 +- .../{ => update}/SessionArtifactsTest.kt | 2 +- .../{ => update}/SessionControllerTestBase.kt | 2 +- .../{ => update}/SessionCreationTest.kt | 2 +- .../{ => update}/SessionQueueCondenserTest.kt | 3 +- .../{ => update}/SessionRecoveryTest.kt | 2 +- .../{ => update}/SessionUpdateQueueTest.kt | 3 +- .../{ => update}/StatusComputationTest.kt | 2 +- .../session/{ => update}/TurnLifecycleTest.kt | 2 +- .../session/{ => update}/ViewSwitchingTest.kt | 2 +- .../{ => update}/WorkspaceWatchingTest.kt | 2 +- 24 files changed, 131 insertions(+), 119 deletions(-) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => ui}/ProgressPanelTest.kt (96%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => ui}/SessionUiUpdateTest.kt (98%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/AppWatchingTest.kt (93%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/ChatLoggingFlowTest.kt (98%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/ConfigSelectionTest.kt (96%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/HistoryLoadingTest.kt (96%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/ListenerLifecycleTest.kt (97%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/MessageListTest.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/ProgressTrackingTest.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/PromptLifecycleTest.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/SessionArtifactsTest.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/SessionControllerTestBase.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/SessionCreationTest.kt (96%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/SessionQueueCondenserTest.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/SessionRecoveryTest.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/SessionUpdateQueueTest.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/StatusComputationTest.kt (97%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/TurnLifecycleTest.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/ViewSwitchingTest.kt (94%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/WorkspaceWatchingTest.kt (96%) diff --git a/.editorconfig b/.editorconfig index aada95f26a8..35ebc0b32db 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,3 +7,8 @@ end_of_line = lf indent_style = space indent_size = 2 max_line_length = 80 + +[*.{kt,kts}] +indent_size = 4 +tab_width = 4 +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL 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 81fafa5a64a..5096b65daaa 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 @@ -24,29 +24,21 @@ import ai.kilocode.log.KiloLog import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI import kotlinx.coroutines.CoroutineScope +import java.awt.Component import java.awt.BorderLayout -import java.awt.CardLayout import javax.swing.BoxLayout +import javax.swing.BoxLayout.Y_AXIS import javax.swing.JPanel import javax.swing.SwingUtilities /** - * Top-level session UI — a thin composition root. + * Top-level session UI composition root. * - * Responsibilities: - * - Creates and wires [ai.kilocode.client.session.update.SessionController], [SessionMessageListPanel], [EmptySessionPanel], - * [ConnectionPanel], - * [PromptPanel], [QuestionPanel], [PermissionPanel]. - * - Switches between the status (loading) card and the transcript card via - * [ai.kilocode.client.session.update.SessionControllerEvent.ViewChanged]. - * - Keeps [ConnectionPanel] on a transparent overlay layer directly above the - * prompt. - * - Delegates all transcript and dock updates to the panels themselves via - * [SessionModelEvent] listeners (no inline rendering logic here). - * - Scrolls to the bottom on new content. + * It builds the session panels, wires controller/model listeners, and swaps the + * center body between the empty state and the message list. * - * Views must never call RPC or services directly; everything goes through - * the controller. + * The only custom layout logic kept here is the prompt-relative connection + * overlay. All user actions still flow through [SessionController]. */ class SessionUi( project: Project, @@ -57,108 +49,127 @@ class SessionUi( ) : JPanel(BorderLayout()), Disposable { companion object { - private const val STATUS = "status" - private const val MESSAGES = "messages" private val LOG = KiloLog.create(SessionUi::class.java) } - private val flushMs = Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt()) - .takeIf { it > 0 } - ?.toLong() - ?: EVENT_FLUSH_MS + private val project = project + + private val flushMs = + Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt()) + .takeIf { it > 0 } + ?.toLong() + ?: EVENT_FLUSH_MS private val controller = SessionController( - this, null, sessions, workspace, app, cs, this, - flushMs = flushMs, - condense = Registry.`is`("kilo.session.condense", true), + this, null, sessions, workspace, app, cs, this, + flushMs = flushMs, + condense = Registry.`is`("kilo.session.condense", true), ) - // ------ card switch ------ - private val cards = CardLayout() - private val center = JPanel(cards) + private lateinit var root: SessionRootPanel - // ------ status (loading) panel ------ + private lateinit var sessionContent: JPanel - private val status = EmptySessionPanel(this) + private lateinit var emptyBody: EmptySessionPanel - // ------ transcript ------ + private lateinit var messageBody: SessionMessageListPanel - private val transcript = SessionMessageListPanel(controller.model, this) + private lateinit var messageScroll: JBScrollPane - private val scroll = JBScrollPane(transcript).apply { - border = JBUI.Borders.empty() - verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED - horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER - } + private lateinit var question: QuestionPanel + private lateinit var permission: PermissionPanel + private lateinit var connection: ConnectionPanel - // ------ dock panels (above prompt) ------ + private lateinit var prompt: PromptPanel - private val question = QuestionPanel(controller) - private val permission = PermissionPanel(controller) - private val connection = ConnectionPanel(this, controller) - - // ------ prompt ------ - - private val prompt = PromptPanel( - project = project, - onSend = { text -> send(text) }, - onAbort = { controller.abort() }, - ) - - private val root = SessionRootPanel().apply { - content.layout = BorderLayout() - } init { - // South area: question dock, permission dock, and prompt stacked vertically. - // BoxLayout(Y_AXIS) collapses invisible panels to zero height automatically, - // so hiding a dock doesn't leave an empty gap above the prompt. - val south = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false + buildUi() + bindUi() + showBody(emptyBody) + } + + private fun buildUi() { + root = SessionRootPanel() + + sessionContent = JPanel(BorderLayout()) + + emptyBody = EmptySessionPanel(this) + messageBody = SessionMessageListPanel(controller.model, this) + + messageScroll = JBScrollPane(messageBody).apply { + border = JBUI.Borders.empty() + verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED + horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER + } + question = QuestionPanel(controller) + permission = PermissionPanel(controller) + connection = ConnectionPanel(this, controller) + + prompt = PromptPanel( + project = project, + onSend = { text -> sendPrompot(text) }, + onAbort = { controller.abort() }, + ) + + root.content.add(sessionContent, BorderLayout.CENTER) + // Question and permission panels stay in normal flow so the prompt moves + // naturally when either dock appears. + root.content.add(JPanel().apply { + this.layout = BoxLayout(this, Y_AXIS) add(question) add(permission) add(prompt) - } + }, BorderLayout.SOUTH) - center.add(status, STATUS) - center.add(scroll, MESSAGES) - cards.show(center, STATUS) - - root.content.add(center, BorderLayout.CENTER) - root.content.add(south, BorderLayout.SOUTH) + // Keep connection status visually attached to the prompt without taking + // space in the south stack. root.addOverlay(connection) { panel, child -> - val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, panel) + val box = SwingUtilities.convertRectangle( + prompt.parent, + prompt.bounds, + panel + ) val h = child.preferredSize.height java.awt.Rectangle(box.x, maxOf(0, box.y - h), box.width, h) } add(root, BorderLayout.CENTER) + } - // ------ picker wiring ------ - + private fun bindUi() { prompt.mode.onSelect = { item -> controller.selectAgent(item.id) } prompt.model.onSelect = picker@{ item -> val group = item.group ?: return@picker controller.selectModel(group, item.id) } - // ------ controller lifecycle events ------ - controller.addListener(this) { event -> when (event) { is SessionControllerEvent.WorkspaceReady -> { val m = controller.model - prompt.mode.setItems(m.agents.map { LabelPicker.Item(it.name, it.display) }, m.agent) - val items = m.models.map { LabelPicker.Item(it.id, it.display, it.provider) } - val selected = m.model?.let { full -> items.firstOrNull { "${it.group}/${it.id}" == full }?.id } + prompt.mode.setItems(m.agents.map { + LabelPicker.Item( + it.name, + it.display + ) + }, m.agent) + val items = m.models.map { + LabelPicker.Item( + it.id, + it.display, + it.provider + ) + } + val selected = + m.model?.let { full -> items.firstOrNull { "${it.group}/${it.id}" == full }?.id } prompt.model.setItems(items, selected) prompt.setReady(m.isReady()) } is SessionControllerEvent.ViewChanged -> - cards.show(center, if (event.show) MESSAGES else STATUS) + showBody(if (event.show) messageScroll else emptyBody) is SessionControllerEvent.AppChanged, is SessionControllerEvent.WorkspaceChanged -> @@ -166,11 +177,9 @@ class SessionUi( } } - // ------ model events — prompt state + dock + auto-scroll ------ - controller.model.addListener(this) { event -> when (event) { - is SessionModelEvent.StateChanged -> onState(event.state) + is SessionModelEvent.StateChanged -> onStateChanged(event.state) is SessionModelEvent.TurnAdded, is SessionModelEvent.TurnUpdated, @@ -192,9 +201,7 @@ class SessionUi( } } - // ------ private helpers ------ - - private fun send(text: String) { + private fun sendPrompot(text: String) { if (text.isBlank()) return LOG.debug { "${ChatLogSummary.prompt(text)} agent=${controller.model.agent ?: "none"} model=${controller.model.model ?: "none"} ready=${controller.ready}" @@ -203,17 +210,19 @@ class SessionUi( prompt.clear() } - private fun onState(state: SessionState) { + private fun onStateChanged(state: SessionState) { prompt.setBusy(state.isBusy()) when (state) { is SessionState.AwaitingQuestion -> { permission.hidePanel() question.show(state.question) } + is SessionState.AwaitingPermission -> { question.hidePanel() permission.show(state.permission) } + else -> { question.hidePanel() permission.hidePanel() @@ -224,22 +233,24 @@ class SessionUi( } private fun scrollToBottom() { - val bar = scroll.verticalScrollBar + val bar = messageScroll.verticalScrollBar bar.value = bar.maximum } private fun refresh() { - center.revalidate() - center.repaint() - root.content.revalidate() - root.content.repaint() root.revalidate() root.repaint() } - override fun doLayout() { - super.doLayout() - root.doLayout() + private fun showBody(panel: Component) { + if (sessionContent.getComponentCount() == 1 && sessionContent.getComponent( + 0 + ) === panel + ) return + sessionContent.removeAll() + sessionContent.add(panel, BorderLayout.CENTER) + sessionContent.revalidate() + sessionContent.repaint() } override fun dispose() {} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt index 0c13740eb17..cb22774bc6b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.ui +import java.awt.BorderLayout import java.awt.Dimension import java.awt.Rectangle import javax.swing.JComponent @@ -8,7 +9,7 @@ import javax.swing.JPanel class SessionRootPanel : JLayeredPane() { - val content = JPanel() + val content = JPanel(BorderLayout()) val overlay = Overlay() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 7522c71aa94..ed3ebcdbb78 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -106,7 +106,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { layout() assertFalse(question.isVisible) - controller().model.setState(questionState()) + controller().model.setState(questionStateChanged()) layout() assertTrue(question.isVisible) @@ -125,7 +125,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { layout() assertFalse(permission.isVisible) - controller().model.setState(permissionState()) + controller().model.setState(permissionStateChanged()) layout() assertTrue(permission.isVisible) @@ -162,7 +162,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { return field.get(ui) as SessionController } - private fun questionState() = SessionState.AwaitingQuestion( + private fun questionStateChanged() = SessionState.AwaitingQuestion( Question( id = "q1", items = listOf( @@ -177,7 +177,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { ) ) - private fun permissionState() = SessionState.AwaitingPermission( + private fun permissionStateChanged() = SessionState.AwaitingPermission( Permission( id = "p1", sessionId = "ses", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressPanelTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt index 2162a5df508..271a89e5617 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt @@ -1,10 +1,9 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.ui import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.ui.ProgressPanel import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index c5044e1b342..18f6888bf89 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -1,8 +1,7 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.ui import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.views.TextView import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt similarity index 93% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt index 157642dc922..521c5b2bb7a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ChatLoggingFlowTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ChatLoggingFlowTest.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ChatLoggingFlowTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ChatLoggingFlowTest.kt index 3a8e1f0c777..a2852df403f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ChatLoggingFlowTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ChatLoggingFlowTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConfigSelectionTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConfigSelectionTest.kt index eb53354c488..ae79e031dcd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConfigSelectionTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update class ConfigSelectionTest : SessionControllerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt index 6a892a4a5f1..3f7acda9ed6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.rpc.dto.MessageWithPartsDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt similarity index 97% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt index f45af5a2ebe..75800512232 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt @@ -1,7 +1,6 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.util.Disposer diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/MessageListTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/MessageListTest.kt index a53b0655c16..b09da2064e1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/MessageListTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressTrackingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ProgressTrackingTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressTrackingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ProgressTrackingTest.kt index f4db69ddd54..03fd591a5a1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressTrackingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ProgressTrackingTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/PromptLifecycleTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/PromptLifecycleTest.kt index 38cb5abf77b..8e3cea3ba9f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/PromptLifecycleTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionArtifactsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionArtifactsTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionArtifactsTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionArtifactsTest.kt index e25d544235a..d2afd1387e5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionArtifactsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionArtifactsTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt index 83688adb057..0ed6891bf8f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt index ac7d2c9a944..f89654996d5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update class SessionCreationTest : SessionControllerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionQueueCondenserTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionQueueCondenserTest.kt index f114099717f..c7871d4332e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionQueueCondenserTest.kt @@ -1,6 +1,5 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update -import ai.kilocode.client.session.update.SessionQueueCondenser import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt index 05af93de749..e407fb61b49 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.PermissionRequestDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionUpdateQueueTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionUpdateQueueTest.kt index ed084a1584e..a204f24dc39 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionUpdateQueueTest.kt @@ -1,10 +1,9 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.update.SessionController import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.SessionStatusDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/StatusComputationTest.kt similarity index 97% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/StatusComputationTest.kt index 98719ec3c36..27adf704416 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/StatusComputationTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/TurnLifecycleTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/TurnLifecycleTest.kt index 317271b1a60..80aa8367511 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/TurnLifecycleTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt similarity index 94% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt index 66f687eb884..2edc4f7fbb9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update class ViewSwitchingTest : SessionControllerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt index 92ba9f79785..2aa2ce3f66c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update class WorkspaceWatchingTest : SessionControllerTestBase() { From 9a0419d1b1991cfeb63b9695795d0d25cfdf3190 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 24 Apr 2026 15:07:17 -0400 Subject: [PATCH 17/96] refactor(jetbrains): polish session ui naming --- .../main/kotlin/ai/kilocode/client/session/SessionUi.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 5096b65daaa..5f948b60cda 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 @@ -36,9 +36,6 @@ import javax.swing.SwingUtilities * * It builds the session panels, wires controller/model listeners, and swaps the * center body between the empty state and the message list. - * - * The only custom layout logic kept here is the prompt-relative connection - * overlay. All user actions still flow through [SessionController]. */ class SessionUi( project: Project, @@ -109,7 +106,7 @@ class SessionUi( prompt = PromptPanel( project = project, - onSend = { text -> sendPrompot(text) }, + onSend = { text -> sendPrompt(text) }, onAbort = { controller.abort() }, ) @@ -201,7 +198,7 @@ class SessionUi( } } - private fun sendPrompot(text: String) { + private fun sendPrompt(text: String) { if (text.isBlank()) return LOG.debug { "${ChatLogSummary.prompt(text)} agent=${controller.model.agent ?: "none"} model=${controller.model.model ?: "none"} ready=${controller.ready}" From f8e31e0b6daa6e0cadcbf70dbe9f0436e1c0e245 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 26 Apr 2026 13:59:33 -0400 Subject: [PATCH 18/96] fix(jetbrains): make connection panel recovery reliable Surface startup, workspace, and config issues in the panel so sessions explain why they are unavailable. Let retry refresh warnings first and restart the app when problems persist, while logging final error and warning states on the backend. --- .changeset/green-planes-reply.md | 5 + .../ai/kilocode/backend/app/KiloAppState.kt | 7 + .../backend/app/KiloBackendAppService.kt | 146 +++++++++++-- .../app/KiloBackendConnectionService.kt | 11 +- .../kilocode/backend/rpc/KiloAppRpcApiImpl.kt | 11 + .../backend/workspace/KiloBackendWorkspace.kt | 19 +- .../backend/app/KiloBackendAppServiceTest.kt | 183 +++++++++++++++- .../backend/app/KiloConnectionServiceTest.kt | 3 +- .../kilocode/backend/testing/MockCliServer.kt | 3 + .../workspace/KiloBackendWorkspaceTest.kt | 1 + .../ai/kilocode/client/app/KiloAppService.kt | 10 + .../client/app/KiloWorkspaceService.kt | 2 +- .../ai/kilocode/client/app/Workspace.kt | 1 + .../client/session/ui/ConnectionPanel.kt | 198 +++++++++++++++++- .../session/update/SessionController.kt | 19 ++ .../resources/messages/KiloBundle.properties | 2 + .../client/session/ui/ConnectionPanelTest.kt | 153 ++++++++++++++ .../client/session/update/AppWatchingTest.kt | 45 ++++ .../kilocode/client/testing/FakeAppRpcApi.kt | 7 + .../client/testing/FakeWorkspaceRpcApi.kt | 3 + .../kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt | 3 + .../ai/kilocode/rpc/dto/KiloAppStateDto.kt | 8 + 22 files changed, 803 insertions(+), 37 deletions(-) create mode 100644 .changeset/green-planes-reply.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt diff --git a/.changeset/green-planes-reply.md b/.changeset/green-planes-reply.md new file mode 100644 index 00000000000..f9b3517d9c4 --- /dev/null +++ b/.changeset/green-planes-reply.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show detailed JetBrains session connection and configuration issues in the connection panel, and make retry recover app-side problems more reliably. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt index 220c11c9b96..7bb9c069e10 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt @@ -40,6 +40,12 @@ data class LoadError( val detail: String? = null, ) +data class ConfigWarning( + val path: String, + val message: String, + val detail: String? = null, +) + /** * All global data that has been successfully loaded. * Present only in [KiloAppState.Ready]. @@ -48,4 +54,5 @@ data class AppData( val profile: KiloProfile200Response?, val config: Config, val notifications: List, + val warnings: List = emptyList(), ) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 9f36144d284..59b7285db2b 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -10,6 +10,7 @@ import ai.kilocode.jetbrains.api.infrastructure.ClientException import ai.kilocode.jetbrains.api.infrastructure.ServerError import ai.kilocode.jetbrains.api.infrastructure.ServerException import ai.kilocode.jetbrains.api.model.Config +import ai.kilocode.jetbrains.api.model.ConfigWarnings200ResponseInner import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner import ai.kilocode.jetbrains.api.model.KiloProfile200Response import ai.kilocode.rpc.dto.HealthDto @@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import okhttp3.OkHttpClient @@ -107,6 +109,9 @@ class KiloBackendAppService private constructor( @Volatile var notifications: List = emptyList() private set + @Volatile var warnings: List = emptyList() + private set + suspend fun connect() { mutex.withLock { val current = _appState.value @@ -130,6 +135,42 @@ class KiloBackendAppService private constructor( } } + suspend fun retry() { + mutex.withLock { + when (val current = _appState.value) { + KiloAppState.Disconnected -> { + ensureWatcher() + connection.connect() + } + KiloAppState.Connecting, + is KiloAppState.Loading -> Unit + is KiloAppState.Ready -> { + if (current.data.warnings.isEmpty()) return + log.info("retry: refreshing config warnings") + refreshConfigState() + val next = _appState.value + val warns = (next as? KiloAppState.Ready)?.data?.warnings + if (next is KiloAppState.Ready && warns.isNullOrEmpty()) return + restartConnection("warnings remained after refresh") + } + is KiloAppState.Error -> { + val load = current.errors.none { it.resource == "connection" } + if (load && connection.api != null) { + log.info("retry: rerunning app load from ${current.message}") + val prev = _appState.value + load() + val next = awaitLoadResult(prev) + val warns = (next as? KiloAppState.Ready)?.data?.warnings + if (next is KiloAppState.Ready && warns.isNullOrEmpty()) return + restartConnection("state remained problematic after load retry") + return + } + restartConnection("connection error: ${current.message}") + } + } + } + } + /** One-shot health check via the generated API client. */ suspend fun health(): HealthDto { val client = api ?: throw IllegalStateException("Not connected") @@ -157,7 +198,12 @@ class KiloBackendAppService private constructor( ConnectionState.Disconnected -> _appState.value = KiloAppState.Disconnected ConnectionState.Connecting -> _appState.value = KiloAppState.Connecting is ConnectionState.Connected -> load() - is ConnectionState.Error -> _appState.value = KiloAppState.Error(next.message) + is ConnectionState.Error -> setAppError( + message = next.message, + errors = next.details?.let { + listOf(LoadError(resource = "connection", detail = it)) + } ?: emptyList(), + ) } } } @@ -186,6 +232,7 @@ class KiloBackendAppService private constructor( var cfg: Config? = null var prof: KiloProfile200Response? = null var notifs: List = emptyList() + var warns: List = emptyList() try { coroutineScope { @@ -229,6 +276,9 @@ class KiloBackendAppService private constructor( throw LoadFailure(err) } } + launch { + warns = fetchWarnings() + } } ensureActive() @@ -238,11 +288,12 @@ class KiloBackendAppService private constructor( sessions.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) chat.start(connection.apiClient!!, connection.port, connection.events) workspaces.start(connection.api!!, connection.events) - _appState.value = KiloAppState.Ready( + setAppReady( AppData( profile = prof, config = cfg!!, notifications = notifs, + warnings = warns, ) ) log.info("Application started — config, profile, notifications loaded") @@ -251,7 +302,7 @@ class KiloBackendAppService private constructor( throw e } catch (e: Exception) { log.warn("Application start failed: ${e.message}") - _appState.value = KiloAppState.Error( + setAppError( message = "Failed to load required data", errors = errors.toList(), ) @@ -321,6 +372,81 @@ class KiloBackendAppService private constructor( } } + private suspend fun fetchWarnings(): List { + val client = connection.api ?: return emptyList() + return try { + client.configWarnings().map(::warning) + } catch (e: Exception) { + log.warn("Config warnings fetch failed: ${e.message}", e) + emptyList() + } + } + + private fun warning(w: ConfigWarnings200ResponseInner) = ConfigWarning( + path = w.path, + message = w.message, + detail = w.detail, + ) + + private suspend fun refreshConfigState() { + val current = _appState.value as? KiloAppState.Ready ?: return + val cfg = fetchConfig().value ?: return + val warns = fetchWarnings() + config = cfg + setAppReady( + current.data.copy( + config = cfg, + warnings = warns, + ) + ) + } + + private fun setAppReady(data: AppData) { + warnings = data.warnings + _appState.value = KiloAppState.Ready(data) + if (data.warnings.isNotEmpty()) warnAppWarnings(data.warnings) + } + + private fun setAppError(message: String, errors: List) { + val state = KiloAppState.Error(message, errors) + _appState.value = state + warnAppError(state) + } + + private fun warnAppError(state: KiloAppState.Error) { + val text = if (state.errors.isEmpty()) state.message + else "${state.message} [${state.errors.joinToString("; ") { error(it) }}]" + log.warn("App error: $text") + } + + private fun warnAppWarnings(warnings: List) { + val text = warnings.joinToString("; ") { warning(it) } + log.warn("App warnings: $text") + } + + private fun error(err: LoadError): String { + val status = err.status?.let { " status=$it" } ?: "" + val detail = err.detail?.let { " detail=$it" } ?: "" + return "${err.resource}$status$detail" + } + + private fun warning(warn: ConfigWarning): String { + val detail = warn.detail?.let { " detail=$it" } ?: "" + return "${warn.path}: ${warn.message}$detail" + } + + private suspend fun restartConnection(reason: String) { + clear() + connection.restart() + log.info("retry: restarted connection ($reason)") + } + + private suspend fun awaitLoadResult(prev: KiloAppState): KiloAppState { + val next = appState.first { it !== prev } + if (next !is KiloAppState.Loading) return next + return appState.first { it !is KiloAppState.Loading } + } + /** * Dump the HTTP response body from a failed API call for debugging. * The generated client wraps the response in [ClientException.response] @@ -380,17 +506,8 @@ class KiloBackendAppService private constructor( "global.config.updated" -> { log.info("SSE global.config.updated — reloading config") launch { - val result = fetchConfig() - if (result.value != null) { - config = result.value - val current = _appState.value - if (current is KiloAppState.Ready) { - _appState.value = current.copy( - data = current.data.copy(config = result.value) - ) - } - log.info("Config reloaded successfully") - } + refreshConfigState() + log.info("Config reloaded successfully") } } "global.disposed" -> { @@ -420,6 +537,7 @@ class KiloBackendAppService private constructor( profile = null config = null notifications = emptyList() + warnings = emptyList() _appState.value = KiloAppState.Disconnected } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt index e64249c3db1..d9a891cbfce 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt @@ -33,7 +33,7 @@ sealed class ConnectionState { data object Disconnected : ConnectionState() data object Connecting : ConnectionState() data class Connected(val port: Int, val password: String) : ConnectionState() - data class Error(val message: String) : ConnectionState() + data class Error(val message: String, val details: String? = null) : ConnectionState() } data class SseEvent(val type: String, val data: String) @@ -165,7 +165,7 @@ class KiloConnectionService( val result = server.init() if (result is CliServer.State.Error) { - setState(ConnectionState.Error(result.message)) + setState(ConnectionState.Error(result.message, result.details)) return } @@ -230,12 +230,17 @@ class KiloConnectionService( } override fun onFailure(src: EventSource, t: Throwable?, response: Response?) { + val detail = when { + t != null -> t.stackTraceToString() + response != null -> response.body?.string() + else -> null + }?.trim()?.ifEmpty { null } if (t != null) { log.warn("SSE: failure (${t.message}) — scheduling reconnect") } else { log.warn("SSE: failure (HTTP ${response?.code}) — scheduling reconnect") } - setState(ConnectionState.Error(t?.message ?: "SSE connection failed (HTTP ${response?.code})")) + setState(ConnectionState.Error(t?.message ?: "SSE connection failed (HTTP ${response?.code})", detail)) scheduleReconnect() } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt index e11f315a3fd..86420b6fc4a 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt @@ -4,10 +4,12 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.app.ConfigWarning import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.app.LoadProgress import ai.kilocode.backend.app.ProfileResult import ai.kilocode.rpc.KiloAppRpcApi +import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.HealthDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto @@ -36,6 +38,8 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { override suspend fun health(): HealthDto = app.health() + override suspend fun retry() = app.retry() + override suspend fun restart() = app.restart() override suspend fun reinstall() = app.reinstall() @@ -56,6 +60,7 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { profile = if (state.data.profile != null) ProfileStatusDto.LOADED else ProfileStatusDto.NOT_LOGGED_IN, ), + warnings = state.data.warnings.map(::warning), ) is KiloAppState.Error -> KiloAppStateDto( status = KiloAppStatusDto.ERROR, @@ -79,4 +84,10 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { status = e.status, detail = e.detail, ) + + private fun warning(w: ConfigWarning) = ConfigWarningDto( + path = w.path, + message = w.message, + detail = w.detail, + ) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt index f46f6715c9e..460bc9b4fe0 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -129,9 +129,7 @@ class KiloBackendWorkspace( throw e } catch (e: Exception) { log.warn("Workspace data load failed for $directory: ${e.message}") - _state.value = KiloWorkspaceState.Error( - "Failed to load: ${synchronized(errors) { errors.joinToString() }}" - ) + setWorkspaceError("Failed to load: ${synchronized(errors) { errors.joinToString() }}") } } } @@ -285,15 +283,20 @@ class KiloBackendWorkspace( ): T? { repeat(MAX_RETRIES) { attempt -> val result = block() - if (result != null) return result - if (attempt < MAX_RETRIES - 1) { - log.warn("$name: attempt ${attempt + 1}/$MAX_RETRIES failed — retrying in ${RETRY_DELAY_MS}ms") - delay(RETRY_DELAY_MS) - } + if (result != null) return result + if (attempt < MAX_RETRIES - 1) { + log.warn("$name: attempt ${attempt + 1}/$MAX_RETRIES failed — retrying in ${RETRY_DELAY_MS}ms") + delay(RETRY_DELAY_MS) + } } log.error("$name: all $MAX_RETRIES attempts failed") return null } + private fun setWorkspaceError(message: String) { + _state.value = KiloWorkspaceState.Error(message) + log.warn("Workspace error [$directory]: $message") + } + private class LoadFailure(resource: String) : Exception("Failed to load $resource") } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index e9b6671b9f6..a736b3ddee8 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -22,6 +22,7 @@ import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.test.assertContains class KiloBackendAppServiceTest { @@ -66,6 +67,72 @@ class KiloBackendAppServiceTest { assertEquals("claude-4", svc.config!!.model) } + @Test + fun `config warnings are loaded without blocking Ready`() = runBlocking { + mock.warnings = """[{"path":".kilo/kilo.json","message":"Invalid JSON","detail":"CloseBraceExpected"}]""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + val ready = svc.appState.value as KiloAppState.Ready + assertEquals(1, ready.data.warnings.size) + assertEquals(".kilo/kilo.json", ready.data.warnings.first().path) + assertEquals("Invalid JSON", ready.data.warnings.first().message) + } + + @Test + fun `retry refreshes warnings while Ready`() = runBlocking { + mock.warnings = """[{"path":".kilo/kilo.json","message":"Invalid JSON","detail":"CloseBraceExpected"}]""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + val before = svc.appState.value as KiloAppState.Ready + assertEquals(1, before.data.warnings.size) + + mock.warnings = "[]" + svc.retry() + + withTimeout(5_000) { + while ((svc.appState.value as? KiloAppState.Ready)?.data?.warnings?.isNotEmpty() == true) { + delay(100) + } + } + + val ready = svc.appState.value as KiloAppState.Ready + assertTrue(ready.data.warnings.isEmpty()) + assertTrue(svc.warnings.isEmpty()) + } + + @Test + fun `retry restarts app when warnings remain after refresh`() = runBlocking { + mock.warnings = """[{"path":".kilo/kilo.json","message":"Invalid JSON","detail":"CloseBraceExpected"}]""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + val before = mock.requestCount("/global/config") + svc.retry() + + withTimeout(15_000) { + while (mock.requestCount("/global/config") <= before) { + delay(100) + } + } + + assertTrue(mock.requestCount("/global/config") > before) + assertTrue(log.messages.any { it.contains("retry: restarted connection") }) + } + @Test fun `profile is loaded when available`() = runBlocking { mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}""" @@ -126,6 +193,89 @@ class KiloBackendAppServiceTest { assertTrue(err.errors.any { it.resource == "notifications" }) } + @Test + fun `retry reruns load for app load error`() = runBlocking { + mock.configStatus = 500 + mock.config = """{"error":"internal"}""" + val svc = create() + svc.connect() + + withTimeout(15_000) { + svc.appState.first { it is KiloAppState.Error } + } + + assertEquals(3, mock.requestCount("/global/config")) + + mock.configStatus = 200 + mock.config = """{"model":"retry/model"}""" + svc.retry() + + withTimeout(15_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertEquals("retry/model", svc.config?.model) + assertEquals(4, mock.requestCount("/global/config")) + } + + @Test + fun `connection error surfaces details as connection load error`() = runBlocking { + val failing = object : ai.kilocode.backend.cli.CliServer { + override var forceExtract = false + override fun process(): Process? = null + override suspend fun init() = ai.kilocode.backend.cli.CliServer.State.Error( + message = "CLI startup failed", + details = "stderr: missing dependency", + ) + override fun exited(proc: Process) {} + override fun stop() {} + override fun dispose() {} + } + val svc = KiloBackendAppService.create(scope, failing, log) + svc.connect() + + withTimeout(5_000) { + svc.appState.first { it is KiloAppState.Error } + } + + val err = svc.appState.value as KiloAppState.Error + assertEquals("CLI startup failed", err.message) + assertContains(err.errors.map { it.resource }, "connection") + assertEquals("stderr: missing dependency", err.errors.first { it.resource == "connection" }.detail) + assertTrue(log.messages.any { it.contains("App error: CLI startup failed") }) + } + + @Test + fun `warning state emits final warn log`() = runBlocking { + mock.warnings = """[{"path":".kilo/kilo.json","message":"Invalid JSON","detail":"CloseBraceExpected"}]""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertTrue(log.messages.any { + it.contains("App warnings:") && it.contains(".kilo/kilo.json: Invalid JSON") + }) + } + + @Test + fun `app load error emits final warn log`() = runBlocking { + mock.configStatus = 500 + mock.config = """{"error":"internal"}""" + val svc = create() + svc.connect() + + withTimeout(15_000) { + svc.appState.first { it is KiloAppState.Error } + } + + assertTrue(log.messages.any { + it.contains("App error: Failed to load required data") && it.contains("config") + }) + } + @Test fun `connect when already Ready is no-op`() = runBlocking { val svc = create() @@ -170,18 +320,18 @@ class KiloBackendAppServiceTest { } @Test - fun `profile 500 transitions to Error`() = runBlocking { + fun `profile 500 does not prevent Ready`() = runBlocking { mock.profileStatus = 500 mock.profile = """{"error":"internal"}""" val svc = create() svc.connect() withTimeout(15_000) { - svc.appState.first { it is KiloAppState.Error } + svc.appState.first { it is KiloAppState.Ready } } - val err = svc.appState.value as KiloAppState.Error - assertTrue(err.errors.any { it.resource == "profile" }) + assertNull(svc.profile) + assertIs(svc.appState.value) } @Test @@ -247,6 +397,31 @@ class KiloBackendAppServiceTest { assertEquals("updated", svc.config?.model) } + @Test + fun `SSE config updated refreshes warnings`() = runBlocking { + mock.warnings = """[{"path":".kilo/kilo.json","message":"Invalid JSON","detail":"CloseBraceExpected"}]""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertEquals(1, (svc.appState.value as KiloAppState.Ready).data.warnings.size) + + mock.warnings = "[]" + mock.awaitSseConnection() + mock.pushEvent("global.config.updated", """{"type":"global.config.updated"}""") + + withTimeout(5_000) { + while ((svc.appState.value as? KiloAppState.Ready)?.data?.warnings?.isNotEmpty() == true) { + delay(100) + } + } + + assertTrue((svc.appState.value as KiloAppState.Ready).data.warnings.isEmpty()) + } + // ------ Concurrency & lifecycle tests ------ @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt index 71d75f474c5..de84bfced22 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt @@ -117,7 +117,7 @@ class KiloConnectionServiceTest { val failing = object : CliServer { override var forceExtract = false override fun process(): Process? = null - override suspend fun init() = CliServer.State.Error("binary not found") + override suspend fun init() = CliServer.State.Error("binary not found", "stderr line 1\nstderr line 2") override fun exited(proc: Process) {} override fun stop() {} override fun dispose() {} @@ -131,6 +131,7 @@ class KiloConnectionServiceTest { } val err = svc.state.value as ConnectionState.Error assertEquals("binary not found", err.message) + assertEquals("stderr line 1\nstderr line 2", err.details) } @Test 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 60b27d1fa9a..5bde260ff31 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 @@ -30,10 +30,12 @@ class MockCliServer : AutoCloseable { // Configurable REST responses — can be changed between requests @Volatile var health = """{"healthy":true,"version":"1.0.0"}""" @Volatile var config = """{"model":"test/model"}""" + @Volatile var warnings = "[]" @Volatile var notifications = "[]" @Volatile var profile = """{"profile":{"email":"test@test.com","name":"Test"},"balance":null,"currentOrgId":null}""" @Volatile var profileStatus = 200 @Volatile var configStatus = 200 + @Volatile var warningsStatus = 200 @Volatile var notificationsStatus = 200 // Project-scoped REST responses @@ -184,6 +186,7 @@ class MockCliServer : AutoCloseable { when { path == "/global/health" -> respond(output, 200, health) path == "/global/config" -> respond(output, configStatus, config) + path.startsWith("/config/warnings") -> respond(output, warningsStatus, warnings) path.startsWith("/kilo/notifications") -> respond(output, notificationsStatus, notifications) path.startsWith("/kilo/profile") -> { if (profileStatus == 401) { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt index f1f6c38ebf4..9da0fa6cd88 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt @@ -179,6 +179,7 @@ class KiloBackendWorkspaceTest { val err = ws.state.value as KiloWorkspaceState.Error assertTrue(err.message.contains("providers")) + assertTrue(log.messages.any { it.contains("Workspace error [/test/project]: Failed to load:") && it.contains("providers") }) } @Test diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index 01b9e201e3e..878d0a393db 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -73,6 +73,11 @@ class KiloAppService internal constructor( null } + suspend fun retry() { + LOG.info("retry: sending RPC") + call { retry() } + } + /** Kill the CLI process and restart it. */ suspend fun restart() { LOG.info("restart: resetting state and sending RPC") @@ -97,6 +102,11 @@ class KiloAppService internal constructor( cs.launch { restart() } } + fun retryAsync() { + LOG.info("retryAsync: launching retry") + cs.launch { retry() } + } + /** Fire-and-forget reinstall from non-suspend context (e.g. action handlers). */ fun reinstallAsync() { LOG.info("reinstallAsync: launching reinstall") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 0909d2a4d74..b63964f745d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -66,7 +66,7 @@ class KiloWorkspaceService internal constructor( LOG.info("Creating workspace for $directory") val state = stream { state(directory) } .stateIn(cs, SharingStarted.Eagerly, INIT) - Workspace(directory, state) + Workspace(directory, state) { reload(directory) } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt index 7f7cd961814..a490a267ec1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt @@ -13,4 +13,5 @@ import kotlinx.coroutines.flow.StateFlow class Workspace( val directory: String, val state: StateFlow, + val reload: () -> Unit, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index 5fd14f8f8a5..be9554f4359 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -4,31 +4,96 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.client.session.update.SessionControllerListener +import ai.kilocode.rpc.dto.ConfigWarningDto +import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer +import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextArea import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.BorderLayout +import java.awt.Cursor +import java.awt.Dimension +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent import javax.swing.JPanel +import javax.swing.ScrollPaneConstants class ConnectionPanel( parent: Disposable, private val controller: SessionController, ) : JPanel(BorderLayout()), SessionControllerListener, Disposable { - private val label = JBLabel().apply { + private val click = object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + flip() + } + } + + private val header = JPanel(BorderLayout()).apply { border = JBUI.Borders.empty(4, 8) + isOpaque = false + } + + private val left = JPanel(BorderLayout(JBUI.scale(4), 0)).apply { + isOpaque = false + addMouseListener(click) + } + + private val toggle = JBLabel().apply { + isVisible = false + addMouseListener(click) + } + + private val label = JBLabel().apply { + foreground = UIUtil.getContextHelpForeground() + addMouseListener(click) + } + + private val retry = ActionLink(KiloBundle.message("session.connection.retry")) { + controller.retryConnection() + }.apply { + isVisible = false + horizontalAlignment = JBLabel.RIGHT + isFocusable = false + setRequestFocusEnabled(false) + } + + private val details = JBTextArea().apply { + isEditable = false + isOpaque = false + lineWrap = true + wrapStyleWord = true foreground = UIUtil.getContextHelpForeground() } + private val scroll = JBScrollPane(details).apply { + border = JBUI.Borders.empty(0, 8, 4, 24) + isOpaque = false + viewport.isOpaque = false + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + isVisible = false + } + + private var detail: String? = null + private var expanded = false + init { Disposer.register(parent, this) isOpaque = true background = UIUtil.getPanelBackground() - add(label, BorderLayout.CENTER) + left.add(toggle, BorderLayout.WEST) + left.add(label, BorderLayout.CENTER) + header.add(left, BorderLayout.CENTER) + header.add(retry, BorderLayout.EAST) + add(header, BorderLayout.NORTH) controller.addListener(this, this) render() } @@ -47,15 +112,22 @@ class ConnectionPanel( val workspace = controller.model.workspace if (app.status == KiloAppStatusDto.ERROR) { - label.foreground = UIUtil.getErrorForeground() - label.text = app.error ?: KiloBundle.message("session.connection.error.unknown") + showError( + app.error ?: KiloBundle.message("session.connection.error.unknown"), + app.errors.toErrorText(), + ) showPanel() return } if (workspace.status == KiloWorkspaceStatusDto.ERROR) { - label.foreground = UIUtil.getErrorForeground() - label.text = workspace.error ?: KiloBundle.message("session.connection.error.unknown") + showError(workspace.error ?: KiloBundle.message("session.connection.error.unknown"), null) + showPanel() + return + } + + if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY && app.warnings.isNotEmpty()) { + showWarning(summary(app.warnings.size), app.warnings.toWarningText()) showPanel() return } @@ -67,9 +139,61 @@ class ConnectionPanel( label.foreground = UIUtil.getContextHelpForeground() label.text = KiloBundle.message("session.connection.connecting") + detail = null + expanded = false + toggle.isVisible = false + retry.isVisible = false + renderDetails() showPanel() } + private fun showError(text: String, detail: String?) { + label.foreground = UIUtil.getErrorForeground() + label.text = text + retry.isVisible = true + this.detail = detail?.takeIf { it.isNotBlank() } + expanded = false + toggle.isVisible = this.detail != null + renderDetails() + } + + private fun showWarning(text: String, detail: String?) { + label.foreground = UIUtil.getContextHelpForeground() + label.text = text + retry.isVisible = true + this.detail = detail?.takeIf { it.isNotBlank() } + expanded = false + toggle.isVisible = this.detail != null + renderDetails() + } + + private fun summary(count: Int): String { + val base = KiloBundle.message("session.connection.warning.config") + if (count <= 1) return base + return "$base ($count)" + } + + private fun renderDetails() { + val text = detail + val show = expanded && text != null + val cursor = if (text != null) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() + toggle.icon = if (expanded) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight + left.cursor = cursor + label.cursor = cursor + toggle.cursor = cursor + details.text = text ?: "" + scroll.isVisible = show + if (show) add(scroll, BorderLayout.CENTER) + else remove(scroll) + } + + private fun flip() { + if (!toggle.isVisible) return + expanded = !expanded + renderDetails() + refresh() + } + private fun showPanel() { if (!isVisible) { isVisible = true @@ -98,4 +222,66 @@ class ConnectionPanel( override fun dispose() { // no-op } + + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!scroll.isVisible) return size + val rows = details.getFontMetrics(details.font).height * 5 + val insets = scroll.insets.top + scroll.insets.bottom + scroll.horizontalScrollBar.preferredSize.height + val height = rows + insets + JBUI.scale(2) + return Dimension(size.width, maxOf(size.height, header.preferredSize.height + height)) + } + + internal fun summaryText() = label.text + + internal fun detailsText() = details.text + + internal fun retryVisible() = retry.isVisible + + internal fun detailsVisible() = scroll.isVisible + + internal fun toggleVisible() = toggle.isVisible + + internal fun toggleExpanded() = expanded + + internal fun clickToggle() { + if (!toggle.isVisible) return + toggle.mouseListeners.firstOrNull()?.mouseClicked( + MouseEvent(toggle, MouseEvent.MOUSE_CLICKED, 0, 0, 0, 0, 1, false) + ) + } + + internal fun clickSummary() { + label.mouseListeners.firstOrNull()?.mouseClicked( + MouseEvent(label, MouseEvent.MOUSE_CLICKED, 0, 0, 0, 0, 1, false) + ) + } + + internal fun retryFocusable() = retry.isFocusable + + internal fun clickRetry() = retry.doClick() +} + +private fun List.toErrorText(): String? { + val out = mapNotNull { it.toDetailLine() } + if (out.isEmpty()) return null + return out.joinToString("\n") +} + +private fun List.toWarningText(): String? { + val out = mapNotNull { it.toDetailLine() } + if (out.isEmpty()) return null + return out.joinToString("\n\n") +} + +private fun LoadErrorDto.toDetailLine(): String? { + val detail = detail?.trim()?.ifEmpty { null } ?: return null + if (resource == "connection") return detail + return "$resource: $detail" +} + +private fun ConfigWarningDto.toDetailLine(): String { + val head = "$path: $message" + val tail = detail?.trim()?.ifEmpty { null } ?: return head + return "$head\n$tail" } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index edacb192472..09a280cad03 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -135,6 +135,25 @@ class SessionController( } } + fun retryConnection() { + LOG.debug { + "${ChatLogSummary.sid(sessionId ?: "pending")} kind=connection-retry app=${model.app.status} workspace=${model.workspace.status}" + } + // App retry policy is backend-owned and may escalate from lightweight refresh to restart. + if (model.app.status != KiloAppStatusDto.READY || model.app.status == KiloAppStatusDto.ERROR) { + app.retryAsync() + return + } + if (model.app.warnings.isNotEmpty()) { + app.retryAsync() + return + } + // Pure workspace failures stay scoped to workspace reload. + if (model.workspace.status == KiloWorkspaceStatusDto.ERROR) { + workspace.reload() + } + } + fun selectAgent(name: String) { LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=config agent=$name" } model.agent = name 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 fde2db71c53..b890d0a66e1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -1,5 +1,7 @@ session.connection.connecting=Loading... session.connection.error.unknown=Unknown error +session.connection.retry=Retry +session.connection.warning.config=Configuration warnings session.status.considering=Considering next steps\u2026 session.status.thinking=Thinking\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt new file mode 100644 index 00000000000..e98e705bec0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -0,0 +1,153 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerTestBase +import ai.kilocode.rpc.dto.ConfigWarningDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.LoadErrorDto + +@Suppress("UnstableApiUsage") +class ConnectionPanelTest : SessionControllerTestBase() { + + private lateinit var panel: ConnectionPanel + private lateinit var controller: SessionController + + override fun setUp() { + super.setUp() + controller = controller("ses_test") + panel = ConnectionPanel(parent, controller) + flush() + } + + fun `test loading hides retry and details`() { + edt { + controller.model.app = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + controller.model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) + panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + } + + assertTrue(panel.isVisible) + assertEquals("Loading...", panel.summaryText()) + assertEquals("", panel.detailsText()) + assertFalse(panel.toggleVisible()) + assertFalse(panel.detailsVisible()) + assertFalse(panel.retryVisible()) + } + + fun `test app error starts collapsed and expands details`() { + edt { + controller.model.app = KiloAppStateDto( + status = KiloAppStatusDto.ERROR, + error = "CLI startup failed", + errors = listOf( + LoadErrorDto(resource = "connection", detail = "stderr line"), + LoadErrorDto(resource = "config", detail = "HTTP 500: broken"), + ), + ) + panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + } + + assertTrue(panel.isVisible) + assertEquals("CLI startup failed", panel.summaryText()) + assertTrue(panel.toggleVisible()) + assertFalse(panel.toggleExpanded()) + assertFalse(panel.detailsVisible()) + assertEquals("stderr line\nconfig: HTTP 500: broken", panel.detailsText()) + assertTrue(panel.retryVisible()) + assertFalse(panel.retryFocusable()) + + edt { panel.clickSummary() } + + assertTrue(panel.toggleExpanded()) + assertTrue(panel.detailsVisible()) + + edt { panel.clickToggle() } + + assertFalse(panel.toggleExpanded()) + assertFalse(panel.detailsVisible()) + } + + fun `test workspace error shows retry without details`() { + edt { + controller.model.app = KiloAppStateDto(KiloAppStatusDto.READY) + controller.model.workspace = KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.ERROR, + error = "Workspace failed", + ) + panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.WorkspaceChanged) + } + + assertTrue(panel.isVisible) + assertEquals("Workspace failed", panel.summaryText()) + assertFalse(panel.toggleVisible()) + assertFalse(panel.detailsVisible()) + assertEquals("", panel.detailsText()) + assertTrue(panel.retryVisible()) + } + + fun `test retry click triggers app retry for app error`() { + edt { + controller.model.app = KiloAppStateDto( + status = KiloAppStatusDto.ERROR, + error = "CLI startup failed", + ) + panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + } + edt { panel.clickRetry() } + flush() + + assertEquals(1, appRpc.retries) + } + + fun `test ready warnings show collapsed banner with retry`() { + edt { + controller.model.app = KiloAppStateDto( + status = KiloAppStatusDto.READY, + warnings = listOf( + ConfigWarningDto( + path = ".kilo/kilo.json", + message = "Invalid JSON", + detail = "CloseBraceExpected at line 11, column 1", + ) + ), + ) + controller.model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + } + + assertTrue(panel.isVisible) + assertEquals("Configuration warnings", panel.summaryText()) + assertTrue(panel.toggleVisible()) + assertFalse(panel.toggleExpanded()) + assertFalse(panel.detailsVisible()) + assertTrue(panel.retryVisible()) + assertFalse(panel.retryFocusable()) + assertEquals( + ".kilo/kilo.json: Invalid JSON\nCloseBraceExpected at line 11, column 1", + panel.detailsText(), + ) + + edt { panel.clickSummary() } + + assertTrue(panel.toggleExpanded()) + assertTrue(panel.detailsVisible()) + } + + fun `test retry click triggers app retry for warnings`() { + edt { + controller.model.app = KiloAppStateDto( + status = KiloAppStatusDto.READY, + warnings = listOf(ConfigWarningDto(path = ".kilo/kilo.json", message = "Invalid JSON")), + ) + controller.model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + } + edt { panel.clickRetry() } + flush() + + assertEquals(1, appRpc.retries) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt index 521c5b2bb7a..b9b261be09f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt @@ -1,5 +1,7 @@ package ai.kilocode.client.session.update +import ai.kilocode.rpc.dto.ConfigWarningDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto @@ -23,4 +25,47 @@ class AppWatchingTest : SessionControllerTestBase() { show = false, ) } + + fun `test retry connection uses app retry when app is failed`() { + val m = controller() + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR, error = "boom") + + flush() + edt { m.retryConnection() } + flush() + + assertEquals(1, appRpc.retries) + assertEquals(0, projectRpc.reloads) + } + + fun `test retry connection reloads workspace when app ready and workspace failed`() { + val m = controller() + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = ai.kilocode.rpc.dto.KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.ERROR, + error = "workspace fail", + ) + + flush() + edt { m.retryConnection() } + flush() + + assertEquals(0, appRpc.retries) + assertEquals(1, projectRpc.reloads) + } + + fun `test retry connection uses app retry when app has warnings`() { + val m = controller() + appRpc.state.value = KiloAppStateDto( + status = KiloAppStatusDto.READY, + warnings = listOf(ConfigWarningDto(path = ".kilo/kilo.json", message = "Invalid JSON")), + ) + + flush() + edt { m.retryConnection() } + flush() + + assertEquals(1, appRpc.retries) + assertEquals(0, projectRpc.reloads) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt index 0919fcf3cbd..ea5b697745c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -21,6 +21,8 @@ class FakeAppRpcApi : KiloAppRpcApi { var connected = false private set + var retries = 0 + private set override suspend fun connect() { assertNotEdt("connect") @@ -37,6 +39,11 @@ class FakeAppRpcApi : KiloAppRpcApi { return health } + override suspend fun retry() { + assertNotEdt("retry") + retries += 1 + } + override suspend fun restart() { assertNotEdt("restart") } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index a85a1078058..c218008a5b1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -18,6 +18,8 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { var directory = "/test" val state = MutableStateFlow(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING)) + var reloads = 0 + private set override suspend fun resolveProjectDirectory(hint: String): String { assertNotEdt("resolveProjectDirectory") @@ -31,5 +33,6 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { override suspend fun reload(directory: String) { assertNotEdt("reload") + reloads += 1 } } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt index e0ea02ea1c2..01c614f7e09 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt @@ -31,6 +31,9 @@ interface KiloAppRpcApi : RemoteApi { /** One-shot health check against /global/health. */ suspend fun health(): HealthDto + /** Retry app connection or loading after a failure. */ + suspend fun retry() + /** Kill the CLI process and restart it. */ suspend fun restart() diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt index 0311799c2a5..3035f93d5f0 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt @@ -32,10 +32,18 @@ data class LoadErrorDto( val detail: String? = null, ) +@Serializable +data class ConfigWarningDto( + val path: String, + val message: String, + val detail: String? = null, +) + @Serializable data class KiloAppStateDto( val status: KiloAppStatusDto, val error: String? = null, val errors: List = emptyList(), val progress: LoadProgressDto? = null, + val warnings: List = emptyList(), ) From 09010ba9561a073e2eb411fcee8cd128162f71d9 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 27 Apr 2026 13:47:17 -0400 Subject: [PATCH 19/96] fix: improve JetBrains session panel layout --- .changeset/fuzzy-berries-press.md | 5 + .../ai/kilocode/client/session/SessionUi.kt | 42 +++----- .../client/session/ui/ConnectionPanel.kt | 19 +++- .../client/session/SessionUiLayoutTest.kt | 97 +++++++++++++------ .../client/session/ui/ConnectionPanelTest.kt | 20 ++++ 5 files changed, 121 insertions(+), 62 deletions(-) create mode 100644 .changeset/fuzzy-berries-press.md diff --git a/.changeset/fuzzy-berries-press.md b/.changeset/fuzzy-berries-press.md new file mode 100644 index 00000000000..0c48bad5166 --- /dev/null +++ b/.changeset/fuzzy-berries-press.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show JetBrains session connection status inline above the prompt, cap expanded connection details, and keep empty sessions in the shared chat scroll area. 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 5f948b60cda..f21ffa42e65 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 @@ -24,12 +24,10 @@ import ai.kilocode.log.KiloLog import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI import kotlinx.coroutines.CoroutineScope -import java.awt.Component import java.awt.BorderLayout import javax.swing.BoxLayout import javax.swing.BoxLayout.Y_AXIS import javax.swing.JPanel -import javax.swing.SwingUtilities /** * Top-level session UI composition root. @@ -72,7 +70,7 @@ class SessionUi( private lateinit var messageBody: SessionMessageListPanel - private lateinit var messageScroll: JBScrollPane + private lateinit var scroll: JBScrollPane private lateinit var question: QuestionPanel private lateinit var permission: PermissionPanel @@ -95,7 +93,7 @@ class SessionUi( emptyBody = EmptySessionPanel(this) messageBody = SessionMessageListPanel(controller.model, this) - messageScroll = JBScrollPane(messageBody).apply { + scroll = JBScrollPane(emptyBody).apply { border = JBUI.Borders.empty() verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER @@ -110,28 +108,18 @@ class SessionUi( onAbort = { controller.abort() }, ) + sessionContent.add(scroll, BorderLayout.CENTER) root.content.add(sessionContent, BorderLayout.CENTER) - // Question and permission panels stay in normal flow so the prompt moves - // naturally when either dock appears. + // Dock panels stay in normal flow so each visible state takes layout space + // above the prompt. root.content.add(JPanel().apply { this.layout = BoxLayout(this, Y_AXIS) add(question) add(permission) + add(connection) add(prompt) }, BorderLayout.SOUTH) - // Keep connection status visually attached to the prompt without taking - // space in the south stack. - root.addOverlay(connection) { panel, child -> - val box = SwingUtilities.convertRectangle( - prompt.parent, - prompt.bounds, - panel - ) - val h = child.preferredSize.height - java.awt.Rectangle(box.x, maxOf(0, box.y - h), box.width, h) - } - add(root, BorderLayout.CENTER) } @@ -166,7 +154,7 @@ class SessionUi( } is SessionControllerEvent.ViewChanged -> - showBody(if (event.show) messageScroll else emptyBody) + showBody(if (event.show) messageBody else emptyBody) is SessionControllerEvent.AppChanged, is SessionControllerEvent.WorkspaceChanged -> @@ -230,7 +218,7 @@ class SessionUi( } private fun scrollToBottom() { - val bar = messageScroll.verticalScrollBar + val bar = scroll.verticalScrollBar bar.value = bar.maximum } @@ -239,15 +227,11 @@ class SessionUi( root.repaint() } - private fun showBody(panel: Component) { - if (sessionContent.getComponentCount() == 1 && sessionContent.getComponent( - 0 - ) === panel - ) return - sessionContent.removeAll() - sessionContent.add(panel, BorderLayout.CENTER) - sessionContent.revalidate() - sessionContent.repaint() + private fun showBody(panel: JPanel) { + if (scroll.viewport.view === panel) return + scroll.viewport.setView(panel) + scroll.revalidate() + scroll.repaint() } override fun dispose() {} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index be9554f4359..794c593d730 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -30,6 +30,10 @@ class ConnectionPanel( private val controller: SessionController, ) : JPanel(BorderLayout()), SessionControllerListener, Disposable { + companion object { + private const val DETAILS_LINES = 10 + } + private val click = object : MouseAdapter() { override fun mouseClicked(e: MouseEvent) { flip() @@ -226,12 +230,16 @@ class ConnectionPanel( override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!scroll.isVisible) return size - val rows = details.getFontMetrics(details.font).height * 5 - val insets = scroll.insets.top + scroll.insets.bottom + scroll.horizontalScrollBar.preferredSize.height - val height = rows + insets + JBUI.scale(2) - return Dimension(size.width, maxOf(size.height, header.preferredSize.height + height)) + return Dimension(size.width, header.preferredSize.height + scrollHeight()) } + private fun scrollHeight(): Int { + val rows = details.text.lineSequence().count().coerceIn(1, DETAILS_LINES) + return details.getFontMetrics(details.font).height * rows + scrollChrome() + } + + private fun scrollChrome() = scroll.insets.top + scroll.insets.bottom + JBUI.scale(2) + internal fun summaryText() = label.text internal fun detailsText() = details.text @@ -260,6 +268,9 @@ class ConnectionPanel( internal fun retryFocusable() = retry.isFocusable internal fun clickRetry() = retry.doClick() + + internal fun maxExpandedHeight() = + header.preferredSize.height + details.getFontMetrics(details.font).height * DETAILS_LINES + scrollChrome() } private fun List.toErrorText(): String? { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index ed3ebcdbb78..8c3e4fe858e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -11,11 +11,14 @@ import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.ConnectionPanel +import ai.kilocode.client.session.ui.EmptySessionPanel import ai.kilocode.client.session.ui.PermissionPanel import ai.kilocode.client.session.ui.PromptPanel import ai.kilocode.client.session.ui.QuestionPanel +import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi @@ -24,11 +27,11 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBScrollPane import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import javax.swing.JLayeredPane -import javax.swing.SwingUtilities @Suppress("UnstableApiUsage") class SessionUiLayoutTest : BasePlatformTestCase() { @@ -81,63 +84,99 @@ class SessionUiLayoutTest : BasePlatformTestCase() { assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay)) } - fun `test overlay panel matches prompt width and sits above prompt`() { - val prompt = find(ui) + fun `test connection panel is docked between permission and prompt`() { + val root = find(ui) + val question = find(ui) + val permission = find(ui) val connection = find(ui) - val overlay = connection.parent + val prompt = find(ui) + val stack = prompt.parent - layout() - - val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, overlay) - val h = connection.preferredSize.height - - assertEquals(box.x, connection.x) - assertEquals(box.width, connection.width) - assertEquals(maxOf(0, box.y - h), connection.y) - assertEquals(h, connection.height) + assertSame(root.content, stack.parent) + assertSame(stack, connection.parent) + assertEquals(0, root.overlay.componentCount) + assertEquals(listOf(question, permission, connection, prompt), stack.components.toList()) } - fun `test overlay follows prompt when question panel changes visibility`() { - val prompt = find(ui) + fun `test connection panel uses stack width and sits above prompt`() { val connection = find(ui) - val overlay = connection.parent - val question = find(ui) + val prompt = find(ui) + val stack = prompt.parent + showConnection() + layout() + + assertTrue(connection.isVisible) + assertEquals(0, connection.x) + assertEquals(stack.width, connection.width) + assertEquals(prompt.width, connection.width) + assertTrue(connection.y + connection.height <= prompt.y) + } + + fun `test connection panel moves after visible question panel`() { + val connection = find(ui) + val question = find(ui) + val prompt = find(ui) + + showConnection() layout() assertFalse(question.isVisible) + val top = connection.y controller().model.setState(questionStateChanged()) layout() assertTrue(question.isVisible) - val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, overlay) - assertEquals(box.width, connection.width) - assertEquals(maxOf(0, box.y - connection.preferredSize.height), connection.y) - assertEquals(connection.preferredSize.height, connection.height) + assertTrue(question.y < connection.y) + assertTrue(top < connection.y) + assertTrue(connection.y + connection.height <= prompt.y) } - fun `test overlay follows prompt when permission panel changes visibility`() { - val prompt = find(ui) + fun `test connection panel moves after visible permission panel`() { val connection = find(ui) - val overlay = connection.parent val permission = find(ui) + val prompt = find(ui) + showConnection() layout() assertFalse(permission.isVisible) + val top = connection.y controller().model.setState(permissionStateChanged()) layout() assertTrue(permission.isVisible) - val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, overlay) - assertEquals(box.width, connection.width) - assertEquals(maxOf(0, box.y - connection.preferredSize.height), connection.y) - assertEquals(connection.preferredSize.height, connection.height) + assertTrue(permission.y < connection.y) + assertTrue(top < connection.y) + assertTrue(connection.y + connection.height <= prompt.y) + } + + fun `test empty and message bodies share the same scroll pane`() { + val scroll = find(ui) + val empty = find(ui) + + assertSame(empty, scroll.viewport.view) + + controller().prompt("hello") + layout() + + assertSame(scroll, find(ui).parent.parent) + assertSame(find(ui), scroll.viewport.view) } private fun layout() { ui.doLayout() - find(ui).doLayout() + val root = find(ui) + root.doLayout() + root.content.doLayout() + find(ui).parent.doLayout() + } + + private fun showConnection() { + val m = controller().model + m.app = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + m.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) + find(ui).onEvent(SessionControllerEvent.AppChanged) } private inline fun find(root: java.awt.Container): T { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt index e98e705bec0..39f8a0dbaf6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -8,6 +8,7 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.LoadErrorDto +import java.awt.Dimension @Suppress("UnstableApiUsage") class ConnectionPanelTest : SessionControllerTestBase() { @@ -150,4 +151,23 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertEquals(1, appRpc.retries) } + + fun `test expanded details height is capped at ten lines`() { + edt { + controller.model.app = KiloAppStateDto( + status = KiloAppStatusDto.ERROR, + error = "CLI startup failed", + errors = listOf(LoadErrorDto(resource = "connection", detail = lines(30))), + ) + panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + panel.size = Dimension(480, 1000) + } + + edt { panel.clickSummary() } + + assertTrue(panel.detailsVisible()) + assertTrue(panel.preferredSize.height <= panel.maxExpandedHeight()) + } + + private fun lines(count: Int) = (1..count).joinToString("\n") { "line $it" } } From 9bd8a66fac79998afab6c57f608d6dfdec97c344 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 27 Apr 2026 15:22:00 -0400 Subject: [PATCH 20/96] docs: add JetBrains UI style skill --- .kilo/skills/jetbrains-ui-style/SKILL.md | 657 +++++++++++++++++++++++ packages/kilo-jetbrains/AGENTS.md | 549 +------------------ 2 files changed, 670 insertions(+), 536 deletions(-) create mode 100644 .kilo/skills/jetbrains-ui-style/SKILL.md diff --git a/.kilo/skills/jetbrains-ui-style/SKILL.md b/.kilo/skills/jetbrains-ui-style/SKILL.md new file mode 100644 index 00000000000..3d3a01e213f --- /dev/null +++ b/.kilo/skills/jetbrains-ui-style/SKILL.md @@ -0,0 +1,657 @@ +--- +name: jetbrains-ui-style +description: Use when creating, modifying, or reviewing Kotlin/Swing UI code for the Kilo JetBrains plugin. Applies to dialogs, settings pages, tool windows, forms, panels, component layout, sizing, spacing, colors, borders, icons, lists, trees, popups, notifications, and IntelliJ platform UI components. +--- + +# JetBrains UI Style + +Use this skill whenever creating, modifying, or reviewing UI code in `packages/kilo-jetbrains`. + +## Scope + +This skill covers Kotlin/Swing UI code for the Kilo JetBrains plugin, including dialogs, settings pages, tool windows, forms, panels, component layout, sizing, spacing, colors, borders, icons, lists, trees, popups, notifications, and IntelliJ Platform UI components. + +This plugin is a split-mode JetBrains plugin. UI code belongs in `frontend` unless there is a specific split-mode reason to place it elsewhere. + +## Primary Rules + +- Prefer Kotlin UI DSL v2 whenever the UI is a dialog, settings page, form, options panel, or structured component layout. +- Use manual Swing only for custom rendering, transcript/chat surfaces, highly dynamic panels, virtualized lists, custom painting, or layouts the DSL cannot express cleanly. +- Keep generated UI code minimal. +- Do not set default Swing properties explicitly. For example, avoid `isOpaque = false` unless the component default differs or there is a documented rendering reason. +- Avoid hardcoded dimensions. Prefer layout semantics, component defaults, DSL sizing helpers, and `JBUI` utilities. +- Do not add decorative helper functions, wrappers, or defensive UI code unless they materially improve clarity or correctness. +- Prefer IntelliJ platform components and theme-aware APIs. +- Put user-visible strings in `*.properties` files. + +## Decision Tree + +- Use Kotlin UI DSL v2 for dialogs, settings pages, forms, options panels, and structured component layouts. +- Use Kotlin UI DSL v2 inside tool windows when rendering form-like controls or structured subpanels. +- Use standard Swing with IntelliJ Platform component replacements for tool-window shells, action-driven UI, custom components, transcript surfaces, and custom renderers. +- Use the Action System for menus and toolbars. +- Do not use Kotlin Compose. +- Do not use JCEF. + +| Need | API | +| --- | --- | +| Dialogs, settings pages, forms, any layout with components | Preferred: Kotlin UI DSL v2 (`com.intellij.ui.dsl.builder`) | +| Tool window panels, action-driven UI, custom components | Standard Swing with IntelliJ Platform component replacements | +| Menus and toolbars | Action System | + +## Minimal Code Rule + +Avoid explicit defaults and manual layout when the DSL can express the UI: + +```kotlin +// Avoid +val panel = JPanel(BorderLayout()).apply { + isOpaque = false + preferredSize = Dimension(420, 260) + border = EmptyBorder(12, 12, 12, 12) +} +``` + +Prefer DSL and platform spacing: + +```kotlin +panel { + row(KiloBundle.message("settings.name")) { + textField() + .align(AlignX.FILL) + .resizableColumn() + } +} +``` + +Avoid explicit default properties: + +```kotlin +// Avoid unless there is a documented rendering reason +component.isOpaque = false +``` + +Prefer omitting the assignment and relying on the component/platform default. + +Avoid raw fixed sizes: + +```kotlin +// Avoid +component.preferredSize = Dimension(300, 40) +``` + +Prefer semantic sizing: + +```kotlin +textField() + .columns(COLUMNS_MEDIUM) + .align(AlignX.FILL) +``` + +If a fixed size is genuinely required, use `JBUI.size(...)` or `JBUI.scale(...)` and keep the reason obvious from context. + +## Do Not Use Kotlin Compose + +Do not use Kotlin Compose or `intellij.platform.compose` in this plugin. The JetBrains modular template uses Compose for its demo tool window, but Kilo should use standard Swing with IntelliJ Platform components only. Keep all plugin UI in the existing Swing-based stack. + +## Do Not Use JCEF + +Do not use JCEF (`JBCefBrowser`) in this plugin. JCEF does not work in JetBrains remote development (split mode): the frontend process runs on the client machine but JCEF requires a display on the host, making it effectively unusable for remote users. Use standard Swing with IntelliJ Platform components for all UI. + +## Kotlin UI DSL v2 + +Use Kotlin UI DSL v2 as the default way to build UI for dialogs, settings pages, forms, and any layout composed of standard components. It produces correct spacing, label alignment, HiDPI scaling, and accessibility automatically. Only fall back to manual Swing layout when you need a fully custom component, such as a canvas, rich list renderer, transcript surface, or tool-window chrome that the DSL cannot express. + +The top-level builder is `panel { }` and returns `DialogPanel`. Structure is `panel -> row -> cells`. Cell factory methods such as `textField()`, `checkBox()`, and `label()` add components. The DSL lives in `com.intellij.ui.dsl.builder`. + +To explore DSL capabilities interactively: Tools -> Internal Actions -> UI -> Kotlin UI DSL -> UI DSL Showcase. This requires internal mode: `-Didea.is.internal=true`. + +### Basics: Panel, Row, Cell + +Rows occupy full width. The last cell in a row takes remaining space. Rows have a `layout` property. + +```kotlin +panel { + row("Row1 label:") { + textField() + label("Some text") + } + row("Row2:") { + label("This text is aligned with previous row") + } +} +``` + +### Row Layout + +Every row uses one of three layouts. Default is `LABEL_ALIGNED` when a label is provided for the row, `INDEPENDENT` otherwise. + +| Layout | Behavior | +| --- | --- | +| `LABEL_ALIGNED` | Label column and content columns, aligned across rows | +| `INDEPENDENT` | All cells are independent, no cross-row alignment | +| `PARENT_GRID` | Cells align with the parent grid columns across rows | + +```kotlin +panel { + row("PARENT_GRID:") { + label("Col 1") + label("Col 2") + }.layout(RowLayout.PARENT_GRID) + + row("PARENT_GRID:") { + textField() + textField() + }.layout(RowLayout.PARENT_GRID) + + row("LABEL_ALIGNED default with label:") { + textField() + } + + row { + label("INDEPENDENT default without label:") + textField() + } +} +``` + +### Components Reference + +All cell factory methods available inside `row { }`: + +| Method | Description | +| --- | --- | +| `checkBox("text")` | Checkbox | +| `threeStateCheckBox("text")` | Three-state checkbox | +| `radioButton("text", value)` | Radio button, must be inside `buttonsGroup {}` | +| `button("text") {}` | Push button | +| `actionButton(action)` | Icon button bound to an `AnAction` | +| `actionsButton(action1, action2, ...)` | Dropdown actions button | +| `segmentedButton(items) { text = it }` | Segmented control | +| `tabbedPaneHeader(items)` | Tab header strip | +| `label("text")` | Static label | +| `text("html")` | Rich text with links, icons, line-width control | +| `link("text") {}` | Focusable clickable link | +| `browserLink("text", "url")` | Opens URL in browser | +| `dropDownLink("default", listOf(...))` | Dropdown link selector | +| `icon(AllIcons.*)` | Icon display | +| `contextHelp("description", "title")` | Help icon with popup | +| `textField()` | Text input | +| `passwordField()` | Password input | +| `textFieldWithBrowseButton()` | Text field and browse dialog | +| `expandableTextField()` | Expandable multi-line text field | +| `extendableTextField()` | Text field with extension icons | +| `intTextField(range)` | Integer input with validation | +| `spinner(intRange)` / `spinner(doubleRange, step)` | Numeric spinner | +| `slider(min, max, minorTick, majorTick)` | Slider, use `.labelTable()` for tick labels | +| `textArea()` | Multi-line text, use `.rows(n)` and `.align(AlignX.FILL)` | +| `comboBox(items)` | Combo box / dropdown | +| `comment("text")` | Gray comment text, standalone | +| `cell(component)` | Wrap any arbitrary Swing component | +| `scrollCell(component)` | Wrap component in a scroll pane | +| `cell()` | Empty placeholder cell for grid alignment | + +Key component examples: + +```kotlin +panel { + var color = "grey" + + buttonsGroup { + row("Color:") { + radioButton("White", "white") + radioButton("Grey", "grey") + } + }.bind({ color }, { color = it }) + + row("Slider:") { + slider(0, 10, 1, 5) + .labelTable(mapOf( + 0 to JBLabel("0"), + 5 to JBLabel("5"), + 10 to JBLabel("10"), + )) + } + + row { + label("Text area:") + .align(AlignY.TOP) + .gap(RightGap.SMALL) + textArea() + .rows(5) + .align(AlignX.FILL) + }.layout(RowLayout.PARENT_GRID) +} +``` + +### Component Labels + +Labels for modifiable components must be connected via one of two methods. This ensures correct spacing, mnemonic support, and accessibility. + +- Row label: `row("&Label:") { textField() }`, mnemonic via `&`, label in left column +- Cell label: `textField().label("&Label:", LabelPosition.TOP)`, label attached to cell, optionally on top + +```kotlin +panel { + row("&Row label:") { + textField() + textField() + .label("Cell label at &left:") + } + row { + textField() + .label("Cell label at &top:", LabelPosition.TOP) + } +} +``` + +When a row contains a `checkBox` or `radioButton`, the DSL automatically increases space after the row label per IntelliJ UI Guidelines. + +### Comments + +Three types of comments, each with different placement and semantics: + +| Type | Method | Placement | +| --- | --- | --- | +| Cell comment, bottom | `cell.comment("text")` | Below the cell | +| Cell comment, right | `cell.commentRight("text")` | Right of the cell | +| Cell context help | `cell.contextHelp("text", "title")` | Help icon with popup | +| Row comment | `row.rowComment("text")` | Below the entire row | +| Arbitrary comment | `comment("text")` | Standalone gray text | + +```kotlin +panel { + row { + textField() + .comment("Bottom comment") + textField() + .commentRight("Right comment") + textField() + .contextHelp("Help popup text") + } + + row("Label:") { + textField() + }.rowComment("This comment sits below the whole row") + + row { + comment("Standalone comment, supports links and  icons") + } +} +``` + +Comments support HTML with clickable links, bundled icons via ``, and line width control via `maxLineLength`. Use `MAX_LINE_LENGTH_NO_WRAP` to prevent wrapping. + +### Groups and Structure + +| Method | Grid | Description | +| --- | --- | --- | +| `panel {}` | Own grid | Sub-panel occupying full width | +| `rowsRange {}` | Parent grid | Grouped rows sharing parent grid, useful with `enabledIf` | +| `group("Title") {}` | Own grid | Titled section with vertical spacing before/after | +| `groupRowsRange("Title") {}` | Parent grid | Titled section sharing parent grid alignment | +| `collapsibleGroup("Title") {}` | Own grid | Expandable section, Tab-focusable, supports mnemonics | +| `buttonsGroup("Title") {}` | None | Groups `radioButton` or `checkBox` under a title | +| `separator()` | None | Horizontal separator line | +| Row `panel {}` | Own grid | Sub-panel inside a cell | + +```kotlin +panel { + group("Settings") { + row("Name:") { textField() } + row("Path:") { textFieldWithBrowseButton() } + } + + collapsibleGroup("Advanced") { + row("Timeout:") { intTextField(0..1000) } + } + + var enabled = true + buttonsGroup("Mode:") { + row { radioButton("Automatic", true) } + row { radioButton("Manual", false) } + }.bind({ enabled }, { enabled = it }) + + separator() + + row { + label("Nested panels:") + panel { + row("Sub row 1:") { textField() } + row("Sub row 2:") { textField() } + } + } +} +``` + +### Gaps and Spacing + +- Horizontal gaps: `cell.gap(RightGap.SMALL)` between a label-like checkbox and its related field. Medium gap is the default between cells. +- Two-column layout: `twoColumnsRow({}, {})` or `gap(RightGap.COLUMNS)` with `.layout(RowLayout.PARENT_GRID)`. +- Left indent: `indent {}` for indented sub-content. +- Vertical gaps: `.topGap(TopGap.MEDIUM)` / `.bottomGap(BottomGap.MEDIUM)` on rows to separate unrelated groups. Attach gaps to the related row so hiding rows does not break layout. + +```kotlin +panel { + group("Horizontal Gaps") { + row { + val cb = checkBox("Use mail:") + .gap(RightGap.SMALL) + textField() + .enabledIf(cb.selected) + } + row("Width:") { + textField() + .gap(RightGap.SMALL) + label("pixels") + } + } + + group("Indent") { + row { label("Not indented") } + indent { + row { label("Indented row") } + } + } + + group("Two Columns") { + twoColumnsRow({ + checkBox("First column") + }, { + checkBox("Second column") + }) + } + + group("Vertical Gaps") { + row { checkBox("Option 1") } + row { checkBox("Option 2") } + row { checkBox("Unrelated option") } + .topGap(TopGap.MEDIUM) + } +} +``` + +### Enabled and Visible State + +Bind enabled/visible state to a checkbox or other observable. Works on rows, `indent {}` blocks, `rowsRange {}`, and individual cells. + +```kotlin +panel { + group("Enabled") { + lateinit var cb: Cell + row { cb = checkBox("Enable options") } + indent { + row { checkBox("Option 1") } + row { checkBox("Option 2") } + }.enabledIf(cb.selected) + } + + group("Visible") { + lateinit var cb: Cell + row { cb = checkBox("Show options") } + indent { + row { checkBox("Option 1") } + row { checkBox("Option 2") } + }.visibleIf(cb.selected) + } +} +``` + +### Binding + +Bind component values to model properties. Values are applied on `DialogPanel.apply()`, checked with `.isModified()`, and reverted with `.reset()`. + +| Method | Component | +| --- | --- | +| `bindSelected(model::prop)` | checkBox | +| `bindText(model::prop)` | textField | +| `bindIntText(model::prop)` | intTextField | +| `bindItem(model::prop.toNullableProperty())` | comboBox | +| `bindValue(model::prop)` | slider | +| `bindIntValue(model::prop)` | spinner | +| `buttonsGroup {}.bind(model::prop)` | radio group | + +```kotlin +enum class Theme { LIGHT, DARK } + +data class Settings( + var name: String = "", + var count: Int = 0, + var enabled: Boolean = false, + var theme: Theme = Theme.LIGHT, +) + +val model = Settings() + +val panel = panel { + row("Name:") { + textField().bindText(model::name) + } + row("Count:") { + intTextField(0..100).bindIntText(model::count) + } + row { + checkBox("Enabled").bindSelected(model::enabled) + } + buttonsGroup("Theme:") { + row { radioButton("Light", Theme.LIGHT) } + row { radioButton("Dark", Theme.DARK) } + }.bind(model::theme) +} + +panel.isModified() +panel.apply() +panel.reset() +``` + +### Validation + +Attach input validation rules to cells. Rules run continuously and display inline error/warning indicators. + +```kotlin +panel { + row("Username:") { + textField() + .columns(COLUMNS_MEDIUM) + .cellValidation { + addInputRule("Must not be empty") { + it.text.isBlank() + } + } + } + row("Port:") { + textField() + .columns(COLUMNS_MEDIUM) + .cellValidation { + addInputRule("Contains non-numeric characters", level = Level.WARNING) { + it.text.contains(Regex("[^0-9]")) + } + } + } +} +``` + +Activate validators by calling `dialogPanel.registerValidators(disposable)` after creating the panel. + +### Tips and Common Patterns + +| Pattern | Usage | +| --- | --- | +| `.bold()` | Bold text on any cell | +| `.columns(COLUMNS_MEDIUM)` | Set preferred width of textField / comboBox / textArea | +| `.text("initial")` | Set initial text on text components | +| `.resizableColumn()` | Column fills remaining horizontal space | +| `cell()` | Empty placeholder cell for grid alignment | +| `.widthGroup("name")` | Equalize widths across rows, cannot combine with `AlignX.FILL` | +| `.align(AlignX.FILL)` | Stretch component to fill available width | +| `.align(AlignY.TOP)` | Top-align component in its cell | +| `.applyToComponent { }` | Direct access to the underlying Swing component | +| `.selected(true)` | Default-select a radioButton when no bound value matches | +| `.gap(RightGap.COLUMNS)` | Column-level gap for multi-column layouts | + +```kotlin +panel { + row { label("Title").bold() } + + row("Name:") { + textField() + .columns(COLUMNS_MEDIUM) + .resizableColumn() + .align(AlignX.FILL) + } + + row("") { + textField() + }.rowComment("""Use row("") for an empty label column that aligns with labeled rows""") + + row { + text("Comment-colored text") + .applyToComponent { foreground = JBUI.CurrentTheme.ContextHelp.FOREGROUND } + } +} +``` + +## Manual Swing + +Use manual Swing only when Kotlin UI DSL cannot express the UI cleanly. + +Preferred manual Swing rules: + +- Use IntelliJ platform components instead of raw Swing components. +- Use layout managers and component defaults rather than fixed sizes. +- Use `JBUI.Borders.empty(...)`, `JBUI.insets(...)`, `JBUI.size(...)`, and `JBUI.scale(...)` instead of raw AWT/Swing sizing primitives. +- Do not set default properties explicitly. +- Keep custom components small and focused. +- For painting, use theme-aware colors and `JBUI.scale(...)` for pixel values. + +## Tool Windows + +- Register declaratively in module XML via `com.intellij.toolWindow` extension point. +- Implement `ToolWindowFactory.createToolWindowContent()`, called lazily on first click. +- Use `SimpleToolWindowPanel(vertical = true)` as a convenient base for toolbar and content layout. +- Add tabs via `ToolWindow.contentManager`: create content with `ContentFactory.getInstance().createContent(component, title, isLockable)`, then `contentManager.addContent()`. +- For conditional display, implement `ToolWindowFactory.isApplicableAsync(project)`. +- Always use `ToolWindowManager.invokeLater()` instead of `Application.invokeLater()` for tool-window-related EDT tasks. + +## Dialogs + +- Extend `DialogWrapper`. +- Call `init()` from the constructor. +- Override `createCenterPanel()` to return UI content. +- Prefer Kotlin UI DSL v2 for panel contents. +- Override `getPreferredFocusedComponent()` for initial focus. +- Override `getDimensionServiceKey()` for size persistence when useful. +- Show with `showAndGet()` for modal boolean result, or `show()` and then `getExitCode()`. +- For input validation, call `initValidation()` in the constructor and override `doValidate()` to return `null` if valid or `ValidationInfo(message, component)` if invalid. + +## Platform Components + +Always use IntelliJ platform components instead of raw Swing where an equivalent exists. + +| Instead of | Use | Package | +| --- | --- | --- | +| `JLabel` | `JBLabel` | `com.intellij.ui.components` | +| `JTextField` | `JBTextField` | `com.intellij.ui.components` | +| `JTextArea` | `JBTextArea` | `com.intellij.ui.components` | +| `JList` | `JBList` | `com.intellij.ui.components` | +| `JScrollPane` | `JBScrollPane` | `com.intellij.ui.components` | +| `JTable` | `JBTable` | `com.intellij.ui.table` | +| `JTree` | `Tree` | `com.intellij.ui.treeStructure` | +| `JSplitPane` | `JBSplitter` | `com.intellij.ui` | +| `JTabbedPane` | `JBTabs` | `com.intellij.ui.tabs` | +| `JCheckBox` | `JBCheckBox` | `com.intellij.ui.components` | +| `Color` | `JBColor` | `com.intellij.ui` | +| `EmptyBorder` | `JBUI.Borders.empty()` | `com.intellij.util.ui` | +| Hardcoded pixel sizes | `JBUI.scale(px)` | `com.intellij.util.ui` | + +Inspection `Plugin DevKit | Code | Undesirable class usage` highlights raw Swing usage where a platform replacement exists. + +## Multi-line and Rich Text + +| Need | Component | +| --- | --- | +| Rich HTML with modern CSS, icons, shortcuts | `JBHtmlPane` (`com.intellij.ui.components.JBHtmlPane`) | +| Simple multi-line label with HTML | `JBLabel` + `XmlStringUtil.wrapInHtml()` | +| Scrollable / wrapping HTML panel | `SwingHelper.createHtmlViewer()` | +| High-performance colored text fragments in trees/lists/tables | `SimpleColoredComponent` | +| Plain-text newline splitting | `MultiLineLabel`, legacy, do not use in new code | + +- Build HTML programmatically with `HtmlChunk` / `HtmlBuilder` (`com.intellij.openapi.util.text.HtmlChunk`). Avoid raw HTML string concatenation because it risks injection and breaks localization. +- For simple wrapping/escaping, use `XmlStringUtil.wrapInHtml(content)`, `XmlStringUtil.wrapInHtmlLines(lines...)`, and `XmlStringUtil.escapeString(text)`. +- For selectable/copyable label text, use `JBLabel.setCopyable(true)`, which switches internally to `JEditorPane` while preserving label appearance. Use `setAllowAutoWrapping(true)` for auto-wrap. +- When creating a `JEditorPane` manually, always use `HTMLEditorKitBuilder` instead of constructing `HTMLEditorKit` directly: `editorPane.setEditorKit(HTMLEditorKitBuilder.simple())` or `.withWordWrapViewFactory().build()`. +- For single-line overflow/ellipsis, use `SwingTextTrimmer`. Do not manually truncate strings. +- Put all user-visible strings in `*.properties` files. HTML markup in values is acceptable. + +## Colors and Theming + +- Never use `java.awt.Color` directly. +- Use `JBColor(lightColor, darkColor)` or `JBColor.namedColor("key", fallback)` for theme-aware colors. +- For lazy color retrieval, such as in painting, use `JBColor.lazy { UIManager.getColor("key") }`. +- Check current theme with `JBColor.isBright()`. +- Use generic UI colors such as `UIUtil.getContextHelpForeground()`, `UIUtil.getLabelForeground()`, and `UIUtil.getPanelBackground()`. + +## Borders, Insets, and Spacing + +- Always create borders via `JBUI.Borders.empty(top, left, bottom, right)` and insets via `JBUI.insets()` so they are DPI-aware and auto-update on zoom. +- Use `JBUI.scale(int)` for any pixel dimension to ensure proper HiDPI scaling. +- Prefer Kotlin UI DSL gaps and row layout semantics over manually assigning borders or insets. +- Do not use `EmptyBorder`, raw `Insets`, or raw `Dimension` unless there is no platform alternative and the reason is obvious. + +## Icons + +- Reuse platform icons. Browse at https://intellij-icons.jetbrains.design and access via `AllIcons.*` constants. +- Custom icons belong in `resources/icons/`. +- Load custom icons via `IconLoader.getIcon("/icons/foo.svg", MyClass::class.java)`. +- Organize custom icons in an `icons` package or a `*Icons` object with `@JvmField` on each constant. +- Icon sizing guideline values: actions/nodes = 16x16, tool window = 13x13 classic or 20x20 + 16x16 compact New UI, editor gutter = 12x12 classic or 14x14 New UI. +- Dark variants: `icon.svg` + `icon_dark.svg`. +- HiDPI variants: `icon@2x.svg` + `icon@2x_dark.svg`. +- New UI support: place New UI icons in `expui/`, create `*IconMappings.json`, register via `com.intellij.iconMapper` extension point. +- New UI icon colors: light `#6C707E`, dark `#CED0D6`. + +## Notifications + +- Declare in module XML: ``. +- Show with `Notification("Kilo Code", "message", NotificationType.INFORMATION).notify(project)`. +- Add actions with `.addAction(NotificationAction.createSimpleExpiring("Label") { ... })`. +- Sticky notifications use `displayType="STICKY_BALLOON"` and `.setSuggestionType(true)`. +- Tool-window-bound notifications use `displayType="TOOL_WINDOW" toolWindowId="Kilo Code"`. +- Prefer non-modal notifications over `Messages.show*()` dialogs. + +## Popups + +- Use `JBPopupFactory.getInstance()` for lightweight floating UI with no chrome and auto-dismiss on focus loss. +- Use `createComponentPopupBuilder(component, focusable)` for arbitrary Swing content. +- Use `createPopupChooserBuilder(list)` for item selection. +- Use `createActionGroupPopup()` for action menus. +- Show with `showInBestPositionFor(editor)`, `showUnderneathOf(component)`, or `showInCenterOf(component)`. + +## Lists and Trees + +- Use `JBList`, not `JList`, for empty text, busy indicator, and tooltip truncation. +- Use `Tree`, not `JTree`, for wide selection painting and auto-scroll on drag-and-drop. +- Use `ColoredListCellRenderer` / `ColoredTreeCellRenderer` for custom renderers. +- Use `append()` for styled text and `setIcon()` for icons. +- Use `ListSpeedSearch(list)` / `TreeSpeedSearch(tree)` for speed search. +- Use `ToolbarDecorator.createDecorator(list).setAddAction { }.setRemoveAction { }.createPanel()` for editable lists with add/remove/reorder toolbar. + +## Before Returning Code + +Review generated UI code and remove: + +- Explicit default property assignments such as unnecessary `isOpaque = false` +- Unnecessary `preferredSize`, `minimumSize`, or `maximumSize` +- Raw `Dimension`, `Insets`, `EmptyBorder`, or `Color` +- Raw Swing components where IntelliJ replacements exist +- Manual layout code that Kotlin UI DSL can express cleanly +- Hardcoded spacing that should be a DSL gap, row gap, or `JBUI` value +- Extra helpers that do not make the UI clearer or more reusable + +## References + +- IntelliJ Platform UI Guidelines: https://jetbrains.design/intellij/ +- User Interface Components: https://plugins.jetbrains.com/docs/intellij/user-interface-components.html +- UI FAQ: https://plugins.jetbrains.com/docs/intellij/ui-faq.html +- Kotlin UI DSL v2: https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html +- Split mode for remote development: https://plugins.jetbrains.com/docs/intellij/split-mode-for-remote-development.html diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 5a41a647ddd..34349525215 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -205,539 +205,16 @@ Sections are separated by `---`. Only non-empty sections appear. The status line ## UI Design Guidelines -Official references: - -- [IntelliJ Platform UI Guidelines](https://jetbrains.design/intellij/) -- [User Interface Components](https://plugins.jetbrains.com/docs/intellij/user-interface-components.html) -- [UI FAQ (colors, borders, icons)](https://plugins.jetbrains.com/docs/intellij/ui-faq.html) - -### Do Not Use Kotlin Compose - -**Do not use Kotlin Compose or `intellij.platform.compose` in this plugin.** The JetBrains modular template uses Compose for its demo tool window, but Kilo should use standard Swing with IntelliJ Platform components only. Keep all plugin UI in the existing Swing-based stack. - -### Do Not Use JCEF (Embedded Browser) - -**Do not use JCEF (`JBCefBrowser`) in this plugin.** JCEF does not work in JetBrains remote development (split mode): the frontend process runs on the client machine but JCEF requires a display on the host, making it effectively unusable for remote users. Use standard Swing with IntelliJ Platform components for all UI. - -### When to Use What - -| Need | API | -| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| Dialogs, settings pages, forms, any layout with components | **Preferred**: [Kotlin UI DSL v2](https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html) (`com.intellij.ui.dsl.builder`) | -| Tool window panels, action-driven UI, custom components | Standard Swing with IntelliJ Platform component replacements (see below) | -| Menus and toolbars | [Action System](https://plugins.jetbrains.com/docs/intellij/action-system.html) | - -### Kotlin UI DSL v2 (Preferred) - -**Use Kotlin UI DSL v2 as the default way to build UI** for dialogs, settings pages, forms, and any layout composed of standard components. It produces correct spacing, label alignment, HiDPI scaling, and accessibility automatically. Only fall back to manual Swing layout when you need a fully custom component (e.g. a canvas, rich list renderer, or tool-window chrome that the DSL can't express). - -The top-level builder is `panel { }` (returns `DialogPanel`). Structure: **panel → row → cells**. Cell factory methods (`textField()`, `checkBox()`, `label()`, etc.) add components. The DSL lives in `com.intellij.ui.dsl.builder`. - -To explore DSL capabilities interactively: **Tools → Internal Actions → UI → Kotlin UI DSL → UI DSL Showcase** (requires internal mode: `-Didea.is.internal=true`). - -Reference: [Kotlin UI DSL v2 docs](https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html) - -#### Basics — Panel / Row / Cell - -Rows occupy full width. The last cell in a row takes remaining space. Rows have a `layout` property (see Row Layout below). - -```kotlin -panel { - row("Row1 label:") { - textField() - label("Some text") - } - row("Row2:") { - label("This text is aligned with previous row") - } -} -``` - -#### Row Layout - -Every row uses one of three layouts. Default is `LABEL_ALIGNED` when a label is provided for the row, `INDEPENDENT` otherwise. - -| Layout | Behavior | -| --------------- | ---------------------------------------------------- | -| `LABEL_ALIGNED` | Label column + content columns, aligned across rows | -| `INDEPENDENT` | All cells are independent, no cross-row alignment | -| `PARENT_GRID` | Cells align with the parent grid columns across rows | - -```kotlin -panel { - row("PARENT_GRID:") { - label("Col 1") - label("Col 2") - }.layout(RowLayout.PARENT_GRID) - - row("PARENT_GRID:") { - textField() - textField() - }.layout(RowLayout.PARENT_GRID) - - row("LABEL_ALIGNED (default with label):") { - textField() - } - - row { - label("INDEPENDENT (default without label):") - textField() - } -} -``` - -#### Components Reference - -All cell factory methods available inside `row { }`: - -| Method | Description | -| -------------------------------------------------- | ---------------------------------------------------------- | -| `checkBox("text")` | Checkbox | -| `threeStateCheckBox("text")` | Three-state checkbox | -| `radioButton("text", value)` | Radio button (must be inside `buttonsGroup {}`) | -| `button("text") {}` | Push button | -| `actionButton(action)` | Icon button bound to an `AnAction` | -| `actionsButton(action1, action2, ...)` | Dropdown actions button | -| `segmentedButton(items) { text = it }` | Segmented control | -| `tabbedPaneHeader(items)` | Tab header strip | -| `label("text")` | Static label | -| `text("html")` | Rich text with links, icons, line-width control | -| `link("text") {}` | Focusable clickable link | -| `browserLink("text", "url")` | Opens URL in browser | -| `dropDownLink("default", listOf(...))` | Dropdown link selector | -| `icon(AllIcons.*)` | Icon display | -| `contextHelp("description", "title")` | Help icon with popup | -| `textField()` | Text input | -| `passwordField()` | Password input | -| `textFieldWithBrowseButton()` | Text field + browse dialog | -| `expandableTextField()` | Expandable multi-line text field | -| `extendableTextField()` | Text field with extension icons | -| `intTextField(range)` | Integer input with validation | -| `spinner(intRange)` / `spinner(doubleRange, step)` | Numeric spinner | -| `slider(min, max, minorTick, majorTick)` | Slider (use `.labelTable()` for tick labels) | -| `textArea()` | Multi-line text (use `.rows(n)` and `.align(AlignX.FILL)`) | -| `comboBox(items)` | Combo box / dropdown | -| `comment("text")` | Gray comment text (standalone) | -| `cell(component)` | Wrap any arbitrary Swing component | -| `scrollCell(component)` | Wrap component in a scroll pane | -| `cell()` | Empty placeholder cell for grid alignment | - -Key component examples: - -```kotlin -panel { - // Radio button group - var color = "grey" - buttonsGroup { - row("Color:") { - radioButton("White", "white") - radioButton("Grey", "grey") - } - }.bind({ color }, { color = it }) - - // Slider with tick labels - row("slider:") { - slider(0, 10, 1, 5) - .labelTable(mapOf( - 0 to JLabel("0"), - 5 to JLabel("5"), - 10 to JLabel("10"), - )) - } - - // Text area with proper alignment - row { - label("textArea:") - .align(AlignY.TOP) - .gap(RightGap.SMALL) - textArea() - .rows(5) - .align(AlignX.FILL) - }.layout(RowLayout.PARENT_GRID) -} -``` - -#### Component Labels - -Labels for modifiable components **must** be connected via one of two methods — this ensures correct spacing, mnemonic support, and accessibility: - -- **Row label**: `row("&Label:") { textField() }` — mnemonic via `&`, label in left column -- **Cell label**: `textField().label("&Label:", LabelPosition.TOP)` — label attached to cell, optionally on top - -```kotlin -panel { - row("&Row label:") { - textField() - textField() - .label("Cell label at &left:") - } - row { - textField() - .label("Cell label at &top:", LabelPosition.TOP) - } -} -``` - -Note: when a row contains a `checkBox` or `radioButton`, the DSL automatically increases space after the row label per [UI Guidelines](https://plugins.jetbrains.com/docs/intellij/layout.html#checkboxes-and-radio-buttons). - -#### Comments - -Three types of comments, each with different placement and semantics: - -| Type | Method | Placement | -| --------------------- | ----------------------------------- | -------------------- | -| Cell comment (bottom) | `cell.comment("text")` | Below the cell | -| Cell comment (right) | `cell.commentRight("text")` | Right of the cell | -| Cell context help | `cell.contextHelp("text", "title")` | Help icon with popup | -| Row comment | `row.rowComment("text")` | Below the entire row | -| Arbitrary comment | `comment("text")` | Standalone gray text | - -```kotlin -panel { - row { - textField() - .comment("Bottom comment") - textField() - .commentRight("Right comment") - textField() - .contextHelp("Help popup text") - } - - row("Label:") { - textField() - }.rowComment("This comment sits below the whole row") - - row { - comment("Standalone comment, supports links and  icons") - } -} -``` - -Comments support HTML with clickable links (pass a lambda for the click handler), bundled icons via ``, and line width control via `maxLineLength` parameter. Use `MAX_LINE_LENGTH_NO_WRAP` to prevent wrapping. - -#### Groups and Structure - -| Method | Grid | Description | -| ------------------------------ | ----------- | ---------------------------------------------------------- | -| `panel {}` | Own grid | Sub-panel occupying full width | -| `rowsRange {}` | Parent grid | Grouped rows sharing parent grid — useful with `enabledIf` | -| `group("Title") {}` | Own grid | Titled section with vertical spacing before/after | -| `groupRowsRange("Title") {}` | Parent grid | Titled section sharing parent grid alignment | -| `collapsibleGroup("Title") {}` | Own grid | Expandable section (Tab-focusable, supports mnemonics) | -| `buttonsGroup("Title") {}` | — | Groups `radioButton` or `checkBox` under a title | -| `separator()` | — | Horizontal separator line | -| Row `panel {}` | Own grid | Sub-panel inside a cell | - -```kotlin -panel { - group("Settings") { - row("Name:") { textField() } - row("Path:") { textFieldWithBrowseButton() } - } - - collapsibleGroup("Advanced") { - row("Timeout:") { intTextField(0..1000) } - } - - var enabled = true - buttonsGroup("Mode:") { - row { radioButton("Automatic", true) } - row { radioButton("Manual", false) } - }.bind({ enabled }, { enabled = it }) - - separator() - - row { - label("Nested panels:") - panel { - row("Sub row 1:") { textField() } - row("Sub row 2:") { textField() } - } - } -} -``` - -#### Gaps and Spacing - -- **Horizontal gaps**: `cell.gap(RightGap.SMALL)` between a label-like checkbox and its related field. Medium gap is the default between cells. -- **Two-column layout**: `twoColumnsRow({}, {})` or `gap(RightGap.COLUMNS)` with `.layout(RowLayout.PARENT_GRID)`. -- **Left indent**: `indent {}` for indented sub-content. -- **Vertical gaps**: `.topGap(TopGap.MEDIUM)` / `.bottomGap(BottomGap.MEDIUM)` on rows to separate unrelated groups. Attach gaps to the "related" row so hiding rows doesn't break layout. - -```kotlin -panel { - group("Horizontal Gaps") { - row { - val cb = checkBox("Use mail:") - .gap(RightGap.SMALL) - textField() - .enabledIf(cb.selected) - } - row("Width:") { - textField() - .gap(RightGap.SMALL) - label("pixels") - } - } - - group("Indent") { - row { label("Not indented") } - indent { - row { label("Indented row") } - } - } - - group("Two Columns") { - twoColumnsRow({ - checkBox("First column") - }, { - checkBox("Second column") - }) - } - - group("Vertical Gaps") { - row { checkBox("Option 1") } - row { checkBox("Option 2") } - row { checkBox("Unrelated option") } - .topGap(TopGap.MEDIUM) - } -} -``` - -#### Enabled / Visible (Reactive State) - -Bind enabled/visible state to a checkbox or other observable. Works on rows, `indent {}` blocks, `rowsRange {}`, and individual cells. - -```kotlin -panel { - group("Enabled") { - lateinit var cb: Cell - row { cb = checkBox("Enable options") } - indent { - row { checkBox("Option 1") } - row { checkBox("Option 2") } - }.enabledIf(cb.selected) - } - - group("Visible") { - lateinit var cb: Cell - row { cb = checkBox("Show options") } - indent { - row { checkBox("Option 1") } - row { checkBox("Option 2") } - }.visibleIf(cb.selected) - } -} -``` - -#### Binding (Property Binding) - -Bind component values to model properties. Values are applied on `DialogPanel.apply()`, checked with `.isModified()`, and reverted with `.reset()`. - -| Method | Component | -| -------------------------------------------- | ------------ | -| `bindSelected(model::prop)` | checkBox | -| `bindText(model::prop)` | textField | -| `bindIntText(model::prop)` | intTextField | -| `bindItem(model::prop.toNullableProperty())` | comboBox | -| `bindValue(model::prop)` | slider | -| `bindIntValue(model::prop)` | spinner | -| `buttonsGroup {}.bind(model::prop)` | radio group | - -```kotlin -enum class Theme { LIGHT, DARK } - -data class Settings( - var name: String = "", - var count: Int = 0, - var enabled: Boolean = false, - var theme: Theme = Theme.LIGHT, -) - -val model = Settings() - -val panel = panel { - row("Name:") { - textField().bindText(model::name) - } - row("Count:") { - intTextField(0..100).bindIntText(model::count) - } - row { - checkBox("Enabled").bindSelected(model::enabled) - } - buttonsGroup("Theme:") { - row { radioButton("Light", Theme.LIGHT) } - row { radioButton("Dark", Theme.DARK) } - }.bind(model::theme) -} - -// Later: -panel.isModified() // true if UI differs from model -panel.apply() // write UI values to model -panel.reset() // revert UI to model values -``` - -#### Validation - -Attach input validation rules to cells. Rules run continuously and display inline error/warning indicators. - -```kotlin -panel { - row("Username:") { - textField() - .columns(COLUMNS_MEDIUM) - .cellValidation { - addInputRule("Must not be empty") { - it.text.isBlank() - } - } - } - row("Port:") { - textField() - .columns(COLUMNS_MEDIUM) - .cellValidation { - addInputRule("Contains non-numeric characters", level = Level.WARNING) { - it.text.contains(Regex("[^0-9]")) - } - } - } -} -``` - -Activate validators by calling `dialogPanel.registerValidators(disposable)` after creating the panel. - -#### Tips and Common Patterns - -| Pattern | Usage | -| -------------------------- | --------------------------------------------------------------- | -| `.bold()` | Bold text on any cell | -| `.columns(COLUMNS_MEDIUM)` | Set preferred width of textField / comboBox / textArea | -| `.text("initial")` | Set initial text on text components | -| `.resizableColumn()` | Column fills remaining horizontal space | -| `cell()` | Empty placeholder cell for grid alignment | -| `.widthGroup("name")` | Equalize widths across rows (cannot combine with `AlignX.FILL`) | -| `.align(AlignX.FILL)` | Stretch component to fill available width | -| `.align(AlignY.TOP)` | Top-align component in its cell | -| `.applyToComponent { }` | Direct access to the underlying Swing component | -| `.selected(true)` | Default-select a radioButton when no bound value matches | -| `.gap(RightGap.COLUMNS)` | Column-level gap for multi-column layouts | - -```kotlin -panel { - row { label("Title").bold() } - - row("Name:") { - textField() - .columns(COLUMNS_MEDIUM) - .resizableColumn() - .align(AlignX.FILL) - } - - row("") { - textField() - }.rowComment("""Use row("") for an empty label column that aligns with labeled rows""") - - row { - text("Comment-colored text") - .applyToComponent { foreground = JBUI.CurrentTheme.ContextHelp.FOREGROUND } - } -} -``` - -### Tool Windows - -- Register declaratively in module XML via `com.intellij.toolWindow` extension point (already done in `kilo.jetbrains.frontend.xml`). -- Implement `ToolWindowFactory.createToolWindowContent()` — called lazily on first click (zero overhead if unused). -- Use `SimpleToolWindowPanel(vertical = true)` as a convenient base — supports toolbar + content layout. -- Add tabs via `ToolWindow.contentManager`: create content with `ContentFactory.getInstance().createContent(component, title, isLockable)`, then `contentManager.addContent()`. -- For conditional display, implement `ToolWindowFactory.isApplicableAsync(project)`. -- Always use `ToolWindowManager.invokeLater()` instead of `Application.invokeLater()` for tool-window-related EDT tasks. - -### Dialogs - -- Extend `DialogWrapper`. Call `init()` from the constructor. Override `createCenterPanel()` to return UI content — prefer Kotlin UI DSL v2 for the panel contents. -- Override `getPreferredFocusedComponent()` for initial focus, `getDimensionServiceKey()` for size persistence. -- Show with `showAndGet()` (modal, returns boolean) or `show()` (then use `getExitCode()`). -- Input validation: call `initValidation()` in constructor, override `doValidate()` → return `null` if valid or `ValidationInfo(message, component)` if not. - -### Platform Components — Always Use Instead of Raw Swing - -| Instead of | Use | Package | -| --------------------- | ---------------------- | ------------------------------- | -| `JLabel` | `JBLabel` | `com.intellij.ui.components` | -| `JTextField` | `JBTextField` | `com.intellij.ui.components` | -| `JTextArea` | `JBTextArea` | `com.intellij.ui.components` | -| `JList` | `JBList` | `com.intellij.ui.components` | -| `JScrollPane` | `JBScrollPane` | `com.intellij.ui.components` | -| `JTable` | `JBTable` | `com.intellij.ui.table` | -| `JTree` | `Tree` | `com.intellij.ui.treeStructure` | -| `JSplitPane` | `JBSplitter` | `com.intellij.ui` | -| `JTabbedPane` | `JBTabs` | `com.intellij.ui.tabs` | -| `JCheckBox` | `JBCheckBox` | `com.intellij.ui.components` | -| `Color` | `JBColor` | `com.intellij.ui` | -| `EmptyBorder` | `JBUI.Borders.empty()` | `com.intellij.util.ui` | -| Hardcoded pixel sizes | `JBUI.scale(px)` | `com.intellij.util.ui` | - -Inspection `Plugin DevKit | Code | Undesirable class usage` highlights when you use raw Swing where a platform replacement exists. - -### Multi-line and Rich Text - -| Need | Component | -| ----------------------------------------------------- | ------------------------------------------------------ | -| Rich HTML with modern CSS, icons, shortcuts | `JBHtmlPane` (`com.intellij.ui.components.JBHtmlPane`) | -| Simple multi-line label with HTML | `JBLabel` + `XmlStringUtil.wrapInHtml()` | -| Scrollable / wrapping HTML panel | `SwingHelper.createHtmlViewer()` | -| High-perf colored text fragments (trees/lists/tables) | `SimpleColoredComponent` | -| Plain-text newline splitting | `MultiLineLabel` — legacy, do not use in new code | - -- Build HTML programmatically with `HtmlChunk`/`HtmlBuilder` (`com.intellij.openapi.util.text.HtmlChunk`). Avoid raw HTML string concatenation — it risks injection and breaks localization. -- For simple wrapping/escaping: `XmlStringUtil.wrapInHtml(content)`, `XmlStringUtil.wrapInHtmlLines(lines...)`, `XmlStringUtil.escapeString(text)`. -- Selectable/copyable label text: `JBLabel.setCopyable(true)` (switches internally to `JEditorPane` while preserving label appearance). Use `setAllowAutoWrapping(true)` for auto-wrap. -- When creating a `JEditorPane` manually, always use `HTMLEditorKitBuilder` instead of constructing `HTMLEditorKit` directly: `editorPane.setEditorKit(HTMLEditorKitBuilder.simple())` or `.withWordWrapViewFactory().build()`. -- Single-line overflow/ellipsis: use `SwingTextTrimmer` — do not manually truncate strings. -- All user-visible strings go in `*.properties` files; HTML markup in values is acceptable. - -### Colors and Theming - -- **Never** use `java.awt.Color` directly. Use `JBColor(lightColor, darkColor)` or `JBColor.namedColor("key", fallback)` for theme-aware colors. -- For lazy color retrieval (e.g. in painting), use `JBColor.lazy { UIManager.getColor("key") }`. -- Check current theme: `JBColor.isBright()` returns `true` for light themes. -- Generic UI colors: `UIUtil.getContextHelpForeground()`, `UIUtil.getLabelForeground()`, `UIUtil.getPanelBackground()`, etc. - -### Borders, Insets, and Spacing - -- Always create via `JBUI.Borders.empty(top, left, bottom, right)` and `JBUI.insets()` — DPI-aware and auto-update on zoom. -- Use `JBUI.scale(int)` for any pixel dimension to ensure proper HiDPI scaling. - -### Icons - -- **Reuse platform icons**: browse at https://intellij-icons.jetbrains.design. Access via `AllIcons.*` constants. -- Custom icons: SVG files in `resources/icons/`. Load via `IconLoader.getIcon("/icons/foo.svg", MyClass::class.java)`. -- Organize in an `icons` package or a `*Icons` object with `@JvmField` on each constant. -- **Sizing**: actions/nodes = 16×16, tool window = 13×13 (classic) or 20×20 + 16×16 compact (New UI), editor gutter = 12×12 (classic) / 14×14 (New UI). -- **Dark variants**: `icon.svg` + `icon_dark.svg`. HiDPI: `icon@2x.svg` + `icon@2x_dark.svg`. -- **New UI support**: place New UI icons in `expui/` directory, create `*IconMappings.json`, register via `com.intellij.iconMapper` extension point. New UI icon colors: light `#6C707E`, dark `#CED0D6`. - -### Notifications - -- Declare in module XML: ``. -- Show: `Notification("Kilo Code", "message", NotificationType.INFORMATION).notify(project)`. -- Add actions: `.addAction(NotificationAction.createSimpleExpiring("Label") { ... })`. -- Sticky (user must dismiss): `displayType="STICKY_BALLOON"` + `.setSuggestionType(true)`. -- Tool-window-bound: `displayType="TOOL_WINDOW" toolWindowId="Kilo Code"`. -- Prefer non-modal notifications over `Messages.show*()` dialogs. - -### Popups - -- Use `JBPopupFactory.getInstance()` for lightweight floating UI (no chrome, auto-dismiss on focus loss). -- `createComponentPopupBuilder(component, focusable)` for arbitrary Swing content; `createPopupChooserBuilder(list)` for item selection; `createActionGroupPopup()` for action menus. -- Show with `showInBestPositionFor(editor)`, `showUnderneathOf(component)`, or `showInCenterOf(component)`. - -### Lists and Trees - -- `JBList` not `JList` — adds empty text, busy indicator, tooltip truncation. -- `Tree` not `JTree` — adds wide selection painting, auto-scroll on DnD. -- Custom renderers: `ColoredListCellRenderer` / `ColoredTreeCellRenderer` — `append()` for styled text, `setIcon()` for icons. -- Speed search: `ListSpeedSearch(list)` / `TreeSpeedSearch(tree)`. -- Editable list with add/remove/reorder toolbar: `ToolbarDecorator.createDecorator(list).setAddAction { }.setRemoveAction { }.createPanel()`. +When creating, modifying, or reviewing UI code in `packages/kilo-jetbrains`, load the `jetbrains-ui-style` skill and follow it. + +Core rules that always apply: + +- Use standard Swing with IntelliJ Platform components only. +- Do not use Kotlin Compose or `intellij.platform.compose`. +- Do not use JCEF or `JBCefBrowser`. +- Prefer Kotlin UI DSL v2 for dialogs, settings pages, forms, and structured component layouts. +- Use manual Swing only when Kotlin UI DSL cannot express the UI cleanly. +- Keep UI code minimal; do not set default Swing properties explicitly unless required. +- Avoid hardcoded dimensions; use DSL layout, component defaults, or `JBUI.scale` / `JBUI.size` when a fixed size is required. +- Use IntelliJ platform components and theme-aware APIs instead of raw Swing, raw colors, raw borders, or raw pixel values. +- Put user-visible strings in `*.properties` files. From 4a44a0d01d490a122d5172035f48be13bd8c0948 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 27 Apr 2026 15:46:26 -0400 Subject: [PATCH 21/96] docs: expand JetBrains UI style guidance --- .kilo/skills/jetbrains-ui-style/SKILL.md | 257 +++++++++++++++++++++-- 1 file changed, 244 insertions(+), 13 deletions(-) diff --git a/.kilo/skills/jetbrains-ui-style/SKILL.md b/.kilo/skills/jetbrains-ui-style/SKILL.md index 3d3a01e213f..92a660d1865 100644 --- a/.kilo/skills/jetbrains-ui-style/SKILL.md +++ b/.kilo/skills/jetbrains-ui-style/SKILL.md @@ -22,6 +22,10 @@ This plugin is a split-mode JetBrains plugin. UI code belongs in `frontend` unle - Avoid hardcoded dimensions. Prefer layout semantics, component defaults, DSL sizing helpers, and `JBUI` utilities. - Do not add decorative helper functions, wrappers, or defensive UI code unless they materially improve clarity or correctness. - Prefer IntelliJ platform components and theme-aware APIs. +- Do not use hardcoded runtime colors in generated UI examples or implementation code. +- Derive UI colors from semantic IntelliJ theme APIs such as `UIUtil`, `JBUI.CurrentTheme`, `NamedColorUtil`, `JBColor.namedColor`, or `JBColor.lazy`. +- Do not use hardcoded font sizes or font families in generated UI examples or implementation code. +- Use component defaults, `JBFont`, `RelativeFont`, or existing component fonts for typography. - Put user-visible strings in `*.properties` files. ## Decision Tree @@ -195,14 +199,14 @@ Key component examples: ```kotlin panel { - var color = "grey" + var mode = "auto" buttonsGroup { - row("Color:") { - radioButton("White", "white") - radioButton("Grey", "grey") + row("Mode:") { + radioButton("Automatic", "auto") + radioButton("Manual", "manual") } - }.bind({ color }, { color = it }) + }.bind({ mode }, { mode = it }) row("Slider:") { slider(0, 10, 1, 5) @@ -524,6 +528,104 @@ Preferred manual Swing rules: - Keep custom components small and focused. - For painting, use theme-aware colors and `JBUI.scale(...)` for pixel values. +## Platform Spacing and Insets + +Use semantic spacing APIs before inventing numbers. Do not copy fallback values from IntelliJ source into generated UI code; those values are defaults behind theme keys and may change by UI theme, New UI, OS, or IDE version. + +| Need | Preferred source | +| --- | --- | +| Dialogs, forms, settings | Kotlin UI DSL row/group/indent/gap APIs | +| Component gaps in Kotlin UI DSL | `RightGap.SMALL`, `RightGap.COLUMNS`, `TopGap.MEDIUM`, `BottomGap.MEDIUM`, `.customize(UnscaledGaps(...))` as a last resort | +| Manual Swing empty padding | `JBUI.Borders.empty(...)` | +| Manual Swing insets | `JBUI.insets(...)`, `JBUI.emptyInsets()`, `JBUI.insetsTop(...)`, `JBUI.insetsLeft(...)`, `JBUI.insetsBottom(...)`, `JBUI.insetsRight(...)` | +| Manual Swing dimensions | `JBUI.size(...)`, `JBDimension`, `JBUI.scale(...)` | +| Side separators | `JBUI.Borders.customLineTop(...)`, `customLineBottom(...)`, `customLineLeft(...)`, `customLineRight(...)` | +| Composed borders | `JBUI.Borders.compound(...)`, `JBUI.Borders.merge(...)` | +| Simple `BorderLayout` panels | `JBUI.Panels.simplePanel(...)`, `BorderLayoutPanel` | +| Simple vertical custom Swing groups | `VerticalLayout` | +| Fluent platform panels | `JBPanel.withBorder(...)`, `.andTransparent()`, `.andOpaque()`, `.withBackground(...)` | + +Use `JBUI.CurrentTheme` for context-specific spacing and dimensions: + +| UI area | Preferred source | +| --- | --- | +| Action list rows | `JBUI.CurrentTheme.ActionsList.cellPadding()` | +| Action icon/text gaps | `JBUI.CurrentTheme.ActionsList.elementIconGap()` | +| Action mnemonic gaps | `JBUI.CurrentTheme.ActionsList.mnemonicIconGap()` / `mnemonicInsets()` | +| Popup selection interior | `JBUI.CurrentTheme.Popup.Selection.innerInsets()` | +| Popup separators | `JBUI.CurrentTheme.Popup.separatorInsets()` / `separatorLabelInsets()` | +| Popup header/search field | `JBUI.CurrentTheme.Popup.headerInsets()`, `searchFieldBorderInsets()`, `searchFieldInputInsets()` | +| Complex popup headers | `JBUI.CurrentTheme.ComplexPopup.headerInsets()` | +| Complex popup text fields | `JBUI.CurrentTheme.ComplexPopup.textFieldBorderInsets()` / `textFieldInputInsets()` | +| Search Everywhere / big popup header | `JBUI.CurrentTheme.BigPopup.headerBorder()` / `headerToolbarInsets()` / `tabInsets()` | +| Popup advertiser/footer | `JBUI.CurrentTheme.Advertiser.border()` | +| Toolbar buttons | `JBUI.CurrentTheme.Toolbar.toolbarButtonInsets()` / `mainToolbarButtonInsets()` | +| Toolbar containers | `JBUI.CurrentTheme.Toolbar.horizontalToolbarInsets()` / `verticalToolbarInsets()` | +| Tool-window headers | `JBUI.CurrentTheme.ToolWindow.headerLabelLeftRightInsets()` / `headerToolbarLeftRightInsets()` / `headerTabLeftRightInsets()` | +| Lists | `JBUI.CurrentTheme.List.rowHeight()` | +| Trees | `JBUI.CurrentTheme.Tree.rowHeight()` | +| VCS log rows | `JBUI.CurrentTheme.VersionControl.Log.rowHeight()` / `verticalPadding()` | +| Help tooltips | `JBUI.CurrentTheme.HelpTooltip.defaultTextBorderInsets()` / `smallTextBorderInsets()` | +| Navigation bar items | `JBUI.CurrentTheme.NavBar.itemInsets()` | + +When using Kotlin UI DSL, prefer DSL semantics over manual padding: + +```kotlin +panel { + group(KiloBundle.message("settings.section")) { + row(KiloBundle.message("settings.name")) { + textField() + .align(AlignX.FILL) + .resizableColumn() + } + indent { + row { checkBox(KiloBundle.message("settings.enabled")) } + } + } +} +``` + +When manual Swing is necessary, get padding from platform utilities: + +```kotlin +val panel = JBUI.Panels.simplePanel(content) + .withBorder(JBUI.Borders.empty(JBUI.CurrentTheme.Popup.headerInsets())) +``` + +For row heights, prefer platform row height APIs over fixed heights: + +```kotlin +component.preferredSize = JBDimension(0, JBUI.CurrentTheme.Tree.rowHeight()) +``` + +## Generic Platform Utilities + +Use existing platform utility classes instead of hand-rolled labels, renderers, borders, colors, fonts, and validation behavior. + +| Need | Preferred API | +| --- | --- | +| Concise border layout | `BorderLayoutPanel`, `JBUI.Panels.simplePanel(...)` | +| Platform panel helpers | `JBPanel.withBorder(...)`, `.andTransparent()`, `.andOpaque()` | +| Platform label behavior | `JBLabel` | +| Context help | `ContextHelpLabel` | +| Links | `HyperlinkLabel`, `LinkLabel`, Kotlin UI DSL `link(...)` / `browserLink(...)` | +| Error/validation label in legacy UI | `ErrorLabel` only when an inline validation component is required and DSL validation is not available | +| High-performance rich fragments | `SimpleColoredComponent` | +| List renderers | `ColoredListCellRenderer`, Kotlin `listCellRenderer` / `textListCellRenderer` / `LcrRow` | +| Tree renderers | `ColoredTreeCellRenderer` | +| Renderer text styles | `SimpleTextAttributes` | +| Editable list toolbar | `ToolbarDecorator` | +| Platform list | `JBList` | +| Platform tree | `Tree` | + +For renderer text, prefer `SimpleTextAttributes` over direct color/font mutation: + +```kotlin +append(text, SimpleTextAttributes.ERROR_ATTRIBUTES) +append(detail, SimpleTextAttributes.GRAYED_ATTRIBUTES) +append(link, SimpleTextAttributes.LINK_ATTRIBUTES) +``` + ## Tool Windows - Register declaratively in module XML via `com.intellij.toolWindow` extension point. @@ -560,7 +662,7 @@ Always use IntelliJ platform components instead of raw Swing where an equivalent | `JSplitPane` | `JBSplitter` | `com.intellij.ui` | | `JTabbedPane` | `JBTabs` | `com.intellij.ui.tabs` | | `JCheckBox` | `JBCheckBox` | `com.intellij.ui.components` | -| `Color` | `JBColor` | `com.intellij.ui` | +| Raw runtime colors | `UIUtil`, `JBUI.CurrentTheme`, `NamedColorUtil`, `JBColor.namedColor`, `JBColor.lazy` | `com.intellij.util.ui`, `com.intellij.ui` | | `EmptyBorder` | `JBUI.Borders.empty()` | `com.intellij.util.ui` | | Hardcoded pixel sizes | `JBUI.scale(px)` | `com.intellij.util.ui` | @@ -583,13 +685,109 @@ Inspection `Plugin DevKit | Code | Undesirable class usage` highlights raw Swing - For single-line overflow/ellipsis, use `SwingTextTrimmer`. Do not manually truncate strings. - Put all user-visible strings in `*.properties` files. HTML markup in values is acceptable. -## Colors and Theming +## Theme-Derived Colors -- Never use `java.awt.Color` directly. -- Use `JBColor(lightColor, darkColor)` or `JBColor.namedColor("key", fallback)` for theme-aware colors. -- For lazy color retrieval, such as in painting, use `JBColor.lazy { UIManager.getColor("key") }`. -- Check current theme with `JBColor.isBright()`. -- Use generic UI colors such as `UIUtil.getContextHelpForeground()`, `UIUtil.getLabelForeground()`, and `UIUtil.getPanelBackground()`. +Do not hardcode runtime colors. IntelliJ UI colors must come from the current theme, component state, editor color scheme, or a centralized semantic named color key. + +Prefer semantic helpers for common UI roles: + +| Need | Preferred API | +| --- | --- | +| Ordinary label text | `UIUtil.getLabelForeground()` | +| Secondary/help text | `UIUtil.getContextHelpForeground()` | +| Info label text | `UIUtil.getLabelInfoForeground()` | +| Success label text | `UIUtil.getLabelSuccessForeground()` | +| Error label text | `UIUtil.getErrorForeground()` | +| Warning label text | `JBUI.CurrentTheme.Label.warningForeground()` | +| Generic label foreground by state | `JBUI.CurrentTheme.Label.foreground(selected)` / `disabledForeground(selected)` | +| Inactive secondary text | `NamedColorUtil.getInactiveTextColor()` | +| Bounds and standard border color | `NamedColorUtil.getBoundsColor()` / `JBColor.border()` | +| Tooltips | `UIUtil.getToolTipForeground()` / `getToolTipBackground()` / `JBUI.CurrentTheme.Tooltip.*` | +| Links | `JBUI.CurrentTheme.Link.Foreground.ENABLED` / `HOVERED` / `PRESSED` / `VISITED` / `DISABLED` / `SECONDARY` | +| List renderer text/background | `UIUtil.getListForeground(selected, focused)` / `UIUtil.getListBackground(selected, focused)` | +| Tree renderer text/background | `UIUtil.getTreeForeground(selected, focused)` / `UIUtil.getTreeBackground(selected, focused)` | +| Non-selected tree content | `UIUtil.getTreeForeground()` / `UIUtil.getTreeBackground()` | + +Prefer `JBUI.CurrentTheme` for component-area colors and borders: + +| Need | Preferred API | +| --- | --- | +| Popup background | `JBUI.CurrentTheme.Popup.BACKGROUND` | +| Complex popup header | `JBUI.CurrentTheme.ComplexPopup.HEADER_BACKGROUND` | +| Separators | `JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground()` | +| Advertiser/footer surfaces | `JBUI.CurrentTheme.Advertiser.foreground()` / `background()` / `border()` | +| Links | `JBUI.CurrentTheme.Link.Foreground.ENABLED` | +| Tree selection state | `JBUI.CurrentTheme.Tree.foreground(...)` and selection helpers | +| Validation errors | `JBUI.CurrentTheme.Validator.errorBorderColor()` / `errorBackgroundColor()` | +| Validation warnings | `JBUI.CurrentTheme.Validator.warningBorderColor()` / `warningBackgroundColor()` | +| Focus error/warning outlines | `JBUI.CurrentTheme.Focus.errorColor(active)` / `warningColor(active)` | +| Banner-like surfaces | `JBUI.CurrentTheme.Banner.*` | +| Notification tool-window surfaces | `JBUI.CurrentTheme.NotificationError.*`, `NotificationWarning.*`, `NotificationInfo.*` | +| Icon badges | `JBUI.CurrentTheme.IconBadge.*` | + +Use `JBColor.lazy { ... }` for colors that depend on runtime state or the active editor color scheme: + +```kotlin +val bg = JBColor.lazy { EditorColorsManager.getInstance().globalScheme.defaultBackground } +``` + +Use `JBColor.namedColor("Some.Semantic.Key", fallback)` only when defining or consuming a semantic color key. Prefer a fallback from an existing theme API over numeric RGB values in examples. + +For HTML/CSS snippets, compute the color from a theme API and convert it with `ColorUtil.toHtmlColor(...)`. Do not write hardcoded CSS color literals. + +```kotlin +val fg = ColorUtil.toHtmlColor(UIUtil.getContextHelpForeground()) +val html = HtmlChunk.span("color: $fg").addText(text) +``` + +Recommended examples: + +```kotlin +label.applyToComponent { + foreground = UIUtil.getContextHelpForeground() + font = JBFont.medium() +} +``` + +```kotlin +foreground = if (selected) { + UIUtil.getTreeForeground(true, hasFocus) +} else { + UIUtil.getContextHelpForeground() +} +``` + +Avoid inline `Color(...)`, numeric `JBColor(...)`, `Gray.xNN`, `JBColor.GRAY`, and hex color literals in runtime UI code. The exception is a centralized semantic color definition with a named color key when no existing platform key exists. + +## Theme-Derived Fonts + +Do not hardcode font sizes or font families. IntelliJ fonts must come from component defaults, platform typography helpers, or existing component fonts. + +- Prefer component default fonts when no style change is needed. +- Use `JBFont.h1()`, `JBFont.h2()`, `JBFont.h3()`, and `JBFont.h4()` for headings. +- Use `.asBold()`, `.asItalic()`, and `.asPlain()` for style changes on `JBFont` values. +- Use `JBFont.regular()`, `JBFont.medium()`, and `JBFont.small()` for regular and secondary text. +- Use `UIUtil.getLabelFont(UIUtil.FontSize.SMALL)` / `MINI` only when an API expects a raw `Font` and `JBFont` / `RelativeFont` is not a good fit. +- Use `RelativeFont` when adjusting an existing component font relatively. +- Use `RelativeFont.fromResource(...)` for theme-configurable font offsets when matching platform component patterns. +- Use `JBUI.Fonts.smallFont()` only when matching an existing platform component pattern that expects it. +- If editor-like text needs editor typography, derive it from the editor color scheme or existing editor component instead of hardcoding a font. + +For errors, grayed text, shortcuts, and links in renderers, prefer `SimpleTextAttributes.ERROR_ATTRIBUTES`, `GRAYED_ATTRIBUTES`, `SHORTCUT_ATTRIBUTES`, and `LINK_ATTRIBUTES` rather than manually selecting colors and styles. + +Recommended examples: + +```kotlin +label(title).applyToComponent { + font = JBFont.h3().asBold() +} +``` + +```kotlin +RelativeFont.BOLD.install(label) +``` + +Avoid `Font("...")`, raw font sizes, and `deriveFont(14f)` style examples. ## Borders, Insets, and Spacing @@ -598,6 +796,36 @@ Inspection `Plugin DevKit | Code | Undesirable class usage` highlights raw Swing - Prefer Kotlin UI DSL gaps and row layout semantics over manually assigning borders or insets. - Do not use `EmptyBorder`, raw `Insets`, or raw `Dimension` unless there is no platform alternative and the reason is obvious. +## Validation and Error UI + +Prefer built-in validation mechanisms over custom error rendering. + +- For Kotlin UI DSL, use `validationOnInput`, `validationOnApply`, `validationInfo`, `errorOnApply`, and `addValidationRule` on cells. +- For DSL dialogs and settings panels, call `DialogPanel.registerValidators(disposable)` when input validation should run continuously. +- Call `DialogPanel.validateAll()` before `apply()` when submitting a DSL panel manually. +- In `DialogWrapper`, use `doValidate()` and return `ValidationInfo(message, component)` for errors. +- Use `ValidationInfo.asWarning()` for warnings and `withOKEnabled()` when a warning should not block submission. +- For hand-built Swing forms, use `ComponentValidator` with `withValidator`, `withFocusValidator`, and `andRegisterOnDocumentListener` instead of custom tooltip/error border logic. +- If custom validation painting is unavoidable, use `JBUI.CurrentTheme.Validator.*` and `JBUI.CurrentTheme.Focus.*` colors. + +Kotlin UI DSL example: + +```kotlin +textField() + .validationOnInput { + if (it.text.isBlank()) error(KiloBundle.message("settings.name.required")) else null + } +``` + +Dialog validation example: + +```kotlin +override fun doValidate(): ValidationInfo? { + if (field.text.isBlank()) return ValidationInfo(KiloBundle.message("settings.name.required"), field) + return null +} +``` + ## Icons - Reuse platform icons. Browse at https://intellij-icons.jetbrains.design and access via `AllIcons.*` constants. @@ -608,7 +836,7 @@ Inspection `Plugin DevKit | Code | Undesirable class usage` highlights raw Swing - Dark variants: `icon.svg` + `icon_dark.svg`. - HiDPI variants: `icon@2x.svg` + `icon@2x_dark.svg`. - New UI support: place New UI icons in `expui/`, create `*IconMappings.json`, register via `com.intellij.iconMapper` extension point. -- New UI icon colors: light `#6C707E`, dark `#CED0D6`. +- For SVG asset palette values, follow the IntelliJ icon asset guidelines. Do not copy guideline color literals into runtime Swing UI code. ## Notifications @@ -643,6 +871,9 @@ Review generated UI code and remove: - Explicit default property assignments such as unnecessary `isOpaque = false` - Unnecessary `preferredSize`, `minimumSize`, or `maximumSize` - Raw `Dimension`, `Insets`, `EmptyBorder`, or `Color` +- Inline runtime colors such as `Color(...)`, numeric `JBColor(...)`, `Gray.xNN`, `JBColor.GRAY`, or hex color literals +- Raw CSS color literals; `ColorUtil.toHtmlColor(themeColor)` is allowed because the source color is theme-derived +- Hardcoded font families, raw font sizes, or numeric-size `deriveFont(...)` calls - Raw Swing components where IntelliJ replacements exist - Manual layout code that Kotlin UI DSL can express cleanly - Hardcoded spacing that should be a DSL gap, row gap, or `JBUI` value From aea087aa235ee84a0a341bc2adee4abd4ec197ba Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 28 Apr 2026 15:37:12 +0300 Subject: [PATCH 22/96] test(cli): reproduce external directory read prompts --- .../external-directory-allow.test.ts | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 packages/opencode/test/kilocode/permission/external-directory-allow.test.ts diff --git a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts new file mode 100644 index 00000000000..c2891333a40 --- /dev/null +++ b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts @@ -0,0 +1,300 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Effect, Layer, ManagedRuntime } from "effect" +import path from "path" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import * as CrossSpawnSpawner from "../../../src/effect/cross-spawn-spawner" +import { Agent } from "../../../src/agent/agent" +import { Global } from "../../../src/global" +import { Permission } from "../../../src/permission" +import { PermissionID } from "../../../src/permission/schema" +import { Instance } from "../../../src/project/instance" +import { MessageID, SessionID } from "../../../src/session/schema" +import { Shell } from "../../../src/shell/shell" +import { Truncate } from "../../../src/tool" +import { BashTool } from "../../../src/tool/bash" +import { Plugin } from "../../../src/plugin" +import { tmpdir } from "../../fixture/fixture" + +const runtime = ManagedRuntime.make( + Layer.mergeAll( + CrossSpawnSpawner.defaultLayer, + AppFileSystem.defaultLayer, + Plugin.defaultLayer, + Truncate.defaultLayer, + Agent.defaultLayer, + ), +) + +const ctx = { + sessionID: SessionID.make("ses_external_directory_allow"), + messageID: MessageID.make("msg_external_directory_allow"), + callID: "call_external_directory_allow", + agent: "code", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const ruleset: Permission.Ruleset = [{ permission: "external_directory", pattern: "*", action: "allow" }] +const psNames = new Set(["powershell", "pwsh"]) +const ps = + process.platform === "win32" + ? [Bun.which("pwsh"), Bun.which("powershell")] + .filter((shell): shell is string => Boolean(shell)) + .map((shell) => ({ label: Shell.name(shell), shell })) + .filter((item) => psNames.has(item.label)) + : [] + +Shell.acceptable.reset() + +const init = () => runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init()))) +const quote = (text: string) => `"${text.replaceAll('"', '\\"')}"` +const glob = (file: string) => + process.platform === "win32" ? AppFileSystem.normalizePathPattern(file) : file.replaceAll("\\", "/") +const config = path.resolve(Global.Path.config) +const configFile = path.join(config, "hello.txt") +const configGlob = glob(path.join(config, "*")) + +const capture = (requests: Array>, stop?: Error) => ({ + ...ctx, + ask: (req: Omit) => + Effect.sync(() => { + requests.push(req) + if (stop) throw stop + }), +}) + +const withShell = (item: { shell: string }, fn: () => Promise) => async () => { + const prev = process.env.SHELL + process.env.SHELL = item.shell + Shell.acceptable.reset() + Shell.preferred.reset() + try { + await fn() + } finally { + if (prev === undefined) delete process.env.SHELL + else process.env.SHELL = prev + Shell.acceptable.reset() + Shell.preferred.reset() + } +} + +async function reject() { + const requests = await Permission.list() + for (const req of requests) { + await Permission.reply({ requestID: req.id, reply: "reject" }) + } +} + +async function immediate(pending: Promise) { + try { + await Promise.race([ + pending, + new Promise((_, reject) => + setTimeout(() => reject(new Error("timed out waiting for permission to resolve")), 500), + ), + ]) + } finally { + const requests = await Permission.list() + if (requests.length > 0) { + await reject() + await pending.catch(() => undefined) + } + } + expect(await Permission.list()).toHaveLength(0) +} + +async function wait(count: number) { + for (const _ of Array.from({ length: 100 })) { + const list = await Permission.list() + if (list.length === count) return list + await Bun.sleep(10) + } + throw new Error(`timed out waiting for ${count} pending permission request(s)`) +} + +afterEach(async () => { + await Instance.disposeAll() +}) + +describe("external_directory allow config protection", () => { + test("allows file-tool external_directory requests for global config paths", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await immediate( + Permission.ask({ + id: PermissionID.make("permission_file_external_read"), + sessionID: SessionID.make("session_file_external_read"), + permission: "external_directory", + patterns: [configGlob], + metadata: { filepath: configFile, parentDir: config }, + always: [configGlob], + ruleset, + }), + ) + }, + }) + }) + + test("allows read-only bash external_directory requests for global config paths", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await immediate( + Permission.ask({ + id: PermissionID.make("permission_bash_external_read"), + sessionID: SessionID.make("session_bash_external_read"), + permission: "external_directory", + patterns: [configGlob], + metadata: { command: `cat ${quote(configFile)}`, access: "read" }, + always: [configGlob], + ruleset, + }), + ) + }, + }) + }) + + test("keeps unknown bash external_directory requests for global config paths protected", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const pending = Permission.ask({ + id: PermissionID.make("permission_bash_external_write"), + sessionID: SessionID.make("session_bash_external_write"), + permission: "external_directory", + patterns: [configGlob], + metadata: { command: `rm ${quote(configFile)}` }, + always: [configGlob], + ruleset, + }) + + const requests = await wait(1) + expect(requests[0]).toMatchObject({ + id: PermissionID.make("permission_bash_external_write"), + permission: "external_directory", + metadata: { disableAlways: true }, + }) + + await Permission.reply({ requestID: PermissionID.make("permission_bash_external_write"), reply: "reject" }) + await expect(pending).rejects.toBeInstanceOf(Permission.RejectedError) + }, + }) + }) +}) + +describe("bash external_directory access metadata", () => { + test("emits read access metadata for cat external files", async () => { + await using outer = await tmpdir({ init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello") }) + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await init() + const err = new Error("stop after external permission") + const requests: Array> = [] + const file = path.join(outer.path, "hello.txt") + const command = `cat ${quote(file)}` + + await expect( + Effect.runPromise(bash.execute({ command, description: "Read external file" }, capture(requests, err))), + ).rejects.toThrow(err.message) + + const req = requests.find((item) => item.permission === "external_directory") + expect(req).toMatchObject({ + patterns: [glob(path.join(outer.path, "*"))], + metadata: { command, access: "read" }, + }) + }, + }) + }) + + for (const item of ps) { + test( + `emits read access metadata for Get-Content external files [${item.label}]`, + withShell(item, async () => { + await using outer = await tmpdir({ init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello") }) + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await init() + const err = new Error("stop after external permission") + const requests: Array> = [] + const file = path.join(outer.path, "hello.txt") + const command = `Get-Content ${quote(file)}` + + await expect( + Effect.runPromise(bash.execute({ command, description: "Read external file" }, capture(requests, err))), + ).rejects.toThrow(err.message) + + const req = requests.find((item) => item.permission === "external_directory") + expect(req).toMatchObject({ + patterns: [glob(path.join(outer.path, "*"))], + metadata: { command, access: "read" }, + }) + }, + }) + }), + ) + } + + test("does not emit read access metadata for mutating external file commands", async () => { + await using outer = await tmpdir({ init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello") }) + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await init() + const file = path.join(outer.path, "hello.txt") + const target = path.join(tmp.path, "target.txt") + const commands = [ + `rm ${quote(file)}`, + `mv ${quote(file)} ${quote(target)}`, + `cp ${quote(file)} ${quote(target)}`, + `touch ${quote(file)}`, + ] + + for (const command of commands) { + const err = new Error("stop after external permission") + const requests: Array> = [] + await expect( + Effect.runPromise(bash.execute({ command, description: "Mutate external file" }, capture(requests, err))), + ).rejects.toThrow(err.message) + + const req = requests.find((item) => item.permission === "external_directory") + expect(req).toBeDefined() + expect(req?.metadata).not.toMatchObject({ access: "read" }) + } + }, + }) + }) + + test("does not emit read access metadata for mixed read and write external commands", async () => { + await using outer = await tmpdir({ init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello") }) + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await init() + const err = new Error("stop after external permission") + const requests: Array> = [] + const file = path.join(outer.path, "hello.txt") + const command = `cat ${quote(file)} && rm ${quote(file)}` + + await expect( + Effect.runPromise(bash.execute({ command, description: "Read then remove external file" }, capture(requests, err))), + ).rejects.toThrow(err.message) + + const req = requests.find((item) => item.permission === "external_directory") + expect(req).toBeDefined() + expect(req?.metadata).not.toMatchObject({ access: "read" }) + }, + }) + }) +}) From b9a263f465c04099731f1628f31d4de37b480bca Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 28 Apr 2026 15:38:53 +0300 Subject: [PATCH 23/96] fix(cli): honor read-only external directory allows --- .../src/kilocode/permission/config-paths.ts | 2 ++ packages/opencode/src/tool/bash.ts | 26 +++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilocode/permission/config-paths.ts b/packages/opencode/src/kilocode/permission/config-paths.ts index dabb2dc4f60..17671dd1782 100644 --- a/packages/opencode/src/kilocode/permission/config-paths.ts +++ b/packages/opencode/src/kilocode/permission/config-paths.ts @@ -92,6 +92,8 @@ export namespace ConfigProtection { // File tools include metadata.filepath. They may read global config // without prompting, but edits are still protected separately via `edit`. if (request.metadata?.filepath) return false + // Bash read-only file commands may read global config when explicitly allowed. + if (request.metadata?.access === "read") return false for (const pattern of request.patterns) { const dir = pattern.replace(/\/\*$/, "") if (path.isAbsolute(dir) && isAbsolute(dir)) return true diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index d571f99d96d..2c477640af9 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -47,6 +47,9 @@ const FILES = new Set([ "new-item", "rename-item", ]) +// kilocode_change start +const READ = new Set(["cat", "get-content"]) +// kilocode_change end const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) @@ -70,10 +73,15 @@ type Part = { text: string } +// kilocode_change start +type Access = "read" | "unknown" +// kilocode_change end + type Scan = { dirs: Set patterns: Set always: Set + access: Access // kilocode_change } type Chunk = { @@ -122,6 +130,14 @@ function source(node: Node) { return (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim() } +// kilocode_change start +function access(cmd: string, node: Node): Access { + if (!READ.has(cmd)) return "unknown" + if (node.parent?.type === "redirected_statement") return "unknown" + return "read" +} +// kilocode_change end + function commands(node: Node) { return node.descendantsOfType("command").filter((child): child is Node => Boolean(child)) } @@ -268,7 +284,7 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan, permission: "external_directory", patterns: globs, always: globs, - metadata: {}, + metadata: scan.access === "read" ? { command, access: "read" } : {}, // kilocode_change }) } @@ -370,6 +386,7 @@ export const BashTool = Tool.define( dirs: new Set(), patterns: new Set(), always: new Set(), + access: "read", // kilocode_change } for (const node of commands(root)) { @@ -378,12 +395,14 @@ export const BashTool = Tool.define( const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] if (cmd && FILES.has(cmd)) { + const kind = access(cmd, node) // kilocode_change for (const arg of pathArgs(command, ps)) { const resolved = yield* argPath(arg, cwd, ps, shell) log.info("resolved path", { arg, resolved }) if (!resolved || Instance.containsPath(resolved)) continue const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved) scan.dirs.add(dir) + if (kind !== "read") scan.access = "unknown" // kilocode_change } } @@ -600,7 +619,10 @@ export const BashTool = Tool.define( const ps = PS.has(name) const root = yield* parse(params.command, ps) const scan = yield* collect(root, cwd, ps, shell) - if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) + if (!Instance.containsPath(cwd)) { + scan.dirs.add(cwd) + scan.access = "unknown" // kilocode_change + } yield* ask(ctx, scan, params.command) // kilocode_change return yield* run( From 5dbf91cc167c16e04bb41e8af68108f8865a18c8 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 28 Apr 2026 15:39:17 +0300 Subject: [PATCH 24/96] chore: add changeset for external directory permissions --- .changeset/external-directory-read-prompts.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/external-directory-read-prompts.md diff --git a/.changeset/external-directory-read-prompts.md b/.changeset/external-directory-read-prompts.md new file mode 100644 index 00000000000..46c284dfc11 --- /dev/null +++ b/.changeset/external-directory-read-prompts.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Honor allowed read-only external-directory access to Kilo config paths without repeated permission prompts. From ae2f41ce5e929573a0d14812b2579a34657c7b7e Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 28 Apr 2026 15:40:45 +0300 Subject: [PATCH 25/96] chore(cli): annotate bash permission change --- packages/opencode/src/tool/bash.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 2c477640af9..d21e6cda211 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -619,10 +619,12 @@ export const BashTool = Tool.define( const ps = PS.has(name) const root = yield* parse(params.command, ps) const scan = yield* collect(root, cwd, ps, shell) + // kilocode_change start if (!Instance.containsPath(cwd)) { scan.dirs.add(cwd) - scan.access = "unknown" // kilocode_change + scan.access = "unknown" } + // kilocode_change end yield* ask(ctx, scan, params.command) // kilocode_change return yield* run( From a43a58784e0a21fc307a9e8fb38ebc1232573004 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 28 Apr 2026 16:56:26 +0300 Subject: [PATCH 26/96] chore(sdk): refresh generated event types --- packages/sdk/js/src/v2/gen/types.gen.ts | 38 ++++++++++++------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6a6cf68daad..b9d92659382 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -590,23 +590,6 @@ export type EventVcsBranchUpdated = { } } -export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby" - -export type IndexingStatus = { - state: IndexingStatusState - message: string - processedFiles: number - totalFiles: number - percent: number -} - -export type EventIndexingStatus = { - type: "indexing.status" - properties: { - status: IndexingStatus - } -} - export type EventSessionCompacted = { type: "session.compacted" properties: { @@ -1170,6 +1153,23 @@ export type EventSessionDeleted = { } } +export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby" + +export type IndexingStatus = { + state: IndexingStatusState + message: string + processedFiles: number + totalFiles: number + percent: number +} + +export type EventIndexingStatus = { + type: "indexing.status" + properties: { + status: IndexingStatus + } +} + export type SyncEventMessageUpdated = { type: "sync" name: "message.updated.1" @@ -1331,7 +1331,6 @@ export type GlobalEvent = { | EventSessionIdle | EventTodoUpdated | EventVcsBranchUpdated - | EventIndexingStatus | EventSessionCompacted | EventKiloSessionsRemoteStatusChanged | EventWorktreeReady @@ -1351,6 +1350,7 @@ export type GlobalEvent = { | EventSessionCreated | EventSessionUpdated | EventSessionDeleted + | EventIndexingStatus | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -2449,7 +2449,6 @@ export type Event = | EventSessionIdle | EventTodoUpdated | EventVcsBranchUpdated - | EventIndexingStatus | EventSessionCompacted | EventKiloSessionsRemoteStatusChanged | EventWorktreeReady @@ -2469,6 +2468,7 @@ export type Event = | EventSessionCreated | EventSessionUpdated | EventSessionDeleted + | EventIndexingStatus export type McpStatusConnected = { status: "connected" From 9427e3a12a2733fdad9c8a0771761abcec74c583 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 28 Apr 2026 17:30:17 +0300 Subject: [PATCH 27/96] fix(review): guard bash redirects --- packages/opencode/src/tool/bash.ts | 2 ++ .../external-directory-allow.test.ts | 24 ++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index d21e6cda211..3a5ba91277f 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -389,6 +389,8 @@ export const BashTool = Tool.define( access: "read", // kilocode_change } + if (root.descendantsOfType("file_redirect").length > 0) scan.access = "unknown" // kilocode_change + for (const node of commands(root)) { const command = parts(node) const tokens = command.map((item) => item.text) diff --git a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts index c2891333a40..39d5eb60605 100644 --- a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts +++ b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts @@ -282,18 +282,24 @@ describe("bash external_directory access metadata", () => { directory: tmp.path, fn: async () => { const bash = await init() - const err = new Error("stop after external permission") - const requests: Array> = [] const file = path.join(outer.path, "hello.txt") - const command = `cat ${quote(file)} && rm ${quote(file)}` + const commands = [ + `cat ${quote(file)} && rm ${quote(file)}`, + `cat ${quote(file)} && printf x > ${quote(file)}`, + ] - await expect( - Effect.runPromise(bash.execute({ command, description: "Read then remove external file" }, capture(requests, err))), - ).rejects.toThrow(err.message) + for (const command of commands) { + const err = new Error("stop after external permission") + const requests: Array> = [] - const req = requests.find((item) => item.permission === "external_directory") - expect(req).toBeDefined() - expect(req?.metadata).not.toMatchObject({ access: "read" }) + await expect( + Effect.runPromise(bash.execute({ command, description: "Read then write external file" }, capture(requests, err))), + ).rejects.toThrow(err.message) + + const req = requests.find((item) => item.permission === "external_directory") + expect(req).toBeDefined() + expect(req?.metadata).not.toMatchObject({ access: "read" }) + } }, }) }) From 9927331c1cfbdb53348e2299e0da51a4edc09740 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 27 Apr 2026 17:25:55 -0400 Subject: [PATCH 28/96] feat: add JetBrains session history panel --- .changeset/chilly-dancers-build.md | 5 + .../backend/app/KiloBackendSessionManager.kt | 40 +++++ .../backend/rpc/KiloSessionRpcApiImpl.kt | 3 + .../app/KiloBackendSessionManagerTest.kt | 64 +++++++ .../kilocode/backend/testing/MockCliServer.kt | 8 + .../kilocode/client/KiloToolWindowFactory.kt | 29 ++-- .../client/actions/NewSessionAction.kt | 22 +++ .../kilocode/client/app/KiloSessionService.kt | 4 + .../kilocode/client/session/SessionManager.kt | 14 ++ .../client/session/SessionSidePanelManager.kt | 64 +++++++ .../ai/kilocode/client/session/SessionUi.kt | 15 +- .../client/session/SessionUiFactory.kt | 36 ++++ .../client/session/ui/EmptySessionPanel.kt | 140 +++++++++++++++- .../client/session/ui/SessionListPanel.kt | 155 +++++++++++++++++ .../session/update/SessionController.kt | 17 ++ .../src/main/resources/icons/plus.svg | 3 + .../src/main/resources/icons/plus_dark.svg | 3 + .../resources/messages/KiloBundle.properties | 11 ++ .../client/actions/NewSessionActionTest.kt | 55 ++++++ .../session/SessionSidePanelManagerTest.kt | 134 +++++++++++++++ .../client/session/SessionUiFactoryTest.kt | 109 ++++++++++++ .../client/session/SessionUiLayoutTest.kt | 57 ++++++- .../session/ui/EmptySessionPanelTest.kt | 158 ++++++++++++++++++ .../client/session/ui/SessionListPanelTest.kt | 89 ++++++++++ .../client/testing/FakeSessionRpcApi.kt | 15 ++ .../ai/kilocode/rpc/KiloSessionRpcApi.kt | 3 + 26 files changed, 1221 insertions(+), 32 deletions(-) create mode 100644 .changeset/chilly-dancers-build.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionListPanel.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/plus.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/plus_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionListPanelTest.kt diff --git a/.changeset/chilly-dancers-build.md b/.changeset/chilly-dancers-build.md new file mode 100644 index 00000000000..3f8b568bc5d --- /dev/null +++ b/.changeset/chilly-dancers-build.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Add a JetBrains tool window action for starting new sessions and let recent sessions reopen in the side panel without duplicate views. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index ebe10cbf3bc..6094437a0f9 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -4,6 +4,7 @@ import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi +import ai.kilocode.jetbrains.api.model.GlobalSession import ai.kilocode.jetbrains.api.model.SessionStatus import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto @@ -96,6 +97,21 @@ class KiloBackendSessionManager( return SessionListDto(mapped, relevant) } + fun recent(dir: String, limit: Int): SessionListDto { + seed(dir) + val raw = requireClient().experimentalSessionList( + directory = dir, + worktrees = true, + roots = true, + limit = limit.toDouble(), + archived = false, + ) + val mapped = raw.map(::dto) + val ids = mapped.map { it.id }.toSet() + val relevant = _statuses.value.filterKeys { it in ids } + return SessionListDto(mapped, relevant) + } + /** * Create a new session in the given directory. * @@ -182,8 +198,32 @@ class KiloBackendSessionManager( }, ) + private fun dto(s: GlobalSession) = SessionDto( + id = s.id, + projectID = s.projectID, + directory = s.directory, + parentID = s.parentID, + title = s.title, + version = s.version, + time = SessionTimeDto( + created = s.time.created, + updated = s.time.updated, + archived = s.time.archived, + ), + summary = s.summary?.let { + SessionSummaryDto( + additions = it.additions.toInt(), + deletions = it.deletions.toInt(), + files = it.files.toInt(), + ) + }, + ) + private fun statusDto(s: SessionStatus) = SessionStatusDto( type = s.type.value, message = s.message.ifBlank { null }, + attempt = s.attempt.toInt(), + next = s.next.toLong(), + requestID = s.requestID.ifBlank { null }, ) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 89d8935c2b8..26b98a02a9f 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -51,6 +51,9 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { override suspend fun list(directory: String): SessionListDto = workspaces.get(directory).sessions() + override suspend fun recent(directory: String, limit: Int): SessionListDto = + sessions.recent(directory, limit) + override suspend fun create(directory: String): SessionDto { LOG.info("create session: directory=$directory") return workspaces.get(directory).createSession() diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt index d98d978f5f3..867ad3ecbf7 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import java.net.URLDecoder import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -121,6 +122,69 @@ class KiloBackendSessionManagerTest { assertEquals("busy", result.statuses["ses_1"]?.type) } + @Test + fun `recent returns global sessions from experimental endpoint`() = runBlocking { + mock.recentSessions = """[ + {"id":"ses_1","slug":"s1","projectID":"prj","directory":"/repo","title":"Session 1","version":"1","time":{"created":1000,"updated":5000},"project":{"id":"prj","worktree":"/repo","name":"Repo"},"summary":{"additions":10,"deletions":2,"files":3}}, + {"id":"ses_2","slug":"s2","projectID":"prj","directory":"/repo-wt","title":"Session 2","version":"1","time":{"created":2000,"updated":4000},"project":{"id":"prj","worktree":"/repo","name":"Repo"},"parentID":"ses_parent"} + ]""" + val app = setup() + ready(app) + + val result = app.sessions.recent("/repo", 5) + + assertEquals(2, result.sessions.size) + assertEquals("ses_1", result.sessions[0].id) + assertEquals("Session 1", result.sessions[0].title) + assertEquals("/repo", result.sessions[0].directory) + assertEquals(10, result.sessions[0].summary?.additions) + assertEquals("ses_parent", result.sessions[1].parentID) + } + + @Test + fun `recent passes worktree filters and limit`() = runBlocking { + val app = setup() + ready(app) + + app.sessions.recent("/repo path", 5) + + val path = mock.lastExperimentalSessionPath ?: error("missing experimental session request") + assertTrue(path.startsWith("/experimental/session?")) + assertTrue(URLDecoder.decode(path, "UTF-8").contains("directory=/repo path"), path) + assertTrue(path.contains("worktrees=true"), path) + assertTrue(path.contains("roots=true"), path) + assertTrue(path.contains("limit=5.0"), path) + assertTrue(path.contains("archived=false"), path) + } + + @Test + fun `recent filters statuses to returned sessions`() = runBlocking { + mock.recentSessions = """[ + {"id":"ses_1","slug":"s1","projectID":"prj","directory":"/repo","title":"Session 1","version":"1","time":{"created":1,"updated":1},"project":{"id":"prj","worktree":"/repo","name":"Repo"}} + ]""" + mock.sessionStatuses = """{ + "ses_1": {"type":"busy","attempt":0,"message":"running","next":0,"requestID":""}, + "ses_other": {"type":"idle","attempt":0,"message":"","next":0,"requestID":""} + }""" + val app = setup() + ready(app) + + val result = app.sessions.recent("/repo", 5) + + assertEquals(setOf("ses_1"), result.statuses.keys) + assertEquals("busy", result.statuses["ses_1"]?.type) + assertEquals("running", result.statuses["ses_1"]?.message) + } + + @Test + fun `recent throws when not started`() = runBlocking { + val app = setup() + + assertFailsWith { + app.sessions.recent("/test", 5) + } + } + // ------ Session create ------ @Test 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 5bde260ff31..3c5010736f3 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 @@ -50,9 +50,11 @@ class MockCliServer : AutoCloseable { // Session REST responses @Volatile var sessions = "[]" + @Volatile var recentSessions = "[]" @Volatile var sessionCreate = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"New Session","version":"1.0.0","time":{"created":1000,"updated":1000}}""" @Volatile var sessionStatuses = "{}" @Volatile var sessionsStatus = 200 + @Volatile var recentSessionsStatus = 200 @Volatile var sessionCreateStatus = 200 @Volatile var sessionGetStatus = 200 @Volatile var sessionDeleteStatus = 200 @@ -67,6 +69,8 @@ class MockCliServer : AutoCloseable { /** Return the number of requests received for [path] (bare, no query). */ fun requestCount(path: String): Int = counts[path]?.get() ?: 0 + @Volatile var lastExperimentalSessionPath: String? = null + /** Reset all request counters. */ fun resetCounts() { counts.clear() } @@ -200,6 +204,10 @@ class MockCliServer : AutoCloseable { bare == "/agent" -> respond(output, agentsStatus, agents) bare == "/command" -> respond(output, commandsStatus, commands) bare == "/skill" -> respond(output, skillsStatus, skills) + bare == "/experimental/session" -> { + lastExperimentalSessionPath = path + respond(output, recentSessionsStatus, recentSessions) + } bare == "/session/status" -> respond(output, sessionStatusesStatus, sessionStatuses) bare == "/session" && method == "GET" -> respond(output, sessionsStatus, sessions) bare == "/session" && method == "POST" -> respond(output, sessionCreateStatus, sessionCreate) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index e44b34367dc..faa8477da37 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -1,13 +1,12 @@ package ai.kilocode.client -import ai.kilocode.client.app.KiloAppService -import ai.kilocode.client.app.KiloSessionService -import ai.kilocode.client.session.SessionUi +import ai.kilocode.client.actions.NewSessionAction import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.SessionSidePanelManager +import ai.kilocode.log.KiloLog import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.components.service -import ai.kilocode.log.KiloLog import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindow @@ -20,7 +19,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** - * Creates the Kilo Code tool window with a single [SessionUi]. + * Creates the Kilo Code tool window and delegates session content management. * * Resolves the project directory through the backend (handles split-mode * where `project.basePath` is a synthetic frontend path) before creating @@ -36,8 +35,6 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { try { val workspaces = service() - val sessions = project.service() - val app = service() val cs = CoroutineScope(SupervisorJob()) val hint = project.basePath ?: "" @@ -45,7 +42,7 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { val dir = workspaces.resolveProjectDirectory(hint) val workspace = workspaces.workspace(dir) withContext(Dispatchers.Main) { - setup(project, toolWindow, workspace, sessions, app, cs) + setup(project, toolWindow, workspace) } } } catch (e: Exception) { @@ -57,19 +54,17 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { project: Project, toolWindow: ToolWindow, workspace: Workspace, - sessions: KiloSessionService, - app: KiloAppService, - cs: CoroutineScope, ) { try { - val ui = SessionUi(project, workspace, sessions, app, cs) - val content = ContentFactory.getInstance() - .createContent(ui, "", false) - content.setDisposer(ui) + val manager = SessionSidePanelManager(project, workspace) + val content = ContentFactory.getInstance().createContent(manager.component, "", false) + content.setDisposer(manager) toolWindow.contentManager.addContent(content) + toolWindow.contentManager.setSelectedContent(content) + manager.newSession() - ActionManager.getInstance().getAction("Kilo.Settings")?.let { - toolWindow.setTitleActions(listOf(it)) + ActionManager.getInstance().getAction("Kilo.Settings")?.let { settings -> + toolWindow.setTitleActions(listOf(NewSessionAction(), settings)) } } catch (e: Exception) { LOG.error("Failed to set up Kilo tool window content", e) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt new file mode 100644 index 00000000000..b6d958e6e88 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt @@ -0,0 +1,22 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.util.IconLoader + +class NewSessionAction : AnAction( + KiloBundle.message("action.Kilo.NewSession.text"), + KiloBundle.message("action.Kilo.NewSession.description"), + IconLoader.getIcon("/icons/plus.svg", NewSessionAction::class.java), +), DumbAware { + override fun actionPerformed(e: AnActionEvent) { + e.getData(SessionManager.KEY)?.newSession() + } + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = e.getData(SessionManager.KEY) != null + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 5905fd0fa52..1b36aeefb02 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -84,6 +84,10 @@ class KiloSessionService internal constructor( } } + /** Load recent sessions for the current worktree family. */ + suspend fun recent(dir: String, limit: Int): List = + call { recent(dir, limit) }.sessions + /** Create a new session. Caller awaits the result. */ suspend fun create(dir: String): SessionDto { LOG.info("create: dir=$dir") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt new file mode 100644 index 00000000000..a65cde6df18 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt @@ -0,0 +1,14 @@ +package ai.kilocode.client.session + +import ai.kilocode.rpc.dto.SessionDto +import com.intellij.openapi.actionSystem.DataKey + +interface SessionManager { + companion object { + val KEY = DataKey.create("ai.kilocode.client.session.SessionManager") + } + + fun newSession() + + fun openSession(session: SessionDto) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt new file mode 100644 index 00000000000..e1c53a88d75 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt @@ -0,0 +1,64 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.rpc.dto.SessionDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import java.awt.BorderLayout +import javax.swing.JPanel + +class SessionSidePanelManager( + private val project: Project, + private val root: Workspace, + private val create: (Project, Workspace, SessionManager, String?) -> SessionUi = { project, workspace, manager, id -> + service().create(project, workspace, manager, id) + }, + private val resolve: (String) -> Workspace = { dir -> service().workspace(dir) }, +) : SessionManager, Disposable { + val component: JPanel = object : JPanel(BorderLayout()), DataProvider { + override fun getData(dataId: String): Any? { + if (SessionManager.KEY.`is`(dataId)) return this@SessionSidePanelManager + return null + } + } + + private val opened = mutableMapOf() + private val all = mutableSetOf() + private var current: SessionUi? = null + + override fun newSession() { + show(create(project, root, this, null)) + } + + override fun openSession(session: SessionDto) { + val ui = opened.getOrPut(session.id) { + create(project, resolve(session.directory), this, session.id).also { + all.add(it) + } + } + show(ui) + } + + private fun show(ui: SessionUi) { + all.add(ui) + if (current === ui) return + component.removeAll() + current = ui + component.add(ui, BorderLayout.CENTER) + component.revalidate() + component.repaint() + } + + override fun dispose() { + val items = all.toList() + opened.clear() + all.clear() + current = null + component.removeAll() + items.forEach { Disposer.dispose(it) } + } +} 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 f21ffa42e65..94dc1e51000 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 @@ -16,6 +16,7 @@ import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.update.EVENT_FLUSH_MS import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.session.update.SessionControllerEvent +import ai.kilocode.rpc.dto.SessionDto import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project import com.intellij.openapi.util.registry.Registry @@ -41,6 +42,8 @@ class SessionUi( sessions: KiloSessionService, app: KiloAppService, cs: CoroutineScope, + id: String? = null, + private val onOpenSession: (SessionDto) -> Unit = {}, ) : JPanel(BorderLayout()), Disposable { companion object { @@ -48,7 +51,6 @@ class SessionUi( } private val project = project - private val flushMs = Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt()) .takeIf { it > 0 } @@ -56,7 +58,7 @@ class SessionUi( ?: EVENT_FLUSH_MS private val controller = SessionController( - this, null, sessions, workspace, app, cs, this, + this, id, sessions, workspace, app, cs, this, flushMs = flushMs, condense = Registry.`is`("kilo.session.condense", true), ) @@ -78,7 +80,6 @@ class SessionUi( private lateinit var prompt: PromptPanel - init { buildUi() bindUi() @@ -90,7 +91,7 @@ class SessionUi( sessionContent = JPanel(BorderLayout()) - emptyBody = EmptySessionPanel(this) + emptyBody = EmptySessionPanel(this, controller, onOpenSession) messageBody = SessionMessageListPanel(controller.model, this) scroll = JBScrollPane(emptyBody).apply { @@ -153,12 +154,14 @@ class SessionUi( prompt.setReady(m.isReady()) } - is SessionControllerEvent.ViewChanged -> + is SessionControllerEvent.ViewChanged -> { showBody(if (event.show) messageBody else emptyBody) + } is SessionControllerEvent.AppChanged, - is SessionControllerEvent.WorkspaceChanged -> + is SessionControllerEvent.WorkspaceChanged -> { prompt.setReady(controller.model.isReady()) + } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt new file mode 100644 index 00000000000..ef5151d1874 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt @@ -0,0 +1,36 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.Workspace +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob + +@Service(Service.Level.APP) +class SessionUiFactory( + private val cs: CoroutineScope, +) { + fun create( + project: Project, + workspace: Workspace, + manager: SessionManager, + id: String? = null, + ): SessionUi = SessionUi( + project = project, + workspace = workspace, + sessions = project.service(), + app = service(), + cs = scope(), + id = id, + onOpenSession = manager::openSession, + ) + + private fun scope(): CoroutineScope { + val parent = cs.coroutineContext[Job] + return CoroutineScope(cs.coroutineContext + SupervisorJob(parent)) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt index 8aa2c1208ee..1cfe45aa5c6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt @@ -1,23 +1,49 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent +import ai.kilocode.client.session.update.SessionControllerListener +import ai.kilocode.client.ui.md.MdView +import ai.kilocode.rpc.dto.SessionDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.IconLoader import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.Centerizer import com.intellij.util.ui.JBUI -import java.awt.GridBagConstraints -import java.awt.GridBagLayout +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import java.awt.Dimension +import javax.swing.Box +import javax.swing.BoxLayout import javax.swing.JPanel +import javax.swing.border.EmptyBorder /** * Centered empty-session panel. */ class EmptySessionPanel( parent: Disposable, -) : JPanel(GridBagLayout()), Disposable { + private val controller: SessionController, + open: (SessionDto) -> Unit = {}, +) : JPanel(BorderLayout()), SessionControllerListener, Disposable { + + companion object { + internal const val LIMIT = 5 + internal const val MAX_WIDTH = 350 + } init { Disposer.register(parent, this) + controller.addListener(this, this) + } + + private val list = SessionListPanel(LIMIT, open) + private val md = MdView.html().apply { + opaque = false + foreground = UIUtil.getContextHelpForeground() + set(KiloBundle.message("session.empty.welcome")) } private val logo = JBLabel( @@ -26,15 +52,113 @@ class EmptySessionPanel( alignmentX = CENTER_ALIGNMENT } + private val intro = JPanel(BorderLayout()).apply { + isOpaque = false + alignmentX = CENTER_ALIGNMENT + add(md.component, BorderLayout.CENTER) + border = JBUI.Borders.empty(0, 12, 0, 12) + } + + private val recent = JPanel(BorderLayout()).apply { + isOpaque = false + alignmentX = CENTER_ALIGNMENT + isVisible = false + add(JBLabel(KiloBundle.message("session.empty.recent")).apply { + foreground = UIUtil.getContextHelpForeground() + font = font.deriveFont(font.size2D - 1f) + border = JBUI.Borders.emptyLeft(8) + }, BorderLayout.NORTH) + add(list, BorderLayout.CENTER) + } + + private val stack = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(logo) + add(Box.createVerticalStrut(JBUI.scale(14))) + add(intro) + add(Box.createVerticalStrut(JBUI.scale(28))) + add(recent) + } + + private val content = object : JPanel(BorderLayout()) { + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + return Dimension(JBUI.scale(MAX_WIDTH), size.height) + } + }.apply { + isOpaque = false + add(stack, BorderLayout.NORTH) + } + + private var loading = false + init { isOpaque = false - - add(logo, GridBagConstraints().apply { - anchor = GridBagConstraints.CENTER - insets = JBUI.insets(12) - }) + border = JBUI.Borders.empty(12) + add(Centerizer(content, Centerizer.TYPE.BOTH), BorderLayout.CENTER) + refresh() } + override fun addNotify() { + super.addNotify() + refresh(force = true) + } + + override fun onEvent(event: SessionControllerEvent) { + when (event) { + is SessionControllerEvent.AppChanged, + is SessionControllerEvent.WorkspaceChanged, + is SessionControllerEvent.WorkspaceReady -> { + if (controller.ready) refresh() + } + + is SessionControllerEvent.ViewChanged -> { + if (!event.show) refresh(force = true) + } + } + } + + fun refresh(force: Boolean = false) { + if (!force && loading) return + loading = true + controller.recent( + limit = LIMIT, + onResult = { + loading = false + setSessions(it) + }, + onError = { + loading = false + }, + ) + } + + internal fun setSessions(sessions: List) { + list.setSessions(sessions) + recent.isVisible = list.count() > 0 + revalidate() + repaint() + } + + internal fun recentCount() = list.count() + + internal fun selectRecent(index: Int) { + list.select(index) + } + + internal fun selectedRecent() = list.selected() + + internal fun clickRecent(index: Int) { + list.click(index) + } + + internal fun recentVisible() = recent.isVisible + + internal fun explanationMarkdown() = md.markdown() + + internal fun contentPreferredSize() = content.preferredSize + override fun dispose() { // no-op } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionListPanel.kt new file mode 100644 index 00000000000..d24aab10911 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionListPanel.kt @@ -0,0 +1,155 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.rpc.dto.SessionDto +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import java.awt.Component +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseMotionAdapter +import javax.swing.DefaultListModel +import javax.swing.JList +import javax.swing.JPanel +import javax.swing.ListCellRenderer +import javax.swing.ListSelectionModel +import kotlin.math.abs + +class SessionListPanel( + private val limit: Int, + private val open: (SessionDto) -> Unit, +) : JPanel(BorderLayout()) { + companion object { + private const val MINUTE = 60_000L + private const val HOUR = 60 * MINUTE + private const val DAY = 24 * HOUR + } + + private val model = DefaultListModel() + private var hover = -1 + + private val list = JBList(model).apply { + isOpaque = false + selectionMode = ListSelectionModel.SINGLE_SELECTION + visibleRowCount = limit + cellRenderer = SessionRenderer() + emptyText.clear() + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val index = row(e) + if (index < 0) return + selectedIndex = index + open(model.getElementAt(index)) + } + + override fun mouseExited(e: MouseEvent) { + hover = -1 + repaint() + } + }) + addMouseMotionListener(object : MouseMotionAdapter() { + override fun mouseMoved(e: MouseEvent) { + val index = row(e) + if (hover == index) return + hover = index + repaint() + } + }) + } + + init { + isOpaque = false + add(list, BorderLayout.CENTER) + } + + fun setSessions(sessions: List) { + model.clear() + sessions.take(limit).forEach(model::addElement) + revalidate() + repaint() + } + + fun count() = model.size() + + internal fun select(index: Int) { + list.selectedIndex = index + } + + internal fun selected() = list.selectedIndex + + internal fun click(index: Int) { + list.selectedIndex = index + open(model.getElementAt(index)) + } + + internal fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = time(session, now) + + internal fun normalize(value: Double): Long { + val raw = value.toLong() + if (abs(raw) < 10_000_000_000L) return raw * 1000 + return raw + } + + internal fun rendererComponent( + session: SessionDto, + selected: Boolean = false, + hover: Boolean = false, + ): Component { + val old = this.hover + this.hover = if (hover) 0 else -1 + return list.cellRenderer.getListCellRendererComponent(list, session, 0, selected, false).also { + this.hover = old + } + } + + private fun row(e: MouseEvent): Int { + val index = list.locationToIndex(e.point) + if (index < 0) return -1 + val box = list.getCellBounds(index, index) ?: return -1 + if (!box.contains(e.point)) return -1 + return index + } + + private inner class SessionRenderer : JPanel(BorderLayout()), ListCellRenderer { + private val title = JBLabel() + private val time = JBLabel() + + init { + border = JBUI.Borders.empty(8, 8, 8, 8) + add(title, BorderLayout.CENTER) + add(time, BorderLayout.EAST) + } + + override fun getListCellRendererComponent( + list: JList, + value: SessionDto?, + index: Int, + selected: Boolean, + focus: Boolean, + ): Component { + val active = selected || hover == index + isOpaque = active + background = if (active) list.selectionBackground else list.background + title.foreground = if (active) list.selectionForeground else UIUtil.getLabelForeground() + time.foreground = if (active) list.selectionForeground else UIUtil.getContextHelpForeground() + title.text = value?.let(::title) ?: "" + time.text = value?.let { time(it) } ?: "" + return this + } + } + + private fun title(session: SessionDto) = + session.title.takeIf { it.isNotBlank() } ?: KiloBundle.message("session.tab.untitled") + + private fun time(session: SessionDto, now: Long = System.currentTimeMillis()): String { + val ms = normalize(session.time.updated) + val diff = (now - ms).coerceAtLeast(0) + if (diff < MINUTE) return KiloBundle.message("session.empty.time.moments") + if (diff < HOUR) return KiloBundle.message("session.empty.time.minutes", (diff / MINUTE).coerceAtLeast(1)) + if (diff < DAY) return KiloBundle.message("session.empty.time.hours", (diff / HOUR).coerceAtLeast(1)) + return KiloBundle.message("session.empty.time.days", (diff / DAY).coerceAtLeast(1)) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index 09a280cad03..76341d30749 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -25,6 +25,7 @@ import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto +import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -89,6 +90,22 @@ class SessionController( val ready: Boolean get() = model.isReady() + fun recent( + limit: Int, + onResult: (List) -> Unit, + onError: () -> Unit = {}, + ) { + cs.launch { + try { + val items = sessions.recent(directory, limit) + edt { onResult(items) } + } catch (e: Exception) { + LOG.warn("kind=session-recent dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + edt { onError() } + } + } + } + fun addListener(parent: Disposable, listener: SessionControllerListener) { listeners.add(listener) Disposer.register(parent) { listeners.remove(listener) } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus.svg new file mode 100644 index 00000000000..84f0a44ca51 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus_dark.svg new file mode 100644 index 00000000000..84f0a44ca51 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus_dark.svg @@ -0,0 +1,3 @@ + + + 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 b890d0a66e1..fc5b4f0c406 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -3,6 +3,15 @@ session.connection.error.unknown=Unknown error session.connection.retry=Retry session.connection.warning.config=Configuration warnings +session.empty.welcome=Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase. +session.empty.recent=RECENT +session.empty.time.moments=Moments ago +session.empty.time.minutes={0} min ago +session.empty.time.hours={0}h ago +session.empty.time.days={0}d ago +session.tab.new=New Session +session.tab.untitled=Untitled Session + session.status.considering=Considering next steps\u2026 session.status.thinking=Thinking\u2026 session.status.writing=Writing response\u2026 @@ -23,6 +32,8 @@ prompt.button.stop=Stop action.Kilo.Settings.text=Settings action.Kilo.Settings.description=Kilo Code settings +action.Kilo.NewSession.text=New Session +action.Kilo.NewSession.description=Start a new Kilo session action.Kilo.SettingsGroup.text=Settings action.Kilo.SettingsGroup.description=Kilo Code settings action.Kilo.Restart.text=Restart Kilo diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt new file mode 100644 index 00000000000..d9da97616d8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt @@ -0,0 +1,55 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.session.SessionManager +import ai.kilocode.rpc.dto.SessionDto +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.Presentation +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +@Suppress("UnstableApiUsage") +class NewSessionActionTest : BasePlatformTestCase() { + fun `test action invokes manager from data context and has presentation`() { + val manager = FakeManager() + val action = NewSessionAction() + val event = event(manager) + + action.update(event) + action.actionPerformed(event) + + assertEquals(1, manager.created) + assertTrue(event.presentation.isEnabled) + assertEquals("New Session", action.templatePresentation.text) + assertEquals("Start a new Kilo session", action.templatePresentation.description) + assertNotNull(action.templatePresentation.icon) + } + + fun `test update disables action without manager`() { + val action = NewSessionAction() + val presentation = Presentation().apply { copyFrom(action.templatePresentation) } + + action.update(AnActionEvent.createFromDataContext("", presentation) { null }) + + assertFalse(presentation.isEnabled) + } + + private fun event(manager: SessionManager): AnActionEvent { + val presentation = Presentation().apply { + copyFrom(NewSessionAction().templatePresentation) + } + val context = DataContext { id -> + if (SessionManager.KEY.`is`(id)) manager else null + } + return AnActionEvent.createFromDataContext("", presentation, context) + } + + private class FakeManager : SessionManager { + var created = 0 + override fun newSession() { + created++ + } + + override fun openSession(session: SessionDto) { + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt new file mode 100644 index 00000000000..fc1d4ad8862 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -0,0 +1,134 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import javax.swing.JPanel + +@Suppress("UnstableApiUsage") +class SessionSidePanelManagerTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var workspaces: KiloWorkspaceService + private lateinit var workspace: Workspace + private lateinit var sessions: KiloSessionService + private lateinit var app: KiloAppService + private val managers = mutableListOf() + private val created = mutableListOf>() + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + sessions = KiloSessionService(project, scope, FakeSessionRpcApi()) + app = KiloAppService(scope, FakeAppRpcApi().also { + it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + }) + workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + }) + workspace = workspaces.workspace("/test") + } + + override fun tearDown() { + try { + managers.forEach { Disposer.dispose(it) } + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test component provides session manager`() { + val manager = manager() + val provider = manager.component as DataProvider + + assertSame(manager, provider.getData(SessionManager.KEY.name)) + } + + fun `test new session replaces active component`() { + val manager = manager() + + manager.newSession() + val first = active(manager) + manager.newSession() + val second = active(manager) + + assertNotSame(first, second) + assertEquals(listOf("/test" to null, "/test" to null), created) + } + + fun `test opening same existing session reuses component`() { + val manager = manager() + val session = session("ses_1") + + manager.openSession(session) + val first = active(manager) + manager.newSession() + manager.openSession(session) + val second = active(manager) + + assertSame(first, second) + assertEquals(listOf("/test" to "ses_1", "/test" to null), created) + } + + fun `test open session resolves historical workspace`() { + val manager = manager() + + manager.openSession(session("ses_1", "/repo")) + + assertEquals(listOf("/repo" to "ses_1"), created) + } + + fun `test dispose removes active component`() { + val manager = manager() + + manager.newSession() + Disposer.dispose(manager) + managers.remove(manager) + + assertEquals(0, manager.component.componentCount) + } + + private fun manager(): SessionSidePanelManager { + val manager = SessionSidePanelManager( + project = project, + root = workspace, + create = { project, workspace, owner, id -> + created.add(workspace.directory to id) + SessionUi(project, workspace, sessions, app, scope, id = id, onOpenSession = owner::openSession) + }, + resolve = { workspaces.workspace(it) }, + ) + managers.add(manager) + return manager + } + + private fun active(manager: SessionSidePanelManager) = manager.component.getComponent(0) as JPanel + + private fun session(id: String) = session(id, "/test") + + private fun session(id: String, dir: String) = SessionDto( + id = id, + projectID = "prj", + directory = dir, + title = "Session $id", + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) + +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt new file mode 100644 index 00000000000..183f44df951 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt @@ -0,0 +1,109 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import com.intellij.openapi.components.service +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +@Suppress("UnstableApiUsage") +class SessionUiFactoryTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var workspace: Workspace + private lateinit var sessions: KiloSessionService + private lateinit var app: KiloAppService + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + sessions = KiloSessionService(project, scope, FakeSessionRpcApi()) + app = KiloAppService(scope, FakeAppRpcApi().also { + it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + }) + val workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + }) + workspace = workspaces.workspace("/test") + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test factory creates blank session ui`() { + val ui = direct().create(project, workspace, FakeManager()) + + assertNotNull(ui) + } + + fun `test factory wires open callback`() { + val manager = FakeManager() + val ui = SessionUi(project, workspace, sessions, app, scope, onOpenSession = manager::openSession) + val panel = find(ui) + + panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.WorkspaceReady) + val rpc = session("ses_1") + panel.setSessions(listOf(rpc)) + panel.clickRecent(0) + + assertEquals(listOf("ses_1"), manager.opened) + } + + fun `test application service is available`() { + assertNotNull(service()) + } + + private fun direct() = SessionUiFactory(scope) + + private inline fun find(root: java.awt.Container): T { + return find(root, T::class.java) ?: error("missing ${T::class.java.simpleName}") + } + + private fun find(root: java.awt.Container, cls: Class): T? { + if (cls.isInstance(root)) return cls.cast(root) + for (child in root.components) { + if (cls.isInstance(child)) return cls.cast(child) + if (child is java.awt.Container) { + val item = find(child, cls) + if (item != null) return item + } + } + return null + } + + private fun session(id: String) = SessionDto( + id = id, + projectID = "prj", + directory = "/test", + title = "Session $id", + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) + + private class FakeManager : SessionManager { + val opened = mutableListOf() + override fun newSession() { + } + + override fun openSession(session: SessionDto) { + opened.add(session.id) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 8c3e4fe858e..cfeee3caa63 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -26,11 +26,18 @@ import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBScrollPane import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import javax.swing.JLayeredPane @Suppress("UnstableApiUsage") @@ -40,6 +47,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { private lateinit var sessions: KiloSessionService private lateinit var app: KiloAppService private lateinit var workspaces: KiloWorkspaceService + private lateinit var rpc: FakeSessionRpcApi private lateinit var workspace: Workspace private lateinit var ui: SessionUi @@ -47,7 +55,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { super.setUp() scope = CoroutineScope(SupervisorJob()) - val rpc = FakeSessionRpcApi() + rpc = FakeSessionRpcApi() val appRpc = FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) } @@ -164,6 +172,30 @@ class SessionUiLayoutTest : BasePlatformTestCase() { assertSame(find(ui), scroll.viewport.view) } + fun `test clicking recent session calls opener`() { + val opened = mutableListOf() + rpc.recent.add(session("ses_1")) + ui = SessionUi(project, workspace, sessions, app, scope, onOpenSession = { opened.add(it.id) }).apply { + setSize(800, 600) + } + + settle() + find(ui).clickRecent(0) + + assertEquals(listOf("ses_1"), opened) + } + + fun `test existing session id loads history and shows message body`() { + rpc.history.add(MessageWithPartsDto(message("msg1"), emptyList())) + + ui = SessionUi(project, workspace, sessions, app, scope, id = "ses_test").apply { + setSize(800, 600) + } + settle() + + assertSame(find(ui), find(ui).viewport.view) + } + private fun layout() { ui.doLayout() val root = find(ui) @@ -172,6 +204,13 @@ class SessionUiLayoutTest : BasePlatformTestCase() { find(ui).parent.doLayout() } + private fun settle() = runBlocking { + repeat(5) { + delay(100) + com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() + } + } + private fun showConnection() { val m = controller().model m.app = KiloAppStateDto(KiloAppStatusDto.CONNECTING) @@ -226,4 +265,20 @@ class SessionUiLayoutTest : BasePlatformTestCase() { meta = PermissionMeta(raw = emptyMap()), ) ) + + private fun session(id: String) = SessionDto( + id = id, + projectID = "prj", + directory = "/test", + title = "Recent $id", + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) + + private fun message(id: String) = MessageDto( + id = id, + sessionID = "ses_test", + role = "user", + time = MessageTimeDto(created = 0.0), + ) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt new file mode 100644 index 00000000000..2068d94fcc7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt @@ -0,0 +1,158 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking + +@Suppress("UnstableApiUsage") +class EmptySessionPanelTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeSessionRpcApi + private lateinit var app: KiloAppService + private lateinit var workspace: Workspace + private lateinit var controller: SessionController + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeSessionRpcApi() + app = KiloAppService(scope, FakeAppRpcApi().also { + it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + }) + val workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + }) + workspace = workspaces.workspace("/test") + controller = SessionController( + parent = testRootDisposable, + id = null, + sessions = KiloSessionService(project, scope, rpc), + workspace = workspace, + app = app, + cs = scope, + ) + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test recent section is hidden when empty`() { + val panel = panel() + + panel.setSessions(emptyList()) + + assertFalse(panel.recentVisible()) + assertEquals(0, panel.recentCount()) + } + + fun `test empty state has visible preferred height`() { + val panel = panel() + + assertTrue(panel.preferredSize.height > 0) + } + + fun `test content has fixed preferred width`() { + val panel = panel() + + assertEquals(com.intellij.util.ui.JBUI.scale(EmptySessionPanel.MAX_WIDTH), panel.contentPreferredSize().width) + assertTrue(panel.contentPreferredSize().height > 0) + } + + fun `test recent sessions are capped at five`() { + val panel = panel() + + panel.setSessions((1..7).map { session("ses_$it") }) + + assertTrue(panel.recentVisible()) + assertEquals(5, panel.recentCount()) + } + + fun `test explanation uses markdown view`() { + val panel = panel() + + assertEquals( + "Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase.", + panel.explanationMarkdown(), + ) + } + + fun `test panel loads recent sessions`() { + rpc.recent.add(session("ses_1")) + val panel = panel() + + settle() + + assertTrue(rpc.recentCalls.contains("/test" to 5)) + assertTrue(panel.recentVisible()) + assertEquals(1, panel.recentCount()) + } + + fun `test panel retries recent sessions when controller becomes ready`() { + rpc.recentFailures = 1 + rpc.recent.add(session("ses_1")) + val panel = panel() + + settle() + panel.onEvent(SessionControllerEvent.WorkspaceReady) + settle() + + assertTrue(rpc.recentCalls.size >= 2) + assertTrue(panel.recentVisible()) + assertEquals(1, panel.recentCount()) + } + + fun `test panel refreshes when shown after hide`() { + rpc.recent.add(session("ses_1")) + val panel = panel() + settle() + rpc.recent.clear() + rpc.recent.add(session("ses_2")) + + panel.onEvent(SessionControllerEvent.ViewChanged(false)) + settle() + + assertTrue(rpc.recentCalls.size >= 2) + assertEquals(1, panel.recentCount()) + } + + private fun panel(open: (SessionDto) -> Unit = {}) = EmptySessionPanel(testRootDisposable, controller, open) + + private fun settle() = runBlocking { + repeat(5) { + delay(100) + com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() + } + } + + private fun session(id: String) = SessionDto( + id = id, + projectID = "prj", + directory = "/repo/$id", + title = "Title $id", + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionListPanelTest.kt new file mode 100644 index 00000000000..cf7ee5e1a26 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionListPanelTest.kt @@ -0,0 +1,89 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.BorderLayout +import javax.swing.JPanel + +@Suppress("UnstableApiUsage") +class SessionListPanelTest : BasePlatformTestCase() { + fun `test sessions are capped by constructor limit`() { + val panel = panel(limit = 5) + + panel.setSessions((1..7).map { session("ses_$it") }) + + assertEquals(5, panel.count()) + } + + fun `test selecting recent session does not invoke callback`() { + val clicked = mutableListOf() + val panel = panel { clicked.add(it.id) } + + panel.setSessions(listOf(session("ses_1"), session("ses_2"))) + panel.select(1) + + assertEquals(1, panel.selected()) + assertEquals(emptyList(), clicked) + } + + fun `test clicking recent session invokes callback`() { + val clicked = mutableListOf() + val panel = panel { clicked.add(it.id) } + + panel.setSessions(listOf(session("ses_1"), session("ses_2"))) + panel.click(1) + + assertEquals(listOf("ses_2"), clicked) + } + + fun `test renderer aligns title center and time east`() { + val cell = panel().rendererComponent(session("ses_1")) as JPanel + val layout = cell.layout as BorderLayout + + assertNotNull(layout.getLayoutComponent(BorderLayout.CENTER)) + assertNotNull(layout.getLayoutComponent(BorderLayout.EAST)) + } + + fun `test hover uses selection colors`() { + val panel = panel() + val session = session("ses_1") + val selected = panel.rendererComponent(session, selected = true) as JPanel + val hovered = panel.rendererComponent(session, hover = true) as JPanel + + assertTrue(selected.isOpaque) + assertTrue(hovered.isOpaque) + assertEquals(selected.background, hovered.background) + } + + fun `test timestamp normalization handles seconds and milliseconds`() { + val panel = panel() + + assertEquals(1_700_000_000_000L, panel.normalize(1_700_000_000.0)) + assertEquals(1_700_000_000_000L, panel.normalize(1_700_000_000_000.0)) + } + + fun `test timestamp renders coarse relative text`() { + val panel = panel() + val now = 1_700_000_000_000L + + assertEquals("Moments ago", panel.text(session("ses_1", now - 30_000), now)) + assertEquals("2 min ago", panel.text(session("ses_1", now - 120_000), now)) + assertEquals("3h ago", panel.text(session("ses_1", now - 10_800_000), now)) + assertEquals("4d ago", panel.text(session("ses_1", now - 345_600_000), now)) + } + + private fun panel( + limit: Int = 5, + open: (SessionDto) -> Unit = {}, + ) = SessionListPanel(limit, open) + + private fun session(id: String, updated: Long = 2_000L) = SessionDto( + id = id, + projectID = "prj", + directory = "/repo/$id", + title = "Title $id", + version = "1", + time = SessionTimeDto(created = 1.0, updated = updated.toDouble()), + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index de70b1424c9..90a39cde34c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -42,6 +42,10 @@ class FakeSessionRpcApi : KiloSessionRpcApi { /** Message history returned by [messages]. */ val history = mutableListOf() + /** Recent sessions returned by [recent]. */ + val recent = mutableListOf() + var recentFailures = 0 + /** Push chat events here; tests collect from [events]. */ val events = MutableSharedFlow(extraBufferCapacity = 64, replay = 64) @@ -66,6 +70,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val permissionRulesSaved = mutableListOf>() val questionReplies = mutableListOf>() val questionRejects = mutableListOf>() + val recentCalls = mutableListOf>() var creates = 0 private set @@ -82,6 +87,16 @@ class FakeSessionRpcApi : KiloSessionRpcApi { return SessionListDto(emptyList(), emptyMap()) } + override suspend fun recent(directory: String, limit: Int): SessionListDto { + assertNotEdt("recent") + recentCalls.add(directory to limit) + if (recentFailures > 0) { + recentFailures-- + throw IllegalStateException("recent unavailable") + } + return SessionListDto(recent.take(limit), emptyMap()) + } + override suspend fun get(id: String, directory: String): SessionDto { assertNotEdt("get") return session diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index bffa2bf5be7..330bfb8c113 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -37,6 +37,9 @@ interface KiloSessionRpcApi : RemoteApi { /** List root sessions for a directory. */ suspend fun list(directory: String): SessionListDto + /** List recent root sessions for the current worktree family. */ + suspend fun recent(directory: String, limit: Int): SessionListDto + /** Create a new session in the given directory. */ suspend fun create(directory: String): SessionDto From 10caa9cf633534a9d1d1b3e32f08f564175fad8e Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Apr 2026 10:06:02 -0400 Subject: [PATCH 29/96] refactor: move JetBrains session view state into controller --- .../ai/kilocode/client/session/SessionUi.kt | 37 ++- .../client/session/SessionUiFactory.kt | 2 +- .../client/session/ui/EmptySessionPanel.kt | 273 +++++++++++------- .../client/session/ui/SessionListPanel.kt | 155 ---------- .../session/update/SessionController.kt | 57 +++- .../session/update/SessionControllerEvent.kt | 16 +- .../session/SessionSidePanelManagerTest.kt | 2 +- .../client/session/SessionUiFactoryTest.kt | 42 +-- .../client/session/SessionUiLayoutTest.kt | 4 +- .../session/ui/EmptySessionPanelTest.kt | 116 ++++---- .../client/session/ui/SessionListPanelTest.kt | 89 ------ .../session/update/HistoryLoadingTest.kt | 8 + .../session/update/ListenerLifecycleTest.kt | 4 +- .../session/update/SessionCreationTest.kt | 2 +- .../session/update/ViewSwitchingTest.kt | 64 +++- .../session/update/WorkspaceWatchingTest.kt | 2 + 16 files changed, 426 insertions(+), 447 deletions(-) delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionListPanel.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionListPanelTest.kt 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 94dc1e51000..6dbca2ca4f0 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.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.ConnectionPanel @@ -17,12 +18,14 @@ import ai.kilocode.client.session.update.EVENT_FLUSH_MS import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.log.ChatLogSummary +import ai.kilocode.log.KiloLog import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project import com.intellij.openapi.util.registry.Registry -import ai.kilocode.log.ChatLogSummary -import ai.kilocode.log.KiloLog +import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.Centerizer import com.intellij.util.ui.JBUI import kotlinx.coroutines.CoroutineScope import java.awt.BorderLayout @@ -43,7 +46,7 @@ class SessionUi( app: KiloAppService, cs: CoroutineScope, id: String? = null, - private val onOpenSession: (SessionDto) -> Unit = {}, + open: (SessionDto) -> Unit = {}, ) : JPanel(BorderLayout()), Disposable { companion object { @@ -61,6 +64,7 @@ class SessionUi( this, id, sessions, workspace, app, cs, this, flushMs = flushMs, condense = Registry.`is`("kilo.session.condense", true), + open = open, ) @@ -68,7 +72,7 @@ class SessionUi( private lateinit var sessionContent: JPanel - private lateinit var emptyBody: EmptySessionPanel + private lateinit var progressBody: JPanel private lateinit var messageBody: SessionMessageListPanel @@ -83,7 +87,7 @@ class SessionUi( init { buildUi() bindUi() - showBody(emptyBody) + showBody(progressBody) } private fun buildUi() { @@ -91,10 +95,16 @@ class SessionUi( sessionContent = JPanel(BorderLayout()) - emptyBody = EmptySessionPanel(this, controller, onOpenSession) + progressBody = JPanel(BorderLayout()).apply { + isOpaque = false + add(Centerizer( + JBLabel(KiloBundle.message("session.empty.loading")), + Centerizer.TYPE.BOTH, + ), BorderLayout.CENTER) + } messageBody = SessionMessageListPanel(controller.model, this) - scroll = JBScrollPane(emptyBody).apply { + scroll = JBScrollPane(progressBody).apply { border = JBUI.Borders.empty() verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER @@ -154,8 +164,17 @@ class SessionUi( prompt.setReady(m.isReady()) } - is SessionControllerEvent.ViewChanged -> { - showBody(if (event.show) messageBody else emptyBody) + is SessionControllerEvent.ViewChanged.ShowProgress -> { + showBody(progressBody) + } + + is SessionControllerEvent.ViewChanged.ShowRecents -> { + val panel = EmptySessionPanel(this, controller, event.recents) + showBody(panel) + } + + is SessionControllerEvent.ViewChanged.ShowSession -> { + showBody(messageBody) } is SessionControllerEvent.AppChanged, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt index ef5151d1874..6707edf97a7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt @@ -26,7 +26,7 @@ class SessionUiFactory( app = service(), cs = scope(), id = id, - onOpenSession = manager::openSession, + open = manager::openSession, ) private fun scope(): CoroutineScope { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt index 1cfe45aa5c6..7757d1e54cf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt @@ -2,23 +2,30 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.update.SessionController -import ai.kilocode.client.session.update.SessionControllerEvent -import ai.kilocode.client.session.update.SessionControllerListener import ai.kilocode.client.ui.md.MdView import ai.kilocode.rpc.dto.SessionDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.IconLoader import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList import com.intellij.util.ui.Centerizer import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.BorderLayout +import java.awt.Component import java.awt.Dimension +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseMotionAdapter import javax.swing.Box import javax.swing.BoxLayout +import javax.swing.DefaultListModel +import javax.swing.JList import javax.swing.JPanel -import javax.swing.border.EmptyBorder +import javax.swing.ListCellRenderer +import javax.swing.ListSelectionModel +import kotlin.math.abs /** * Centered empty-session panel. @@ -26,139 +33,205 @@ import javax.swing.border.EmptyBorder class EmptySessionPanel( parent: Disposable, private val controller: SessionController, - open: (SessionDto) -> Unit = {}, -) : JPanel(BorderLayout()), SessionControllerListener, Disposable { + recents: List, +) : JPanel(BorderLayout()), Disposable { companion object { internal const val LIMIT = 5 internal const val MAX_WIDTH = 350 + private const val MINUTE = 60_000L + private const val HOUR = 60 * MINUTE + private const val DAY = 24 * HOUR } - init { - Disposer.register(parent, this) - controller.addListener(this, this) - } + private val model = DefaultListModel() + private var hover = -1 - private val list = SessionListPanel(LIMIT, open) + private val list = JBList(model).apply { + isOpaque = false + selectionMode = ListSelectionModel.SINGLE_SELECTION + visibleRowCount = LIMIT + cellRenderer = SessionRenderer() + emptyText.clear() + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val index = row(e) + if (index < 0) return + selectedIndex = index + controller.openSession(model.getElementAt(index)) + } + + override fun mouseExited(e: MouseEvent) { + hover = -1 + repaint() + } + }) + addMouseMotionListener(object : MouseMotionAdapter() { + override fun mouseMoved(e: MouseEvent) { + val index = row(e) + if (hover == index) return + hover = index + repaint() + } + }) + } private val md = MdView.html().apply { opaque = false foreground = UIUtil.getContextHelpForeground() set(KiloBundle.message("session.empty.welcome")) } - - private val logo = JBLabel( - IconLoader.getIcon("/icons/kilo-content.svg", EmptySessionPanel::class.java), - ).apply { - alignmentX = CENTER_ALIGNMENT - } - - private val intro = JPanel(BorderLayout()).apply { - isOpaque = false - alignmentX = CENTER_ALIGNMENT - add(md.component, BorderLayout.CENTER) - border = JBUI.Borders.empty(0, 12, 0, 12) - } - - private val recent = JPanel(BorderLayout()).apply { - isOpaque = false - alignmentX = CENTER_ALIGNMENT - isVisible = false - add(JBLabel(KiloBundle.message("session.empty.recent")).apply { - foreground = UIUtil.getContextHelpForeground() - font = font.deriveFont(font.size2D - 1f) - border = JBUI.Borders.emptyLeft(8) - }, BorderLayout.NORTH) - add(list, BorderLayout.CENTER) - } - - private val stack = JPanel().apply { - isOpaque = false - layout = BoxLayout(this, BoxLayout.Y_AXIS) - add(logo) - add(Box.createVerticalStrut(JBUI.scale(14))) - add(intro) - add(Box.createVerticalStrut(JBUI.scale(28))) - add(recent) - } - - private val content = object : JPanel(BorderLayout()) { - override fun getPreferredSize(): Dimension { - val size = super.getPreferredSize() - return Dimension(JBUI.scale(MAX_WIDTH), size.height) - } - }.apply { - isOpaque = false - add(stack, BorderLayout.NORTH) - } - - private var loading = false + private val content = createContent() init { + Disposer.register(parent, this) isOpaque = false border = JBUI.Borders.empty(12) + setSessions(recents) add(Centerizer(content, Centerizer.TYPE.BOTH), BorderLayout.CENTER) - refresh() } - override fun addNotify() { - super.addNotify() - refresh(force = true) - } - - override fun onEvent(event: SessionControllerEvent) { - when (event) { - is SessionControllerEvent.AppChanged, - is SessionControllerEvent.WorkspaceChanged, - is SessionControllerEvent.WorkspaceReady -> { - if (controller.ready) refresh() - } - - is SessionControllerEvent.ViewChanged -> { - if (!event.show) refresh(force = true) - } - } - } - - fun refresh(force: Boolean = false) { - if (!force && loading) return - loading = true - controller.recent( - limit = LIMIT, - onResult = { - loading = false - setSessions(it) - }, - onError = { - loading = false - }, - ) - } - - internal fun setSessions(sessions: List) { - list.setSessions(sessions) - recent.isVisible = list.count() > 0 + private fun setSessions(sessions: List) { + model.clear() + sessions.take(LIMIT).forEach(model::addElement) revalidate() repaint() } - internal fun recentCount() = list.count() + private fun createContent(): JPanel { + val logo = JBLabel( + IconLoader.getIcon("/icons/kilo-content.svg", EmptySessionPanel::class.java), + ).apply { + alignmentX = CENTER_ALIGNMENT + } + val intro = JPanel(BorderLayout()).apply { + isOpaque = false + alignmentX = CENTER_ALIGNMENT + add(md.component, BorderLayout.CENTER) + border = JBUI.Borders.empty(0, 12, 0, 12) + } + val recent = JPanel(BorderLayout()).apply { + isOpaque = false + alignmentX = CENTER_ALIGNMENT + add(JBLabel(KiloBundle.message("session.empty.recent")).apply { + foreground = UIUtil.getContextHelpForeground() + font = font.deriveFont(font.size2D - 1f) + border = JBUI.Borders.emptyLeft(8) + }, BorderLayout.NORTH) + add(list, BorderLayout.CENTER) + } + val stack = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(logo) + add(Box.createVerticalStrut(JBUI.scale(14))) + add(intro) + add(Box.createVerticalStrut(JBUI.scale(28))) + add(recent) + } + return object : JPanel(BorderLayout()) { + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + return Dimension(JBUI.scale(MAX_WIDTH), size.height) + } + }.apply { + isOpaque = false + add(stack, BorderLayout.NORTH) + } + } + + internal fun recentCount() = model.size() internal fun selectRecent(index: Int) { - list.select(index) + list.selectedIndex = index } - internal fun selectedRecent() = list.selected() + internal fun selectedRecent() = list.selectedIndex internal fun clickRecent(index: Int) { - list.click(index) + list.selectedIndex = index + controller.openSession(model.getElementAt(index)) } - internal fun recentVisible() = recent.isVisible + internal fun recentVisible() = true internal fun explanationMarkdown() = md.markdown() internal fun contentPreferredSize() = content.preferredSize + internal fun initialized() = true + + internal fun loadingVisible() = false + + internal fun activeView() = getComponent(0) + + internal fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = time(session, now) + + internal fun normalize(value: Double): Long { + val raw = value.toLong() + if (abs(raw) < 10_000_000_000L) return raw * 1000 + return raw + } + + internal fun rendererComponent( + session: SessionDto, + selected: Boolean = false, + hover: Boolean = false, + ): Component { + val old = this.hover + this.hover = if (hover) 0 else -1 + return list.cellRenderer.getListCellRendererComponent(list, session, 0, selected, false).also { + this.hover = old + } + } + + private fun row(e: MouseEvent): Int { + val index = list.locationToIndex(e.point) + if (index < 0) return -1 + val box = list.getCellBounds(index, index) ?: return -1 + if (!box.contains(e.point)) return -1 + return index + } + + private inner class SessionRenderer : JPanel(BorderLayout()), ListCellRenderer { + private val title = JBLabel() + private val time = JBLabel() + + init { + border = JBUI.Borders.empty(8, 8, 8, 8) + add(title, BorderLayout.CENTER) + add(time, BorderLayout.EAST) + } + + override fun getListCellRendererComponent( + list: JList, + value: SessionDto?, + index: Int, + selected: Boolean, + focus: Boolean, + ): Component { + val active = selected || hover == index + isOpaque = active + background = if (active) list.selectionBackground else list.background + title.foreground = if (active) list.selectionForeground else UIUtil.getLabelForeground() + time.foreground = if (active) list.selectionForeground else UIUtil.getContextHelpForeground() + title.text = value?.let(::title) ?: "" + time.text = value?.let { time(it) } ?: "" + return this + } + } + + private fun title(session: SessionDto) = + session.title.takeIf { it.isNotBlank() } ?: KiloBundle.message("session.tab.untitled") + + private fun time(session: SessionDto, now: Long = System.currentTimeMillis()): String { + val ms = normalize(session.time.updated) + val diff = (now - ms).coerceAtLeast(0) + if (diff < MINUTE) return KiloBundle.message("session.empty.time.moments") + if (diff < HOUR) return KiloBundle.message("session.empty.time.minutes", (diff / MINUTE).coerceAtLeast(1)) + if (diff < DAY) return KiloBundle.message("session.empty.time.hours", (diff / HOUR).coerceAtLeast(1)) + return KiloBundle.message("session.empty.time.days", (diff / DAY).coerceAtLeast(1)) + } + override fun dispose() { // no-op } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionListPanel.kt deleted file mode 100644 index d24aab10911..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionListPanel.kt +++ /dev/null @@ -1,155 +0,0 @@ -package ai.kilocode.client.session.ui - -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.rpc.dto.SessionDto -import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBList -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import java.awt.BorderLayout -import java.awt.Component -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import java.awt.event.MouseMotionAdapter -import javax.swing.DefaultListModel -import javax.swing.JList -import javax.swing.JPanel -import javax.swing.ListCellRenderer -import javax.swing.ListSelectionModel -import kotlin.math.abs - -class SessionListPanel( - private val limit: Int, - private val open: (SessionDto) -> Unit, -) : JPanel(BorderLayout()) { - companion object { - private const val MINUTE = 60_000L - private const val HOUR = 60 * MINUTE - private const val DAY = 24 * HOUR - } - - private val model = DefaultListModel() - private var hover = -1 - - private val list = JBList(model).apply { - isOpaque = false - selectionMode = ListSelectionModel.SINGLE_SELECTION - visibleRowCount = limit - cellRenderer = SessionRenderer() - emptyText.clear() - addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - val index = row(e) - if (index < 0) return - selectedIndex = index - open(model.getElementAt(index)) - } - - override fun mouseExited(e: MouseEvent) { - hover = -1 - repaint() - } - }) - addMouseMotionListener(object : MouseMotionAdapter() { - override fun mouseMoved(e: MouseEvent) { - val index = row(e) - if (hover == index) return - hover = index - repaint() - } - }) - } - - init { - isOpaque = false - add(list, BorderLayout.CENTER) - } - - fun setSessions(sessions: List) { - model.clear() - sessions.take(limit).forEach(model::addElement) - revalidate() - repaint() - } - - fun count() = model.size() - - internal fun select(index: Int) { - list.selectedIndex = index - } - - internal fun selected() = list.selectedIndex - - internal fun click(index: Int) { - list.selectedIndex = index - open(model.getElementAt(index)) - } - - internal fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = time(session, now) - - internal fun normalize(value: Double): Long { - val raw = value.toLong() - if (abs(raw) < 10_000_000_000L) return raw * 1000 - return raw - } - - internal fun rendererComponent( - session: SessionDto, - selected: Boolean = false, - hover: Boolean = false, - ): Component { - val old = this.hover - this.hover = if (hover) 0 else -1 - return list.cellRenderer.getListCellRendererComponent(list, session, 0, selected, false).also { - this.hover = old - } - } - - private fun row(e: MouseEvent): Int { - val index = list.locationToIndex(e.point) - if (index < 0) return -1 - val box = list.getCellBounds(index, index) ?: return -1 - if (!box.contains(e.point)) return -1 - return index - } - - private inner class SessionRenderer : JPanel(BorderLayout()), ListCellRenderer { - private val title = JBLabel() - private val time = JBLabel() - - init { - border = JBUI.Borders.empty(8, 8, 8, 8) - add(title, BorderLayout.CENTER) - add(time, BorderLayout.EAST) - } - - override fun getListCellRendererComponent( - list: JList, - value: SessionDto?, - index: Int, - selected: Boolean, - focus: Boolean, - ): Component { - val active = selected || hover == index - isOpaque = active - background = if (active) list.selectionBackground else list.background - title.foreground = if (active) list.selectionForeground else UIUtil.getLabelForeground() - time.foreground = if (active) list.selectionForeground else UIUtil.getContextHelpForeground() - title.text = value?.let(::title) ?: "" - time.text = value?.let { time(it) } ?: "" - return this - } - } - - private fun title(session: SessionDto) = - session.title.takeIf { it.isNotBlank() } ?: KiloBundle.message("session.tab.untitled") - - private fun time(session: SessionDto, now: Long = System.currentTimeMillis()): String { - val ms = normalize(session.time.updated) - val diff = (now - ms).coerceAtLeast(0) - if (diff < MINUTE) return KiloBundle.message("session.empty.time.moments") - if (diff < HOUR) return KiloBundle.message("session.empty.time.minutes", (diff / MINUTE).coerceAtLeast(1)) - if (diff < DAY) return KiloBundle.message("session.empty.time.hours", (diff / HOUR).coerceAtLeast(1)) - return KiloBundle.message("session.empty.time.days", (diff / DAY).coerceAtLeast(1)) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index 76341d30749..fa30730c989 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -60,10 +60,12 @@ class SessionController( comp: Component? = null, private val flushMs: Long = EVENT_FLUSH_MS, private val condense: Boolean = true, + private val open: (SessionDto) -> Unit = {}, ) : Disposable { companion object { private val LOG = KiloLog.create(SessionController::class.java) + internal const val RECENT_LIMIT = 5 } init { @@ -87,25 +89,43 @@ class SessionController( private var partType: String? = null private var tool: String? = null private var eventJob: Job? = null + private var recentsLoading = false + private var recentsLoaded = false + private var view: SessionControllerEvent.ViewChanged? = null val ready: Boolean get() = model.isReady() - fun recent( - limit: Int, - onResult: (List) -> Unit, - onError: () -> Unit = {}, - ) { + fun refreshRecents(force: Boolean = false) { + if (model.showMessages) return + if (recentsLoading) return + if (recentsLoaded && !force) return + recentsLoading = true + view(SessionControllerEvent.ViewChanged.ShowProgress) cs.launch { try { - val items = sessions.recent(directory, limit) - edt { onResult(items) } + val items = sessions.recent(directory, RECENT_LIMIT) + edt { + recentsLoading = false + recentsLoaded = true + if (model.showMessages) return@edt + view(SessionControllerEvent.ViewChanged.ShowRecents(items)) + } } catch (e: Exception) { LOG.warn("kind=session-recent dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) - edt { onError() } + edt { + recentsLoading = false + recentsLoaded = true + if (model.showMessages) return@edt + view(SessionControllerEvent.ViewChanged.ShowRecents(emptyList())) + } } } } + fun openSession(session: SessionDto) { + open(session) + } + fun addListener(parent: Disposable, listener: SessionControllerListener) { listeners.add(listener) Disposer.register(parent) { listeners.remove(listener) } @@ -286,6 +306,9 @@ class SessionController( if (state.status == KiloWorkspaceStatusDto.READY) { fire(SessionControllerEvent.WorkspaceReady) + edt { + if (sessionId == null) refreshRecents() + } } } } @@ -294,16 +317,24 @@ class SessionController( private fun loadHistory() { val id = sessionId ?: return cs.launch { + view(SessionControllerEvent.ViewChanged.ShowProgress) try { val history = sessions.messages(id, directory) LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(history)}" } runEdt { this@SessionController.model.loadHistory(history) - if (!model.isEmpty()) showMessages() } recoverPending(id) + edt { + if (!model.isEmpty()) { + showMessages() + return@edt + } + refreshRecents(force = true) + } } catch (e: Exception) { LOG.warn("${ChatLogSummary.sid(id)} kind=history dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + edt { refreshRecents(force = true) } } finally { updates.holdFlush(false) updates.requestFlush(true) @@ -519,10 +550,16 @@ class SessionController( private fun showMessages() { if (!model.showMessages) { model.showMessages = true - fire(SessionControllerEvent.ViewChanged(true)) + view(SessionControllerEvent.ViewChanged.ShowSession) } } + private fun view(event: SessionControllerEvent.ViewChanged) { + if (view == event) return + view = event + fire(event) + } + private fun status(): String = when (partType) { "reasoning" -> KiloBundle.message("session.status.thinking") "text" -> KiloBundle.message("session.status.writing") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt index b872969180f..c11ff6ec532 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.rpc.dto.SessionDto /** * Lifecycle events fired by [SessionController] on the EDT. @@ -18,8 +19,19 @@ sealed class SessionControllerEvent { // Workspace ready (pickers populated) data object WorkspaceReady : SessionControllerEvent() - data class ViewChanged(val show: Boolean) : SessionControllerEvent() { - override fun toString() = if (show) "ViewChanged show" else "ViewChanged hide" + + sealed class ViewChanged : SessionControllerEvent() { + data object ShowProgress : ViewChanged() { + override fun toString() = "ViewChanged progress" + } + + data class ShowRecents(val recents: List) : ViewChanged() { + override fun toString() = "ViewChanged recents=${recents.size}" + } + + data object ShowSession : ViewChanged() { + override fun toString() = "ViewChanged session" + } } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt index fc1d4ad8862..5de11cff2c2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -110,7 +110,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { root = workspace, create = { project, workspace, owner, id -> created.add(workspace.directory to id) - SessionUi(project, workspace, sessions, app, scope, id = id, onOpenSession = owner::openSession) + SessionUi(project, workspace, sessions, app, scope, id = id, open = owner::openSession) }, resolve = { workspaces.workspace(it) }, ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt index 183f44df951..4f7287c3161 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt @@ -55,39 +55,39 @@ class SessionUiFactoryTest : BasePlatformTestCase() { fun `test factory wires open callback`() { val manager = FakeManager() - val ui = SessionUi(project, workspace, sessions, app, scope, onOpenSession = manager::openSession) - val panel = find(ui) - - panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.WorkspaceReady) val rpc = session("ses_1") - panel.setSessions(listOf(rpc)) + val ui = SessionUi(project, workspace, sessions, app, scope, open = manager::openSession) + val controller = controller(ui) + + controller.openSession(rpc) + + assertEquals(listOf("ses_1"), manager.opened) + } + + fun `test empty panel opens through controller`() { + val manager = FakeManager() + val rpc = session("ses_1") + val ui = SessionUi(project, workspace, sessions, app, scope, open = manager::openSession) + val controller = controller(ui) + val panel = ai.kilocode.client.session.ui.EmptySessionPanel(testRootDisposable, controller, listOf(rpc)) + panel.clickRecent(0) assertEquals(listOf("ses_1"), manager.opened) } + private fun controller(ui: SessionUi): ai.kilocode.client.session.update.SessionController { + val field = SessionUi::class.java.getDeclaredField("controller") + field.isAccessible = true + return field.get(ui) as ai.kilocode.client.session.update.SessionController + } + fun `test application service is available`() { assertNotNull(service()) } private fun direct() = SessionUiFactory(scope) - private inline fun find(root: java.awt.Container): T { - return find(root, T::class.java) ?: error("missing ${T::class.java.simpleName}") - } - - private fun find(root: java.awt.Container, cls: Class): T? { - if (cls.isInstance(root)) return cls.cast(root) - for (child in root.components) { - if (cls.isInstance(child)) return cls.cast(child) - if (child is java.awt.Container) { - val item = find(child, cls) - if (item != null) return item - } - } - return null - } - private fun session(id: String) = SessionDto( id = id, projectID = "prj", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index cfeee3caa63..6b18b94f604 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -160,6 +160,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { } fun `test empty and message bodies share the same scroll pane`() { + settle() val scroll = find(ui) val empty = find(ui) @@ -175,11 +176,12 @@ class SessionUiLayoutTest : BasePlatformTestCase() { fun `test clicking recent session calls opener`() { val opened = mutableListOf() rpc.recent.add(session("ses_1")) - ui = SessionUi(project, workspace, sessions, app, scope, onOpenSession = { opened.add(it.id) }).apply { + ui = SessionUi(project, workspace, sessions, app, scope, open = { opened.add(it.id) }).apply { setSize(800, 600) } settle() + layout() find(ui).clickRecent(0) assertEquals(listOf("ses_1"), opened) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt index 2068d94fcc7..50ee03c03af 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt @@ -5,7 +5,6 @@ import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.update.SessionController -import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi @@ -19,21 +18,20 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking +import java.awt.BorderLayout +import javax.swing.JPanel @Suppress("UnstableApiUsage") class EmptySessionPanelTest : BasePlatformTestCase() { private lateinit var scope: CoroutineScope - private lateinit var rpc: FakeSessionRpcApi private lateinit var app: KiloAppService private lateinit var workspace: Workspace private lateinit var controller: SessionController + private val opened = mutableListOf() override fun setUp() { super.setUp() scope = CoroutineScope(SupervisorJob()) - rpc = FakeSessionRpcApi() app = KiloAppService(scope, FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) }) @@ -44,10 +42,11 @@ class EmptySessionPanelTest : BasePlatformTestCase() { controller = SessionController( parent = testRootDisposable, id = null, - sessions = KiloSessionService(project, scope, rpc), + sessions = KiloSessionService(project, scope, FakeSessionRpcApi()), workspace = workspace, app = app, cs = scope, + open = { opened.add(it.id) }, ) } @@ -59,12 +58,17 @@ class EmptySessionPanelTest : BasePlatformTestCase() { } } - fun `test recent section is hidden when empty`() { + fun `test content is initialized immediately`() { val panel = panel() - panel.setSessions(emptyList()) + assertTrue(panel.initialized()) + assertFalse(panel.loadingVisible()) + } - assertFalse(panel.recentVisible()) + fun `test recent section remains visible when empty`() { + val panel = panel() + + assertTrue(panel.recentVisible()) assertEquals(0, panel.recentCount()) } @@ -82,9 +86,7 @@ class EmptySessionPanelTest : BasePlatformTestCase() { } fun `test recent sessions are capped at five`() { - val panel = panel() - - panel.setSessions((1..7).map { session("ses_$it") }) + val panel = panel((1..7).map { session("ses_$it") }) assertTrue(panel.recentVisible()) assertEquals(5, panel.recentCount()) @@ -99,60 +101,68 @@ class EmptySessionPanelTest : BasePlatformTestCase() { ) } - fun `test panel loads recent sessions`() { - rpc.recent.add(session("ses_1")) + fun `test selecting recent session does not open it`() { + val panel = panel(listOf(session("ses_1"), session("ses_2"))) + + panel.selectRecent(1) + + assertEquals(1, panel.selectedRecent()) + assertEquals(emptyList(), opened) + } + + fun `test clicking recent session delegates to controller`() { + val panel = panel(listOf(session("ses_1"), session("ses_2"))) + + panel.clickRecent(1) + + assertEquals(listOf("ses_2"), opened) + } + + fun `test renderer aligns title center and time east`() { + val cell = panel().rendererComponent(session("ses_1")) as JPanel + val layout = cell.layout as BorderLayout + + assertNotNull(layout.getLayoutComponent(BorderLayout.CENTER)) + assertNotNull(layout.getLayoutComponent(BorderLayout.EAST)) + } + + fun `test hover uses selection colors`() { + val panel = panel() + val session = session("ses_1") + val selected = panel.rendererComponent(session, selected = true) as JPanel + val hovered = panel.rendererComponent(session, hover = true) as JPanel + + assertTrue(selected.isOpaque) + assertTrue(hovered.isOpaque) + assertEquals(selected.background, hovered.background) + } + + fun `test timestamp normalization handles seconds and milliseconds`() { val panel = panel() - settle() - - assertTrue(rpc.recentCalls.contains("/test" to 5)) - assertTrue(panel.recentVisible()) - assertEquals(1, panel.recentCount()) + assertEquals(1_700_000_000_000L, panel.normalize(1_700_000_000.0)) + assertEquals(1_700_000_000_000L, panel.normalize(1_700_000_000_000.0)) } - fun `test panel retries recent sessions when controller becomes ready`() { - rpc.recentFailures = 1 - rpc.recent.add(session("ses_1")) + fun `test timestamp renders coarse relative text`() { val panel = panel() + val now = 1_700_000_000_000L - settle() - panel.onEvent(SessionControllerEvent.WorkspaceReady) - settle() - - assertTrue(rpc.recentCalls.size >= 2) - assertTrue(panel.recentVisible()) - assertEquals(1, panel.recentCount()) + assertEquals("Moments ago", panel.text(session("ses_1", now - 30_000), now)) + assertEquals("2 min ago", panel.text(session("ses_1", now - 120_000), now)) + assertEquals("3h ago", panel.text(session("ses_1", now - 10_800_000), now)) + assertEquals("4d ago", panel.text(session("ses_1", now - 345_600_000), now)) } - fun `test panel refreshes when shown after hide`() { - rpc.recent.add(session("ses_1")) - val panel = panel() - settle() - rpc.recent.clear() - rpc.recent.add(session("ses_2")) + private fun panel(recents: List = emptyList()) = + EmptySessionPanel(testRootDisposable, controller, recents) - panel.onEvent(SessionControllerEvent.ViewChanged(false)) - settle() - - assertTrue(rpc.recentCalls.size >= 2) - assertEquals(1, panel.recentCount()) - } - - private fun panel(open: (SessionDto) -> Unit = {}) = EmptySessionPanel(testRootDisposable, controller, open) - - private fun settle() = runBlocking { - repeat(5) { - delay(100) - com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() - } - } - - private fun session(id: String) = SessionDto( + private fun session(id: String, updated: Long = 2_000L) = SessionDto( id = id, projectID = "prj", directory = "/repo/$id", title = "Title $id", version = "1", - time = SessionTimeDto(created = 1.0, updated = 2.0), + time = SessionTimeDto(created = 1.0, updated = updated.toDouble()), ) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionListPanelTest.kt deleted file mode 100644 index cf7ee5e1a26..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionListPanelTest.kt +++ /dev/null @@ -1,89 +0,0 @@ -package ai.kilocode.client.session.ui - -import ai.kilocode.rpc.dto.SessionDto -import ai.kilocode.rpc.dto.SessionTimeDto -import com.intellij.testFramework.fixtures.BasePlatformTestCase -import java.awt.BorderLayout -import javax.swing.JPanel - -@Suppress("UnstableApiUsage") -class SessionListPanelTest : BasePlatformTestCase() { - fun `test sessions are capped by constructor limit`() { - val panel = panel(limit = 5) - - panel.setSessions((1..7).map { session("ses_$it") }) - - assertEquals(5, panel.count()) - } - - fun `test selecting recent session does not invoke callback`() { - val clicked = mutableListOf() - val panel = panel { clicked.add(it.id) } - - panel.setSessions(listOf(session("ses_1"), session("ses_2"))) - panel.select(1) - - assertEquals(1, panel.selected()) - assertEquals(emptyList(), clicked) - } - - fun `test clicking recent session invokes callback`() { - val clicked = mutableListOf() - val panel = panel { clicked.add(it.id) } - - panel.setSessions(listOf(session("ses_1"), session("ses_2"))) - panel.click(1) - - assertEquals(listOf("ses_2"), clicked) - } - - fun `test renderer aligns title center and time east`() { - val cell = panel().rendererComponent(session("ses_1")) as JPanel - val layout = cell.layout as BorderLayout - - assertNotNull(layout.getLayoutComponent(BorderLayout.CENTER)) - assertNotNull(layout.getLayoutComponent(BorderLayout.EAST)) - } - - fun `test hover uses selection colors`() { - val panel = panel() - val session = session("ses_1") - val selected = panel.rendererComponent(session, selected = true) as JPanel - val hovered = panel.rendererComponent(session, hover = true) as JPanel - - assertTrue(selected.isOpaque) - assertTrue(hovered.isOpaque) - assertEquals(selected.background, hovered.background) - } - - fun `test timestamp normalization handles seconds and milliseconds`() { - val panel = panel() - - assertEquals(1_700_000_000_000L, panel.normalize(1_700_000_000.0)) - assertEquals(1_700_000_000_000L, panel.normalize(1_700_000_000_000.0)) - } - - fun `test timestamp renders coarse relative text`() { - val panel = panel() - val now = 1_700_000_000_000L - - assertEquals("Moments ago", panel.text(session("ses_1", now - 30_000), now)) - assertEquals("2 min ago", panel.text(session("ses_1", now - 120_000), now)) - assertEquals("3h ago", panel.text(session("ses_1", now - 10_800_000), now)) - assertEquals("4d ago", panel.text(session("ses_1", now - 345_600_000), now)) - } - - private fun panel( - limit: Int = 5, - open: (SessionDto) -> Unit = {}, - ) = SessionListPanel(limit, open) - - private fun session(id: String, updated: Long = 2_000L) = SessionDto( - id = id, - projectID = "prj", - directory = "/repo/$id", - title = "Title $id", - version = "1", - time = SessionTimeDto(created = 1.0, updated = updated.toDouble()), - ) -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt index 3f7acda9ed6..2ee8e5efc15 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt @@ -29,8 +29,16 @@ class HistoryLoadingTest : SessionControllerTestBase() { rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList())) val c = controller("ses_test") + val events = collect(c) flush() + assertControllerEvents(""" + AppChanged + WorkspaceChanged + ViewChanged progress + ViewChanged session + """, events) + assertSession( """ user#msg1 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt index 75800512232..e5d61e373fc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt @@ -24,7 +24,7 @@ class ListenerLifecycleTest : SessionControllerTestBase() { flush() assertControllerEvents(""" - ViewChanged show + ViewChanged session AppChanged WorkspaceChanged """, events) @@ -47,7 +47,7 @@ class ListenerLifecycleTest : SessionControllerTestBase() { assertEquals(events1, events2) assertControllerEvents(""" - ViewChanged show + ViewChanged session AppChanged WorkspaceChanged """, events1) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt index f89654996d5..36fe6220905 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt @@ -14,7 +14,7 @@ class SessionCreationTest : SessionControllerTestBase() { assertEquals(1, rpc.creates) assertEquals(1, rpc.prompts.size) assertEquals("ses_test", rpc.prompts[0].first) - assertControllerEvents("ViewChanged show", events) + assertControllerEvents("ViewChanged session", events) assertSession( """ [app: DISCONNECTED] [workspace: PENDING] diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt index 2edc4f7fbb9..1eff29033fb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt @@ -11,7 +11,7 @@ class ViewSwitchingTest : SessionControllerTestBase() { edt { m.prompt("hello") } flush() - assertControllerEvents("ViewChanged show", events) + assertControllerEvents("ViewChanged session", events) assertSession( """ [app: DISCONNECTED] [workspace: PENDING] @@ -31,6 +31,66 @@ class ViewSwitchingTest : SessionControllerTestBase() { edt { m.prompt("second") } flush() - assertControllerEvents("ViewChanged show", events) + assertControllerEvents("ViewChanged session", events) } + + fun `test recent sessions show after workspace ready`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller() + val events = collect(m) + + flush() + + assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) + assertControllerEvents(""" + AppChanged + WorkspaceChanged + WorkspaceReady + ViewChanged progress + ViewChanged recents=1 + """, events) + } + + fun `test recent load failure shows empty recents`() { + projectRpc.state.value = workspaceReady() + rpc.recentFailures = 1 + val m = controller() + val events = collect(m) + + flush() + + assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) + assertControllerEvents(""" + AppChanged + WorkspaceChanged + WorkspaceReady + ViewChanged progress + ViewChanged recents=0 + """, events) + } + + fun `test empty history transitions to recents`() { + rpc.recent.add(session("ses_1")) + val m = controller("ses_test") + val events = collect(m) + + flush() + + assertControllerEvents(""" + AppChanged + WorkspaceChanged + ViewChanged progress + ViewChanged recents=1 + """, events) + } + + private fun session(id: String) = ai.kilocode.rpc.dto.SessionDto( + id = id, + projectID = "prj", + directory = "/test", + title = "Title $id", + version = "1", + time = ai.kilocode.rpc.dto.SessionTimeDto(created = 1.0, updated = 2.0), + ) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt index 2aa2ce3f66c..0fd38ca981e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt @@ -17,6 +17,8 @@ class WorkspaceWatchingTest : SessionControllerTestBase() { assertEquals("gpt-5", m.model.models[0].id) assertFalse(m.model.isReady()) assertControllerEvents(""" + ViewChanged progress + ViewChanged recents=0 WorkspaceChanged WorkspaceReady """, events) From 87609f2f9e21cccfa97fb4eb360a68ded7a0005d Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Apr 2026 15:03:49 -0400 Subject: [PATCH 30/96] fix: avoid transient JetBrains session loading states --- .changeset/tidy-otters-wait.md | 5 + .../client/session/SessionSidePanelManager.kt | 10 +- .../ai/kilocode/client/session/SessionUi.kt | 46 ++++- .../client/session/SessionUiFactory.kt | 2 + .../client/session/model/SessionModel.kt | 2 +- .../client/session/ui/ConnectionPanel.kt | 82 ++------- .../client/session/update/DelayedState.kt | 63 +++++++ .../session/update/SessionController.kt | 167 ++++++++++++++++-- .../session/update/SessionControllerEvent.kt | 18 ++ .../resources/messages/KiloBundle.properties | 1 + .../session/SessionSidePanelManagerTest.kt | 37 +++- .../client/session/SessionUiFactoryTest.kt | 2 +- .../client/session/SessionUiLayoutTest.kt | 65 ++++++- .../client/session/ui/ConnectionPanelTest.kt | 65 +++---- .../session/update/ConnectionDelayTest.kt | 156 ++++++++++++++++ .../update/SessionControllerTestBase.kt | 32 +++- .../session/update/ViewSwitchingTest.kt | 79 ++++++++- .../session/update/WorkspaceWatchingTest.kt | 1 - .../client/testing/FakeSessionRpcApi.kt | 3 + 19 files changed, 677 insertions(+), 159 deletions(-) create mode 100644 .changeset/tidy-otters-wait.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt diff --git a/.changeset/tidy-otters-wait.md b/.changeset/tidy-otters-wait.md new file mode 100644 index 00000000000..151fcfe79af --- /dev/null +++ b/.changeset/tidy-otters-wait.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Avoid flickering transient loading and connection error states in JetBrains sessions. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt index e1c53a88d75..3c0e3f741e7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt @@ -14,8 +14,8 @@ import javax.swing.JPanel class SessionSidePanelManager( private val project: Project, private val root: Workspace, - private val create: (Project, Workspace, SessionManager, String?) -> SessionUi = { project, workspace, manager, id -> - service().create(project, workspace, manager, id) + private val create: (Project, Workspace, SessionManager, String?, Boolean) -> SessionUi = { project, workspace, manager, id, loading -> + service().create(project, workspace, manager, id, loading) }, private val resolve: (String) -> Workspace = { dir -> service().workspace(dir) }, ) : SessionManager, Disposable { @@ -31,12 +31,14 @@ class SessionSidePanelManager( private var current: SessionUi? = null override fun newSession() { - show(create(project, root, this, null)) + val active = current + if (active?.blank == true) return + show(create(project, root, this, null, active == null)) } override fun openSession(session: SessionDto) { val ui = opened.getOrPut(session.id) { - create(project, resolve(session.directory), this, session.id).also { + create(project, resolve(session.directory), this, session.id, false).also { all.add(it) } } 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 6dbca2ca4f0..3c1fa7cd473 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 @@ -39,16 +39,41 @@ import javax.swing.JPanel * It builds the session panels, wires controller/model listeners, and swaps the * center body between the empty state and the message list. */ -class SessionUi( +class SessionUi private constructor( project: Project, workspace: Workspace, sessions: KiloSessionService, app: KiloAppService, cs: CoroutineScope, - id: String? = null, - open: (SessionDto) -> Unit = {}, + id: String?, + displayMs: Long, + open: (SessionDto) -> Unit, + private val loading: Boolean, ) : JPanel(BorderLayout()), Disposable { + constructor( + project: Project, + workspace: Workspace, + sessions: KiloSessionService, + app: KiloAppService, + cs: CoroutineScope, + id: String? = null, + displayMs: Long = SessionController.DISPLAY_DELAY_MS, + open: (SessionDto) -> Unit = {}, + ) : this(project, workspace, sessions, app, cs, id, displayMs, open, id == null) + + internal constructor( + project: Project, + workspace: Workspace, + sessions: KiloSessionService, + app: KiloAppService, + cs: CoroutineScope, + id: String? = null, + displayMs: Long = SessionController.DISPLAY_DELAY_MS, + loading: Boolean, + open: (SessionDto) -> Unit = {}, + ) : this(project, workspace, sessions, app, cs, id, displayMs, open, loading) + companion object { private val LOG = KiloLog.create(SessionUi::class.java) } @@ -64,6 +89,7 @@ class SessionUi( this, id, sessions, workspace, app, cs, this, flushMs = flushMs, condense = Registry.`is`("kilo.session.condense", true), + displayMs = displayMs, open = open, ) @@ -72,6 +98,8 @@ class SessionUi( private lateinit var sessionContent: JPanel + private lateinit var blankBody: JPanel + private lateinit var progressBody: JPanel private lateinit var messageBody: SessionMessageListPanel @@ -87,14 +115,20 @@ class SessionUi( init { buildUi() bindUi() - showBody(progressBody) + showBody(if (loading) progressBody else blankBody) } + internal val blank: Boolean get() = controller.blank + private fun buildUi() { root = SessionRootPanel() sessionContent = JPanel(BorderLayout()) + blankBody = JPanel(BorderLayout()).apply { + isOpaque = false + } + progressBody = JPanel(BorderLayout()).apply { isOpaque = false add(Centerizer( @@ -104,7 +138,7 @@ class SessionUi( } messageBody = SessionMessageListPanel(controller.model, this) - scroll = JBScrollPane(progressBody).apply { + scroll = JBScrollPane(blankBody).apply { border = JBUI.Borders.empty() verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER @@ -181,6 +215,8 @@ class SessionUi( is SessionControllerEvent.WorkspaceChanged -> { prompt.setReady(controller.model.isReady()) } + + is SessionControllerEvent.ConnectionChanged -> Unit } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt index 6707edf97a7..c55e607d926 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt @@ -19,6 +19,7 @@ class SessionUiFactory( workspace: Workspace, manager: SessionManager, id: String? = null, + loading: Boolean = id == null, ): SessionUi = SessionUi( project = project, workspace = workspace, @@ -26,6 +27,7 @@ class SessionUiFactory( app = service(), cs = scope(), id = id, + loading = loading, open = manager::openSession, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 0c93461c5e8..93a1560a676 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -46,7 +46,7 @@ class SessionModel { var models: List = emptyList() var agent: String? = null var model: String? = null - var showMessages: Boolean = false + var showSession: Boolean = false var state: SessionState = SessionState.Idle private set diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index 794c593d730..c9e866887fc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -4,10 +4,6 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.client.session.update.SessionControllerListener -import ai.kilocode.rpc.dto.ConfigWarningDto -import ai.kilocode.rpc.dto.LoadErrorDto -import ai.kilocode.rpc.dto.KiloAppStatusDto -import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer @@ -99,48 +95,30 @@ class ConnectionPanel( header.add(retry, BorderLayout.EAST) add(header, BorderLayout.NORTH) controller.addListener(this, this) - render() + hidePanel() } override fun onEvent(event: SessionControllerEvent) { when (event) { - is SessionControllerEvent.AppChanged, - is SessionControllerEvent.WorkspaceChanged -> render() + is SessionControllerEvent.ConnectionChanged.Hide -> hidePanel() + + is SessionControllerEvent.ConnectionChanged.ShowConnecting -> showConnecting() + + is SessionControllerEvent.ConnectionChanged.ShowError -> { + showError(event.summary, event.detail) + showPanel() + } + + is SessionControllerEvent.ConnectionChanged.ShowWarning -> { + showWarning(event.summary, event.detail) + showPanel() + } else -> Unit } } - private fun render() { - val app = controller.model.app - val workspace = controller.model.workspace - - if (app.status == KiloAppStatusDto.ERROR) { - showError( - app.error ?: KiloBundle.message("session.connection.error.unknown"), - app.errors.toErrorText(), - ) - showPanel() - return - } - - if (workspace.status == KiloWorkspaceStatusDto.ERROR) { - showError(workspace.error ?: KiloBundle.message("session.connection.error.unknown"), null) - showPanel() - return - } - - if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY && app.warnings.isNotEmpty()) { - showWarning(summary(app.warnings.size), app.warnings.toWarningText()) - showPanel() - return - } - - if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY) { - hidePanel() - return - } - + private fun showConnecting() { label.foreground = UIUtil.getContextHelpForeground() label.text = KiloBundle.message("session.connection.connecting") detail = null @@ -171,12 +149,6 @@ class ConnectionPanel( renderDetails() } - private fun summary(count: Int): String { - val base = KiloBundle.message("session.connection.warning.config") - if (count <= 1) return base - return "$base ($count)" - } - private fun renderDetails() { val text = detail val show = expanded && text != null @@ -272,27 +244,3 @@ class ConnectionPanel( internal fun maxExpandedHeight() = header.preferredSize.height + details.getFontMetrics(details.font).height * DETAILS_LINES + scrollChrome() } - -private fun List.toErrorText(): String? { - val out = mapNotNull { it.toDetailLine() } - if (out.isEmpty()) return null - return out.joinToString("\n") -} - -private fun List.toWarningText(): String? { - val out = mapNotNull { it.toDetailLine() } - if (out.isEmpty()) return null - return out.joinToString("\n\n") -} - -private fun LoadErrorDto.toDetailLine(): String? { - val detail = detail?.trim()?.ifEmpty { null } ?: return null - if (resource == "connection") return detail - return "$resource: $detail" -} - -private fun ConfigWarningDto.toDetailLine(): String { - val head = "$path: $message" - val tail = detail?.trim()?.ifEmpty { null } ?: return head - return "$head\n$tail" -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt new file mode 100644 index 00000000000..99647496ffa --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt @@ -0,0 +1,63 @@ +package ai.kilocode.client.session.update + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import javax.swing.Timer + +internal class DelayedState( + private val ms: Long, + private val current: () -> T, +) : Disposable { + private var timer: Timer? = null + private var state: T? = null + @Volatile private var alive = true + + fun run(state: T, action: (T) -> Unit) { + edt { + if (!alive) return@edt + timer?.stop() + this.state = state + if (ms <= 0) { + apply(state, action) + return@edt + } + timer = Timer(ms.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()) { + apply(state, action) + }.apply { + isRepeats = false + start() + } + } + } + + fun cancel() { + edt { + timer?.stop() + timer = null + state = null + } + } + + private fun apply(state: T, action: (T) -> Unit) { + if (!alive) return + if (this.state != state) return + if (current() != state) return + this.state = null + timer = null + action(state) + } + + private fun edt(block: () -> Unit) { + val app = ApplicationManager.getApplication() + if (app.isDispatchThread) { + block() + return + } + app.invokeLater(block) + } + + override fun dispose() { + alive = false + cancel() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index fa30730c989..58950e52757 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -17,9 +17,11 @@ import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto @@ -60,12 +62,14 @@ class SessionController( comp: Component? = null, private val flushMs: Long = EVENT_FLUSH_MS, private val condense: Boolean = true, + private val displayMs: Long = DISPLAY_DELAY_MS, private val open: (SessionDto) -> Unit = {}, ) : Disposable { companion object { private val LOG = KiloLog.create(SessionController::class.java) internal const val RECENT_LIMIT = 5 + internal const val DISPLAY_DELAY_MS = 1_000L } init { @@ -89,33 +93,43 @@ class SessionController( private var partType: String? = null private var tool: String? = null private var eventJob: Job? = null - private var recentsLoading = false - private var recentsLoaded = false + private var history: HistoryState = HistoryState.Idle + private var recents: RecentsState = RecentsState.Idle private var view: SessionControllerEvent.ViewChanged? = null + private var conn: SessionControllerEvent.ConnectionChanged? = null + private val connDelay = DelayedState(displayMs, ::connectionState) + private val historyDelay = DelayedState(displayMs) { history } + private val recentsDelay = DelayedState(displayMs) { recents } val ready: Boolean get() = model.isReady() + internal val blank: Boolean get() = sessionId == null && model.isEmpty() && !model.showSession fun refreshRecents(force: Boolean = false) { - if (model.showMessages) return - if (recentsLoading) return - if (recentsLoaded && !force) return - recentsLoading = true - view(SessionControllerEvent.ViewChanged.ShowProgress) + if (model.showSession) return + if (recents is RecentsState.Loading) return + if (recents is RecentsState.Loaded && !force) return + val state = RecentsState.Loading() + recents = state + recentsDelay.run(state) { + view(SessionControllerEvent.ViewChanged.ShowProgress) + } cs.launch { try { val items = sessions.recent(directory, RECENT_LIMIT) edt { - recentsLoading = false - recentsLoaded = true - if (model.showMessages) return@edt + if (recents != state) return@edt + recentsDelay.cancel() + recents = RecentsState.Loaded + if (model.showSession) return@edt view(SessionControllerEvent.ViewChanged.ShowRecents(items)) } } catch (e: Exception) { LOG.warn("kind=session-recent dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) edt { - recentsLoading = false - recentsLoaded = true - if (model.showMessages) return@edt + if (recents != state) return@edt + recentsDelay.cancel() + recents = RecentsState.Loaded + if (model.showSession) return@edt view(SessionControllerEvent.ViewChanged.ShowRecents(emptyList())) } } @@ -273,6 +287,7 @@ class SessionController( fire(SessionControllerEvent.AppChanged) { model.app = state model.version = app.version + syncConnection() } } } @@ -281,6 +296,7 @@ class SessionController( workspace.state.collect { state -> fire(SessionControllerEvent.WorkspaceChanged) { model.workspace = state + syncConnection() if (state.status != KiloWorkspaceStatusDto.READY) return@fire @@ -317,7 +333,13 @@ class SessionController( private fun loadHistory() { val id = sessionId ?: return cs.launch { - view(SessionControllerEvent.ViewChanged.ShowProgress) + val state = HistoryState.Loading() + runEdt { + history = state + } + historyDelay.run(state) { + if (!model.showSession) view(SessionControllerEvent.ViewChanged.ShowProgress) + } try { val history = sessions.messages(id, directory) LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(history)}" } @@ -336,6 +358,11 @@ class SessionController( LOG.warn("${ChatLogSummary.sid(id)} kind=history dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) edt { refreshRecents(force = true) } } finally { + edt { + if (history != state) return@edt + historyDelay.cancel() + history = HistoryState.Idle + } updates.holdFlush(false) updates.requestFlush(true) } @@ -548,8 +575,12 @@ class SessionController( } private fun showMessages() { - if (!model.showMessages) { - model.showMessages = true + if (!model.showSession) { + model.showSession = true + historyDelay.cancel() + history = HistoryState.Idle + recentsDelay.cancel() + recents = RecentsState.Idle view(SessionControllerEvent.ViewChanged.ShowSession) } } @@ -560,6 +591,66 @@ class SessionController( fire(event) } + private fun connection(event: SessionControllerEvent.ConnectionChanged) { + if (event is SessionControllerEvent.ConnectionChanged.Hide || event is SessionControllerEvent.ConnectionChanged.ShowWarning) { + connDelay.cancel() + showConnection(event) + return + } + if (conn == event) { + connDelay.cancel() + return + } + connDelay.run(event, ::showConnection) + } + + private fun showConnection(event: SessionControllerEvent.ConnectionChanged) { + if (conn == event) return + if (conn == null && event is SessionControllerEvent.ConnectionChanged.Hide) { + conn = event + return + } + conn = event + fire(event) + } + + private fun syncConnection() { + connection(connectionState()) + } + + private fun connectionState(): SessionControllerEvent.ConnectionChanged { + val app = model.app + val workspace = model.workspace + + if (app.status == KiloAppStatusDto.ERROR) { + return SessionControllerEvent.ConnectionChanged.ShowError( + app.error ?: KiloBundle.message("session.connection.error.unknown"), + app.errors.toErrorText(), + ) + } + + if (workspace.status == KiloWorkspaceStatusDto.ERROR) { + return SessionControllerEvent.ConnectionChanged.ShowError( + workspace.error ?: KiloBundle.message("session.connection.error.unknown"), + null, + "workspace", + ) + } + + if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY && app.warnings.isNotEmpty()) { + return SessionControllerEvent.ConnectionChanged.ShowWarning( + summary(app.warnings.size), + app.warnings.toWarningText(), + ) + } + + if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY) { + return SessionControllerEvent.ConnectionChanged.Hide + } + + return SessionControllerEvent.ConnectionChanged.ShowConnecting + } + private fun status(): String = when (partType) { "reasoning" -> KiloBundle.message("session.status.thinking") "text" -> KiloBundle.message("session.status.writing") @@ -604,6 +695,9 @@ class SessionController( } override fun dispose() { + connDelay.dispose() + historyDelay.dispose() + recentsDelay.dispose() eventJob?.cancel() cs.cancel() } @@ -676,6 +770,47 @@ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (eve is ChatEventDto.TodoUpdated -> event.sessionID == id } +private fun summary(count: Int): String { + val base = KiloBundle.message("session.connection.warning.config") + if (count <= 1) return base + return "$base ($count)" +} + +private sealed interface RecentsState { + data object Idle : RecentsState + data class Loading(val id: Any = Any()) : RecentsState + data object Loaded : RecentsState +} + +private sealed interface HistoryState { + data object Idle : HistoryState + data class Loading(val id: Any = Any()) : HistoryState +} + +private fun List.toErrorText(): String? { + val out = mapNotNull { it.toDetailLine() } + if (out.isEmpty()) return null + return out.joinToString("\n") +} + +private fun List.toWarningText(): String? { + val out = mapNotNull { it.toDetailLine() } + if (out.isEmpty()) return null + return out.joinToString("\n\n") +} + +private fun LoadErrorDto.toDetailLine(): String? { + val detail = detail?.trim()?.ifEmpty { null } ?: return null + if (resource == "connection") return detail + return "$resource: $detail" +} + +private fun ConfigWarningDto.toDetailLine(): String { + val head = "$path: $message" + val tail = detail?.trim()?.ifEmpty { null } ?: return head + return "$head\n$tail" +} + private fun toPermission(dto: PermissionRequestDto): Permission { val ref = dto.tool?.let { ToolCallRef(it.messageID, it.callID) } val file = dto.metadata["file"] ?: dto.metadata["path"] diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt index c11ff6ec532..121af83b5d8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt @@ -33,6 +33,24 @@ sealed class SessionControllerEvent { override fun toString() = "ViewChanged session" } } + + sealed class ConnectionChanged : SessionControllerEvent() { + data object Hide : ConnectionChanged() { + override fun toString() = "ConnectionChanged hide" + } + + data object ShowConnecting : ConnectionChanged() { + override fun toString() = "ConnectionChanged connecting" + } + + data class ShowError(val summary: String, val detail: String?, val source: String = "app") : ConnectionChanged() { + override fun toString() = "ConnectionChanged error $source" + } + + data class ShowWarning(val summary: String, val detail: String?) : ConnectionChanged() { + override fun toString() = "ConnectionChanged warning" + } + } } /** 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 fc5b4f0c406..3253e1da3a4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -4,6 +4,7 @@ session.connection.retry=Retry session.connection.warning.config=Configuration warnings session.empty.welcome=Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase. +session.empty.loading=Loading... session.empty.recent=RECENT session.empty.time.moments=Moments ago session.empty.time.minutes={0} min ago diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt index 5de11cff2c2..9e017aec555 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -30,6 +30,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { private lateinit var app: KiloAppService private val managers = mutableListOf() private val created = mutableListOf>() + private val loading = mutableListOf() override fun setUp() { super.setUp() @@ -65,11 +66,27 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { manager.newSession() val first = active(manager) + first.controller().prompt("hello") + settle() manager.newSession() val second = active(manager) assertNotSame(first, second) assertEquals(listOf("/test" to null, "/test" to null), created) + assertEquals(listOf(true, false), loading) + } + + fun `test new session on blank session keeps active component`() { + val manager = manager() + + manager.newSession() + val first = active(manager) + manager.newSession() + val second = active(manager) + + assertSame(first, second) + assertEquals(listOf("/test" to null), created) + assertEquals(listOf(true), loading) } fun `test opening same existing session reuses component`() { @@ -84,6 +101,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { assertSame(first, second) assertEquals(listOf("/test" to "ses_1", "/test" to null), created) + assertEquals(listOf(false, false), loading) } fun `test open session resolves historical workspace`() { @@ -92,6 +110,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { manager.openSession(session("ses_1", "/repo")) assertEquals(listOf("/repo" to "ses_1"), created) + assertEquals(listOf(false), loading) } fun `test dispose removes active component`() { @@ -108,9 +127,10 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { val manager = SessionSidePanelManager( project = project, root = workspace, - create = { project, workspace, owner, id -> + create = { project, workspace, owner, id, show -> created.add(workspace.directory to id) - SessionUi(project, workspace, sessions, app, scope, id = id, open = owner::openSession) + loading.add(show) + SessionUi(project, workspace, sessions, app, scope, id = id, loading = show, open = owner::openSession) }, resolve = { workspaces.workspace(it) }, ) @@ -120,6 +140,19 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { private fun active(manager: SessionSidePanelManager) = manager.component.getComponent(0) as JPanel + private fun JPanel.controller(): ai.kilocode.client.session.update.SessionController { + val field = SessionUi::class.java.getDeclaredField("controller") + field.isAccessible = true + return field.get(this) as ai.kilocode.client.session.update.SessionController + } + + private fun settle() = kotlinx.coroutines.runBlocking { + repeat(5) { + kotlinx.coroutines.delay(100) + com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() + } + } + private fun session(id: String) = session(id, "/test") private fun session(id: String, dir: String) = SessionDto( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt index 4f7287c3161..bdb66f78bdd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt @@ -48,7 +48,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { } fun `test factory creates blank session ui`() { - val ui = direct().create(project, workspace, FakeManager()) + val ui = direct().create(project, workspace, FakeManager(), null, true) assertNotNull(ui) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 6b18b94f604..3c4888d93ee 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -68,7 +68,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { workspaces = KiloWorkspaceService(scope, workspaceRpc) workspace = workspaces.workspace("/test") - ui = SessionUi(project, workspace, sessions, app, scope).apply { + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 0).apply { setSize(800, 600) } layout() @@ -173,10 +173,27 @@ class SessionUiLayoutTest : BasePlatformTestCase() { assertSame(find(ui), scroll.viewport.view) } + fun `test new session starts with loading body`() { + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 1_000).apply { + setSize(800, 600) + } + + assertFalse(find(ui).viewport.view is EmptySessionPanel) + } + + fun `test action-created new session starts blank`() { + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 1_000, loading = false).apply { + setSize(800, 600) + } + + assertFalse(find(ui).viewport.view is EmptySessionPanel) + assertFalse(find(ui).viewport.view is SessionMessageListPanel) + } + fun `test clicking recent session calls opener`() { val opened = mutableListOf() rpc.recent.add(session("ses_1")) - ui = SessionUi(project, workspace, sessions, app, scope, open = { opened.add(it.id) }).apply { + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 0, open = { opened.add(it.id) }).apply { setSize(800, 600) } @@ -190,7 +207,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { fun `test existing session id loads history and shows message body`() { rpc.history.add(MessageWithPartsDto(message("msg1"), emptyList())) - ui = SessionUi(project, workspace, sessions, app, scope, id = "ses_test").apply { + ui = SessionUi(project, workspace, sessions, app, scope, id = "ses_test", displayMs = 0).apply { setSize(800, 600) } settle() @@ -198,6 +215,38 @@ class SessionUiLayoutTest : BasePlatformTestCase() { assertSame(find(ui), find(ui).viewport.view) } + fun `test new session keeps loading body before recents delay`() { + rpc.recentGate = kotlinx.coroutines.CompletableDeferred() + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 1_000).apply { + setSize(800, 600) + } + + settleShort(100) + + assertFalse(find(ui).viewport.view is EmptySessionPanel) + } + + fun `test slow recents switch to loading body only after progress event`() { + rpc.recentGate = kotlinx.coroutines.CompletableDeferred() + rpc.recent.add(session("ses_1")) + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 50).apply { + setSize(800, 600) + } + + settleShort(20) + assertFalse(find(ui).viewport.view is EmptySessionPanel) + + settleShort(80) + assertFalse(find(ui).viewport.view is EmptySessionPanel) + + rpc.recentGate!!.complete(Unit) + settle() + + val panel = find(ui) + assertSame(panel, find(ui).viewport.view) + assertEquals(1, panel.recentCount()) + } + private fun layout() { ui.doLayout() val root = find(ui) @@ -213,11 +262,13 @@ class SessionUiLayoutTest : BasePlatformTestCase() { } } + private fun settleShort(ms: Long) = runBlocking { + delay(ms) + com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() + } + private fun showConnection() { - val m = controller().model - m.app = KiloAppStateDto(KiloAppStatusDto.CONNECTING) - m.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) - find(ui).onEvent(SessionControllerEvent.AppChanged) + find(ui).onEvent(SessionControllerEvent.ConnectionChanged.ShowConnecting) } private inline fun find(root: java.awt.Container): T { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt index 39f8a0dbaf6..620e8fce34c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -1,13 +1,11 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.client.session.update.SessionControllerTestBase import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto -import ai.kilocode.rpc.dto.KiloWorkspaceStateDto -import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto -import ai.kilocode.rpc.dto.LoadErrorDto import java.awt.Dimension @Suppress("UnstableApiUsage") @@ -25,9 +23,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { fun `test loading hides retry and details`() { edt { - controller.model.app = KiloAppStateDto(KiloAppStatusDto.CONNECTING) - controller.model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) - panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowConnecting) } assertTrue(panel.isVisible) @@ -40,15 +36,10 @@ class ConnectionPanelTest : SessionControllerTestBase() { fun `test app error starts collapsed and expands details`() { edt { - controller.model.app = KiloAppStateDto( - status = KiloAppStatusDto.ERROR, - error = "CLI startup failed", - errors = listOf( - LoadErrorDto(resource = "connection", detail = "stderr line"), - LoadErrorDto(resource = "config", detail = "HTTP 500: broken"), - ), - ) - panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowError( + "CLI startup failed", + "stderr line\nconfig: HTTP 500: broken", + )) } assertTrue(panel.isVisible) @@ -73,12 +64,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { fun `test workspace error shows retry without details`() { edt { - controller.model.app = KiloAppStateDto(KiloAppStatusDto.READY) - controller.model.workspace = KiloWorkspaceStateDto( - status = KiloWorkspaceStatusDto.ERROR, - error = "Workspace failed", - ) - panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.WorkspaceChanged) + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowError("Workspace failed", null)) } assertTrue(panel.isVisible) @@ -95,7 +81,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { status = KiloAppStatusDto.ERROR, error = "CLI startup failed", ) - panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowError("CLI startup failed", null)) } edt { panel.clickRetry() } flush() @@ -105,18 +91,10 @@ class ConnectionPanelTest : SessionControllerTestBase() { fun `test ready warnings show collapsed banner with retry`() { edt { - controller.model.app = KiloAppStateDto( - status = KiloAppStatusDto.READY, - warnings = listOf( - ConfigWarningDto( - path = ".kilo/kilo.json", - message = "Invalid JSON", - detail = "CloseBraceExpected at line 11, column 1", - ) - ), - ) - controller.model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) - panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowWarning( + "Configuration warnings", + ".kilo/kilo.json: Invalid JSON\nCloseBraceExpected at line 11, column 1", + )) } assertTrue(panel.isVisible) @@ -143,8 +121,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { status = KiloAppStatusDto.READY, warnings = listOf(ConfigWarningDto(path = ".kilo/kilo.json", message = "Invalid JSON")), ) - controller.model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) - panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowWarning("Configuration warnings", null)) } edt { panel.clickRetry() } flush() @@ -154,12 +131,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { fun `test expanded details height is capped at ten lines`() { edt { - controller.model.app = KiloAppStateDto( - status = KiloAppStatusDto.ERROR, - error = "CLI startup failed", - errors = listOf(LoadErrorDto(resource = "connection", detail = lines(30))), - ) - panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowError("CLI startup failed", lines(30))) panel.size = Dimension(480, 1000) } @@ -169,5 +141,14 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertTrue(panel.preferredSize.height <= panel.maxExpandedHeight()) } + fun `test raw app and workspace events do not render panel`() { + edt { + panel.onEvent(SessionControllerEvent.AppChanged) + panel.onEvent(SessionControllerEvent.WorkspaceChanged) + } + + assertFalse(panel.isVisible) + } + private fun lines(count: Int) = (1..count).joinToString("\n") { "line $it" } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt new file mode 100644 index 00000000000..db8902a06b6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt @@ -0,0 +1,156 @@ +package ai.kilocode.client.session.update + +import ai.kilocode.rpc.dto.ConfigWarningDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.LoadErrorDto + +class ConnectionDelayTest : SessionControllerTestBase() { + + fun `test short connecting state does not fire connection banner`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 100) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(25) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + pause(150) + + assertFalse(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) + } + + fun `test persistent connecting state fires connection banner after delay`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(20) + assertFalse(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) + + pause(80) + + assertEquals(1, events.count { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) + } + + fun `test short app error is suppressed`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 100) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR, error = "boom") + pause(25) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + pause(150) + + assertFalse(events.any { it is SessionControllerEvent.ConnectionChanged.ShowError }) + } + + fun `test persistent app error fires latest error after delay`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto( + status = KiloAppStatusDto.ERROR, + error = "CLI startup failed", + errors = listOf( + LoadErrorDto(resource = "connection", detail = "stderr line"), + LoadErrorDto(resource = "config", detail = "HTTP 500"), + ), + ) + pause(80) + + val event = events.filterIsInstance().single() + assertEquals("CLI startup failed", event.summary) + assertEquals("stderr line\nconfig: HTTP 500", event.detail) + } + + fun `test changed error restarts delay and shows latest state`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 100) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR, error = "first") + pause(50) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR, error = "second") + pause(150) + + val errors = events.filterIsInstance() + assertEquals(listOf("second"), errors.map { it.summary }) + } + + fun `test persistent workspace error is delayed`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + projectRpc.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.ERROR, error = "workspace failed") + pause(20) + assertFalse(events.any { it is SessionControllerEvent.ConnectionChanged.ShowError }) + + pause(80) + + val event = events.filterIsInstance().single() + assertEquals("workspace failed", event.summary) + assertNull(event.detail) + } + + fun `test ready hides visible delayed connection banner immediately`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(80) + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + pause(10) + + assertTrue(events.any { it is SessionControllerEvent.ConnectionChanged.Hide }) + } + + fun `test config warning remains immediate`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 1_000) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto( + status = KiloAppStatusDto.READY, + warnings = listOf(ConfigWarningDto(path = ".kilo/kilo.json", message = "Invalid JSON")), + ) + pause(10) + + val event = events.filterIsInstance().single() + assertEquals("Configuration warnings", event.summary) + assertEquals(".kilo/kilo.json: Invalid JSON", event.detail) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt index 0ed6891bf8f..b6c136b9531 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt @@ -10,8 +10,6 @@ import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace -import ai.kilocode.client.session.update.SessionController -import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.AgentsDto import ai.kilocode.rpc.dto.ChatEventDto @@ -122,13 +120,20 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { // ------ Controller creation ------ - protected fun controller(id: String? = null) = controller(id, Long.MAX_VALUE) - - protected fun controller(id: String? = null, flushMs: Long): SessionController { - return controller(id, flushMs, true) + protected fun controller( + id: String? = null, + flushMs: Long = Long.MAX_VALUE, + displayMs: Long = Long.MAX_VALUE, + ): SessionController { + return controller(id, flushMs, true, displayMs) } - protected fun controller(id: String? = null, flushMs: Long, condense: Boolean): SessionController { + protected fun controller( + id: String? = null, + flushMs: Long, + condense: Boolean, + displayMs: Long = Long.MAX_VALUE, + ): SessionController { val root = Root() val m = SessionController( parent, @@ -139,7 +144,8 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { scope, root, flushMs, - condense + condense, + displayMs ) controllers.add(m) roots[m] = root @@ -198,6 +204,14 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { } } + protected fun pause(ms: Long) = runBlocking { + val tick = 10L + repeat((ms / tick).coerceAtLeast(1).toInt()) { + delay(tick) + edt { UIUtil.dispatchAllInvocationEvents() } + } + } + protected fun edt(block: () -> Unit) { ApplicationManager.getApplication().invokeAndWait(block) } @@ -231,7 +245,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { protected fun assertSession(expected: String, c: SessionController, show: Boolean = true) { assertEquals(expected.trimIndent().trim(), c.toString().trim()) - assertEquals(show, c.model.showMessages) + assertEquals(show, c.model.showSession) } protected fun assertControllerEvents(expected: String, events: List) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt index 1eff29033fb..1b8b2684a81 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt @@ -1,5 +1,7 @@ package ai.kilocode.client.session.update +import kotlinx.coroutines.CompletableDeferred + class ViewSwitchingTest : SessionControllerTestBase() { fun `test first prompt shows messages view`() { @@ -47,7 +49,6 @@ class ViewSwitchingTest : SessionControllerTestBase() { AppChanged WorkspaceChanged WorkspaceReady - ViewChanged progress ViewChanged recents=1 """, events) } @@ -65,14 +66,13 @@ class ViewSwitchingTest : SessionControllerTestBase() { AppChanged WorkspaceChanged WorkspaceReady - ViewChanged progress ViewChanged recents=0 """, events) } fun `test empty history transitions to recents`() { rpc.recent.add(session("ses_1")) - val m = controller("ses_test") + val m = controller("ses_test", displayMs = 1_000) val events = collect(m) flush() @@ -80,11 +80,82 @@ class ViewSwitchingTest : SessionControllerTestBase() { assertControllerEvents(""" AppChanged WorkspaceChanged - ViewChanged progress ViewChanged recents=1 """, events) } + fun `test slow recents show progress after delay then recents`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val gate = CompletableDeferred() + rpc.recentGate = gate + val m = controller(displayMs = 50) + val events = collect(m) + + pause(20) + assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + + pause(80) + assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + + gate.complete(Unit) + flush() + + assertEquals( + """ + ViewChanged progress + ViewChanged recents=1 + """.trimIndent().trim(), + events.filterIsInstance().joinToString("\n"), + ) + } + + fun `test fast recents suppress progress`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller(displayMs = 1_000) + val events = collect(m) + + flush() + + assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) + } + + fun `test failed fast recents suppress progress and show empty recents`() { + projectRpc.state.value = workspaceReady() + rpc.recentFailures = 1 + val m = controller(displayMs = 1_000) + val events = collect(m) + + flush() + + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + assertEquals(1, events.count { it is SessionControllerEvent.ViewChanged.ShowRecents }) + assertTrue(events.filterIsInstance().single().recents.isEmpty()) + } + + fun `test recents progress is canceled when messages view appears`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val gate = CompletableDeferred() + rpc.recentGate = gate + val m = controller(displayMs = 50) + val events = collect(m) + + pause(20) + edt { m.prompt("hello") } + pause(80) + gate.complete(Unit) + flush() + + assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowSession }) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) + } + private fun session(id: String) = ai.kilocode.rpc.dto.SessionDto( id = id, projectID = "prj", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt index 0fd38ca981e..cf711158cd1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt @@ -17,7 +17,6 @@ class WorkspaceWatchingTest : SessionControllerTestBase() { assertEquals("gpt-5", m.model.models[0].id) assertFalse(m.model.isReady()) assertControllerEvents(""" - ViewChanged progress ViewChanged recents=0 WorkspaceChanged WorkspaceReady diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index 90a39cde34c..8759f4d913e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -14,6 +14,7 @@ import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionTimeDto +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -45,6 +46,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { /** Recent sessions returned by [recent]. */ val recent = mutableListOf() var recentFailures = 0 + var recentGate: CompletableDeferred? = null /** Push chat events here; tests collect from [events]. */ val events = MutableSharedFlow(extraBufferCapacity = 64, replay = 64) @@ -90,6 +92,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { override suspend fun recent(directory: String, limit: Int): SessionListDto { assertNotEdt("recent") recentCalls.add(directory to limit) + recentGate?.await() if (recentFailures > 0) { recentFailures-- throw IllegalStateException("recent unavailable") From 37176027a94f74a890d9bcdabc041a7426f6b584 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Apr 2026 16:18:54 -0400 Subject: [PATCH 31/96] refactor: consolidate JetBrains session delayed state --- .../client/session/update/DelayedState.kt | 69 +++-- .../session/update/SessionController.kt | 256 +++++++++++------- .../session/SessionSidePanelManagerTest.kt | 4 +- .../client/session/SessionUiFactoryTest.kt | 4 +- .../client/session/SessionUiLayoutTest.kt | 4 +- .../client/session/update/AppWatchingTest.kt | 24 ++ .../session/update/ConnectionDelayTest.kt | 69 +++++ .../client/session/update/DelayedStateTest.kt | 126 +++++++++ .../session/update/HistoryLoadingTest.kt | 1 - .../update/SessionControllerTestBase.kt | 18 +- .../session/update/ViewSwitchingTest.kt | 45 +++ 11 files changed, 491 insertions(+), 129 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt index 99647496ffa..9dee0ea2acb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt @@ -4,47 +4,56 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import javax.swing.Timer -internal class DelayedState( +internal class DelayedState( private val ms: Long, - private val current: () -> T, ) : Disposable { - private var timer: Timer? = null - private var state: T? = null + private val tick = when { + ms <= 0 -> 1 + ms > Int.MAX_VALUE -> Int.MAX_VALUE + else -> ms.coerceAtMost(TICK_MS).toInt() + } + private val timer = Timer(tick) { flush() } + private val pending = mutableListOf>() @Volatile private var alive = true - fun run(state: T, action: (T) -> Unit) { + fun run(state: T, current: () -> T, action: (T) -> Unit) { edt { if (!alive) return@edt - timer?.stop() - this.state = state + val next = Pending(state, due(), current, action) + pending.add(next) if (ms <= 0) { - apply(state, action) + apply(next) return@edt } - timer = Timer(ms.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()) { - apply(state, action) - }.apply { - isRepeats = false - start() - } + timer.start() } } fun cancel() { edt { - timer?.stop() - timer = null - state = null + pending.clear() } } - private fun apply(state: T, action: (T) -> Unit) { + private fun apply(item: Pending) { if (!alive) return - if (this.state != state) return - if (current() != state) return - this.state = null - timer = null - action(state) + if (!pending.remove(item)) return + if (item.current() != item.state) return + item.action(item.state) + } + + private fun flush() { + if (!alive) return + val now = System.currentTimeMillis() + for (item in pending.toList()) { + if (item.due > now) continue + apply(item) + } + } + + private fun due(): Long { + val now = System.currentTimeMillis() + return now + ms.coerceAtMost(Long.MAX_VALUE - now) } private fun edt(block: () -> Unit) { @@ -59,5 +68,19 @@ internal class DelayedState( override fun dispose() { alive = false cancel() + edt { + timer.stop() + } + } + + private data class Pending( + val state: T, + val due: Long, + val current: () -> T, + val action: (T) -> Unit, + ) + + private companion object { + const val TICK_MS = 25L } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index 58950e52757..a8349a7c7cd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -93,50 +93,18 @@ class SessionController( private var partType: String? = null private var tool: String? = null private var eventJob: Job? = null - private var history: HistoryState = HistoryState.Idle - private var recents: RecentsState = RecentsState.Idle - private var view: SessionControllerEvent.ViewChanged? = null - private var conn: SessionControllerEvent.ConnectionChanged? = null - private val connDelay = DelayedState(displayMs, ::connectionState) - private val historyDelay = DelayedState(displayMs) { history } - private val recentsDelay = DelayedState(displayMs) { recents } + private var historyState: HistoryState = HistoryState.Idle + private var recentsState: RecentsState = RecentsState.Idle + private var viewState: SessionControllerEvent.ViewChanged? = null + private var connectionState: SessionControllerEvent.ConnectionChanged? = null + private var connectionTargetState: SessionControllerEvent.ConnectionChanged? = null + private val delayedState = DelayedState(displayMs) val ready: Boolean get() = model.isReady() internal val blank: Boolean get() = sessionId == null && model.isEmpty() && !model.showSession - fun refreshRecents(force: Boolean = false) { - if (model.showSession) return - if (recents is RecentsState.Loading) return - if (recents is RecentsState.Loaded && !force) return - val state = RecentsState.Loading() - recents = state - recentsDelay.run(state) { - view(SessionControllerEvent.ViewChanged.ShowProgress) - } - cs.launch { - try { - val items = sessions.recent(directory, RECENT_LIMIT) - edt { - if (recents != state) return@edt - recentsDelay.cancel() - recents = RecentsState.Loaded - if (model.showSession) return@edt - view(SessionControllerEvent.ViewChanged.ShowRecents(items)) - } - } catch (e: Exception) { - LOG.warn("kind=session-recent dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) - edt { - if (recents != state) return@edt - recentsDelay.cancel() - recents = RecentsState.Loaded - if (model.showSession) return@edt - view(SessionControllerEvent.ViewChanged.ShowRecents(emptyList())) - } - } - } - } - fun openSession(session: SessionDto) { + assertEdt() open(session) } @@ -145,9 +113,25 @@ class SessionController( Disposer.register(parent) { listeners.remove(listener) } } - internal fun flushEvents() = updates.requestFlush(true) + internal fun snapshotState(): ControllerStateSnapshot { + assertEdt() + return ControllerStateSnapshot( + showSession = model.showSession, + viewState = viewState, + connectionState = connectionState, + connectionTargetState = connectionTargetState, + historyState = historyState.toString(), + recentsState = recentsState.toString(), + ) + } + + internal fun flushEvents() { + assertEdt() + updates.requestFlush(true) + } fun prompt(text: String) { + assertEdt() val sid = sessionId ?: "pending" LOG.debug { "${ChatLogSummary.sid(sid)} ${ChatLogSummary.prompt(text)} ${ChatLogSummary.dir(directory)}" } showMessages() @@ -155,7 +139,9 @@ class SessionController( try { val id = sessionId ?: run { val session = sessions.create(directory) - sessionId = session.id + runEdt { + sessionId = session.id + } val meta = if (LOG.isDebugEnabled) ChatLogSummary.dir(directory) else "kind=session" LOG.info("${ChatLogSummary.sid(session.id)} kind=session $meta created=true") subscribeEvents() @@ -174,6 +160,7 @@ class SessionController( } fun abort() { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=abort" } val id = sessionId ?: return cs.launch { @@ -187,9 +174,12 @@ class SessionController( } fun retryConnection() { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=connection-retry app=${model.app.status} workspace=${model.workspace.status}" } + setConnectionTargetState(SessionControllerEvent.ConnectionChanged.ShowConnecting) + setVisibleConnectionState(SessionControllerEvent.ConnectionChanged.ShowConnecting) // App retry policy is backend-owned and may escalate from lightweight refresh to restart. if (model.app.status != KiloAppStatusDto.READY || model.app.status == KiloAppStatusDto.ERROR) { app.retryAsync() @@ -206,8 +196,8 @@ class SessionController( } fun selectAgent(name: String) { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=config agent=$name" } - model.agent = name cs.launch { try { sessions.updateConfig(directory, ConfigUpdateDto(agent = name)) @@ -215,12 +205,14 @@ class SessionController( LOG.warn("${ChatLogSummary.sid(sessionId ?: "pending")} kind=config agent=$name dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) } } - fire(SessionControllerEvent.WorkspaceReady) + fire(SessionControllerEvent.WorkspaceReady) { + model.agent = name + } } fun selectModel(provider: String, id: String) { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=config model=$provider/$id" } - model.model = "$provider/$id" cs.launch { try { sessions.updateConfig(directory, ConfigUpdateDto(model = "$provider/$id")) @@ -228,12 +220,15 @@ class SessionController( LOG.warn("${ChatLogSummary.sid(sessionId ?: "pending")} kind=config model=$provider/$id dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) } } - fire(SessionControllerEvent.WorkspaceReady) + fire(SessionControllerEvent.WorkspaceReady) { + model.model = "$provider/$id" + } } // ------ permission / question resolution ------ fun replyPermission(requestId: String, reply: PermissionReplyDto, rules: PermissionAlwaysRulesDto? = null) { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=permission rid=$requestId reply=${reply.reply}" } cs.launch { try { @@ -247,6 +242,7 @@ class SessionController( } fun replyQuestion(requestId: String, answers: QuestionReplyDto) { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size}" } cs.launch { try { @@ -259,6 +255,7 @@ class SessionController( } fun rejectQuestion(requestId: String) { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId rejected=true" } cs.launch { try { @@ -287,7 +284,7 @@ class SessionController( fire(SessionControllerEvent.AppChanged) { model.app = state model.version = app.version - syncConnection() + syncConnectionState() } } } @@ -296,7 +293,7 @@ class SessionController( workspace.state.collect { state -> fire(SessionControllerEvent.WorkspaceChanged) { model.workspace = state - syncConnection() + syncConnectionState() if (state.status != KiloWorkspaceStatusDto.READY) return@fire @@ -335,16 +332,16 @@ class SessionController( cs.launch { val state = HistoryState.Loading() runEdt { - history = state + setHistoryState(state) } - historyDelay.run(state) { - if (!model.showSession) view(SessionControllerEvent.ViewChanged.ShowProgress) + delayedState.run(state, { historyState }) { + if (!model.showSession) setControllerViewState(SessionControllerEvent.ViewChanged.ShowProgress) } try { - val history = sessions.messages(id, directory) - LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(history)}" } + val items = sessions.messages(id, directory) + LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(items)}" } runEdt { - this@SessionController.model.loadHistory(history) + this@SessionController.model.loadHistory(items) } recoverPending(id) edt { @@ -359,9 +356,8 @@ class SessionController( edt { refreshRecents(force = true) } } finally { edt { - if (history != state) return@edt - historyDelay.cancel() - history = HistoryState.Idle + if (historyState != state) return@edt + setHistoryState(HistoryState.Idle) } updates.holdFlush(false) updates.requestFlush(true) @@ -575,50 +571,115 @@ class SessionController( } private fun showMessages() { + assertEdt() if (!model.showSession) { - model.showSession = true - historyDelay.cancel() - history = HistoryState.Idle - recentsDelay.cancel() - recents = RecentsState.Idle - view(SessionControllerEvent.ViewChanged.ShowSession) + setControllerViewState(SessionControllerEvent.ViewChanged.ShowSession) } } - private fun view(event: SessionControllerEvent.ViewChanged) { - if (view == event) return - view = event - fire(event) + private fun status(): String = when (partType) { + "reasoning" -> KiloBundle.message("session.status.thinking") + "text" -> KiloBundle.message("session.status.writing") + "tool" -> when (tool) { + "task" -> KiloBundle.message("session.status.delegating") + "todowrite", "todoread" -> KiloBundle.message("session.status.planning") + "read" -> KiloBundle.message("session.status.gathering") + "glob", "grep", "list" -> KiloBundle.message("session.status.searching.codebase") + "webfetch", "websearch", "codesearch" -> KiloBundle.message("session.status.searching.web") + "edit", "write" -> KiloBundle.message("session.status.editing") + "bash" -> KiloBundle.message("session.status.commands") + else -> KiloBundle.message("session.status.considering") + } + else -> KiloBundle.message("session.status.considering") } - private fun connection(event: SessionControllerEvent.ConnectionChanged) { + fun refreshRecents(force: Boolean = false) { + assertEdt() + if (model.showSession) return + if (recentsState is RecentsState.Loading) return + if (recentsState is RecentsState.Loaded && !force) return + val state = RecentsState.Loading() + setRecentSessionsState(state) + delayedState.run(state, { recentsState }) { + setControllerViewState(SessionControllerEvent.ViewChanged.ShowProgress) + } + cs.launch { + try { + val items = sessions.recent(directory, RECENT_LIMIT) + edt { + if (recentsState != state) return@edt + setRecentSessionsState(RecentsState.Loaded) + if (model.showSession) return@edt + setControllerViewState(SessionControllerEvent.ViewChanged.ShowRecents(items)) + } + } catch (e: Exception) { + LOG.warn("kind=session-recent dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + edt { + if (recentsState != state) return@edt + setRecentSessionsState(RecentsState.Loaded) + if (model.showSession) return@edt + setControllerViewState(SessionControllerEvent.ViewChanged.ShowRecents(emptyList())) + } + } + } + } + + private fun setControllerViewState(event: SessionControllerEvent.ViewChanged) { + assertEdt() + if (viewState == event) return + fire(event) { + viewState = event + if (event is SessionControllerEvent.ViewChanged.ShowSession) { + model.showSession = true + setHistoryState(HistoryState.Idle) + setRecentSessionsState(RecentsState.Idle) + } + } + } + + private fun setConnectionTargetState(event: SessionControllerEvent.ConnectionChanged) { + assertEdt() + connectionTargetState = event + val state = event if (event is SessionControllerEvent.ConnectionChanged.Hide || event is SessionControllerEvent.ConnectionChanged.ShowWarning) { - connDelay.cancel() - showConnection(event) + setVisibleConnectionState(event) return } - if (conn == event) { - connDelay.cancel() + if (connectionState == event) { return } - connDelay.run(event, ::showConnection) + delayedState.run(event, { if (connectionTargetState == state) state else resolveConnectionState() }, ::setVisibleConnectionState) } - private fun showConnection(event: SessionControllerEvent.ConnectionChanged) { - if (conn == event) return - if (conn == null && event is SessionControllerEvent.ConnectionChanged.Hide) { - conn = event + private fun setVisibleConnectionState(event: SessionControllerEvent.ConnectionChanged) { + assertEdt() + if (connectionState == event) return + if (connectionState == null && event is SessionControllerEvent.ConnectionChanged.Hide) { + connectionState = event return } - conn = event - fire(event) + fire(event) { + connectionState = event + } } - private fun syncConnection() { - connection(connectionState()) + private fun syncConnectionState() { + assertEdt() + setConnectionTargetState(resolveConnectionState()) } - private fun connectionState(): SessionControllerEvent.ConnectionChanged { + private fun setHistoryState(state: HistoryState) { + assertEdt() + historyState = state + } + + private fun setRecentSessionsState(state: RecentsState) { + assertEdt() + recentsState = state + } + + private fun resolveConnectionState(): SessionControllerEvent.ConnectionChanged { + assertEdt() val app = model.app val workspace = model.workspace @@ -651,22 +712,6 @@ class SessionController( return SessionControllerEvent.ConnectionChanged.ShowConnecting } - private fun status(): String = when (partType) { - "reasoning" -> KiloBundle.message("session.status.thinking") - "text" -> KiloBundle.message("session.status.writing") - "tool" -> when (tool) { - "task" -> KiloBundle.message("session.status.delegating") - "todowrite", "todoread" -> KiloBundle.message("session.status.planning") - "read" -> KiloBundle.message("session.status.gathering") - "glob", "grep", "list" -> KiloBundle.message("session.status.searching.codebase") - "webfetch", "websearch", "codesearch" -> KiloBundle.message("session.status.searching.web") - "edit", "write" -> KiloBundle.message("session.status.editing") - "bash" -> KiloBundle.message("session.status.commands") - else -> KiloBundle.message("session.status.considering") - } - else -> KiloBundle.message("session.status.considering") - } - private fun fire(event: SessionControllerEvent, before: (() -> Unit)? = null) { LOG.debug { "session=$sessionId controller: $event" } val application = ApplicationManager.getApplication() @@ -681,6 +726,10 @@ class SessionController( } } + private fun assertEdt() { + check(ApplicationManager.getApplication().isDispatchThread) { "SessionController state must be accessed on EDT" } + } + private fun edt(block: () -> Unit) { ApplicationManager.getApplication().invokeLater(block) } @@ -695,9 +744,7 @@ class SessionController( } override fun dispose() { - connDelay.dispose() - historyDelay.dispose() - recentsDelay.dispose() + delayedState.dispose() eventJob?.cancel() cs.cancel() } @@ -787,6 +834,15 @@ private sealed interface HistoryState { data class Loading(val id: Any = Any()) : HistoryState } +internal data class ControllerStateSnapshot( + val showSession: Boolean, + val viewState: SessionControllerEvent.ViewChanged?, + val connectionState: SessionControllerEvent.ConnectionChanged?, + val connectionTargetState: SessionControllerEvent.ConnectionChanged?, + val historyState: String, + val recentsState: String, +) + private fun List.toErrorText(): String? { val out = mapNotNull { it.toDetailLine() } if (out.isEmpty()) return null diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt index 9e017aec555..d26ae6deeef 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -66,7 +66,9 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { manager.newSession() val first = active(manager) - first.controller().prompt("hello") + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + first.controller().prompt("hello") + } settle() manager.newSession() val second = active(manager) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt index bdb66f78bdd..cad1b00915c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt @@ -59,7 +59,9 @@ class SessionUiFactoryTest : BasePlatformTestCase() { val ui = SessionUi(project, workspace, sessions, app, scope, open = manager::openSession) val controller = controller(ui) - controller.openSession(rpc) + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + controller.openSession(rpc) + } assertEquals(listOf("ses_1"), manager.opened) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 3c4888d93ee..4602afa07fa 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -166,7 +166,9 @@ class SessionUiLayoutTest : BasePlatformTestCase() { assertSame(empty, scroll.viewport.view) - controller().prompt("hello") + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + controller().prompt("hello") + } layout() assertSame(scroll, find(ui).parent.parent) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt index b9b261be09f..efda159ca97 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt @@ -28,18 +28,22 @@ class AppWatchingTest : SessionControllerTestBase() { fun `test retry connection uses app retry when app is failed`() { val m = controller() + val events = collect(m) appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR, error = "boom") flush() + events.clear() edt { m.retryConnection() } flush() assertEquals(1, appRpc.retries) assertEquals(0, projectRpc.reloads) + assertTrue(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) } fun `test retry connection reloads workspace when app ready and workspace failed`() { val m = controller() + val events = collect(m) appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) projectRpc.state.value = ai.kilocode.rpc.dto.KiloWorkspaceStateDto( status = KiloWorkspaceStatusDto.ERROR, @@ -47,25 +51,45 @@ class AppWatchingTest : SessionControllerTestBase() { ) flush() + events.clear() edt { m.retryConnection() } flush() assertEquals(0, appRpc.retries) assertEquals(1, projectRpc.reloads) + assertTrue(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) } fun `test retry connection uses app retry when app has warnings`() { val m = controller() + val events = collect(m) appRpc.state.value = KiloAppStateDto( status = KiloAppStatusDto.READY, warnings = listOf(ConfigWarningDto(path = ".kilo/kilo.json", message = "Invalid JSON")), ) flush() + events.clear() edt { m.retryConnection() } flush() assertEquals(1, appRpc.retries) assertEquals(0, projectRpc.reloads) + assertTrue(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) + } + + fun `test retry connection immediately updates connection state`() { + val m = controller() + val states = collectStates(m) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR, error = "boom") + flush() + states.clear() + + edt { m.retryConnection() } + flush() + + val state = states.single { it.first is SessionControllerEvent.ConnectionChanged.ShowConnecting }.second + assertEquals(SessionControllerEvent.ConnectionChanged.ShowConnecting, state.connectionState) + assertEquals(SessionControllerEvent.ConnectionChanged.ShowConnecting, state.connectionTargetState) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt index db8902a06b6..a753eb1e640 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt @@ -6,6 +6,7 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.LoadErrorDto +import com.intellij.openapi.util.Disposer class ConnectionDelayTest : SessionControllerTestBase() { @@ -42,6 +43,21 @@ class ConnectionDelayTest : SessionControllerTestBase() { assertEquals(1, events.count { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) } + fun `test connecting event sees updated connection state on EDT`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val states = collectStates(m) + flush() + states.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(80) + + val state = states.single { it.first is SessionControllerEvent.ConnectionChanged.ShowConnecting }.second + assertEquals(SessionControllerEvent.ConnectionChanged.ShowConnecting, state.connectionState) + } + fun `test short app error is suppressed`() { appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() @@ -135,6 +151,25 @@ class ConnectionDelayTest : SessionControllerTestBase() { assertTrue(events.any { it is SessionControllerEvent.ConnectionChanged.Hide }) } + fun `test hide event sees updated connection state on EDT`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val states = collectStates(m) + flush() + states.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(80) + states.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + pause(10) + + val state = states.single { it.first is SessionControllerEvent.ConnectionChanged.Hide }.second + assertEquals(SessionControllerEvent.ConnectionChanged.Hide, state.connectionState) + } + fun `test config warning remains immediate`() { appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() @@ -153,4 +188,38 @@ class ConnectionDelayTest : SessionControllerTestBase() { assertEquals("Configuration warnings", event.summary) assertEquals(".kilo/kilo.json: Invalid JSON", event.detail) } + + fun `test warning event sees updated connection state on EDT`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 1_000) + val states = collectStates(m) + flush() + states.clear() + + appRpc.state.value = KiloAppStateDto( + status = KiloAppStatusDto.READY, + warnings = listOf(ConfigWarningDto(path = ".kilo/kilo.json", message = "Invalid JSON")), + ) + pause(10) + + val event = states.single { it.first is SessionControllerEvent.ConnectionChanged.ShowWarning } + assertEquals(event.first, event.second.connectionState) + } + + fun `test dispose suppresses pending delayed connection event`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(20) + Disposer.dispose(m) + pause(100) + + assertFalse(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt new file mode 100644 index 00000000000..d9b22f7e1fd --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt @@ -0,0 +1,126 @@ +package ai.kilocode.client.session.update + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking + +class DelayedStateTest : BasePlatformTestCase() { + private val states = mutableListOf() + + override fun tearDown() { + try { + states.forEach { Disposer.dispose(it) } + states.clear() + } finally { + super.tearDown() + } + } + + fun `test run applies action after delay when state still matches`() { + var state = "loading" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("loading", { state }) { out.add(it) } + pause(120) + + assertEquals(listOf("loading"), out) + } + + fun `test run does not apply when current state changed`() { + var state = "loading" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("loading", { state }) { out.add(it) } + edt { state = "loaded" } + pause(120) + + assertTrue(out.isEmpty()) + } + + fun `test multiple pending matching actions can run from shared timer`() { + var first = "loading" + var second = "connecting" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("loading", { first }) { out.add("first:$it") } + delay.run("connecting", { second }) { out.add("second:$it") } + pause(120) + + assertEquals(listOf("first:loading", "second:connecting"), out) + } + + fun `test stale and matching pending actions are evaluated independently`() { + var state = "first" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("first", { state }) { out.add(it) } + edt { state = "second" } + delay.run("second", { state }) { out.add(it) } + pause(120) + + assertEquals(listOf("second"), out) + } + + fun `test cancel suppresses pending actions`() { + var state = "loading" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("loading", { state }) { out.add(it) } + delay.cancel() + pause(120) + + assertTrue(out.isEmpty()) + } + + fun `test dispose suppresses pending actions`() { + var state = "loading" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("loading", { state }) { out.add(it) } + Disposer.dispose(delay) + states.remove(delay) + pause(120) + + assertTrue(out.isEmpty()) + } + + fun `test zero delay applies on EDT`() { + var state = "loading" + val out = mutableListOf() + val delay = delayed(0) + + delay.run("loading", { state }) { + out.add(ApplicationManager.getApplication().isDispatchThread) + } + pause(30) + + assertEquals(listOf(true), out) + } + + private fun delayed(ms: Long): DelayedState { + val state = DelayedState(ms) + states.add(state) + return state + } + + private fun pause(ms: Long) = runBlocking { + val tick = 10L + repeat((ms / tick).coerceAtLeast(1).toInt()) { + delay(tick) + edt { UIUtil.dispatchAllInvocationEvents() } + } + } + + private fun edt(block: () -> Unit) { + ApplicationManager.getApplication().invokeAndWait(block) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt index 2ee8e5efc15..6f0cf34d24f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt @@ -35,7 +35,6 @@ class HistoryLoadingTest : SessionControllerTestBase() { assertControllerEvents(""" AppChanged WorkspaceChanged - ViewChanged progress ViewChanged session """, events) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt index b6c136b9531..a3db4287546 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt @@ -174,12 +174,24 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { return events } + internal fun collectStates(m: SessionController): MutableList> { + val events = mutableListOf>() + val disposable = Disposer.newDisposable("state-listener") + Disposer.register(parent, disposable) + m.addListener(disposable) { event -> + assertTrue("Listener must be called on EDT", ApplicationManager.getApplication().isDispatchThread) + events.add(event to m.snapshotState()) + } + return events + } + /** Attach a listener that collects model events (messages, parts, phase). */ protected fun collectModelEvents(m: SessionController): MutableList { val events = mutableListOf() val disposable = Disposer.newDisposable("model-listener") Disposer.register(parent, disposable) m.model.addListener(disposable) { event -> + assertTrue("Model listener must be called on EDT", ApplicationManager.getApplication().isDispatchThread) events.add(event) } return events @@ -199,8 +211,10 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { protected fun flush() = runBlocking { repeat(5) { delay(100) - controllers.forEach { it.flushEvents() } - edt { UIUtil.dispatchAllInvocationEvents() } + edt { + controllers.forEach { it.flushEvents() } + UIUtil.dispatchAllInvocationEvents() + } } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt index 1b8b2684a81..1f5de63b397 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt @@ -22,6 +22,20 @@ class ViewSwitchingTest : SessionControllerTestBase() { ) } + fun `test session event sees updated view state on EDT`() { + val m = controller() + val states = collectStates(m) + flush() + states.clear() + + edt { m.prompt("hello") } + flush() + + val state = states.single { it.first is SessionControllerEvent.ViewChanged.ShowSession }.second + assertTrue(state.showSession) + assertEquals(SessionControllerEvent.ViewChanged.ShowSession, state.viewState) + } + fun `test ViewChanged not fired twice`() { val m = controller() val events = collect(m) @@ -111,6 +125,24 @@ class ViewSwitchingTest : SessionControllerTestBase() { ) } + fun `test recents progress event sees loading state on EDT`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val gate = CompletableDeferred() + rpc.recentGate = gate + val m = controller(displayMs = 50) + val states = collectStates(m) + + pause(80) + + val state = states.single { it.first is SessionControllerEvent.ViewChanged.ShowProgress }.second + assertEquals(SessionControllerEvent.ViewChanged.ShowProgress, state.viewState) + assertTrue(state.recentsState.startsWith("Loading")) + + gate.complete(Unit) + flush() + } + fun `test fast recents suppress progress`() { projectRpc.state.value = workspaceReady() rpc.recent.add(session("ses_1")) @@ -124,6 +156,19 @@ class ViewSwitchingTest : SessionControllerTestBase() { assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) } + fun `test recents event sees loaded state on EDT`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller(displayMs = 1_000) + val states = collectStates(m) + + flush() + + val event = states.single { it.first is SessionControllerEvent.ViewChanged.ShowRecents } + assertEquals(event.first, event.second.viewState) + assertEquals("Loaded", event.second.recentsState) + } + fun `test failed fast recents suppress progress and show empty recents`() { projectRpc.state.value = workspaceReady() rpc.recentFailures = 1 From 8ff62a4f5d22dd87bd59134e373c0a5135181e98 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Apr 2026 17:23:04 -0400 Subject: [PATCH 32/96] fix: adjust connection panel layout and rename retry action --- .../ai/kilocode/client/session/ui/ConnectionPanel.kt | 9 +++++++-- .../src/main/resources/messages/KiloBundle.properties | 2 +- .../ai/kilocode/client/session/ui/ConnectionPanelTest.kt | 5 +++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index c9e866887fc..06bd1d6905f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -37,7 +37,7 @@ class ConnectionPanel( } private val header = JPanel(BorderLayout()).apply { - border = JBUI.Borders.empty(4, 8) + border = JBUI.Borders.empty(4, 8, 0, 8) isOpaque = false } @@ -74,7 +74,7 @@ class ConnectionPanel( } private val scroll = JBScrollPane(details).apply { - border = JBUI.Borders.empty(0, 8, 4, 24) + border = JBUI.Borders.empty(0, 8, 4, 0) isOpaque = false viewport.isOpaque = false horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER @@ -89,6 +89,7 @@ class ConnectionPanel( Disposer.register(parent, this) isOpaque = true background = UIUtil.getPanelBackground() + border = JBUI.Borders.customLine(UIUtil.getBoundsColor(), 1, 0, 0, 0) left.add(toggle, BorderLayout.WEST) left.add(label, BorderLayout.CENTER) header.add(left, BorderLayout.CENTER) @@ -218,6 +219,8 @@ class ConnectionPanel( internal fun retryVisible() = retry.isVisible + internal fun retryText() = retry.text + internal fun detailsVisible() = scroll.isVisible internal fun toggleVisible() = toggle.isVisible @@ -241,6 +244,8 @@ class ConnectionPanel( internal fun clickRetry() = retry.doClick() + internal fun hasSeparator() = border != null + internal fun maxExpandedHeight() = header.preferredSize.height + details.getFontMetrics(details.font).height * DETAILS_LINES + scrollChrome() } 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 3253e1da3a4..d4df97b4b5a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -1,6 +1,6 @@ session.connection.connecting=Loading... session.connection.error.unknown=Unknown error -session.connection.retry=Retry +session.connection.retry=Try again session.connection.warning.config=Configuration warnings session.empty.welcome=Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt index 620e8fce34c..87bdb68fa18 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -73,6 +73,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertFalse(panel.detailsVisible()) assertEquals("", panel.detailsText()) assertTrue(panel.retryVisible()) + assertEquals("Try again", panel.retryText()) } fun `test retry click triggers app retry for app error`() { @@ -150,5 +151,9 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertFalse(panel.isVisible) } + fun `test panel has top separator`() { + assertTrue(panel.hasSeparator()) + } + private fun lines(count: Int) = (1..count).joinToString("\n") { "line $it" } } From 51c74132f931104e27d1fd61489ff4e9f54c8d0e Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Apr 2026 17:36:20 -0400 Subject: [PATCH 33/96] fix: use theme-safe JetBrains session icons and colors --- .../ai/kilocode/client/actions/NewSessionAction.kt | 4 ++-- .../kilocode/client/session/ui/ConnectionPanel.kt | 14 ++++++++++++-- .../client/session/ui/ConnectionPanelTest.kt | 3 +++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt index b6d958e6e88..dbe8aefcd6c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt @@ -2,15 +2,15 @@ package ai.kilocode.client.actions import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionManager +import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.project.DumbAware -import com.intellij.openapi.util.IconLoader class NewSessionAction : AnAction( KiloBundle.message("action.Kilo.NewSession.text"), KiloBundle.message("action.Kilo.NewSession.description"), - IconLoader.getIcon("/icons/plus.svg", NewSessionAction::class.java), + AllIcons.General.Add, ), DumbAware { override fun actionPerformed(e: AnActionEvent) { e.getData(SessionManager.KEY)?.newSession() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index 06bd1d6905f..b943f9d8de4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -7,6 +7,7 @@ import ai.kilocode.client.session.update.SessionControllerListener import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer +import com.intellij.ui.JBColor import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane @@ -20,6 +21,7 @@ import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.JPanel import javax.swing.ScrollPaneConstants +import javax.swing.UIManager class ConnectionPanel( parent: Disposable, @@ -28,6 +30,12 @@ class ConnectionPanel( companion object { private const val DETAILS_LINES = 10 + private val ERROR = JBColor.namedColor("Label.errorForeground", UIUtil.getErrorForeground()) + private val WARNING = JBColor.lazy { + UIManager.getColor("Component.warningFocusColor") + ?: UIManager.getColor("Label.warningForeground") + ?: UIUtil.getContextHelpForeground() + } } private val click = object : MouseAdapter() { @@ -131,7 +139,7 @@ class ConnectionPanel( } private fun showError(text: String, detail: String?) { - label.foreground = UIUtil.getErrorForeground() + label.foreground = ERROR label.text = text retry.isVisible = true this.detail = detail?.takeIf { it.isNotBlank() } @@ -141,7 +149,7 @@ class ConnectionPanel( } private fun showWarning(text: String, detail: String?) { - label.foreground = UIUtil.getContextHelpForeground() + label.foreground = WARNING label.text = text retry.isVisible = true this.detail = detail?.takeIf { it.isNotBlank() } @@ -215,6 +223,8 @@ class ConnectionPanel( internal fun summaryText() = label.text + internal fun summaryColor() = label.foreground + internal fun detailsText() = details.text internal fun retryVisible() = retry.isVisible diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt index 87bdb68fa18..3d29188009f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.update.SessionControllerTestBase import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto +import com.intellij.util.ui.UIUtil import java.awt.Dimension @Suppress("UnstableApiUsage") @@ -44,6 +45,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertTrue(panel.isVisible) assertEquals("CLI startup failed", panel.summaryText()) + assertEquals(UIUtil.getErrorForeground(), panel.summaryColor()) assertTrue(panel.toggleVisible()) assertFalse(panel.toggleExpanded()) assertFalse(panel.detailsVisible()) @@ -100,6 +102,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertTrue(panel.isVisible) assertEquals("Configuration warnings", panel.summaryText()) + assertNotSame(UIUtil.getContextHelpForeground(), panel.summaryColor()) assertTrue(panel.toggleVisible()) assertFalse(panel.toggleExpanded()) assertFalse(panel.detailsVisible()) From 382924d3983b116b0fc3ce3a6dd2549eae39bd1f Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Apr 2026 17:52:28 -0400 Subject: [PATCH 34/96] fix: address JetBrains session review feedback --- .../backend/app/KiloBackendAppService.kt | 4 ++ .../client/session/SessionSidePanelManager.kt | 18 +++++++++ .../ai/kilocode/client/session/SessionUi.kt | 2 + .../client/session/update/DelayedState.kt | 4 ++ .../session/update/SessionController.kt | 1 + .../session/SessionSidePanelManagerTest.kt | 39 ++++++++++++++++++- .../client/session/update/DelayedStateTest.kt | 22 +++++++++++ 7 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 59b7285db2b..8f8e5c36754 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -390,8 +390,12 @@ class KiloBackendAppService private constructor( private suspend fun refreshConfigState() { val current = _appState.value as? KiloAppState.Ready ?: return + val connection = connection.state.value as? ConnectionState.Connected ?: return val cfg = fetchConfig().value ?: return val warns = fetchWarnings() + val state = _appState.value + if (state !is KiloAppState.Ready || state.data !== current.data) return + if (this.connection.state.value != connection) return config = cfg setAppReady( current.data.copy( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt index 3c0e3f741e7..afc5d989606 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt @@ -33,10 +33,12 @@ class SessionSidePanelManager( override fun newSession() { val active = current if (active?.blank == true) return + register(active) show(create(project, root, this, null, active == null)) } override fun openSession(session: SessionDto) { + register(current) val ui = opened.getOrPut(session.id) { create(project, resolve(session.directory), this, session.id, false).also { all.add(it) @@ -48,6 +50,7 @@ class SessionSidePanelManager( private fun show(ui: SessionUi) { all.add(ui) if (current === ui) return + release(current) component.removeAll() current = ui component.add(ui, BorderLayout.CENTER) @@ -55,6 +58,21 @@ class SessionSidePanelManager( component.repaint() } + private fun register(ui: SessionUi?) { + val id = ui?.id ?: return + opened.putIfAbsent(id, ui) + } + + private fun release(ui: SessionUi?) { + if (ui == null) return + if (ui.id != null) { + register(ui) + return + } + all.remove(ui) + Disposer.dispose(ui) + } + override fun dispose() { val items = all.toList() opened.clear() 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 3c1fa7cd473..7fb382d00b9 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 @@ -120,6 +120,8 @@ class SessionUi private constructor( internal val blank: Boolean get() = controller.blank + internal val id: String? get() = controller.id + private fun buildUi() { root = SessionRootPanel() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt index 9dee0ea2acb..2dba541f6b1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt @@ -32,9 +32,12 @@ internal class DelayedState( fun cancel() { edt { pending.clear() + timer.stop() } } + internal fun active() = timer.isRunning + private fun apply(item: Pending) { if (!alive) return if (!pending.remove(item)) return @@ -49,6 +52,7 @@ internal class DelayedState( if (item.due > now) continue apply(item) } + if (pending.isEmpty()) timer.stop() } private fun due(): Long { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index a8349a7c7cd..3a3df476d87 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -102,6 +102,7 @@ class SessionController( val ready: Boolean get() = model.isReady() internal val blank: Boolean get() = sessionId == null && model.isEmpty() && !model.showSession + internal val id: String? get() = sessionId fun openSession(session: SessionDto) { assertEdt() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt index d26ae6deeef..e2525228094 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -24,6 +24,7 @@ import javax.swing.JPanel @Suppress("UnstableApiUsage") class SessionSidePanelManagerTest : BasePlatformTestCase() { private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeSessionRpcApi private lateinit var workspaces: KiloWorkspaceService private lateinit var workspace: Workspace private lateinit var sessions: KiloSessionService @@ -31,11 +32,13 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { private val managers = mutableListOf() private val created = mutableListOf>() private val loading = mutableListOf() + private val ui = mutableListOf() override fun setUp() { super.setUp() scope = CoroutineScope(SupervisorJob()) - sessions = KiloSessionService(project, scope, FakeSessionRpcApi()) + rpc = FakeSessionRpcApi() + sessions = KiloSessionService(project, scope, rpc) app = KiloAppService(scope, FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) }) @@ -106,6 +109,35 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { assertEquals(listOf(false, false), loading) } + fun `test prompted blank session is reused from recents`() { + val manager = manager() + manager.newSession() + val first = active(manager) + + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + first.controller().prompt("hello") + } + settle() + manager.newSession() + manager.openSession(session("ses_test")) + val second = active(manager) + + assertSame(first, second) + assertEquals(1, rpc.creates) + assertEquals(listOf("/test" to null, "/test" to null), created) + } + + fun `test anonymous blank session is disposed when replaced`() { + val manager = manager() + manager.newSession() + val first = active(manager) + + manager.openSession(session("ses_1")) + + assertNotSame(first, active(manager)) + assertFalse(ui.contains(first)) + } + fun `test open session resolves historical workspace`() { val manager = manager() @@ -132,7 +164,10 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { create = { project, workspace, owner, id, show -> created.add(workspace.directory to id) loading.add(show) - SessionUi(project, workspace, sessions, app, scope, id = id, loading = show, open = owner::openSession) + SessionUi(project, workspace, sessions, app, scope, id = id, loading = show, open = owner::openSession).also { + ui.add(it) + Disposer.register(it) { ui.remove(it) } + } }, resolve = { workspaces.workspace(it) }, ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt index d9b22f7e1fd..8f6e5cfc3e0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt @@ -80,6 +80,28 @@ class DelayedStateTest : BasePlatformTestCase() { assertTrue(out.isEmpty()) } + fun `test cancel stops timer`() { + var state = "loading" + val delay = delayed(30) + + delay.run("loading", { state }) {} + pause(10) + delay.cancel() + pause(10) + + assertFalse(delay.active()) + } + + fun `test timer stops after pending actions drain`() { + var state = "loading" + val delay = delayed(30) + + delay.run("loading", { state }) {} + pause(120) + + assertFalse(delay.active()) + } + fun `test dispose suppresses pending actions`() { var state = "loading" val out = mutableListOf() From 4a08f618d7eac596fe8ab75d2112fa9ced9b03f3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Apr 2026 18:33:04 -0400 Subject: [PATCH 35/96] fix: show JetBrains workspace error details --- .../backend/app/KiloBackendAppService.kt | 2 +- .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 9 + .../backend/workspace/KiloBackendWorkspace.kt | 195 +++++++++++------- .../workspace/KiloBackendWorkspaceManager.kt | 12 +- .../backend/workspace/KiloWorkspaceState.kt | 4 +- .../workspace/KiloBackendWorkspaceTest.kt | 17 ++ .../client/session/ui/ConnectionPanel.kt | 4 +- .../session/update/SessionController.kt | 8 +- .../resources/messages/KiloBundle.properties | 2 + .../client/session/ui/ConnectionPanelTest.kt | 1 + .../session/update/ConnectionDelayTest.kt | 15 +- .../kilocode/rpc/dto/KiloWorkspaceStateDto.kt | 1 + 12 files changed, 183 insertions(+), 87 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 8f8e5c36754..7d806d678d9 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -287,7 +287,7 @@ class KiloBackendAppService private constructor( notifications = notifs sessions.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) chat.start(connection.apiClient!!, connection.port, connection.events) - workspaces.start(connection.api!!, connection.events) + workspaces.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) setAppReady( AppData( profile = prof, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 171a2e47d78..f8159030df2 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -4,6 +4,7 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.workspace.AgentData import ai.kilocode.backend.workspace.AgentInfo import ai.kilocode.backend.workspace.CommandInfo @@ -21,6 +22,7 @@ import ai.kilocode.rpc.dto.CommandDto import ai.kilocode.rpc.dto.KiloWorkspaceLoadProgressDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.ModelDto import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.ProvidersDto @@ -99,9 +101,16 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { is KiloWorkspaceState.Error -> KiloWorkspaceStateDto( status = KiloWorkspaceStatusDto.ERROR, error = state.message, + errors = state.errors.map(::error), ) } + private fun error(e: LoadError) = LoadErrorDto( + resource = e.resource, + status = e.status, + detail = e.detail, + ) + private fun progress(p: KiloWorkspaceLoadProgress) = KiloWorkspaceLoadProgressDto( providers = p.providers, agents = p.agents, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt index 460bc9b4fe0..acbd32856a3 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -1,12 +1,20 @@ package ai.kilocode.backend.workspace import ai.kilocode.backend.app.KiloBackendSessionManager +import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.app.SseEvent import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import ai.kilocode.jetbrains.api.model.Agent import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -18,6 +26,8 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request import java.util.concurrent.atomic.AtomicReference /** @@ -36,6 +46,8 @@ class KiloBackendWorkspace( val directory: String, private val cs: CoroutineScope, private val api: DefaultApi, + private val http: OkHttpClient, + private val port: Int, private val events: SharedFlow, private val sessions: KiloBackendSessionManager, private val log: KiloLog, @@ -43,6 +55,7 @@ class KiloBackendWorkspace( companion object { private const val MAX_RETRIES = 3 private const val RETRY_DELAY_MS = 1000L + private val json = Json { ignoreUnknownKeys = true } } private val _state = MutableStateFlow(KiloWorkspaceState.Pending) @@ -66,52 +79,56 @@ class KiloBackendWorkspace( var ag: AgentData? = null var cmd: List? = null var sk: List? = null - val errors = mutableListOf() + val errors = mutableListOf() try { coroutineScope { launch { val result = fetchWithRetry("providers") { fetchProviders() } - if (result != null) { - prov = result + if (result.value != null) { + prov = result.value progress.updateAndGet { it.copy(providers = true) } .also { _state.value = KiloWorkspaceState.Loading(it) } } else { - synchronized(errors) { errors.add("providers") } - throw LoadFailure("providers") + val err = result.error ?: LoadError(resource = "providers") + synchronized(errors) { errors.add(err) } + throw LoadFailure(err) } } launch { val result = fetchWithRetry("agents") { fetchAgents() } - if (result != null) { - ag = result + if (result.value != null) { + ag = result.value progress.updateAndGet { it.copy(agents = true) } .also { _state.value = KiloWorkspaceState.Loading(it) } } else { - synchronized(errors) { errors.add("agents") } - throw LoadFailure("agents") + val err = result.error ?: LoadError(resource = "agents") + synchronized(errors) { errors.add(err) } + throw LoadFailure(err) } } launch { val result = fetchWithRetry("commands") { fetchCommands() } - if (result != null) { - cmd = result + if (result.value != null) { + cmd = result.value progress.updateAndGet { it.copy(commands = true) } .also { _state.value = KiloWorkspaceState.Loading(it) } } else { - synchronized(errors) { errors.add("commands") } - throw LoadFailure("commands") + val err = result.error ?: LoadError(resource = "commands") + synchronized(errors) { errors.add(err) } + throw LoadFailure(err) } } launch { val result = fetchWithRetry("skills") { fetchSkills() } - if (result != null) { - sk = result + if (result.value != null) { + sk = result.value progress.updateAndGet { it.copy(skills = true) } .also { _state.value = KiloWorkspaceState.Loading(it) } } else { - synchronized(errors) { errors.add("skills") } - throw LoadFailure("skills") + val err = result.error ?: LoadError(resource = "skills") + synchronized(errors) { errors.add(err) } + throw LoadFailure(err) } } } @@ -129,7 +146,9 @@ class KiloBackendWorkspace( throw e } catch (e: Exception) { log.warn("Workspace data load failed for $directory: ${e.message}") - setWorkspaceError("Failed to load: ${synchronized(errors) { errors.joinToString() }}") + val items = synchronized(errors) { errors.toList() } + val names = items.joinToString { it.resource } + setWorkspaceError("Failed to load: $names", items) } } } @@ -189,79 +208,56 @@ class KiloBackendWorkspace( // ------ fetch methods ------ - private fun fetchProviders(): ProviderData? = + private fun fetchProviders(): FetchResult = try { - val response = api.providerList(directory = directory) - ProviderData( - providers = response.all.map { p -> - ProviderInfo( - id = p.id, - name = p.name, - source = p.source.value, - models = p.models.mapValues { (_, m) -> - ModelInfo( - id = m.id, - name = m.name, - attachment = m.capabilities.attachment, - reasoning = m.capabilities.reasoning, - temperature = m.capabilities.temperature, - toolCall = m.capabilities.toolcall, - free = m.isFree ?: false, - status = m.status.value, - ) - }, - ) - }, - connected = response.connected, - defaults = response.default, - ) + FetchResult.ok(parseProviders(fetch("/provider?directory=${encode(directory)}"))) } catch (e: Exception) { log.warn("Providers fetch failed: ${e.message}", e) - null + FetchResult.fail("providers", e) } - private fun fetchAgents(): AgentData? = + private fun fetchAgents(): FetchResult = try { val response = api.appAgents(directory = directory) val mapped = response.map(::mapAgent) val visible = response.filter { it.mode != Agent.Mode.SUBAGENT && it.hidden != true } - AgentData( + FetchResult.ok(AgentData( agents = visible.map(::mapAgent), all = mapped, default = visible.firstOrNull()?.name ?: "code", - ) + )) } catch (e: Exception) { log.warn("Agents fetch failed: ${e.message}", e) - null + FetchResult.fail("agents", e) } - private fun fetchCommands(): List? = + private fun fetchCommands(): FetchResult> = try { - api.commandList(directory = directory).map { c -> + FetchResult.ok(api.commandList(directory = directory).map { c -> CommandInfo( name = c.name, description = c.description, source = c.source?.value, hints = c.hints, ) - } + }) } catch (e: Exception) { log.warn("Commands fetch failed: ${e.message}", e) - null + FetchResult.fail("commands", e) } - private fun fetchSkills(): List? = + private fun fetchSkills(): FetchResult> = try { - api.appSkills(directory = directory).map { s -> + FetchResult.ok(api.appSkills(directory = directory).map { s -> SkillInfo( name = s.name, description = s.description, location = s.location, ) - } + }) } catch (e: Exception) { log.warn("Skills fetch failed: ${e.message}", e) - null + FetchResult.fail("skills", e) } // ------ helpers ------ @@ -277,26 +273,79 @@ class KiloBackendWorkspace( deprecated = a.deprecated, ) - private suspend fun fetchWithRetry( - name: String, - block: () -> T?, - ): T? { - repeat(MAX_RETRIES) { attempt -> - val result = block() - if (result != null) return result - if (attempt < MAX_RETRIES - 1) { - log.warn("$name: attempt ${attempt + 1}/$MAX_RETRIES failed — retrying in ${RETRY_DELAY_MS}ms") - delay(RETRY_DELAY_MS) - } - } - log.error("$name: all $MAX_RETRIES attempts failed") - return null + private fun parseProviders(raw: String): ProviderData { + val obj = json.parseToJsonElement(raw).jsonObject + return ProviderData( + providers = obj["all"]?.jsonArray?.map { provider(it.jsonObject) } ?: emptyList(), + connected = obj["connected"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(), + defaults = obj["default"]?.jsonObject?.mapValues { (_, value) -> value.jsonPrimitive.content } ?: emptyMap(), + ) } - private fun setWorkspaceError(message: String) { - _state.value = KiloWorkspaceState.Error(message) + private fun provider(obj: JsonObject) = ProviderInfo( + id = obj.str("id") ?: "", + name = obj.str("name") ?: "", + source = obj.str("source"), + models = obj["models"]?.jsonObject?.mapValues { (id, value) -> model(id, value.jsonObject) } ?: emptyMap(), + ) + + private fun model(id: String, obj: JsonObject): ModelInfo { + val cap = obj["capabilities"]?.jsonObject + return ModelInfo( + id = obj.str("id") ?: id, + name = obj.str("name") ?: id, + attachment = cap.bool("attachment"), + reasoning = cap.bool("reasoning"), + temperature = cap.bool("temperature"), + toolCall = cap.bool("toolcall"), + free = obj.bool("isFree"), + status = obj.str("status"), + ) + } + + private fun fetch(path: String): String { + val request = Request.Builder().url("http://localhost:$port$path").get().build() + http.newCall(request).execute().use { response -> + val raw = response.body?.string().orEmpty() + if (!response.isSuccessful) throw RuntimeException("HTTP ${response.code}: $raw") + return raw + } + } + + private suspend fun fetchWithRetry( + name: String, + block: () -> FetchResult, + ): FetchResult { + var last = FetchResult.fail(name) + repeat(MAX_RETRIES) { attempt -> + val result = block() + if (result.value != null) return result + last = result + if (attempt < MAX_RETRIES - 1) { + log.warn("$name: attempt ${attempt + 1}/$MAX_RETRIES failed — retrying in ${RETRY_DELAY_MS}ms") + delay(RETRY_DELAY_MS) + } + } + log.error("$name: all $MAX_RETRIES attempts failed") + return last + } + + private fun setWorkspaceError(message: String, errors: List) { + _state.value = KiloWorkspaceState.Error(message, errors) log.warn("Workspace error [$directory]: $message") } - private class LoadFailure(resource: String) : Exception("Failed to load $resource") + private data class FetchResult(val value: T?, val error: LoadError?) { + companion object { + fun ok(value: T) = FetchResult(value, null) + fun fail(resource: String, e: Exception? = null) = FetchResult(null, LoadError(resource, detail = e?.message)) + } + } + + private class LoadFailure(val error: LoadError) : Exception("Failed to load ${error.resource}") + } + +private fun encode(value: String) = java.net.URLEncoder.encode(value, Charsets.UTF_8) +private fun JsonObject.str(key: String) = this[key]?.jsonPrimitive?.contentOrNull +private fun JsonObject?.bool(key: String) = this?.get(key)?.jsonPrimitive?.booleanOrNull ?: false diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt index 2af74bd151e..93ec913349d 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt @@ -7,6 +7,7 @@ import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharedFlow +import okhttp3.OkHttpClient import java.util.concurrent.ConcurrentHashMap /** @@ -28,6 +29,8 @@ class KiloBackendWorkspaceManager( private val workspaces = ConcurrentHashMap() private var api: DefaultApi? = null + private var http: OkHttpClient? = null + private var port = 0 private var events: SharedFlow? = null /** @@ -35,9 +38,11 @@ class KiloBackendWorkspaceManager( * Called by [KiloBackendAppService] after [KiloAppState.Ready]. * Clears any stale workspaces from a previous connection. */ - fun start(api: DefaultApi, events: SharedFlow) { + fun start(api: DefaultApi, http: OkHttpClient, port: Int, events: SharedFlow) { stop() this.api = api + this.http = http + this.port = port this.events = events log.info("Workspace manager started") } @@ -49,6 +54,8 @@ class KiloBackendWorkspaceManager( workspaces.values.forEach { it.stop() } workspaces.clear() api = null + http = null + port = 0 events = null log.info("Workspace manager stopped") } @@ -59,10 +66,11 @@ class KiloBackendWorkspaceManager( */ fun get(dir: String): KiloBackendWorkspace { val client = api ?: throw IllegalStateException("Workspace manager not started") + val http = http ?: throw IllegalStateException("Workspace manager not started") val ev = events!! return workspaces.computeIfAbsent(dir) { d -> log.info("Creating workspace for $d") - KiloBackendWorkspace(d, cs, client, ev, sessions, log).also { it.load() } + KiloBackendWorkspace(d, cs, client, http, port, ev, sessions, log).also { it.load() } } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt index 7a4ad30b029..0006a331750 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt @@ -1,5 +1,7 @@ package ai.kilocode.backend.workspace +import ai.kilocode.backend.app.LoadError + /** * Workspace data lifecycle state, combining connection readiness * with directory-scoped data loading progress. @@ -17,7 +19,7 @@ sealed class KiloWorkspaceState { val commands: List, val skills: List, ) : KiloWorkspaceState() - data class Error(val message: String) : KiloWorkspaceState() + data class Error(val message: String, val errors: List = emptyList()) : KiloWorkspaceState() } /** diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt index 9da0fa6cd88..a5b177710e0 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt @@ -179,9 +179,25 @@ class KiloBackendWorkspaceTest { val err = ws.state.value as KiloWorkspaceState.Error assertTrue(err.message.contains("providers")) + assertTrue(err.errors.any { it.resource == "providers" }) assertTrue(log.messages.any { it.contains("Workspace error [/test/project]: Failed to load:") && it.contains("providers") }) } + @Test + fun `providers decode failure includes detail`() = runBlocking { + mock.providers = """{"all":[false],"default":{},"connected":[]}""" + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Error } + } + + val err = ws.state.value as KiloWorkspaceState.Error + val detail = err.errors.single { it.resource == "providers" }.detail + assertTrue(detail?.isNotBlank() == true) + } + @Test fun `agents failure retries then transitions to Error`() = runBlocking { mock.agentsStatus = 500 @@ -237,6 +253,7 @@ class KiloBackendWorkspaceTest { val err = ws.state.value as KiloWorkspaceState.Error assertTrue(err.message.contains("providers") || err.message.contains("skills")) + assertTrue(err.errors.any { it.resource == "providers" } || err.errors.any { it.resource == "skills" }) } // ------ Reload ------ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index b943f9d8de4..f0adfbf7ce6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -78,7 +78,7 @@ class ConnectionPanel( isOpaque = false lineWrap = true wrapStyleWord = true - foreground = UIUtil.getContextHelpForeground() + foreground = UIUtil.getLabelForeground() } private val scroll = JBScrollPane(details).apply { @@ -227,6 +227,8 @@ class ConnectionPanel( internal fun detailsText() = details.text + internal fun detailsColor() = details.foreground + internal fun retryVisible() = retry.isVisible internal fun retryText() = retry.text diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index 3a3df476d87..80de3621b36 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -686,15 +686,15 @@ class SessionController( if (app.status == KiloAppStatusDto.ERROR) { return SessionControllerEvent.ConnectionChanged.ShowError( - app.error ?: KiloBundle.message("session.connection.error.unknown"), - app.errors.toErrorText(), + KiloBundle.message("session.connection.error.app"), + app.errors.toErrorText() ?: app.error, ) } if (workspace.status == KiloWorkspaceStatusDto.ERROR) { return SessionControllerEvent.ConnectionChanged.ShowError( - workspace.error ?: KiloBundle.message("session.connection.error.unknown"), - null, + KiloBundle.message("session.connection.error.workspace"), + workspace.errors.toErrorText() ?: workspace.error, "workspace", ) } 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 d4df97b4b5a..b07d44b5b36 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -1,4 +1,6 @@ session.connection.connecting=Loading... +session.connection.error.app=Connection failed +session.connection.error.workspace=Workspace loading failed session.connection.error.unknown=Unknown error session.connection.retry=Try again session.connection.warning.config=Configuration warnings diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt index 3d29188009f..772275be866 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -50,6 +50,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertFalse(panel.toggleExpanded()) assertFalse(panel.detailsVisible()) assertEquals("stderr line\nconfig: HTTP 500: broken", panel.detailsText()) + assertEquals(UIUtil.getLabelForeground(), panel.detailsColor()) assertTrue(panel.retryVisible()) assertFalse(panel.retryFocusable()) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt index a753eb1e640..d5269a7ca86 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt @@ -93,7 +93,7 @@ class ConnectionDelayTest : SessionControllerTestBase() { pause(80) val event = events.filterIsInstance().single() - assertEquals("CLI startup failed", event.summary) + assertEquals("Connection failed", event.summary) assertEquals("stderr line\nconfig: HTTP 500", event.detail) } @@ -111,7 +111,8 @@ class ConnectionDelayTest : SessionControllerTestBase() { pause(150) val errors = events.filterIsInstance() - assertEquals(listOf("second"), errors.map { it.summary }) + assertEquals(listOf("Connection failed"), errors.map { it.summary }) + assertEquals(listOf("second"), errors.map { it.detail }) } fun `test persistent workspace error is delayed`() { @@ -122,15 +123,19 @@ class ConnectionDelayTest : SessionControllerTestBase() { flush() events.clear() - projectRpc.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.ERROR, error = "workspace failed") + projectRpc.state.value = KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.ERROR, + error = "workspace failed", + errors = listOf(LoadErrorDto(resource = "providers", detail = "bad provider json")), + ) pause(20) assertFalse(events.any { it is SessionControllerEvent.ConnectionChanged.ShowError }) pause(80) val event = events.filterIsInstance().single() - assertEquals("workspace failed", event.summary) - assertNull(event.detail) + assertEquals("Workspace loading failed", event.summary) + assertEquals("providers: bad provider json", event.detail) } fun `test ready hides visible delayed connection banner immediately`() { diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt index c1dd8585db5..8b162af5d14 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt @@ -27,4 +27,5 @@ data class KiloWorkspaceStateDto( val commands: List = emptyList(), val skills: List = emptyList(), val error: String? = null, + val errors: List = emptyList(), ) From bc91af398db6e52e4a6bbd1d26efce1b306d4697 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Apr 2026 20:53:29 -0400 Subject: [PATCH 36/96] fix(vscode): refine prompt toolbar layout --- .changeset/center-sidebar-toolbar.md | 5 + .../src/components/chat/ChatView.tsx | 206 +++++++++--------- .../webview-ui/src/styles/session-actions.css | 13 +- 3 files changed, 112 insertions(+), 112 deletions(-) create mode 100644 .changeset/center-sidebar-toolbar.md diff --git a/.changeset/center-sidebar-toolbar.md b/.changeset/center-sidebar-toolbar.md new file mode 100644 index 00000000000..f44e3b3b055 --- /dev/null +++ b/.changeset/center-sidebar-toolbar.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Center the sidebar prompt toolbar on wider sidebars and hide unavailable toolbar actions. diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 0b656f5c02f..46daa3d76f3 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -190,116 +190,120 @@ export const ChatView: Component = (props) => { vscode.postMessage({ type: "agentManager.requestRepoInfo" }) }) + const canStartSession = (hasChat: boolean) => hasChat + + const canStartWorktree = () => isSidebar() && server.gitInstalled() + + const canMoveToWorktree = (hasChat: boolean) => hasChat && canContinueInWorktree() && server.gitInstalled() + + const hasActions = (hasChat: boolean) => canStartSession(hasChat) || canStartWorktree() || canMoveToWorktree(hasChat) + const renderActions = (hasChat: boolean) => ( -
-
- - - - -
- + +
+
+ + - worktreeRef?.getBoundingClientRect()}> - - + +
+ + - - - - - - - - {language.t("agentManager.dialog.configureWorktree")} - - - - - -
-
- - <> - - - - - - - - + worktreeRef?.getBoundingClientRect()}> + + + + + + + + + + + + + {language.t("agentManager.dialog.configureWorktree")} + + + + + +
+ + + <> + + + + + + + + +
-
+ ) return ( @@ -332,7 +336,7 @@ export const ChatView: Component = (props) => { /> )} - + {renderActions(hasMessages())} diff --git a/packages/kilo-vscode/webview-ui/src/styles/session-actions.css b/packages/kilo-vscode/webview-ui/src/styles/session-actions.css index e027aa6d406..3e3378cf11b 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/session-actions.css +++ b/packages/kilo-vscode/webview-ui/src/styles/session-actions.css @@ -22,7 +22,7 @@ align-items: center; flex-wrap: nowrap; min-height: 22px; - justify-content: flex-start; + justify-content: center; } .session-actions-row > * { @@ -36,10 +36,6 @@ flex: 0 0 auto; } -.session-actions-row > .session-move-changes-trigger { - margin-left: auto; -} - .session-actions-row > [data-component="tooltip-trigger"] > [data-component="button"] { flex: 0 0 auto; border-radius: var(--session-action-radius); @@ -333,6 +329,7 @@ .session-actions-row { gap: 4px; flex-wrap: wrap; + justify-content: flex-start; } .session-actions-row > *, @@ -363,9 +360,3 @@ opacity: 1; } } - -@container (max-width: 420px) { - .session-actions-row > .session-move-changes-trigger { - margin-left: 0; - } -} From 3165def37737cff4e21e1f2f0e3b04f7c882ca4b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 00:58:21 +0000 Subject: [PATCH 37/96] chore: update kilo-vscode visual regression baselines --- .../visual-regression/chat/chat-view-idle-chromium-linux.png | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-idle-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-idle-chromium-linux.png index 32ab1dbd8c6..2cd1d92c8c1 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-idle-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-idle-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6fbf2dc34c9c1ba549244a750a5ce828089fc378fa2352645c9a2267270a0c08 -size 18021 +oid sha256:d7eede94f30f617eab9435e21861f2d56b85bdf347c94f1acfdedef0be65ee77 +size 16452 From 7dc7dad6614057e5e94f71e98e0c2522f347a218 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 29 Apr 2026 11:10:51 +0300 Subject: [PATCH 38/96] fix(cli): stabilize external directory permission tests --- .../src/kilocode/permission/config-paths.ts | 2 +- packages/opencode/src/tool/bash.ts | 8 ++- .../external-directory-allow.test.ts | 57 +++++++++++-------- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/kilocode/permission/config-paths.ts b/packages/opencode/src/kilocode/permission/config-paths.ts index 17671dd1782..500f241a9e2 100644 --- a/packages/opencode/src/kilocode/permission/config-paths.ts +++ b/packages/opencode/src/kilocode/permission/config-paths.ts @@ -95,7 +95,7 @@ export namespace ConfigProtection { // Bash read-only file commands may read global config when explicitly allowed. if (request.metadata?.access === "read") return false for (const pattern of request.patterns) { - const dir = pattern.replace(/\/\*$/, "") + const dir = pattern.replace(/[\\/]\*$/, "") if (path.isAbsolute(dir) && isAbsolute(dir)) return true } return false diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 3a5ba91277f..274ac41cd8f 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -389,9 +389,15 @@ export const BashTool = Tool.define( access: "read", // kilocode_change } + const nodes = commands(root) // kilocode_change if (root.descendantsOfType("file_redirect").length > 0) scan.access = "unknown" // kilocode_change + // kilocode_change start + if (nodes.some((node) => !READ.has((ps ? parts(node)[0]?.text.toLowerCase() : parts(node)[0]?.text) ?? ""))) { + scan.access = "unknown" + } + // kilocode_change end - for (const node of commands(root)) { + for (const node of nodes) { const command = parts(node) const tokens = command.map((item) => item.text) const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] diff --git a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts index 39d5eb60605..d2e322d1780 100644 --- a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts +++ b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts @@ -52,6 +52,13 @@ const init = () => runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => inf const quote = (text: string) => `"${text.replaceAll('"', '\\"')}"` const glob = (file: string) => process.platform === "win32" ? AppFileSystem.normalizePathPattern(file) : file.replaceAll("\\", "/") +const variants = (dir: string) => { + if (process.platform !== "win32") return [dir] + const full = AppFileSystem.normalizePath(dir) + const slash = full.replaceAll("\\", "/") + const root = slash.replace(/^[A-Za-z]:/, "") + return Array.from(new Set([full, slash, root, root.toLowerCase()])) +} const config = path.resolve(Global.Path.config) const configFile = path.join(config, "hello.txt") const configGlob = glob(path.join(config, "*")) @@ -159,33 +166,35 @@ describe("external_directory allow config protection", () => { }) }) - test("keeps unknown bash external_directory requests for global config paths protected", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const pending = Permission.ask({ - id: PermissionID.make("permission_bash_external_write"), - sessionID: SessionID.make("session_bash_external_write"), - permission: "external_directory", - patterns: [configGlob], - metadata: { command: `rm ${quote(configFile)}` }, - always: [configGlob], - ruleset, - }) + for (const pattern of variants(configGlob)) { + test(`keeps unknown bash external_directory requests for global config paths protected [${pattern}]`, async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const pending = Permission.ask({ + id: PermissionID.make("permission_bash_external_write"), + sessionID: SessionID.make("session_bash_external_write"), + permission: "external_directory", + patterns: [pattern], + metadata: { command: `rm ${quote(configFile)}` }, + always: [pattern], + ruleset, + }) - const requests = await wait(1) - expect(requests[0]).toMatchObject({ - id: PermissionID.make("permission_bash_external_write"), - permission: "external_directory", - metadata: { disableAlways: true }, - }) + const requests = await wait(1) + expect(requests[0]).toMatchObject({ + id: PermissionID.make("permission_bash_external_write"), + permission: "external_directory", + metadata: { disableAlways: true }, + }) - await Permission.reply({ requestID: PermissionID.make("permission_bash_external_write"), reply: "reject" }) - await expect(pending).rejects.toBeInstanceOf(Permission.RejectedError) - }, + await Permission.reply({ requestID: PermissionID.make("permission_bash_external_write"), reply: "reject" }) + await expect(pending).rejects.toBeInstanceOf(Permission.RejectedError) + }, + }) }) - }) + } }) describe("bash external_directory access metadata", () => { From 9ff1e20743d0b03edee3cf080ab1d2c152ebc9d0 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 29 Apr 2026 11:15:47 +0300 Subject: [PATCH 39/96] chore(cli): annotate bash node iteration --- packages/opencode/src/tool/bash.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 274ac41cd8f..edaddeb714e 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -397,7 +397,7 @@ export const BashTool = Tool.define( } // kilocode_change end - for (const node of nodes) { + for (const node of nodes) { // kilocode_change const command = parts(node) const tokens = command.map((item) => item.text) const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] From 0bf14eb2ff5ef59f9dc98342218addc670a87481 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:52:51 +0000 Subject: [PATCH 40/96] chore(telemetry): remove ai.* and gen_ai.* span emission Drops the PostHogSpanExporter that converted AI SDK OpenTelemetry spans into PostHog ai.* / gen_ai.* events, and disables AI SDK experimental_telemetry in the CLI so those spans are no longer emitted. Tool/session/indexing telemetry is unchanged. --- .changeset/remove-ai-gen_ai-telemetry.md | 6 + bun.lock | 6 - packages/kilo-telemetry/package.json | 8 +- .../src/__tests__/telemetry.test.ts | 127 ---------- packages/kilo-telemetry/src/index.ts | 2 - packages/kilo-telemetry/src/otel-exporter.ts | 231 ------------------ packages/kilo-telemetry/src/telemetry.ts | 22 -- packages/kilo-telemetry/src/tracer.ts | 71 ------ packages/opencode/src/kilocode/agent/index.ts | 14 +- packages/opencode/src/session/llm.ts | 11 +- 10 files changed, 12 insertions(+), 486 deletions(-) create mode 100644 .changeset/remove-ai-gen_ai-telemetry.md delete mode 100644 packages/kilo-telemetry/src/otel-exporter.ts delete mode 100644 packages/kilo-telemetry/src/tracer.ts diff --git a/.changeset/remove-ai-gen_ai-telemetry.md b/.changeset/remove-ai-gen_ai-telemetry.md new file mode 100644 index 00000000000..5479a8bab1c --- /dev/null +++ b/.changeset/remove-ai-gen_ai-telemetry.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Stop emitting `ai.*` and `gen_ai.*` OpenTelemetry spans from AI SDK calls, and remove the PostHog bridge that forwarded them. Tool/session/indexing telemetry is unchanged. diff --git a/bun.lock b/bun.lock index cf6fa323bd1..9f017ccf39f 100644 --- a/bun.lock +++ b/bun.lock @@ -285,12 +285,6 @@ "version": "7.2.26", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", - "@opentelemetry/api": "1.9.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1", - "@opentelemetry/sdk-trace-node": "2.6.1", - "@opentelemetry/semantic-conventions": "1.40.0", "posthog-node": "4.4.0", }, "devDependencies": { diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 69aa0259dc9..46ec4c39d81 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -18,13 +18,7 @@ }, "dependencies": { "posthog-node": "4.4.0", - "@kilocode/kilo-gateway": "workspace:*", - "@opentelemetry/api": "1.9.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1", - "@opentelemetry/sdk-trace-node": "2.6.1", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/semantic-conventions": "1.40.0" + "@kilocode/kilo-gateway": "workspace:*" }, "devDependencies": { "@tsconfig/bun": "catalog:", diff --git a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts index 38470f03eb7..4c3c62714ad 100644 --- a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts +++ b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts @@ -2,21 +2,6 @@ import { describe, test, expect, beforeEach } from "bun:test" import { Identity } from "../identity.js" import { TelemetryEvent } from "../events.js" import { Telemetry } from "../telemetry.js" -import { PostHogSpanExporter } from "../otel-exporter.js" -import { ExportResultCode } from "@opentelemetry/core" -import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" -import type { PostHog } from "posthog-node" - -function createMockPostHogClient(): PostHog { - return { - capture: () => {}, - alias: () => {}, - flush: async () => {}, - shutdown: async () => {}, - optIn: () => {}, - optOut: () => {}, - } as unknown as PostHog -} describe("Identity", () => { beforeEach(() => { @@ -104,115 +89,3 @@ describe("Telemetry", () => { expect(typeof Telemetry.trackIndexingError).toBe("function") }) }) - -describe("PostHogSpanExporter", () => { - function createMockSpan(name: string, attributes: Record): ReadableSpan { - return { - name, - attributes, - spanContext: () => ({ - traceId: "trace-123", - spanId: "span-456", - traceFlags: 1, - }), - parentSpanContext: undefined, - startTime: [1000, 0], - endTime: [1001, 0], - status: { code: 0 }, - kind: 0, - resource: { attributes: {} }, - instrumentationScope: { name: "test" }, - events: [], - links: [], - ended: true, - duration: [1, 0], - droppedAttributesCount: 0, - droppedEventsCount: 0, - droppedLinksCount: 0, - } as unknown as ReadableSpan - } - - test("export returns success when disabled", () => { - const exporter = new PostHogSpanExporter(createMockPostHogClient(), { - appName: "test", - appVersion: "1.0.0", - platform: "test", - }) - exporter.setEnabled(false) - - const span = createMockSpan("ai.generateText", { "ai.model.id": "gpt-4" }) - let result: { code: number } | null = null - - exporter.export([span], (r) => { - result = r - }) - - expect(result).not.toBeNull() - expect(result!.code).toBe(ExportResultCode.SUCCESS) - }) - - test("sensitive attributes are not included in exported properties", () => { - // This test verifies the filtering logic by checking the SENSITIVE_ATTRIBUTES set - // and the mapAttributes method behavior through the export function - const exporter = new PostHogSpanExporter(createMockPostHogClient(), { - appName: "test", - appVersion: "1.0.0", - platform: "test", - }) - - // Create a span with both safe and sensitive attributes - const span = createMockSpan("ai.generateText", { - // Safe attributes (should be passed through) - "ai.model.id": "gpt-4", - "ai.model.provider": "openai", - "gen_ai.request.model": "gpt-4", - "gen_ai.usage.input_tokens": 100, - "gen_ai.usage.output_tokens": 50, - // Sensitive attributes (should be filtered) - "ai.prompt": '{"messages": [{"role": "user", "content": "secret data"}]}', - "ai.prompt.messages": '[{"role": "user", "content": "secret"}]', - "ai.response.text": "This is a secret response", - "ai.toolCall.args": '{"secret": "value"}', - "ai.toolCall.result": '{"result": "secret"}', - "gen_ai.prompt": "secret prompt", - "gen_ai.completion": "secret completion", - }) - - // The exporter should complete successfully - let result: { code: number } | null = null - exporter.export([span], (r) => { - result = r - }) - - expect(result).not.toBeNull() - expect(result!.code).toBe(ExportResultCode.SUCCESS) - }) - - test("SENSITIVE_ATTRIBUTES blocklist contains all required patterns", () => { - // Verify all sensitive attribute patterns are in the blocklist - const sensitivePatterns = [ - "ai.prompt", - "ai.prompt.messages", - "ai.response.text", - "ai.response.toolCalls", - "ai.toolCall.args", - "ai.toolCall.result", - "ai.value", - "ai.values", - "ai.embedding", - "ai.embeddings", - "ai.prompt.tools", - "gen_ai.prompt", - "gen_ai.completion", - "gen_ai.input.messages", - "gen_ai.output.messages", - "gen_ai.system_instructions", - "gen_ai.tool.definitions", - ] - - // Import the module to check the exported constant exists - // Since SENSITIVE_ATTRIBUTES is not exported, we verify through behavior - // by ensuring the exporter handles these attributes correctly - expect(sensitivePatterns.length).toBe(17) - }) -}) diff --git a/packages/kilo-telemetry/src/index.ts b/packages/kilo-telemetry/src/index.ts index efd9c211115..10f657fc46e 100644 --- a/packages/kilo-telemetry/src/index.ts +++ b/packages/kilo-telemetry/src/index.ts @@ -1,6 +1,4 @@ export { Telemetry } from "./telemetry.js" export { TelemetryEvent } from "./events.js" export { Identity } from "./identity.js" -export { TracerSetup } from "./tracer.js" -export { PostHogSpanExporter } from "./otel-exporter.js" export type { TelemetryProperties } from "./telemetry.js" diff --git a/packages/kilo-telemetry/src/otel-exporter.ts b/packages/kilo-telemetry/src/otel-exporter.ts deleted file mode 100644 index a672e76fcf0..00000000000 --- a/packages/kilo-telemetry/src/otel-exporter.ts +++ /dev/null @@ -1,231 +0,0 @@ -import type { SpanExporter, ReadableSpan } from "@opentelemetry/sdk-trace-base" -import type { ExportResult } from "@opentelemetry/core" -import { ExportResultCode } from "@opentelemetry/core" -import type { PostHog } from "posthog-node" -import { Identity } from "./identity.js" - -/** - * Sensitive attributes that should never be sent to PostHog. - * This is a defense-in-depth measure - the AI SDK should already filter these - * when recordInputs/recordOutputs are set to false. - */ -const SENSITIVE_ATTRIBUTES = new Set([ - // AI SDK prompt/message content - "ai.prompt", - "ai.prompt.messages", - "ai.response.text", - "ai.response.toolCalls", - "ai.toolCall.args", - "ai.toolCall.result", - "ai.value", - "ai.values", - "ai.embedding", - "ai.embeddings", - "ai.prompt.tools", - // OpenTelemetry GenAI semantic conventions - "gen_ai.prompt", - "gen_ai.completion", - "gen_ai.input.messages", - "gen_ai.output.messages", - "gen_ai.system_instructions", - "gen_ai.tool.definitions", -]) - -/** - * PostHogSpanExporter converts OpenTelemetry spans to PostHog AI events. - * Maps OTel spans to PostHog's $ai_span, $ai_generation, and $ai_trace events. - */ -export class PostHogSpanExporter implements SpanExporter { - private client: PostHog - private enabled = true - private appName: string - private appVersion: string - private platform: string - private editorName?: string - private vscodeVersion?: string - - constructor( - client: PostHog, - options: { appName: string; appVersion: string; platform: string; editorName?: string; vscodeVersion?: string }, - ) { - this.client = client - this.appName = options.appName - this.appVersion = options.appVersion - this.platform = options.platform - this.editorName = options.editorName - this.vscodeVersion = options.vscodeVersion - } - - setEnabled(value: boolean) { - this.enabled = value - if (value) this.client.optIn() - else this.client.optOut() - } - - export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void { - if (!this.enabled) { - resultCallback({ code: ExportResultCode.SUCCESS }) - return - } - - for (const span of spans) { - this.exportSpan(span) - } - - resultCallback({ code: ExportResultCode.SUCCESS }) - } - - private exportSpan(span: ReadableSpan) { - const distinctId = Identity.getDistinctId() - const orgId = Identity.getOrganizationId() - - const name = span.name - const attrs = span.attributes - const duration = span.endTime[0] - span.startTime[0] + (span.endTime[1] - span.startTime[1]) / 1e9 - - // Determine event type based on span name/attributes - const eventType = this.determineEventType(name, attrs) - - // Build PostHog AI event properties - const properties: Record = { - appName: this.appName, - appVersion: this.appVersion, - platform: this.platform, - ...(this.editorName && { editorName: this.editorName }), - ...(this.vscodeVersion && { vscodeVersion: this.vscodeVersion }), - $ai_trace_id: span.spanContext().traceId, - $ai_span_id: span.spanContext().spanId, - $ai_span_name: name, - $ai_latency: duration, - ...(orgId && { kilocodeOrganizationId: orgId }), - } - - // Add parent ID if present - const parentSpanId = span.parentSpanContext?.spanId - if (parentSpanId) { - properties.$ai_parent_id = parentSpanId - } - - // Map span attributes to PostHog properties - this.mapAttributes(attrs, properties) - - // Handle errors - if (span.status.code === 2) { - // SpanStatusCode.ERROR - properties.$ai_is_error = true - properties.$ai_error = span.status.message || "Unknown error" - } - - // Capture the event - this.client.capture({ - distinctId, - event: eventType, - properties, - }) - } - - private determineEventType(name: string, attrs: Record): string { - // AI SDK spans typically have these patterns: - // - "ai.generateText" / "ai.streamText" -> $ai_generation - // - "ai.toolCall" -> $ai_span (tool call) - // - "ai.embed" -> $ai_span (embedding) - - const lowerName = name.toLowerCase() - - if ( - lowerName.includes("generatetext") || - lowerName.includes("streamtext") || - lowerName.includes("generateobject") || - lowerName.includes("streamobject") - ) { - return "$ai_generation" - } - - if (lowerName.includes("toolcall") || lowerName.includes("tool")) { - return "$ai_span" - } - - if (lowerName.includes("embed")) { - return "$ai_span" - } - - // Check for gen_ai semantic conventions - if (attrs["gen_ai.operation.name"]) { - const op = String(attrs["gen_ai.operation.name"]) - if (op === "chat" || op === "text_completion") { - return "$ai_generation" - } - } - - // Default to span for other AI operations - return "$ai_span" - } - - private mapAttributes(attrs: Record, props: Record) { - // Map OpenTelemetry semantic conventions to PostHog AI properties - - // Model info - if (attrs["gen_ai.request.model"]) { - props.$ai_model = attrs["gen_ai.request.model"] - } - if (attrs["gen_ai.system"]) { - props.$ai_provider = attrs["gen_ai.system"] - } - - // Token usage - if (attrs["gen_ai.usage.input_tokens"]) { - props.$ai_input_tokens = attrs["gen_ai.usage.input_tokens"] - } - if (attrs["gen_ai.usage.output_tokens"]) { - props.$ai_output_tokens = attrs["gen_ai.usage.output_tokens"] - } - if (attrs["gen_ai.usage.total_tokens"]) { - props.$ai_total_tokens = attrs["gen_ai.usage.total_tokens"] - } - - // Track presence of prompt/completion without content (for analytics) - if (attrs["gen_ai.prompt"] || attrs["ai.prompt"]) { - props.$ai_has_prompt = true - } - if (attrs["gen_ai.completion"] || attrs["ai.response.text"]) { - props.$ai_has_completion = true - } - - // Tool calls - only track name, not args/result - if (attrs["ai.toolCall.name"]) { - props.$ai_tool_name = attrs["ai.toolCall.name"] - } - - // Temperature and other settings - if (attrs["gen_ai.request.temperature"]) { - props.$ai_temperature = attrs["gen_ai.request.temperature"] - } - if (attrs["gen_ai.request.max_tokens"]) { - props.$ai_max_tokens = attrs["gen_ai.request.max_tokens"] - } - - // Finish reason - if (attrs["gen_ai.response.finish_reasons"]) { - props.$ai_finish_reason = attrs["gen_ai.response.finish_reasons"] - } - - // Copy safe ai.* prefixed attributes (filter out sensitive ones) - for (const [key, value] of Object.entries(attrs)) { - if (!key.startsWith("ai.")) continue - if (SENSITIVE_ATTRIBUTES.has(key)) continue - // Additional pattern-based filtering for safety - if (key.includes("prompt") || key.includes("messages") || key.includes("response.text")) continue - const propKey = key.replace(/\./g, "_") - props[propKey] = value - } - } - - async shutdown(): Promise { - // Only flush, don't shutdown - the shared client is managed by Client namespace - await this.client.flush() - } - - async forceFlush(): Promise { - await this.client.flush() - } -} diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 7a80c9e39c0..94a8d946ce8 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -1,8 +1,6 @@ import { Client } from "./client.js" import { Identity } from "./identity.js" import { TelemetryEvent } from "./events.js" -import { TracerSetup } from "./tracer.js" -import type { Tracer } from "@opentelemetry/api" export interface TelemetryProperties { appName: string @@ -83,16 +81,6 @@ export namespace Telemetry { const enabled = level ? level === "all" : options.enabled Client.setEnabled(enabled) - // Initialize OpenTelemetry tracer for AI SDK spans - TracerSetup.init({ - version: props.appVersion, - enabled, - appName: props.appName, - platform: props.platform, - editorName: props.editorName, - vscodeVersion: props.vscodeVersion, - }) - await Identity.getMachineId() initialized = true @@ -101,15 +89,6 @@ export namespace Telemetry { export function setEnabled(value: boolean) { Client.setEnabled(value) - TracerSetup.setEnabled(value) - } - - /** - * Get the OpenTelemetry tracer for use with AI SDK's experimental_telemetry. - * Returns null if telemetry is not initialized. - */ - export function getTracer(): Tracer | null { - return TracerSetup.getTracer() } export function isEnabled(): boolean { @@ -263,7 +242,6 @@ export namespace Telemetry { } export async function shutdown(): Promise { - await TracerSetup.shutdown() await Client.shutdown() } } diff --git a/packages/kilo-telemetry/src/tracer.ts b/packages/kilo-telemetry/src/tracer.ts deleted file mode 100644 index b49ef7e5375..00000000000 --- a/packages/kilo-telemetry/src/tracer.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node" -import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base" -import { resourceFromAttributes } from "@opentelemetry/resources" -import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions" -import type { Tracer } from "@opentelemetry/api" -import { PostHogSpanExporter } from "./otel-exporter.js" -import { Client } from "./client.js" - -let provider: NodeTracerProvider | null = null -let exporter: PostHogSpanExporter | null = null -let tracer: Tracer | null = null - -export namespace TracerSetup { - export function init(options: { - version: string - enabled: boolean - appName: string - platform: string - editorName?: string - vscodeVersion?: string - }): Tracer { - if (tracer) return tracer - - const client = Client.getClient() - if (!client) { - throw new Error("PostHog client not initialized. Call Client.init() first.") - } - - exporter = new PostHogSpanExporter(client, { - appName: options.appName, - appVersion: options.version, - platform: options.platform, - editorName: options.editorName, - vscodeVersion: options.vscodeVersion, - }) - exporter.setEnabled(options.enabled) - - provider = new NodeTracerProvider({ - resource: resourceFromAttributes({ - [ATTR_SERVICE_NAME]: options.appName, - [ATTR_SERVICE_VERSION]: options.version, - }), - spanProcessors: [new SimpleSpanProcessor(exporter)], - }) - - // Register the provider globally so all tracers use our exporter - provider.register() - - // Get tracer from our provider - tracer = provider.getTracer(options.appName, options.version) - - return tracer - } - - export function getTracer(): Tracer | null { - return tracer - } - - export function setEnabled(value: boolean) { - exporter?.setEnabled(value) - } - - export async function shutdown(): Promise { - if (provider) { - await provider.shutdown() - provider = null - tracer = null - exporter = null - } - } -} diff --git a/packages/opencode/src/kilocode/agent/index.ts b/packages/opencode/src/kilocode/agent/index.ts index c5a4894d67d..39fb9ad264a 100644 --- a/packages/opencode/src/kilocode/agent/index.ts +++ b/packages/opencode/src/kilocode/agent/index.ts @@ -7,7 +7,6 @@ import { Config } from "../../config" import { Instance } from "../../project/instance" import { makeRuntime } from "@/effect/run-service" import { Global } from "@/global" -import { Telemetry } from "@kilocode/kilo-telemetry" import z from "zod" import path from "path" @@ -173,16 +172,9 @@ export function processConfigItem(item: { } // Returns experimental_telemetry config for generate calls. -export function telemetryOptions(cfg: Config.Info) { - return { - isEnabled: cfg.experimental?.openTelemetry !== false, - recordInputs: false, - recordOutputs: false, - tracer: Telemetry.getTracer() ?? undefined, - metadata: { - userId: cfg.username ?? "unknown", - }, - } +// AI SDK span recording (ai.* / gen_ai.*) is disabled. +export function telemetryOptions(_cfg: Config.Info) { + return { isEnabled: false as const } } // Patch the base agents map in-place with all kilo-specific changes: diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4e97daefaf4..82d5503c0a1 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -20,7 +20,6 @@ import { Wildcard } from "@/util" import { SessionID } from "@/session/schema" import { Auth } from "@/auth" // kilocode_change start -import { Telemetry } from "@kilocode/kilo-telemetry" import { DEFAULT_HEADERS } from "@/kilocode/const" import { getKiloProjectId } from "@/kilocode/project-id" import { HEADER_PROJECTID, HEADER_MACHINEID, HEADER_TASKID } from "@kilocode/kilo-gateway" @@ -430,14 +429,8 @@ const live: Layer.Layer< }, ], }), - // kilocode_change start - enable telemetry by default with custom PostHog tracer - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry !== false, - recordInputs: false, - recordOutputs: false, - tracer: Telemetry.getTracer() ?? undefined, - }, - // kilocode_change end + // kilocode_change - disable AI SDK span recording (ai.* / gen_ai.*) + experimental_telemetry: { isEnabled: false }, }) }) From 02adfec4ad628dc15cc06d8f573e25388ac0d754 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 29 Apr 2026 13:58:42 +0300 Subject: [PATCH 41/96] fix(cli): protect drive-stripped config paths on Windows --- .../src/kilocode/permission/config-paths.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/kilocode/permission/config-paths.ts b/packages/opencode/src/kilocode/permission/config-paths.ts index 500f241a9e2..fc084a0ee43 100644 --- a/packages/opencode/src/kilocode/permission/config-paths.ts +++ b/packages/opencode/src/kilocode/permission/config-paths.ts @@ -53,21 +53,30 @@ export namespace ConfigProtection { return CONFIG_ROOT_FILES.has(normalized) } + function keys(p: string): string[] { + const resolved = path.resolve(p) + if (process.platform !== "win32") return [resolved] + + const full = resolved.replaceAll("\\", "/").toLowerCase() + return Array.from(new Set([full, full.replace(/^[a-z]:/, "")])) + } + /** Check if `child` is equal to or nested inside `parent`. */ function within(child: string, parent: string): boolean { - return child === parent || child.startsWith(parent + path.sep) + const sep = process.platform === "win32" ? "/" : path.sep + return keys(child).some((child) => + keys(parent).some((parent) => child === parent || child.startsWith(parent + sep)), + ) } /** Check if an absolute path is inside a known CLI config directory. */ export function isAbsolute(filepath: string): boolean { - const resolved = path.resolve(filepath) - // ~/.config/kilo/ (XDG config) - if (within(resolved, path.resolve(Global.Path.config))) return true + if (within(filepath, Global.Path.config)) return true // ~/.kilo/ and ~/.kilocode/ (legacy global dirs) for (const dir of KilocodePaths.globalDirs()) { - if (within(resolved, path.resolve(dir))) return true + if (within(filepath, dir)) return true } return false From 3e3ec9399194014669ee822f5446b40f0b376b75 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 29 Apr 2026 14:14:32 +0300 Subject: [PATCH 42/96] fix(kilo-docs): normalize source link extraction on Windows --- script/extract-source-links.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/script/extract-source-links.ts b/script/extract-source-links.ts index 6aa3acc59ec..25e78ddad46 100755 --- a/script/extract-source-links.ts +++ b/script/extract-source-links.ts @@ -98,12 +98,18 @@ function shouldSkipFile(filepath: string): boolean { if (/\.test\.[jt]sx?$/.test(filepath)) return true if (/\.spec\.[jt]sx?$/.test(filepath)) return true if (/\.stories\.[jt]sx?$/.test(filepath)) return true - if (/\/i18n\//.test(filepath) && !filepath.endsWith("en.ts")) return true + if (parts.includes("i18n") && path.basename(filepath) !== "en.ts") return true // kilocode_change const basename = path.basename(filepath) if (SKIP_FILES.includes(basename)) return true return false } +// kilocode_change start +function source(filepath: string): string { + return path.relative(ROOT, filepath).replaceAll(path.sep, "/") +} +// kilocode_change end + function clean(url: string): string { return url.replace(/[.),:;]+$/, "").replace(/<\/?\w+>$/, "") } @@ -115,16 +121,19 @@ async function extract(): Promise>> { for (const ext of EXTENSIONS) { const glob = new Glob(`**/*.${ext}`) for await (const entry of glob.scan({ cwd: dir, absolute: true })) { - if (shouldSkipFile(entry)) continue + // kilocode_change start + const file = source(entry) + if (shouldSkipFile(file)) continue const content = await Bun.file(entry).text() for (const line of content.split("\n")) { for (const match of line.matchAll(URL_RE)) { const url = clean(match[0]) if (shouldExclude(url)) continue if (!links.has(url)) links.set(url, new Set()) - links.get(url)!.add(path.relative(ROOT, entry)) + links.get(url)!.add(file) } } + // kilocode_change end } } } From 2366679f4ef44c2098b5cf2e59d934bc45d16900 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 29 Apr 2026 14:24:32 +0300 Subject: [PATCH 43/96] fix: keep local checks passing on Windows --- bun.lock | 1 + package.json | 1 + packages/app/src/custom-elements.d.ts | 4 +++- packages/kilo-docs/tsconfig.json | 4 ++-- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index cf6fa323bd1..abff85175c1 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "@changesets/changelog-github": "^0.5.1", "@changesets/cli": "^2.27.10", "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", "@types/mime-types": "3.0.1", "@typescript/native-preview": "catalog:", "glob": "13.0.5", diff --git a/package.json b/package.json index 099481de25e..06af98416ee 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "devDependencies": { "@actions/artifact": "5.0.1", "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", "@types/mime-types": "3.0.1", "@typescript/native-preview": "catalog:", "glob": "13.0.5", diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts index e4ea0d6cebd..ce6acfabc34 120000 --- a/packages/app/src/custom-elements.d.ts +++ b/packages/app/src/custom-elements.d.ts @@ -1 +1,3 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file +// kilocode_change start - keep Windows checkouts parseable when symlinks are disabled. +import "../../ui/src/custom-elements" +// kilocode_change end diff --git a/packages/kilo-docs/tsconfig.json b/packages/kilo-docs/tsconfig.json index 23c73185186..d94c3d4d701 100644 --- a/packages/kilo-docs/tsconfig.json +++ b/packages/kilo-docs/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "ES2017", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, @@ -10,7 +10,7 @@ "incremental": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx" From 60bac866968a2c85349d5a1b5089f89a1df0a680 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 29 Apr 2026 15:51:16 +0300 Subject: [PATCH 44/96] fix: handle CI symlinks and config paths --- .../src/kilocode/permission/config-paths.ts | 2 +- script/check-opencode-annotations.ts | 20 +++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/kilocode/permission/config-paths.ts b/packages/opencode/src/kilocode/permission/config-paths.ts index fc084a0ee43..6dd42da7776 100644 --- a/packages/opencode/src/kilocode/permission/config-paths.ts +++ b/packages/opencode/src/kilocode/permission/config-paths.ts @@ -105,7 +105,7 @@ export namespace ConfigProtection { if (request.metadata?.access === "read") return false for (const pattern of request.patterns) { const dir = pattern.replace(/[\\/]\*$/, "") - if (path.isAbsolute(dir) && isAbsolute(dir)) return true + if (isAbsolute(dir)) return true } return false } diff --git a/script/check-opencode-annotations.ts b/script/check-opencode-annotations.ts index ec5d5b46d24..53ab14b7c93 100644 --- a/script/check-opencode-annotations.ts +++ b/script/check-opencode-annotations.ts @@ -29,7 +29,7 @@ */ import { spawnSync } from "node:child_process" -import { readFileSync } from "node:fs" +import { existsSync, readFileSync } from "node:fs" import path from "node:path" const ROOT = path.resolve(import.meta.dir, "..") @@ -100,7 +100,7 @@ function isSource(file: string) { const ext = path.extname(file) if (SOURCE_EXTS.has(ext)) return true if (ext) return false - return readFileSync(path.join(ROOT, file), "utf8").startsWith("#!") + return content(file).startsWith("#!") // kilocode_change } function addedLines(file: string): Set { @@ -116,6 +116,19 @@ function addedLines(file: string): Set { return out } +// kilocode_change start +function content(file: string) { + const abs = path.join(ROOT, file) + if (existsSync(abs)) return readFileSync(abs, "utf8") + + const out = run("git", ["show", `HEAD:${file}`]) + const target = out.trim() + if (!target.startsWith("../")) return out + + return readFileSync(path.resolve(path.dirname(abs), target), "utf8") +} +// kilocode_change end + // Matches the start of a kilocode_change marker in JS, JSX, YAML, TOML, and shell comments. const MARKER_PREFIX = /(?:\/\/|\{?\s*\/\*|#)\s*kilocode_change\b/ @@ -182,8 +195,7 @@ for (const file of files) { const nums = addedLines(file) if (nums.size === 0) continue - const abs = path.join(ROOT, file) - const text = readFileSync(abs, "utf8") + const text = content(file) // kilocode_change const { lines, covered } = coveredLines(text) for (const n of nums) { From 5bdf98269bff683c5db90b47f9ef4367eefa6174 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 29 Apr 2026 16:01:12 +0300 Subject: [PATCH 45/96] test(cli): avoid parallel pending permission requests --- .../permission/external-directory-allow.test.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts index d2e322d1780..f1d55098e38 100644 --- a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts +++ b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts @@ -166,8 +166,8 @@ describe("external_directory allow config protection", () => { }) }) - for (const pattern of variants(configGlob)) { - test(`keeps unknown bash external_directory requests for global config paths protected [${pattern}]`, async () => { + test("keeps unknown bash external_directory requests for global config paths protected", async () => { + for (const pattern of variants(configGlob)) { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, @@ -193,8 +193,8 @@ describe("external_directory allow config protection", () => { await expect(pending).rejects.toBeInstanceOf(Permission.RejectedError) }, }) - }) - } + } + }) }) describe("bash external_directory access metadata", () => { @@ -292,17 +292,16 @@ describe("bash external_directory access metadata", () => { fn: async () => { const bash = await init() const file = path.join(outer.path, "hello.txt") - const commands = [ - `cat ${quote(file)} && rm ${quote(file)}`, - `cat ${quote(file)} && printf x > ${quote(file)}`, - ] + const commands = [`cat ${quote(file)} && rm ${quote(file)}`, `cat ${quote(file)} && printf x > ${quote(file)}`] for (const command of commands) { const err = new Error("stop after external permission") const requests: Array> = [] await expect( - Effect.runPromise(bash.execute({ command, description: "Read then write external file" }, capture(requests, err))), + Effect.runPromise( + bash.execute({ command, description: "Read then write external file" }, capture(requests, err)), + ), ).rejects.toThrow(err.message) const req = requests.find((item) => item.permission === "external_directory") From 8c1c5aa2f5cb9a3c8154d5b086dd2d856d9b212f Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 29 Apr 2026 16:33:50 +0300 Subject: [PATCH 46/96] test(cli): isolate config path variant coverage --- .../external-directory-allow.test.ts | 63 +++++++++++-------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts index f1d55098e38..d1a65eba5b8 100644 --- a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts +++ b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts @@ -14,6 +14,7 @@ import { Truncate } from "../../../src/tool" import { BashTool } from "../../../src/tool/bash" import { Plugin } from "../../../src/plugin" import { tmpdir } from "../../fixture/fixture" +import { ConfigProtection } from "../../../src/kilocode/permission/config-paths" const runtime = ManagedRuntime.make( Layer.mergeAll( @@ -113,7 +114,7 @@ async function immediate(pending: Promise) { } async function wait(count: number) { - for (const _ of Array.from({ length: 100 })) { + for (const _ of Array.from({ length: 500 })) { const list = await Permission.list() if (list.length === count) return list await Bun.sleep(10) @@ -166,34 +167,44 @@ describe("external_directory allow config protection", () => { }) }) + for (const pattern of variants(configGlob)) { + test(`detects unknown bash external_directory requests for global config paths [${pattern}]`, () => { + expect( + ConfigProtection.isRequest({ + permission: "external_directory", + patterns: [pattern], + metadata: { command: `rm ${quote(configFile)}` }, + }), + ).toBe(true) + }) + } + test("keeps unknown bash external_directory requests for global config paths protected", async () => { - for (const pattern of variants(configGlob)) { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const pending = Permission.ask({ - id: PermissionID.make("permission_bash_external_write"), - sessionID: SessionID.make("session_bash_external_write"), - permission: "external_directory", - patterns: [pattern], - metadata: { command: `rm ${quote(configFile)}` }, - always: [pattern], - ruleset, - }) + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const pending = Permission.ask({ + id: PermissionID.make("permission_bash_external_write"), + sessionID: SessionID.make("session_bash_external_write"), + permission: "external_directory", + patterns: [configGlob], + metadata: { command: `rm ${quote(configFile)}` }, + always: [configGlob], + ruleset, + }) - const requests = await wait(1) - expect(requests[0]).toMatchObject({ - id: PermissionID.make("permission_bash_external_write"), - permission: "external_directory", - metadata: { disableAlways: true }, - }) + const requests = await wait(1) + expect(requests[0]).toMatchObject({ + id: PermissionID.make("permission_bash_external_write"), + permission: "external_directory", + metadata: { disableAlways: true }, + }) - await Permission.reply({ requestID: PermissionID.make("permission_bash_external_write"), reply: "reject" }) - await expect(pending).rejects.toBeInstanceOf(Permission.RejectedError) - }, - }) - } + await Permission.reply({ requestID: PermissionID.make("permission_bash_external_write"), reply: "reject" }) + await expect(pending).rejects.toBeInstanceOf(Permission.RejectedError) + }, + }) }) }) From 9d84754482dc89896a30e2cafac11c5aaa189d10 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 29 Apr 2026 16:44:31 +0300 Subject: [PATCH 47/96] fix(cli): compare raw Windows config paths --- .../src/kilocode/permission/config-paths.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/kilocode/permission/config-paths.ts b/packages/opencode/src/kilocode/permission/config-paths.ts index 6dd42da7776..9bd66ea0500 100644 --- a/packages/opencode/src/kilocode/permission/config-paths.ts +++ b/packages/opencode/src/kilocode/permission/config-paths.ts @@ -54,11 +54,21 @@ export namespace ConfigProtection { } function keys(p: string): string[] { - const resolved = path.resolve(p) - if (process.platform !== "win32") return [resolved] + if (process.platform !== "win32") return [path.resolve(p)] - const full = resolved.replaceAll("\\", "/").toLowerCase() - return Array.from(new Set([full, full.replace(/^[a-z]:/, "")])) + const expand = (value: string) => { + const full = path.posix.normalize(value.replaceAll("\\", "/")).toLowerCase() + const msys = full.replace(/^\/([a-z])(?=\/)/, "$1:") + return [full, full.replace(/^[a-z]:/, ""), msys, msys.replace(/^[a-z]:/, "")] + } + + return Array.from(new Set([...expand(p), ...expand(path.resolve(p))])) + } + + function configs(): string[] { + return Array.from( + new Set([Global.Path.config, process.env.XDG_CONFIG_HOME ? path.join(process.env.XDG_CONFIG_HOME, "kilo") : ""]), + ).filter(Boolean) } /** Check if `child` is equal to or nested inside `parent`. */ @@ -72,7 +82,9 @@ export namespace ConfigProtection { /** Check if an absolute path is inside a known CLI config directory. */ export function isAbsolute(filepath: string): boolean { // ~/.config/kilo/ (XDG config) - if (within(filepath, Global.Path.config)) return true + for (const dir of configs()) { + if (within(filepath, dir)) return true + } // ~/.kilo/ and ~/.kilocode/ (legacy global dirs) for (const dir of KilocodePaths.globalDirs()) { From a72d2a65f858a62e2685f6bc29f571484598cbf6 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 29 Apr 2026 16:57:15 +0300 Subject: [PATCH 48/96] fix(cli): guard Windows config fallback paths --- .../src/kilocode/permission/config-paths.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/opencode/src/kilocode/permission/config-paths.ts b/packages/opencode/src/kilocode/permission/config-paths.ts index 9bd66ea0500..67b6a4385a4 100644 --- a/packages/opencode/src/kilocode/permission/config-paths.ts +++ b/packages/opencode/src/kilocode/permission/config-paths.ts @@ -71,6 +71,17 @@ export namespace ConfigProtection { ).filter(Boolean) } + function fallback(p: string): boolean { + if (process.platform !== "win32") return false + return keys(p).some( + (key) => + key.endsWith("/config/kilo") || + key.includes("/config/kilo/") || + key.endsWith("/.config/kilo") || + key.includes("/.config/kilo/"), + ) + } + /** Check if `child` is equal to or nested inside `parent`. */ function within(child: string, parent: string): boolean { const sep = process.platform === "win32" ? "/" : path.sep @@ -81,6 +92,8 @@ export namespace ConfigProtection { /** Check if an absolute path is inside a known CLI config directory. */ export function isAbsolute(filepath: string): boolean { + if (fallback(filepath)) return true + // ~/.config/kilo/ (XDG config) for (const dir of configs()) { if (within(filepath, dir)) return true From 3fa38e34aa71e68ec7bed20d65deda50d09b475e Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 29 Apr 2026 17:24:20 +0300 Subject: [PATCH 49/96] fix(app): make custom elements file real --- packages/app/src/custom-elements.d.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 120000 => 100644 packages/app/src/custom-elements.d.ts diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts deleted file mode 120000 index ce6acfabc34..00000000000 --- a/packages/app/src/custom-elements.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// kilocode_change start - keep Windows checkouts parseable when symlinks are disabled. -import "../../ui/src/custom-elements" -// kilocode_change end diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts new file mode 100644 index 00000000000..ce6acfabc34 --- /dev/null +++ b/packages/app/src/custom-elements.d.ts @@ -0,0 +1,3 @@ +// kilocode_change start - keep Windows checkouts parseable when symlinks are disabled. +import "../../ui/src/custom-elements" +// kilocode_change end From b7b107bb1698f6fa393d56413cb38029b0232c8f Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:31:15 +0000 Subject: [PATCH 50/96] docs(kilo-docs): add SEO titles and descriptions to AI provider pages Every page under /docs/ai-providers was falling back to the site-wide default 'Kilo Code Documentation' because the frontmatter only set sidebar_label. Search ranked these pages at positions 5-8, so unique provider-specific titles should improve click-through and rankings. --- packages/kilo-docs/pages/ai-providers/anthropic.md | 2 ++ packages/kilo-docs/pages/ai-providers/bedrock.md | 2 ++ packages/kilo-docs/pages/ai-providers/cerebras.md | 2 ++ packages/kilo-docs/pages/ai-providers/chutes-ai.md | 2 ++ packages/kilo-docs/pages/ai-providers/claude-code.md | 2 ++ packages/kilo-docs/pages/ai-providers/deepseek.md | 2 ++ packages/kilo-docs/pages/ai-providers/fireworks.md | 3 ++- packages/kilo-docs/pages/ai-providers/gemini.md | 2 ++ packages/kilo-docs/pages/ai-providers/glama.md | 2 ++ packages/kilo-docs/pages/ai-providers/groq.md | 2 ++ packages/kilo-docs/pages/ai-providers/human-relay.md | 6 ++++++ packages/kilo-docs/pages/ai-providers/inception.md | 2 ++ packages/kilo-docs/pages/ai-providers/kilocode.md | 2 ++ packages/kilo-docs/pages/ai-providers/lmstudio.md | 2 ++ packages/kilo-docs/pages/ai-providers/minimax.md | 2 ++ packages/kilo-docs/pages/ai-providers/mistral.md | 2 ++ packages/kilo-docs/pages/ai-providers/moonshot.md | 2 ++ packages/kilo-docs/pages/ai-providers/ollama.md | 2 ++ .../kilo-docs/pages/ai-providers/openai-chatgpt-plus-pro.md | 2 ++ packages/kilo-docs/pages/ai-providers/openai-compatible.md | 2 ++ packages/kilo-docs/pages/ai-providers/openai.md | 2 ++ packages/kilo-docs/pages/ai-providers/openrouter.md | 2 ++ packages/kilo-docs/pages/ai-providers/ovhcloud.md | 2 ++ packages/kilo-docs/pages/ai-providers/requesty.md | 2 ++ packages/kilo-docs/pages/ai-providers/sap-ai-core.md | 2 ++ packages/kilo-docs/pages/ai-providers/synthetic.md | 2 ++ packages/kilo-docs/pages/ai-providers/unbound.md | 2 ++ packages/kilo-docs/pages/ai-providers/v0.md | 2 ++ packages/kilo-docs/pages/ai-providers/vercel-ai-gateway.md | 1 + packages/kilo-docs/pages/ai-providers/vertex.md | 2 ++ .../kilo-docs/pages/ai-providers/virtual-quota-fallback.md | 2 ++ packages/kilo-docs/pages/ai-providers/vscode-lm.md | 2 ++ packages/kilo-docs/pages/ai-providers/xai.md | 2 ++ packages/kilo-docs/pages/ai-providers/zenmux.md | 3 ++- 34 files changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/kilo-docs/pages/ai-providers/anthropic.md b/packages/kilo-docs/pages/ai-providers/anthropic.md index 767aed584cb..e5e75b486e0 100644 --- a/packages/kilo-docs/pages/ai-providers/anthropic.md +++ b/packages/kilo-docs/pages/ai-providers/anthropic.md @@ -1,4 +1,6 @@ --- +title: "Using Anthropic Claude with Kilo Code" +description: "Configure Anthropic's Claude models in Kilo Code. Guide to getting an API key, setting up Claude Sonnet and Opus in VS Code and the CLI." sidebar_label: Anthropic --- diff --git a/packages/kilo-docs/pages/ai-providers/bedrock.md b/packages/kilo-docs/pages/ai-providers/bedrock.md index 64f4bb7aea2..5b6388dc0ab 100644 --- a/packages/kilo-docs/pages/ai-providers/bedrock.md +++ b/packages/kilo-docs/pages/ai-providers/bedrock.md @@ -1,4 +1,6 @@ --- +title: "Using AWS Bedrock with Kilo Code" +description: "Configure AWS Bedrock in Kilo Code to access Claude, Llama, and other foundation models through your AWS account." sidebar_label: AWS Bedrock --- diff --git a/packages/kilo-docs/pages/ai-providers/cerebras.md b/packages/kilo-docs/pages/ai-providers/cerebras.md index 24c19bdc421..6ca2f432ed0 100644 --- a/packages/kilo-docs/pages/ai-providers/cerebras.md +++ b/packages/kilo-docs/pages/ai-providers/cerebras.md @@ -1,4 +1,6 @@ --- +title: "Using Cerebras with Kilo Code | Ultra-Fast Inference" +description: "Run Llama and Qwen models at record-breaking speeds by configuring Cerebras in Kilo Code. Setup for VS Code and the CLI." sidebar_label: Cerebras --- diff --git a/packages/kilo-docs/pages/ai-providers/chutes-ai.md b/packages/kilo-docs/pages/ai-providers/chutes-ai.md index 348b3b84feb..e8b6ce70ab1 100644 --- a/packages/kilo-docs/pages/ai-providers/chutes-ai.md +++ b/packages/kilo-docs/pages/ai-providers/chutes-ai.md @@ -1,4 +1,6 @@ --- +title: "Using Chutes AI with Kilo Code" +description: "Access open-source AI models through Chutes AI in Kilo Code. Setup guide for getting an API key and configuring models." sidebar_label: Chutes AI --- diff --git a/packages/kilo-docs/pages/ai-providers/claude-code.md b/packages/kilo-docs/pages/ai-providers/claude-code.md index 239e3d779ca..f7a6f639018 100644 --- a/packages/kilo-docs/pages/ai-providers/claude-code.md +++ b/packages/kilo-docs/pages/ai-providers/claude-code.md @@ -1,4 +1,6 @@ --- +title: "Using Claude Code with Kilo Code" +description: "Use your Claude Code subscription with Kilo Code. Setup guide for authenticating and selecting Claude models." sidebar_label: Claude Code --- diff --git a/packages/kilo-docs/pages/ai-providers/deepseek.md b/packages/kilo-docs/pages/ai-providers/deepseek.md index d70c070f3e8..8323bc9d0c1 100644 --- a/packages/kilo-docs/pages/ai-providers/deepseek.md +++ b/packages/kilo-docs/pages/ai-providers/deepseek.md @@ -1,4 +1,6 @@ --- +title: "Using DeepSeek with Kilo Code" +description: "Connect DeepSeek's reasoning and coding models to Kilo Code. Setup guide for DeepSeek-V3 and DeepSeek-R1 in VS Code and the CLI." sidebar_label: DeepSeek --- diff --git a/packages/kilo-docs/pages/ai-providers/fireworks.md b/packages/kilo-docs/pages/ai-providers/fireworks.md index f354b533760..97db2ca21ff 100644 --- a/packages/kilo-docs/pages/ai-providers/fireworks.md +++ b/packages/kilo-docs/pages/ai-providers/fireworks.md @@ -1,5 +1,6 @@ --- -title: Fireworks AI with Kilo Code +title: "Using Fireworks AI with Kilo Code | Fast Inference" +description: "Run open-source and proprietary models on Fireworks AI's high-performance platform in Kilo Code. Setup guide for VS Code and the CLI." --- # Using Fireworks AI With Kilo Code diff --git a/packages/kilo-docs/pages/ai-providers/gemini.md b/packages/kilo-docs/pages/ai-providers/gemini.md index 3a0ffe09eec..775b9d244e5 100644 --- a/packages/kilo-docs/pages/ai-providers/gemini.md +++ b/packages/kilo-docs/pages/ai-providers/gemini.md @@ -1,4 +1,6 @@ --- +title: "Using Google Gemini with Kilo Code" +description: "Connect Google Gemini models to Kilo Code. Guide to getting an API key from Google AI Studio and configuring Gemini in VS Code and the CLI." sidebar_label: Google Gemini --- diff --git a/packages/kilo-docs/pages/ai-providers/glama.md b/packages/kilo-docs/pages/ai-providers/glama.md index fb000fc64bb..04a957b5d8d 100644 --- a/packages/kilo-docs/pages/ai-providers/glama.md +++ b/packages/kilo-docs/pages/ai-providers/glama.md @@ -1,4 +1,6 @@ --- +title: "Using Glama with Kilo Code" +description: "Connect Glama's unified AI gateway to Kilo Code to access Claude, GPT, and other leading models through a single API key." sidebar_label: Glama --- diff --git a/packages/kilo-docs/pages/ai-providers/groq.md b/packages/kilo-docs/pages/ai-providers/groq.md index 6e0ab33b4fc..9bbe353871d 100644 --- a/packages/kilo-docs/pages/ai-providers/groq.md +++ b/packages/kilo-docs/pages/ai-providers/groq.md @@ -1,4 +1,6 @@ --- +title: "Using Groq with Kilo Code | Fast LLM Inference" +description: "Run Llama, Mixtral, and other models at ultra-low latency by configuring Groq in Kilo Code. Setup guide for VS Code and the CLI." sidebar_label: Groq --- diff --git a/packages/kilo-docs/pages/ai-providers/human-relay.md b/packages/kilo-docs/pages/ai-providers/human-relay.md index f39befdbe9c..a0b3219310a 100644 --- a/packages/kilo-docs/pages/ai-providers/human-relay.md +++ b/packages/kilo-docs/pages/ai-providers/human-relay.md @@ -1,3 +1,9 @@ +--- +title: "Using Human Relay with Kilo Code | No API Key Needed" +description: "Use Kilo Code with web-based AI chats like ChatGPT and Claude without an API key by manually relaying messages via Human Relay." +sidebar_label: Human Relay +--- + # Human Relay Provider The Human Relay provider allows you to use Kilo Code with web-based AI models like ChatGPT or Claude without needing an API key. Instead, it relies on you to manually relay messages between Kilo Code and the AI's web interface. diff --git a/packages/kilo-docs/pages/ai-providers/inception.md b/packages/kilo-docs/pages/ai-providers/inception.md index 8431a37fc6f..1698531ff0c 100644 --- a/packages/kilo-docs/pages/ai-providers/inception.md +++ b/packages/kilo-docs/pages/ai-providers/inception.md @@ -1,4 +1,6 @@ --- +title: "Using Inception Labs with Kilo Code" +description: "Connect Inception Labs' Mercury diffusion LLMs to Kilo Code for ultra-fast code generation. Setup guide for VS Code and the CLI." sidebar_label: Inception --- diff --git a/packages/kilo-docs/pages/ai-providers/kilocode.md b/packages/kilo-docs/pages/ai-providers/kilocode.md index 706ac4a5f63..25b1341b7dd 100644 --- a/packages/kilo-docs/pages/ai-providers/kilocode.md +++ b/packages/kilo-docs/pages/ai-providers/kilocode.md @@ -1,4 +1,6 @@ --- +title: "Using the Kilo Code Provider" +description: "The built-in Kilo Code provider gives you access to top AI models with one account. Setup and sign-in guide." sidebar_label: Kilo Code Provider --- diff --git a/packages/kilo-docs/pages/ai-providers/lmstudio.md b/packages/kilo-docs/pages/ai-providers/lmstudio.md index f9aaf02832c..aca742cdd27 100644 --- a/packages/kilo-docs/pages/ai-providers/lmstudio.md +++ b/packages/kilo-docs/pages/ai-providers/lmstudio.md @@ -1,4 +1,6 @@ --- +title: "Using LM Studio with Kilo Code | Local LLMs" +description: "Run local LLMs in Kilo Code by connecting to LM Studio's OpenAI-compatible server. Setup guide for VS Code and the CLI." sidebar_label: LM Studio --- diff --git a/packages/kilo-docs/pages/ai-providers/minimax.md b/packages/kilo-docs/pages/ai-providers/minimax.md index 4f36072519f..4641f5d136b 100644 --- a/packages/kilo-docs/pages/ai-providers/minimax.md +++ b/packages/kilo-docs/pages/ai-providers/minimax.md @@ -1,4 +1,6 @@ --- +title: "Using MiniMax with Kilo Code" +description: "Configure MiniMax AI models in Kilo Code. Guide to getting an API key and setup for VS Code and the CLI." sidebar_label: MiniMax --- diff --git a/packages/kilo-docs/pages/ai-providers/mistral.md b/packages/kilo-docs/pages/ai-providers/mistral.md index e1ce39948b2..df400da55aa 100644 --- a/packages/kilo-docs/pages/ai-providers/mistral.md +++ b/packages/kilo-docs/pages/ai-providers/mistral.md @@ -1,4 +1,6 @@ --- +title: "Using Mistral AI with Kilo Code" +description: "Configure Mistral AI models, including Codestral, in Kilo Code. Guide to getting an API key and setup for VS Code and the CLI." sidebar_label: Mistral AI --- diff --git a/packages/kilo-docs/pages/ai-providers/moonshot.md b/packages/kilo-docs/pages/ai-providers/moonshot.md index 7b92cf10287..9f0bcaabe56 100644 --- a/packages/kilo-docs/pages/ai-providers/moonshot.md +++ b/packages/kilo-docs/pages/ai-providers/moonshot.md @@ -1,4 +1,6 @@ --- +title: "Using Moonshot AI (Kimi) with Kilo Code" +description: "Connect Moonshot's Kimi models to Kilo Code. Setup guide for getting an API key and selecting models in VS Code and the CLI." sidebar_label: Moonshot.ai --- diff --git a/packages/kilo-docs/pages/ai-providers/ollama.md b/packages/kilo-docs/pages/ai-providers/ollama.md index 3f2ba1887c2..a2782d9c451 100644 --- a/packages/kilo-docs/pages/ai-providers/ollama.md +++ b/packages/kilo-docs/pages/ai-providers/ollama.md @@ -1,4 +1,6 @@ --- +title: "Using Ollama with Kilo Code | Run Local Models" +description: "Run local AI models with Ollama in Kilo Code for offline, private coding. Setup guide for VS Code and the CLI." sidebar_label: Ollama --- diff --git a/packages/kilo-docs/pages/ai-providers/openai-chatgpt-plus-pro.md b/packages/kilo-docs/pages/ai-providers/openai-chatgpt-plus-pro.md index 36c6b3c0551..5331c364476 100644 --- a/packages/kilo-docs/pages/ai-providers/openai-chatgpt-plus-pro.md +++ b/packages/kilo-docs/pages/ai-providers/openai-chatgpt-plus-pro.md @@ -1,4 +1,6 @@ --- +title: "Using ChatGPT Plus/Pro with Kilo Code" +description: "Use your ChatGPT Plus or Pro subscription with Kilo Code. Setup guide for authenticating without a separate API key." sidebar_label: ChatGPT Plus/Pro --- diff --git a/packages/kilo-docs/pages/ai-providers/openai-compatible.md b/packages/kilo-docs/pages/ai-providers/openai-compatible.md index d8999c3d3b6..89551016dcd 100644 --- a/packages/kilo-docs/pages/ai-providers/openai-compatible.md +++ b/packages/kilo-docs/pages/ai-providers/openai-compatible.md @@ -1,4 +1,6 @@ --- +title: "Using OpenAI-Compatible Providers with Kilo Code" +description: "Connect any OpenAI-compatible API endpoint to Kilo Code, including Azure OpenAI, Together AI, and self-hosted servers." sidebar_label: OpenAI Compatible --- diff --git a/packages/kilo-docs/pages/ai-providers/openai.md b/packages/kilo-docs/pages/ai-providers/openai.md index 4c5a379b539..f100680ccfa 100644 --- a/packages/kilo-docs/pages/ai-providers/openai.md +++ b/packages/kilo-docs/pages/ai-providers/openai.md @@ -1,4 +1,6 @@ --- +title: "Using OpenAI with Kilo Code | Setup & Models" +description: "Connect the official OpenAI API to Kilo Code. Step-by-step guide to creating an API key and configuring GPT models in VS Code and the CLI." sidebar_label: OpenAI --- diff --git a/packages/kilo-docs/pages/ai-providers/openrouter.md b/packages/kilo-docs/pages/ai-providers/openrouter.md index bdcbbf1a528..0c08b0254c0 100644 --- a/packages/kilo-docs/pages/ai-providers/openrouter.md +++ b/packages/kilo-docs/pages/ai-providers/openrouter.md @@ -1,4 +1,6 @@ --- +title: "Using OpenRouter with Kilo Code | Unified AI API" +description: "Access hundreds of AI models through a single API by configuring OpenRouter in Kilo Code. Setup guide for VS Code and the CLI." sidebar_label: OpenRouter --- diff --git a/packages/kilo-docs/pages/ai-providers/ovhcloud.md b/packages/kilo-docs/pages/ai-providers/ovhcloud.md index 7b9890a2598..9e1a79b72bc 100644 --- a/packages/kilo-docs/pages/ai-providers/ovhcloud.md +++ b/packages/kilo-docs/pages/ai-providers/ovhcloud.md @@ -1,4 +1,6 @@ --- +title: "Using OVHcloud AI Endpoints with Kilo Code" +description: "Connect OVHcloud's European AI Endpoints to Kilo Code for sovereign, GDPR-compliant model access. Setup guide for VS Code and the CLI." sidebar_label: OVHcloud AI Endpoints --- diff --git a/packages/kilo-docs/pages/ai-providers/requesty.md b/packages/kilo-docs/pages/ai-providers/requesty.md index fda3c5796e3..7b35d99901d 100644 --- a/packages/kilo-docs/pages/ai-providers/requesty.md +++ b/packages/kilo-docs/pages/ai-providers/requesty.md @@ -1,4 +1,6 @@ --- +title: "Using Requesty with Kilo Code" +description: "Route AI model requests through Requesty in Kilo Code for cost optimization and access to multiple providers from one API key." sidebar_label: Requesty --- diff --git a/packages/kilo-docs/pages/ai-providers/sap-ai-core.md b/packages/kilo-docs/pages/ai-providers/sap-ai-core.md index 16dfec8bbdf..655148fe95c 100644 --- a/packages/kilo-docs/pages/ai-providers/sap-ai-core.md +++ b/packages/kilo-docs/pages/ai-providers/sap-ai-core.md @@ -1,4 +1,6 @@ --- +title: "Using SAP AI Core with Kilo Code" +description: "Connect SAP AI Core to Kilo Code to use enterprise foundation models from your SAP BTP account. Setup and authentication guide." sidebar_label: SAP AI Core --- diff --git a/packages/kilo-docs/pages/ai-providers/synthetic.md b/packages/kilo-docs/pages/ai-providers/synthetic.md index 853f3a68b63..96051ba442f 100644 --- a/packages/kilo-docs/pages/ai-providers/synthetic.md +++ b/packages/kilo-docs/pages/ai-providers/synthetic.md @@ -1,4 +1,6 @@ --- +title: "Using Synthetic with Kilo Code" +description: "Access open-source AI models through Synthetic in Kilo Code. Setup guide for getting an API key and configuring models." sidebar_label: Synthetic --- diff --git a/packages/kilo-docs/pages/ai-providers/unbound.md b/packages/kilo-docs/pages/ai-providers/unbound.md index fd7f6fb6e27..055bd113951 100644 --- a/packages/kilo-docs/pages/ai-providers/unbound.md +++ b/packages/kilo-docs/pages/ai-providers/unbound.md @@ -1,4 +1,6 @@ --- +title: "Using Unbound with Kilo Code" +description: "Access Claude, GPT, and other leading models through the Unbound proxy in Kilo Code. Setup guide for VS Code and the CLI." sidebar_label: Unbound --- diff --git a/packages/kilo-docs/pages/ai-providers/v0.md b/packages/kilo-docs/pages/ai-providers/v0.md index 2ba2ba4ac67..bd1d1b16c37 100644 --- a/packages/kilo-docs/pages/ai-providers/v0.md +++ b/packages/kilo-docs/pages/ai-providers/v0.md @@ -1,4 +1,6 @@ --- +title: "Using Vercel v0 with Kilo Code" +description: "Connect Vercel's v0 models to Kilo Code for UI-focused code generation. Setup guide for VS Code and the CLI." sidebar_label: v0 --- diff --git a/packages/kilo-docs/pages/ai-providers/vercel-ai-gateway.md b/packages/kilo-docs/pages/ai-providers/vercel-ai-gateway.md index bbfd9ccf991..58eee777d5e 100644 --- a/packages/kilo-docs/pages/ai-providers/vercel-ai-gateway.md +++ b/packages/kilo-docs/pages/ai-providers/vercel-ai-gateway.md @@ -1,4 +1,5 @@ --- +title: "Using Vercel AI Gateway with Kilo Code" description: Configure the Vercel AI Gateway in Kilo Code to robustly access 100+ language models from various providers through a centralized interface. keywords: - kilo code diff --git a/packages/kilo-docs/pages/ai-providers/vertex.md b/packages/kilo-docs/pages/ai-providers/vertex.md index abbb56da3b8..ea5bc6891e4 100644 --- a/packages/kilo-docs/pages/ai-providers/vertex.md +++ b/packages/kilo-docs/pages/ai-providers/vertex.md @@ -1,4 +1,6 @@ --- +title: "Using GCP Vertex AI with Kilo Code" +description: "Connect Google Cloud Vertex AI to Kilo Code to use Claude, Gemini, and other models through your GCP account." sidebar_label: GCP Vertex AI --- diff --git a/packages/kilo-docs/pages/ai-providers/virtual-quota-fallback.md b/packages/kilo-docs/pages/ai-providers/virtual-quota-fallback.md index e590eedbb93..fe6b6bd809c 100644 --- a/packages/kilo-docs/pages/ai-providers/virtual-quota-fallback.md +++ b/packages/kilo-docs/pages/ai-providers/virtual-quota-fallback.md @@ -1,4 +1,6 @@ --- +title: "Virtual Quota Fallback in Kilo Code" +description: "Configure automatic provider fallback in Kilo Code to handle quota limits and keep your AI coding workflow uninterrupted." sidebar_label: Virtual Quota Fallback --- diff --git a/packages/kilo-docs/pages/ai-providers/vscode-lm.md b/packages/kilo-docs/pages/ai-providers/vscode-lm.md index df8e16e1bd0..60ad4fbe7b1 100644 --- a/packages/kilo-docs/pages/ai-providers/vscode-lm.md +++ b/packages/kilo-docs/pages/ai-providers/vscode-lm.md @@ -1,4 +1,6 @@ --- +title: "Using the VS Code Language Model API with Kilo Code" +description: "Use GitHub Copilot's models in Kilo Code via the VS Code Language Model API. Setup guide and requirements." sidebar_label: VS Code Language Model API --- diff --git a/packages/kilo-docs/pages/ai-providers/xai.md b/packages/kilo-docs/pages/ai-providers/xai.md index 5435c6c7ecd..dc74332d781 100644 --- a/packages/kilo-docs/pages/ai-providers/xai.md +++ b/packages/kilo-docs/pages/ai-providers/xai.md @@ -1,4 +1,6 @@ --- +title: "Using xAI Grok with Kilo Code" +description: "Connect xAI's Grok models to Kilo Code. Guide to getting an API key and configuring Grok in VS Code and the CLI." sidebar_label: xAI (Grok) --- diff --git a/packages/kilo-docs/pages/ai-providers/zenmux.md b/packages/kilo-docs/pages/ai-providers/zenmux.md index de7985830eb..0092201a526 100644 --- a/packages/kilo-docs/pages/ai-providers/zenmux.md +++ b/packages/kilo-docs/pages/ai-providers/zenmux.md @@ -1,5 +1,6 @@ --- -title: ZenMux +title: "Using ZenMux with Kilo Code | Unified AI Gateway" +description: "Access OpenAI, Anthropic, Google, and other AI models through ZenMux in Kilo Code with automatic routing and cost optimization." --- import Codicon from "@site/src/components/Codicon"; From ddf9608bd39bedb99883eb9ef9890e10da3a3546 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:31:16 +0000 Subject: [PATCH 51/96] docs(kilo-docs): add redirects for deleted KiloClaw flat pages Commit 3494a56b2 removed /docs/kiloclaw/control-ui, /docs/kiloclaw/pricing, and /docs/kiloclaw/troubleshooting without adding redirects, producing 404s on high-traffic URLs. Redirect each to its new nested canonical location. --- packages/kilo-docs/previous-docs-redirects.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/kilo-docs/previous-docs-redirects.js b/packages/kilo-docs/previous-docs-redirects.js index 5a0f3027bce..ccec57cb35b 100644 --- a/packages/kilo-docs/previous-docs-redirects.js +++ b/packages/kilo-docs/previous-docs-redirects.js @@ -841,4 +841,22 @@ module.exports = [ basePath: false, permanent: true, }, + { + source: "/docs/kiloclaw/control-ui", + destination: "/docs/kiloclaw/control-ui/overview", + basePath: false, + permanent: true, + }, + { + source: "/docs/kiloclaw/pricing", + destination: "/docs/kiloclaw/faq/pricing", + basePath: false, + permanent: true, + }, + { + source: "/docs/kiloclaw/troubleshooting", + destination: "/docs/kiloclaw/troubleshooting/common-questions", + basePath: false, + permanent: true, + }, ] From 6ff4f3db92de6f7d9a93166edbc7020758e6b260 Mon Sep 17 00:00:00 2001 From: kilo-code-bot <kilo-code-bot@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:44:27 +0000 Subject: [PATCH 52/96] docs(kilo-docs): add redirect for deleted kiloclaw/version-pinning Commit 3494a56b2 also removed /docs/kiloclaw/version-pinning alongside the three flat pages this PR already covers; its content was moved to /docs/kiloclaw/control-ui/version-pinning. Add the missing redirect so the old URL no longer 404s. --- packages/kilo-docs/previous-docs-redirects.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/kilo-docs/previous-docs-redirects.js b/packages/kilo-docs/previous-docs-redirects.js index ccec57cb35b..44fe2ff0fdb 100644 --- a/packages/kilo-docs/previous-docs-redirects.js +++ b/packages/kilo-docs/previous-docs-redirects.js @@ -859,4 +859,10 @@ module.exports = [ basePath: false, permanent: true, }, + { + source: "/docs/kiloclaw/version-pinning", + destination: "/docs/kiloclaw/control-ui/version-pinning", + basePath: false, + permanent: true, + }, ] From d1998e8f0d1b0d3a6cdc47710d2f0dd7e94dfb13 Mon Sep 17 00:00:00 2001 From: "kilo-code-bot[bot]" <240665456+kilo-code-bot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:24:13 +0200 Subject: [PATCH 53/96] docs(kilo-docs): sync recent opencode user docs (#9661) Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../kilo-docs/pages/ai-providers/mistral.md | 6 +++ .../pages/automate/how-tools-work.md | 7 ++- .../kilo-docs/pages/automate/tools/index.md | 5 +- .../code-with-ai/agents/custom-models.md | 4 +- .../pages/code-with-ai/platforms/cli.md | 47 ++++++++++++++++++- .../customize/context/context-condensing.md | 32 +++++++++---- .../kilo-docs/pages/customize/custom-modes.md | 4 +- .../settings/auto-approving-actions.md | 2 - .../pages/getting-started/settings/index.md | 6 ++- 9 files changed, 88 insertions(+), 25 deletions(-) diff --git a/packages/kilo-docs/pages/ai-providers/mistral.md b/packages/kilo-docs/pages/ai-providers/mistral.md index e1ce39948b2..3feb13c6e9f 100644 --- a/packages/kilo-docs/pages/ai-providers/mistral.md +++ b/packages/kilo-docs/pages/ai-providers/mistral.md @@ -66,6 +66,12 @@ Then set your default model: {% /tab %} {% /tabs %} +## Reasoning Variants + +Mistral's adjustable reasoning support is exposed only for reasoning-capable Mistral Small 4 models: `mistral-small-2603` and `mistral-small-latest`. When one of these models is selected, Kilo offers a `high` variant that sends `reasoningEffort: "high"` to the Mistral provider. + +Other Mistral models do not get automatic reasoning variants, even if they appear in the same provider. See Mistral's [adjustable reasoning documentation](https://docs.mistral.ai/capabilities/reasoning/adjustable) for provider-level details. + ## Using Codestral [Codestral](https://docs.mistral.ai/capabilities/code_generation/) is a model specifically designed for code generation and interaction. diff --git a/packages/kilo-docs/pages/automate/how-tools-work.md b/packages/kilo-docs/pages/automate/how-tools-work.md index 10e35292c11..3f4d293c0cf 100644 --- a/packages/kilo-docs/pages/automate/how-tools-work.md +++ b/packages/kilo-docs/pages/automate/how-tools-work.md @@ -24,7 +24,7 @@ Describe what you want to accomplish in natural language, and Kilo Code will: | Category | Purpose | Tool Names | |:---|:---|:---| | Read | Access file content and code structure | `read`, `glob`, `grep` | -| Edit | Create or modify files and code | `edit`, `multiedit`, `write`, `apply_patch` | +| Edit | Create or modify files and code | `edit`, `write`, `apply_patch` | | Execute | Run commands and perform system operations | `bash` | | Web | Fetch and search web content | `webfetch`, `websearch`, `codesearch` | | Workflow | Manage task flow and sub-agents | `question`, `task`, `todowrite`, `todoread`, `plan`, `skill` | @@ -103,8 +103,8 @@ Every tool use is subject to a permission check. The default action for any tool | Tool(s) | Default | |:---|:---| -| `read`, `glob`, `grep`, `list` | `ask` | -| `edit`, `write`, `multiedit`, `apply_patch` | `ask` | +| `read`, `glob`, `grep` | `ask` | +| `edit`, `write`, `apply_patch` | `ask` | | `bash` | `ask` (per-command) | | `external_directory` | `ask` (when accessing paths outside the project) | | `task` | `ask` | @@ -160,7 +160,6 @@ This safety mechanism ensures you maintain control over which files are modified | `glob` | Finds files by glob pattern | Read | | `grep` | Searches file contents with regex | Read | | `edit` | Makes precise text replacements in a file | Edit | -| `multiedit` | Multiple edits in a single call | Edit | | `write` | Creates new files or overwrites existing ones | Edit | | `apply_patch` | Applies unified diffs (used with certain models) | Edit | | `bash` | Runs shell commands | Execute | diff --git a/packages/kilo-docs/pages/automate/tools/index.md b/packages/kilo-docs/pages/automate/tools/index.md index 7b1f8056d93..30713b21384 100644 --- a/packages/kilo-docs/pages/automate/tools/index.md +++ b/packages/kilo-docs/pages/automate/tools/index.md @@ -19,7 +19,7 @@ Tools are organized into logical groups based on their functionality: | Category | Purpose | Tools | Common Use | |---|---|---|---| | **Read Group** | File system reading and searching | `read`, `glob`, `grep` | Code exploration and analysis | -| **Edit Group** | File system modifications | `edit`, `multiedit`, `write`, `apply_patch` | Code changes and file manipulation | +| **Edit Group** | File system modifications | `edit`, `write`, `apply_patch` | Code changes and file manipulation | | **Execute Group** | Shell command execution | `bash` | Running scripts, building projects | | **Web Group** | Fetch and search web content | `webfetch`, `websearch`, `codesearch` | Research, documentation lookup | | **Browser Group** | Web browser automation | `kilo-playwright_*` (via built-in Playwright MCP) | Browser testing and interaction | @@ -49,10 +49,11 @@ These tools help Kilo Code understand your code and project: These tools help Kilo Code make changes to your code: - `edit` - Makes precise text replacements in a file -- `multiedit` - Multiple edits in a single call - `write` - Creates new files or fully overwrites existing ones - `apply_patch` - Applies unified diffs (used with certain models) +For multiple replacements in one file, Kilo uses repeated `edit` calls or a patch-style edit when the model supports it. + ### Execute Tools These tools help Kilo Code run commands: diff --git a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md index c68dc61d6b0..27678482729 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md @@ -329,7 +329,7 @@ You can also set options that apply to all models from a provider: "options": { "apiKey": "{env:OPENAI_API_KEY}", "baseURL": "https://my-proxy.example.com/v1", - "timeout": 120000, + "timeout": 300000, }, }, }, @@ -340,7 +340,7 @@ You can also set options that apply to all models from a provider: |---|---|---| | `apiKey` | `string` | API key (supports `{env:VAR}` syntax) | | `baseURL` | `string` | Override the provider's base API URL | -| `timeout` | `number \| false` | Request timeout in milliseconds, or `false` to disable | +| `timeout` | `number \| false` | Request timeout in milliseconds. Defaults to `300000` (5 minutes); set to `false` to disable | ## Filtering Available Models diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md index a150898752b..d3915a24163 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -241,7 +241,7 @@ You can use `~` or `$HOME` at the start of a pattern to reference your home dire ### External Directories -Use `external_directory` to allow tool calls that touch paths outside the working directory where Kilo was started. This applies to any tool that takes a path as input (for example `read`, `edit`, `list`, `glob`, `grep`, and many bash commands). +Use `external_directory` to allow tool calls that touch paths outside the working directory where Kilo was started. This applies to any tool that takes a path as input (for example `read`, `edit`, `glob`, `grep`, and many bash commands). ```json { @@ -308,13 +308,56 @@ Common configuration options include: - **`mcp`** - MCP server configuration - **`permission`** - Tool permission settings (`allow` or `ask`) - **`instructions`** - Paths to instruction files (e.g., `["CONTRIBUTING.md", ".cursor/rules/*.md"]`) -- **`formatter`** - Code formatter configuration +- **`formatter`** - Code formatter configuration (`true`, `false`, or formatter-specific entries) +- **`lsp`** - Language server configuration (`true`, `false`, or server-specific entries) - **`disabled_providers`** / **`enabled_providers`** - Control which providers are available {% callout type="tip" %} **Using a model that's not in the built-in list?** You can register any model by adding it under `provider.<provider_id>.models` in your config file. See [Custom Models](/docs/code-with-ai/agents/custom-models) for full details and examples. {% /callout %} +### Formatter and LSP Toggles + +Set `formatter` or `lsp` to `true` to use built-in defaults, or `false` to disable the feature completely: + +```jsonc +{ + "formatter": true, + "lsp": false, +} +``` + +Both keys also accept object configuration for specific tools or language servers. Custom LSP server entries must include an `extensions` array unless the entry disables a built-in server: + +```jsonc +{ + "lsp": { + "my-language-server": { + "command": ["my-lsp", "--stdio"], + "extensions": [".foo"], + }, + }, +} +``` + +### TUI Keybindings on Windows + +The TUI gives `Ctrl+Z` to input undo on Windows because native Windows terminals do not support POSIX terminal suspend. On Windows, `input_undo` defaults to `ctrl+z,ctrl+-,super+z` and `terminal_suspend` is disabled. On macOS and Linux, `terminal_suspend` defaults to `ctrl+z`. + +### OpenTelemetry Export + +Kilo telemetry is enabled by default and can be disabled with `experimental.openTelemetry = false`: + +```jsonc +{ + "experimental": { + "openTelemetry": false, + }, +} +``` + +If `OTEL_EXPORTER_OTLP_ENDPOINT` is set, the CLI exports OpenTelemetry traces and logs to that OTLP HTTP endpoint. You can also pass `OTEL_EXPORTER_OTLP_HEADERS` as comma-separated `key=value` pairs and `OTEL_RESOURCE_ATTRIBUTES` as comma-separated resource attributes. Request spans include `http.method`, `http.path`, route params such as `session.id` and `message.id`, and internal params under the `opencode.*` namespace. + ### Environment Variables Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variables: diff --git a/packages/kilo-docs/pages/customize/context/context-condensing.md b/packages/kilo-docs/pages/customize/context/context-condensing.md index 8fc83cf763f..9993b4bcca3 100644 --- a/packages/kilo-docs/pages/customize/context/context-condensing.md +++ b/packages/kilo-docs/pages/customize/context/context-condensing.md @@ -22,15 +22,15 @@ Every AI model has a maximum context window — a limit on how much text it can ## The Solution: Auto-Compaction -Kilo Code uses a **Compaction** system to manage context automatically. When your conversation approaches the token limit, compaction kicks in and produces a structured summary that captures: +Kilo Code uses a **Compaction** system to manage context automatically. When your conversation approaches the token limit, compaction kicks in and produces an anchored summary that captures: - The overall goal of the session -- Instructions given along the way -- Key discoveries made -- What has been accomplished so far +- Constraints and preferences you gave along the way +- Progress, key decisions, and next steps +- Critical context needed to continue - Relevant files and directories -This summary replaces the earlier conversation history, freeing up context window space while maintaining continuity in your work. +This summary replaces older conversation history while Kilo keeps the most recent turns verbatim when they fit. If a session has already been compacted, Kilo updates the previous summary instead of starting over, preserving still-relevant details and removing stale ones. ## How Compaction Triggers @@ -60,6 +60,8 @@ You can trigger compaction at any time: |---|---|---| | `compaction.auto` | `true` | Automatically compact when the usable window is reached | | `compaction.prune` | `true` | Clear old tool outputs beyond the 40K recency window | +| `compaction.tail_turns` | `2` | Keep the most recent user turns and their responses verbatim when possible | +| `compaction.preserve_recent_tokens` | 25% of usable context, clamped between 2,000 and 8,000 tokens | Token budget for the verbatim recent tail | | `compaction.reserved` | `min(20,000, model_max_output_tokens)` | Token headroom kept free for the next turn — also defines the compaction trigger point | ## Configuration @@ -71,6 +73,8 @@ Compaction is configured in your `kilo.jsonc` file: "compaction": { "auto": true, // Enable or disable automatic compaction "prune": true, // Enable pruning of old tool outputs beyond the recency window + "tail_turns": 2, // Recent user turns to keep verbatim during compaction + "preserve_recent_tokens": 8000, // Maximum token budget for the recent tail "reserved": 20000, // Token buffer kept free; smaller = later trigger, larger = earlier trigger }, } @@ -80,6 +84,8 @@ Compaction is configured in your `kilo.jsonc` file: |---|---|---|---| | `compaction.auto` | boolean | `true` | Enable or disable automatic compaction when the usable window is reached | | `compaction.prune` | boolean | `true` | Enable pruning of old tool outputs outside the 40K token recency window | +| `compaction.tail_turns` | number | `2` | Number of recent user turns, including following assistant and tool responses, to keep verbatim during compaction | +| `compaction.preserve_recent_tokens` | number | 25% of usable context, clamped between 2,000 and 8,000 tokens | Maximum token budget for recent turns kept verbatim after compaction | | `compaction.reserved` | number | `min(20000, model_max_output)` | Token headroom reserved for the next turn. Applies only to models that advertise a separate input limit; models with a single context window use their full output cap as the reserve instead. | ### Use a different model for compaction @@ -111,15 +117,15 @@ If no compaction agent is set, the current session's model is used. ## The Solution: Auto-Compaction -Kilo CLI uses a **Compaction** system to manage context automatically. When your conversation approaches the token limit, compaction kicks in and produces a structured summary that captures: +Kilo CLI uses a **Compaction** system to manage context automatically. When your conversation approaches the token limit, compaction kicks in and produces an anchored summary that captures: - The overall goal of the session -- Instructions given along the way -- Key discoveries made -- What has been accomplished so far +- Constraints and preferences you gave along the way +- Progress, key decisions, and next steps +- Critical context needed to continue - Relevant files and directories -This summary replaces the earlier conversation history, freeing up context window space while maintaining continuity in your work. +This summary replaces older conversation history while Kilo keeps the most recent turns verbatim when they fit. If a session has already been compacted, Kilo updates the previous summary instead of starting over, preserving still-relevant details and removing stale ones. ## How Compaction Triggers @@ -148,6 +154,8 @@ You can trigger compaction at any time: |---|---|---| | `compaction.auto` | `true` | Automatically compact when the usable window is reached | | `compaction.prune` | `true` | Clear old tool outputs beyond the 40K recency window | +| `compaction.tail_turns` | `2` | Keep the most recent user turns and their responses verbatim when possible | +| `compaction.preserve_recent_tokens` | 25% of usable context, clamped between 2,000 and 8,000 tokens | Token budget for the verbatim recent tail | | `compaction.reserved` | `min(20,000, model_max_output_tokens)` | Token headroom kept free for the next turn — also defines the compaction trigger point | ## Configuration @@ -159,6 +167,8 @@ Compaction is configured in your `kilo.jsonc` file: "compaction": { "auto": true, // Enable or disable automatic compaction "prune": true, // Enable pruning of old tool outputs beyond the recency window + "tail_turns": 2, // Recent user turns to keep verbatim during compaction + "preserve_recent_tokens": 8000, // Maximum token budget for the recent tail "reserved": 20000, // Token buffer kept free; smaller = later trigger, larger = earlier trigger }, } @@ -168,6 +178,8 @@ Compaction is configured in your `kilo.jsonc` file: |---|---|---|---| | `compaction.auto` | boolean | `true` | Enable or disable automatic compaction when the usable window is reached | | `compaction.prune` | boolean | `true` | Enable pruning of old tool outputs outside the 40K token recency window | +| `compaction.tail_turns` | number | `2` | Number of recent user turns, including following assistant and tool responses, to keep verbatim during compaction | +| `compaction.preserve_recent_tokens` | number | 25% of usable context, clamped between 2,000 and 8,000 tokens | Maximum token budget for recent turns kept verbatim after compaction | | `compaction.reserved` | number | `min(20000, model_max_output)` | Token headroom reserved for the next turn. Applies only to models that advertise a separate input limit; models with a single context window use their full output cap as the reserve instead. | ### Use a different model for compaction diff --git a/packages/kilo-docs/pages/customize/custom-modes.md b/packages/kilo-docs/pages/customize/custom-modes.md index e541d8b46aa..928a4feda93 100644 --- a/packages/kilo-docs/pages/customize/custom-modes.md +++ b/packages/kilo-docs/pages/customize/custom-modes.md @@ -151,7 +151,7 @@ permission: read: allow ``` -Known permission types include: `read`, `edit`, `bash`, `glob`, `grep`, `list`, `task`, `webfetch`, `websearch`, `codesearch`, `todowrite`, `todoread`, and more. +Known permission types include: `read`, `edit`, `bash`, `glob`, `grep`, `task`, `webfetch`, `websearch`, `codesearch`, `todowrite`, `todoread`, and more. ### `model` @@ -387,7 +387,7 @@ permission: read: allow ``` -Known permission types include: `read`, `edit`, `bash`, `glob`, `grep`, `list`, `task`, `webfetch`, `websearch`, `codesearch`, `todowrite`, `todoread`, and more. +Known permission types include: `read`, `edit`, `bash`, `glob`, `grep`, `task`, `webfetch`, `websearch`, `codesearch`, `todowrite`, `todoread`, and more. ### `model` diff --git a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md index fb797dc0f0a..8e4bee7cefb 100644 --- a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md +++ b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md @@ -44,7 +44,6 @@ The Auto Approve tab lists the following tool-specific permissions. Some tools a | `edit` | Editing existing files | | `glob` | File pattern matching / searching by name | | `grep` | Searching file contents by regex | -| `list` | Listing directory contents | | `task` | Launching sub-agents | | `skill` | Loading specialized skills | | `lsp` | Language server protocol operations | @@ -109,7 +108,6 @@ Permissions are configured under the `permission` key in `kilo.jsonc`. The follo | `edit` | Editing existing files | | `glob` | File pattern matching / searching by name | | `grep` | Searching file contents by regex | -| `list` | Listing directory contents | | `task` | Launching sub-agents | | `skill` | Loading specialized skills | | `lsp` | Language server protocol operations | diff --git a/packages/kilo-docs/pages/getting-started/settings/index.md b/packages/kilo-docs/pages/getting-started/settings/index.md index 9f66c73005c..f72cbd0e0aa 100644 --- a/packages/kilo-docs/pages/getting-started/settings/index.md +++ b/packages/kilo-docs/pages/getting-started/settings/index.md @@ -171,6 +171,7 @@ Available experimental toggles include: - **LSP integration** — expose language server diagnostics to the agent - **Paste summary** — summarize large clipboard pastes before including them - **Batch tool** — allow the agent to batch multiple tool calls in one step +- **OpenTelemetry** — enable Kilo telemetry and optional OTLP export when configured Advanced options not exposed in the UI can be configured via the `experimental` key in `kilo.jsonc`: @@ -179,6 +180,7 @@ Advanced options not exposed in the UI can be configured via the `experimental` "experimental": { "codebase_search": true, "batch_tool": false, + "openTelemetry": true, "disable_paste_summary": false, "mcp_timeout": 30000 } @@ -190,7 +192,9 @@ Refer to the auto-generated `$schema` in your `kilo.jsonc` for the full list of {% /tab %} {% tab label="CLI" %} -The CLI does not currently expose the same experimental feature toggles as the **VSCode (Legacy)** version. Configuration of model behavior, file editing strategies, and other advanced options is handled directly in the JSONC config files. Refer to the auto-generated `$schema` in your `kilo.jsonc` for the full list of available options. +The CLI does not currently expose the same experimental feature toggles as the **VSCode (Legacy)** version. Configuration of model behavior, file editing strategies, telemetry, and other advanced options is handled directly in the JSONC config files. Refer to the auto-generated `$schema` in your `kilo.jsonc` for the full list of available options. + +Telemetry is enabled by default. Set `experimental.openTelemetry` to `false` in `kilo.jsonc` to opt out. If `OTEL_EXPORTER_OTLP_ENDPOINT` is set in the environment, the CLI also exports OpenTelemetry traces and logs to that OTLP HTTP endpoint. {% /tab %} {% tab label="VSCode (Legacy)" %} From 8a6bde813888019633003f89b0a6cacd82c3fbe1 Mon Sep 17 00:00:00 2001 From: Marius <marius@kilocode.ai> Date: Wed, 29 Apr 2026 17:28:05 +0200 Subject: [PATCH 54/96] docs: clarify extension settings guidance (#9692) --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 104ffc67032..2c726e9a1cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,8 @@ All products are clients of the **CLI** (`packages/opencode/`), which contains t **Agent Manager** refers to a feature inside `packages/kilo-vscode/` (extension code in `src/agent-manager/`, webview in `webview-ui/agent-manager/`). It is not a standalone product. See the extension's `AGENTS.md` for details. +Extension-specific settings should live in the Kilo extension settings, not default VS Code settings, unless they are intentionally VS Code-wide. + ## Monorepo Structure Turborepo + Bun workspaces. The packages you'll work with most: From fe3cbd86232391e8e8ef523c023c63b8861ddfd6 Mon Sep 17 00:00:00 2001 From: "kilo-code-bot[bot]" <240665456+kilo-code-bot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:31:00 +0000 Subject: [PATCH 55/96] docs(kilo-docs): update cli architecture patterns (#9660) Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../pages/contributing/architecture/index.md | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/kilo-docs/pages/contributing/architecture/index.md b/packages/kilo-docs/pages/contributing/architecture/index.md index 8c1db4d8cdd..bbc3e1fe482 100644 --- a/packages/kilo-docs/pages/contributing/architecture/index.md +++ b/packages/kilo-docs/pages/contributing/architecture/index.md @@ -210,22 +210,35 @@ const client = new KiloClient({ baseUrl: "http://localhost:3000" }) const session = await client.session.create({ ... }) ``` -### Namespace Module Pattern +### Module Export Pattern -The CLI uses a namespace module pattern for organizing related functionality: +The CLI uses flat ESM exports inside each module, then re-exports the module as a namespace from an index file when callers need grouped access. Avoid adding new `export namespace` declarations; top-level exports are easier to tree-shake and work better with Node's type-stripping runtime. ```typescript -export namespace Session { - export const create = fn(CreateSchema, async (input) => { - // ... - }) +// packages/opencode/src/session/session.ts +export const create = fn(CreateSchema, async (input) => { + // ... +}) - export const list = fn(ListSchema, async (input) => { - // ... - }) -} +export const list = fn(ListSchema, async (input) => { + // ... +}) + +// packages/opencode/src/session/index.ts +export * as Session from "./session" ``` +Prefer importing the specific export when possible. Use the namespace re-export (`Session.create`, `Session.list`) when a caller benefits from grouped module access or when preserving the existing public shape. + +### CLI Server API + +The CLI server is Hono-based and publishes an OpenAPI-compatible HTTP + SSE API consumed by `@kilocode/sdk`. Some route groups are being migrated behind the experimental Effect `HttpApi` bridge while preserving the generated SDK shape. + +- Keep the SDK output stable when moving routes between Hono and Effect `HttpApi`. +- Use `KILO_EXPERIMENTAL_HTTPAPI` only for migration testing; public clients should not depend on the bridge detail. +- Regenerate `packages/sdk/js/` after server endpoint changes. +- Keep request handling observable with route spans and stable attributes where possible. + ### Tool Implementation Tools follow a consistent pattern with Zod schema validation: From 9028174cfd5fdd0cf2f3dd87d5ace7cfa780cc4d Mon Sep 17 00:00:00 2001 From: Marius <marius@kilocode.ai> Date: Wed, 29 Apr 2026 17:31:04 +0200 Subject: [PATCH 56/96] fix(vscode): compact todo update cards (#9687) * fix(vscode): compact todo update cards * chore: update kilo-vscode visual regression baselines --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> --- .changeset/compact-todo-updates.md | 6 ++ ...do-write-compact-update-chromium-linux.png | 3 + .../kilo-ui/src/components/message-part.css | 14 ++++ .../kilo-ui/src/components/message-part.tsx | 37 ++++++++- .../src/stories/composite.stories.tsx | 43 +++++++++++ packages/opencode/src/kilocode/todo-view.ts | 77 +++++++++++++++++++ packages/opencode/src/tool/todo.ts | 14 ++++ .../opencode/test/kilocode/todo-view.test.ts | 57 ++++++++++++++ 8 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 .changeset/compact-todo-updates.md create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-compact-update-chromium-linux.png create mode 100644 packages/opencode/src/kilocode/todo-view.ts create mode 100644 packages/opencode/test/kilocode/todo-view.test.ts diff --git a/.changeset/compact-todo-updates.md b/.changeset/compact-todo-updates.md new file mode 100644 index 00000000000..6ef497be78d --- /dev/null +++ b/.changeset/compact-todo-updates.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Show compact todo update cards when checking off items in long todo lists. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-compact-update-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-compact-update-chromium-linux.png new file mode 100644 index 00000000000..bb7d8b71d2d --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-compact-update-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b47f6adfc72399cb1535be3a69661b027eec3068880aba35821d5240e0c3492 +size 16739 diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index b4a7c60c159..5b90068c9c6 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -221,6 +221,20 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { margin-bottom: 0px; } +[data-component="todos"] { + [data-slot="message-part-todo-hidden"] { + padding: 2px 0 2px 28px; + color: var(--text-weaker); + font-size: var(--font-size-small); + line-height: var(--line-height-normal); + } + + [data-slot="message-part-todo-content"][data-changed="changed"] { + color: var(--text-base); + font-weight: var(--font-weight-medium); + } +} + /* User message copy button: collapse wrapper when idle, show on hover. * The wrapper must be tall enough to fully contain the button (20px) * so that hovering the button keeps [data-component="user-message"] diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 5cc9ce26870..18dc83834bf 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -85,6 +85,17 @@ interface Diagnostic { severity?: number } +type TodoView = { + mode?: "full" | "compact" + todos?: TodoItem[] + hiddenBefore?: number + hiddenAfter?: number +} + +type TodoItem = Todo & { + changed?: boolean +} + function getDiagnostics( diagnosticsByFile: Record<string, Diagnostic[]> | undefined, filePath: string | undefined, @@ -2403,6 +2414,7 @@ ToolRegistry.register({ name: "todowrite", render(props) { const i18n = useI18n() + const view = createMemo(() => (isTodoView(props.metadata?.view) ? props.metadata.view : undefined)) const todos = createMemo(() => { const meta = props.metadata?.todos if (Array.isArray(meta)) return meta @@ -2412,6 +2424,7 @@ ToolRegistry.register({ return [] }) + const shown = createMemo(() => view()?.todos ?? todos()) const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { @@ -2434,20 +2447,27 @@ ToolRegistry.register({ /> } > - <Show when={todos().length}> + <Show when={shown().length}> <div data-component="todos"> - <For each={todos()}> - {(todo: Todo) => ( + <Show when={view()?.mode === "compact" && (view()?.hiddenBefore ?? 0) > 0}> + <div data-slot="message-part-todo-hidden">{hiddenText("earlier", view()?.hiddenBefore ?? 0)}</div> + </Show> + <For each={shown()}> + {(todo: TodoItem) => ( <Checkbox readOnly checked={todo.status === "completed"}> <span data-slot="message-part-todo-content" data-completed={todo.status === "completed" ? "completed" : undefined} + data-changed={todo.changed ? "changed" : undefined} > {todo.content} </span> </Checkbox> )} </For> + <Show when={view()?.mode === "compact" && (view()?.hiddenAfter ?? 0) > 0}> + <div data-slot="message-part-todo-hidden">{hiddenText("later", view()?.hiddenAfter ?? 0)}</div> + </Show> </div> </Show> </BasicTool> @@ -2455,6 +2475,17 @@ ToolRegistry.register({ }, }) +function isTodoView(value: unknown): value is TodoView { + if (!value || typeof value !== "object") return false + const view = value as TodoView + return Array.isArray(view.todos) +} + +function hiddenText(dir: "earlier" | "later", count: number) { + const noun = count === 1 ? "to-do" : "to-dos" + return `${count} ${dir} ${noun} hidden` +} + ToolRegistry.register({ name: "question", render(props) { diff --git a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx index bbe2b0ede3a..340287ae4e1 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx @@ -338,6 +338,37 @@ const todoWriteDocsOverview: ToolPart = { }, } +const compactTodos = docsTodos.map((todo, index) => + index < 5 ? { ...todo, status: "completed" } : { ...todo, status: index === 5 ? "pending" : todo.status }, +) +const compactViewTodos = compactTodos.slice(3, 6).map((todo, index) => ({ ...todo, changed: index === 1 })) + +const todoWriteCompactUpdate: ToolPart = { + id: "part-todo-compact-001", + sessionID: SESSION_ID, + messageID: ASST_MSG_ID, + type: "tool", + callID: "call-todo-compact-001", + tool: "todowrite", + state: { + status: "completed", + input: { todos: compactTodos }, + output: "Updated 10 todos", + title: "Todo List Updated", + metadata: { + todos: compactTodos, + view: { + mode: "compact", + todos: compactViewTodos, + hiddenBefore: 3, + hiddenAfter: 4, + changed: 1, + }, + }, + time: { start: now - 3000, end: now - 2800 }, + }, +} + const todoWritePermission: PermissionRequest = { id: "perm-todo-001", sessionID: SESSION_ID, @@ -667,6 +698,18 @@ export const TodoWriteDocsOverview: Story = { }, } +export const TodoWriteCompactUpdate: Story = { + name: "TodoWrite - Compact update", + render: () => { + const data = dataWith([todoWriteCompactUpdate]) + return ( + <StoryProviders data={data} sessionID={SESSION_ID}> + <AssistantMessage message={baseAssistantMessage} /> + </StoryProviders> + ) + }, +} + // --------------------------------------------------------------------------- // 12. Permission dock — edit tool with file patterns // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/kilocode/todo-view.ts b/packages/opencode/src/kilocode/todo-view.ts new file mode 100644 index 00000000000..bbd6094dc17 --- /dev/null +++ b/packages/opencode/src/kilocode/todo-view.ts @@ -0,0 +1,77 @@ +export namespace TodoView { + export type Todo = { + content: string + status: string + priority: string + } + + export type Item = Todo & { + changed?: boolean + } + + export type Info = { + mode: "full" | "compact" + todos: Item[] + hiddenBefore: number + hiddenAfter: number + changed: number + } + + export function calculate(before: Todo[], after: Todo[]): Info { + const diff = after + .map((todo, index) => ({ + index, + changed: !same(before[index], todo), + })) + .filter((item) => item.changed) + + const wide = + before.length === 0 || + after.length === 0 || + structural(before, after) || + terminal(after) || + diff.length === 0 + if (wide) return full(after, diff.length) + + const first = Math.max(0, Math.min(...diff.map((item) => item.index)) - 1) + const last = Math.min(after.length - 1, Math.max(...diff.map((item) => item.index)) + 1) + const hidden = first + after.length - last - 1 + if (hidden === 0) return full(after, diff.length) + + const set = new Set(diff.map((item) => item.index)) + return { + mode: "compact", + todos: after.slice(first, last + 1).map((todo, index) => ({ + ...todo, + changed: set.has(first + index), + })), + hiddenBefore: first, + hiddenAfter: after.length - last - 1, + changed: diff.length, + } + } + + function full(todos: Todo[], changed: number): Info { + return { + mode: "full", + todos, + hiddenBefore: 0, + hiddenAfter: 0, + changed, + } + } + + function same(before: Todo | undefined, after: Todo) { + if (!before) return false + return before.content === after.content && before.status === after.status && before.priority === after.priority + } + + function terminal(todos: Todo[]) { + return todos.every((todo) => todo.status === "completed" || todo.status === "cancelled") + } + + function structural(before: Todo[], after: Todo[]) { + if (before.length !== after.length) return true + return after.some((todo, index) => before[index]?.content !== todo.content) + } +} diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index 18d21cf61e0..30ffcfe6d79 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -2,6 +2,9 @@ import { Effect, Schema } from "effect" import * as Tool from "./tool" import DESCRIPTION_WRITE from "./todowrite.txt" import { Todo } from "../session/todo" +// kilocode_change start +import { TodoView } from "../kilocode/todo-view" +// kilocode_change end // Todo.Info is still a zod schema (session/todo.ts). Inline the field shape // here rather than referencing its `.shape` — the LLM-visible JSON Schema is @@ -20,6 +23,9 @@ export const Parameters = Schema.Struct({ type Metadata = { todos: Todo.Info[] + // kilocode_change start + view?: TodoView.Info + // kilocode_change end } export const TodoWriteTool = Tool.define<typeof Parameters, Metadata, Todo.Service>( @@ -39,6 +45,11 @@ export const TodoWriteTool = Tool.define<typeof Parameters, Metadata, Todo.Servi metadata: {}, }) + // kilocode_change start + const before = yield* todo.get(ctx.sessionID) + const view = TodoView.calculate(before, params.todos) + // kilocode_change end + yield* todo.update({ sessionID: ctx.sessionID, todos: params.todos, @@ -49,6 +60,9 @@ export const TodoWriteTool = Tool.define<typeof Parameters, Metadata, Todo.Servi output: JSON.stringify(params.todos, null, 2), metadata: { todos: params.todos, + // kilocode_change start + view, + // kilocode_change end }, } }), diff --git a/packages/opencode/test/kilocode/todo-view.test.ts b/packages/opencode/test/kilocode/todo-view.test.ts new file mode 100644 index 00000000000..b3260f62bb6 --- /dev/null +++ b/packages/opencode/test/kilocode/todo-view.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { TodoView } from "../../src/kilocode/todo-view" + +function item(content: string, status = "pending"): TodoView.Todo { + return { content, status, priority: "medium" } +} + +describe("TodoView.calculate", () => { + test("shows the full list when todos are first created", () => { + const after = [item("Inspect files"), item("Implement fix"), item("Run checks")] + const view = TodoView.calculate([], after) + + expect(view.mode).toBe("full") + expect(view.todos).toEqual(after) + expect(view.hiddenBefore).toBe(0) + expect(view.hiddenAfter).toBe(0) + }) + + test("shows a compact window around the changed item", () => { + const before = Array.from({ length: 10 }, (_, index) => item(`Task ${index + 1}`)) + const after = before.map((todo, index) => (index === 4 ? { ...todo, status: "completed" } : todo)) + const view = TodoView.calculate(before, after) + + expect(view.mode).toBe("compact") + expect(view.hiddenBefore).toBe(3) + expect(view.hiddenAfter).toBe(4) + expect(view.todos.map((todo) => todo.content)).toEqual(["Task 4", "Task 5", "Task 6"]) + expect(view.todos.map((todo) => Boolean(todo.changed))).toEqual([false, true, false]) + }) + + test("shows the full list when all todos are terminal", () => { + const before = [item("Inspect files", "completed"), item("Implement fix"), item("Run checks")] + const after = before.map((todo) => ({ ...todo, status: "completed" })) + const view = TodoView.calculate(before, after) + + expect(view.mode).toBe("full") + expect(view.todos).toEqual(after) + }) + + test("shows the full list when todo content is rewritten", () => { + const before = [item("One"), item("Two"), item("Three"), item("Four")] + const after = [item("One"), item("Two changed"), item("Three"), item("Four")] + const view = TodoView.calculate(before, after) + + expect(view.mode).toBe("full") + expect(view.todos).toEqual(after) + }) + + test("shows the full list when todos are added", () => { + const before = [item("One"), item("Two"), item("Three")] + const after = [...before, item("Four")] + const view = TodoView.calculate(before, after) + + expect(view.mode).toBe("full") + expect(view.todos).toEqual(after) + }) +}) From 9ad91c214ae0b674cac1ef3d532723dd5679d1bb Mon Sep 17 00:00:00 2001 From: "kilo-code-bot[bot]" <240665456+kilo-code-bot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:31:07 +0000 Subject: [PATCH 57/96] docs(kilo-docs): add upstream provider updates (#9663) Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/kilo-docs/lib/nav/ai-providers.ts | 3 + .../kilo-docs/pages/ai-providers/alibaba.md | 79 +++++++++++ .../pages/ai-providers/cloudflare.md | 128 ++++++++++++++++++ .../kilo-docs/pages/ai-providers/index.md | 5 + .../kilo-docs/pages/ai-providers/moonshot.md | 1 + .../kilo-docs/pages/ai-providers/venice.md | 76 +++++++++++ .../kilo-docs/pages/ai-providers/vertex.md | 1 + .../pages/customize/context/large-projects.md | 2 +- 8 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 packages/kilo-docs/pages/ai-providers/alibaba.md create mode 100644 packages/kilo-docs/pages/ai-providers/cloudflare.md create mode 100644 packages/kilo-docs/pages/ai-providers/venice.md diff --git a/packages/kilo-docs/lib/nav/ai-providers.ts b/packages/kilo-docs/lib/nav/ai-providers.ts index bb9f109ae75..92ad10f6504 100644 --- a/packages/kilo-docs/lib/nav/ai-providers.ts +++ b/packages/kilo-docs/lib/nav/ai-providers.ts @@ -42,6 +42,8 @@ export const AiProvidersNav: NavSection[] = [ links: [ { href: "/ai-providers/vertex", children: "Google Vertex AI" }, { href: "/ai-providers/bedrock", children: "AWS Bedrock" }, + { href: "/ai-providers/alibaba", children: "Alibaba Cloud" }, + { href: "/ai-providers/cloudflare", children: "Cloudflare" }, { href: "/ai-providers/groq", children: "Groq" }, { href: "/ai-providers/cerebras", children: "Cerebras" }, { href: "/ai-providers/fireworks", children: "Fireworks AI" }, @@ -68,6 +70,7 @@ export const AiProvidersNav: NavSection[] = [ { href: "/ai-providers/moonshot", children: "Moonshot" }, { href: "/ai-providers/ovhcloud", children: "OVHcloud" }, { href: "/ai-providers/sap-ai-core", children: "SAP AI Core" }, + { href: "/ai-providers/venice", children: "Venice AI" }, ], }, { diff --git a/packages/kilo-docs/pages/ai-providers/alibaba.md b/packages/kilo-docs/pages/ai-providers/alibaba.md new file mode 100644 index 00000000000..63ac7038406 --- /dev/null +++ b/packages/kilo-docs/pages/ai-providers/alibaba.md @@ -0,0 +1,79 @@ +--- +sidebar_label: Alibaba Cloud +--- + +# Using Alibaba Cloud With Kilo Code + +Kilo Code supports Alibaba Cloud Model Studio (DashScope) through the native Alibaba AI SDK provider. Use it to run Qwen and other DashScope-hosted models directly from Kilo. + +**Website:** [https://www.alibabacloud.com/product/modelstudio](https://www.alibabacloud.com/product/modelstudio) + +## Getting an API Key + +1. Sign in to Alibaba Cloud Model Studio or DashScope. +2. Open the API key section for your workspace. +3. Create a key and copy it immediately. +4. Store the key securely; do not commit it to your repository. + +## Configuration in Kilo Code + +{% tabs %} +{% tab label="VSCode (Legacy)" %} + +Use the **OpenAI Compatible** provider if the legacy provider list does not include Alibaba Cloud. Set the base URL and API key from your DashScope account, then choose or enter a supported model ID. + +{% /tab %} +{% tab label="VSCode" %} + +Open **Settings** (gear icon) and go to the **Providers** tab to add Alibaba Cloud. Enter your DashScope API key and choose a supported model from the model picker. + +The extension stores this in your `kilo.json` config file. You can also edit the config file directly — see the **CLI** tab for the file format. + +{% /tab %} +{% tab label="CLI" %} + +Set the DashScope API key as an environment variable or configure it in your `kilo.json` config file: + +**Environment variable:** + +```bash +export DASHSCOPE_API_KEY="your-api-key" +``` + +**Config file** (`~/.config/kilo/kilo.json` or `./kilo.json`): + +```jsonc +{ + "provider": { + "alibaba": { + "env": ["DASHSCOPE_API_KEY"] + } + } +} +``` + +Then set your default model: + +```jsonc +{ + "model": "alibaba/qwen-plus" +} +``` + +Some regions and model catalogs may expose Alibaba models under `alibaba-cn` instead. In that case, use the same API key and set the model as `alibaba-cn/<model-id>`. + +{% /tab %} +{% /tabs %} + +## Tips and Notes + +- **Native provider:** Kilo uses the native Alibaba AI SDK provider, not a generic compatibility shim, when you select the built-in Alibaba provider. +- **Cache controls:** Kilo forwards provider cache controls for compatible Alibaba/DashScope models, which can reduce repeated context cost where the model supports caching. +- **Regional catalogs:** Model availability varies by account, region, and gateway. Check the DashScope console for the exact model IDs available to your account. +- **Pricing and limits:** Refer to Alibaba Cloud Model Studio pricing and rate-limit documentation for current usage details. + +## Troubleshooting + +- **Authentication errors:** Verify `DASHSCOPE_API_KEY` is set in the same shell that launches Kilo, or stored in your Kilo provider config. +- **Model not found:** Confirm the model ID is available in your DashScope account and try the model picker to see Kilo's current catalog. +- **Region mismatch:** If your account uses the China-region catalog, try the `alibaba-cn/<model-id>` provider prefix. diff --git a/packages/kilo-docs/pages/ai-providers/cloudflare.md b/packages/kilo-docs/pages/ai-providers/cloudflare.md new file mode 100644 index 00000000000..c7bb94a236e --- /dev/null +++ b/packages/kilo-docs/pages/ai-providers/cloudflare.md @@ -0,0 +1,128 @@ +--- +sidebar_label: Cloudflare +--- + +# Using Cloudflare With Kilo Code + +Kilo Code supports both Cloudflare Workers AI and Cloudflare AI Gateway. Workers AI runs Cloudflare-hosted models directly, while AI Gateway routes requests through your Cloudflare gateway to one or more upstream providers. + +**Website:** [https://developers.cloudflare.com/ai/](https://developers.cloudflare.com/ai/) + +## Provider Options + +| Provider ID | Use it for | Required values | +|---|---|---| +| `cloudflare-workers-ai` | Cloudflare-hosted Workers AI models | Account ID and API key | +| `cloudflare-ai-gateway` | Routing through Cloudflare AI Gateway | Account ID, Gateway ID, and Gateway API token | + +You can enter these values interactively with `/connect` in the TUI or `kilo auth` from the CLI, or provide them through environment variables. + +## Cloudflare Workers AI + +### Get Credentials + +1. Open the Cloudflare dashboard and select your account. +2. Copy the **Account ID** from the dashboard. +3. Create an API token that can invoke Workers AI. + +### Configure Kilo + +{% tabs %} +{% tab label="VSCode" %} + +Open **Settings** (gear icon) and go to the **Providers** tab to add Cloudflare Workers AI. If your account ID is not already available from the environment, Kilo prompts for it while connecting the provider. + +{% /tab %} +{% tab label="CLI" %} + +Use `/connect` in the TUI and choose Cloudflare Workers AI, or run: + +```bash +kilo auth cloudflare-workers-ai +``` + +Alternatively, set environment variables before launching Kilo: + +```bash +export CLOUDFLARE_ACCOUNT_ID="your-account-id" +export CLOUDFLARE_API_KEY="your-workers-ai-api-key" +``` + +Then choose a Workers AI model from the model picker, or set a default model such as: + +```jsonc +{ + "model": "cloudflare-workers-ai/@cf/meta/llama-3.1-8b-instruct" +} +``` + +{% /tab %} +{% /tabs %} + +## Cloudflare AI Gateway + +### Get Credentials + +1. Create or open a Cloudflare AI Gateway. +2. Copy your **Account ID** and **Gateway ID**. +3. Create a Gateway API token. + +### Configure Kilo + +{% tabs %} +{% tab label="VSCode" %} + +Open **Settings** (gear icon) and go to the **Providers** tab to add Cloudflare AI Gateway. Kilo prompts for the account ID and gateway ID when they are not already set in the environment. + +{% /tab %} +{% tab label="CLI" %} + +Use `/connect` in the TUI and choose Cloudflare AI Gateway, or run: + +```bash +kilo auth cloudflare-ai-gateway +``` + +Alternatively, set environment variables before launching Kilo: + +```bash +export CLOUDFLARE_ACCOUNT_ID="your-account-id" +export CLOUDFLARE_GATEWAY_ID="your-gateway-id" +export CLOUDFLARE_API_TOKEN="your-gateway-api-token" +``` + +`CF_AIG_TOKEN` is also accepted as an alternative to `CLOUDFLARE_API_TOKEN`. + +Then choose a gateway-backed model from the model picker, or set a default model such as: + +```jsonc +{ + "model": "cloudflare-ai-gateway/openai/gpt-5.1" +} +``` + +{% /tab %} +{% /tabs %} + +## Advanced Configuration + +If your organization provides a fully configured gateway URL, set `provider.<id>.options.baseURL`. When `baseURL` is configured, Kilo skips the built-in Account ID and Gateway ID checks because the URL already identifies the target service. + +```jsonc +{ + "provider": { + "cloudflare-ai-gateway": { + "options": { + "baseURL": "https://gateway.example.com/v1" + } + } + } +} +``` + +## Troubleshooting + +- **`CLOUDFLARE_ACCOUNT_ID is missing`** — set `CLOUDFLARE_ACCOUNT_ID` or reconnect the provider with `/connect` so Kilo can store the account ID in auth metadata. +- **`CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_GATEWAY_ID missing`** — set both values or run `kilo auth cloudflare-ai-gateway`. +- **`CLOUDFLARE_API_TOKEN (or CF_AIG_TOKEN) is required`** — set one of those variables or reconnect Cloudflare AI Gateway with `kilo auth cloudflare-ai-gateway`. +- **Model errors through AI Gateway** — confirm the gateway route supports the selected upstream provider/model and that your Gateway API token has access. diff --git a/packages/kilo-docs/pages/ai-providers/index.md b/packages/kilo-docs/pages/ai-providers/index.md index bc9822b1db1..a7c8c05fee3 100644 --- a/packages/kilo-docs/pages/ai-providers/index.md +++ b/packages/kilo-docs/pages/ai-providers/index.md @@ -22,6 +22,10 @@ Major AI companies offering powerful models via API: - **[Anthropic](/docs/ai-providers/anthropic)** - Claude models (Claude 4, Claude 3.5 Sonnet, etc.) - **[OpenAI](/docs/ai-providers/openai)** - GPT-4, GPT-4o, o1, and more - **[Google Gemini](/docs/ai-providers/gemini)** - Gemini Pro, Gemini Ultra +- **[Google Vertex AI](/docs/ai-providers/vertex)** - Google Cloud-hosted Gemini and partner models +- **[AWS Bedrock](/docs/ai-providers/bedrock)** - AWS-hosted foundation models +- **[Alibaba Cloud](/docs/ai-providers/alibaba)** - DashScope and Qwen models through Model Studio +- **[Cloudflare](/docs/ai-providers/cloudflare)** - Workers AI and Cloudflare AI Gateway - **[DeepSeek](/docs/ai-providers/deepseek)** - DeepSeek V3., R1 - **[Mistral](/docs/ai-providers/mistral)** - Mistral Large, Codestral @@ -40,6 +44,7 @@ Route requests through unified APIs with additional features: - **[OpenRouter](/docs/ai-providers/openrouter)** - Access multiple providers through one API - **[Glama](/docs/ai-providers/glama)** - Enterprise AI gateway - **[Requesty](/docs/ai-providers/requesty)** - Smart routing and fallbacks +- **[Cloudflare AI Gateway](/docs/ai-providers/cloudflare)** - Route providers through your Cloudflare account ## Choosing a Provider diff --git a/packages/kilo-docs/pages/ai-providers/moonshot.md b/packages/kilo-docs/pages/ai-providers/moonshot.md index 7b92cf10287..463dc1bb59b 100644 --- a/packages/kilo-docs/pages/ai-providers/moonshot.md +++ b/packages/kilo-docs/pages/ai-providers/moonshot.md @@ -70,5 +70,6 @@ Then set your default model: - **Ultra-Long Context:** Kimi models excel at handling large codebases and complex projects with their extended context windows. - **Reasoning Capabilities:** The K2-Thinking variant provides enhanced problem-solving through extended reasoning chains. +- **Kimi-specific prompting:** Kilo automatically uses a Kimi-tuned system prompt for model IDs containing `kimi`; no extra configuration is required. - **Language Support:** Kimi models have strong support for both English and Chinese languages. - **Pricing:** Refer to the Moonshot.ai platform for current pricing information on different models. diff --git a/packages/kilo-docs/pages/ai-providers/venice.md b/packages/kilo-docs/pages/ai-providers/venice.md new file mode 100644 index 00000000000..2c45bb6032b --- /dev/null +++ b/packages/kilo-docs/pages/ai-providers/venice.md @@ -0,0 +1,76 @@ +--- +sidebar_label: Venice AI +--- + +# Using Venice AI With Kilo Code + +Kilo Code supports Venice AI through the native Venice provider. Venice offers privacy-focused access to open and reasoning-capable models through its API. + +**Website:** [https://venice.ai/](https://venice.ai/) + +## Getting an API Key + +1. Sign in to Venice AI. +2. Open your API settings. +3. Create a key for Kilo Code. +4. Copy the key immediately and store it securely. + +## Configuration in Kilo Code + +{% tabs %} +{% tab label="VSCode (Legacy)" %} + +Use the **OpenAI Compatible** provider if the legacy provider list does not include Venice AI. Enter your Venice API base URL, API key, and model ID from the Venice dashboard. + +{% /tab %} +{% tab label="VSCode" %} + +Open **Settings** (gear icon) and go to the **Providers** tab to add Venice AI and enter your API key. + +The extension stores this in your `kilo.json` config file. You can also edit the config file directly — see the **CLI** tab for the file format. + +{% /tab %} +{% tab label="CLI" %} + +Set the API key as an environment variable or configure it in your `kilo.json` config file: + +**Environment variable:** + +```bash +export VENICE_API_KEY="your-api-key" +``` + +**Config file** (`~/.config/kilo/kilo.json` or `./kilo.json`): + +```jsonc +{ + "provider": { + "venice": { + "env": ["VENICE_API_KEY"] + } + } +} +``` + +Then select a Venice model from the model picker, or set a default model after confirming the model ID in your account: + +```jsonc +{ + "model": "venice/<model-id>" +} +``` + +{% /tab %} +{% /tabs %} + +## Tips and Notes + +- **Native provider:** Kilo uses the Venice AI SDK provider for built-in Venice models. +- **Reasoning models:** Some Venice models expose reasoning controls. Kilo maps supported reasoning effort and disable-thinking options when the selected model supports them. +- **Model availability:** Venice model IDs and access can vary by plan. Use the model picker or Venice dashboard for the current list. +- **Pricing:** Refer to Venice AI pricing and account limits for current token costs and rate limits. + +## Troubleshooting + +- **Invalid API key:** Verify `VENICE_API_KEY` is set in the same environment that launches Kilo, or reconnect the provider in Settings. +- **Model not available:** Confirm your Venice account has access to the selected model and try selecting it from the model picker. diff --git a/packages/kilo-docs/pages/ai-providers/vertex.md b/packages/kilo-docs/pages/ai-providers/vertex.md index abbb56da3b8..26fef6fbcf0 100644 --- a/packages/kilo-docs/pages/ai-providers/vertex.md +++ b/packages/kilo-docs/pages/ai-providers/vertex.md @@ -81,4 +81,5 @@ Then set your default model: ## Tips and Notes - **Permissions:** Ensure your Google Cloud account has the necessary permissions to access Vertex AI and the specific models you want to use. +- **Prompt caching:** Claude models served through Vertex AI support Kilo prompt caching. Kilo applies Anthropic cache controls and tracks cache write/read tokens when Vertex reports them. Native Vertex Gemini models use Google's implicit server-side caching; no extra Kilo configuration is required, and Gemini may not report cache write tokens. - **Pricing:** Refer to the [Vertex AI pricing](https://cloud.google.com/vertex-ai/pricing) page for details. diff --git a/packages/kilo-docs/pages/customize/context/large-projects.md b/packages/kilo-docs/pages/customize/context/large-projects.md index 101fb1621b6..cfa129e40b1 100644 --- a/packages/kilo-docs/pages/customize/context/large-projects.md +++ b/packages/kilo-docs/pages/customize/context/large-projects.md @@ -31,7 +31,7 @@ The context window includes: 5. **Prioritize Recent History:** Kilo Code automatically truncates older messages in the conversation history to stay within the context window. Be mindful of this, and re-include important context if needed. -6. **Use Prompt Caching (if available):** Some API providers like Anthropic, OpenAI, OpenRouter and Requesty support "prompt caching". This caches your prompts for use in future tasks and helps reduce the cost and latency of requests. +6. **Use Prompt Caching (if available):** Some API providers like Anthropic, OpenAI, OpenRouter, Requesty, and Vertex AI Claude support "prompt caching". This caches your prompts for use in future tasks and helps reduce the cost and latency of requests. ## Example: Refactoring a Large File From 26046c9b2bec3b299dd32d004c0420376dfc15f6 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:05:48 +0000 Subject: [PATCH 58/96] chore: add opencode merge reference worktrees --- script/upstream/README.md | 11 +++ script/upstream/merge.ts | 53 +++++++++++++ script/upstream/utils/worktree.ts | 124 ++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 script/upstream/utils/worktree.ts diff --git a/script/upstream/README.md b/script/upstream/README.md index 219e0ee1839..9a664ecbb8b 100644 --- a/script/upstream/README.md +++ b/script/upstream/README.md @@ -218,11 +218,22 @@ Options: --base-branch <name> Base branch to merge into (default: main) --dry-run Preview changes without applying them --no-push Don't push branches to remote + --no-worktrees Don't create reference worktrees --report-only Only generate conflict report --verbose Enable verbose logging --author <name> Author name for branch prefix ``` +By default, `merge.ts` also prepares prompt-friendly reference worktrees under `.worktrees/opencode-merge/`: + +| Path | Snapshot | +|---|---| +| `.worktrees/opencode-merge/opencode` | Pristine upstream opencode at the requested version or commit | +| `.worktrees/opencode-merge/kilo-main` | The Kilo base branch snapshot used for the merge | +| `.worktrees/opencode-merge/auto-merge` | The automated merge result before final lockfile or SDK regeneration | + +If conflicts remain after automation, `auto-merge` is a committed local snapshot branch that may intentionally contain conflict markers as normal file content. The real merge branch remains unresolved so manual resolution can continue with accurate git conflict state. + ### analyze.ts ``` diff --git a/script/upstream/merge.ts b/script/upstream/merge.ts index 42489fcbe6a..01affc03caf 100644 --- a/script/upstream/merge.ts +++ b/script/upstream/merge.ts @@ -13,6 +13,7 @@ * --base-branch <name> Base branch to merge into (default: main) * --dry-run Preview changes without applying them * --no-push Don't push branches to remote + * --no-worktrees Don't create reference worktrees for manual resolution * --report-only Only generate conflict report, don't merge * --verbose Enable verbose logging * --author <name> Author name for branch prefix (default: from git config) @@ -23,6 +24,7 @@ import * as git from "./utils/git" import * as logger from "./utils/logger" import * as version from "./utils/version" import * as report from "./utils/report" +import * as worktree from "./utils/worktree" import { defaultConfig, loadConfig, type MergeConfig } from "./utils/config" import { transformAll as transformPackageNames } from "./transforms/package-names" import { preserveAllVersions } from "./transforms/preserve-versions" @@ -56,6 +58,7 @@ interface MergeOptions { baseBranch?: string dryRun: boolean push: boolean + worktrees: boolean reportOnly: boolean verbose: boolean author?: string @@ -150,6 +153,7 @@ function parseArgs(): MergeOptions { const options: MergeOptions = { dryRun: args.includes("--dry-run"), push: !args.includes("--no-push"), + worktrees: !args.includes("--no-worktrees"), reportOnly: args.includes("--report-only"), verbose: args.includes("--verbose"), } @@ -177,6 +181,29 @@ function parseArgs(): MergeOptions { return options } +function logWorktrees(refs: worktree.RefInfo, input: worktree.RefInput, baseName: string): void { + logger.divider() + logger.info("Reference worktrees:") + logger.info(` opencode: ${refs.opencode} (${input.tag}, ${input.upstream.slice(0, 8)})`) + logger.info(` kilo-main: ${refs.main} (${baseName}, ${input.base.slice(0, 8)})`) + logger.info(` auto-merge: ${refs.auto} (${refs.branch}, ${refs.snapshot.slice(0, 8)})`) + logger.info("") + logger.info("Agent prompt:") + logger.info(" Use these references while resolving the merge:") + logger.info(` - upstream opencode: ${refs.opencode}`) + logger.info(` - Kilo base main: ${refs.main}`) + logger.info(` - automated merge snapshot: ${refs.auto}`) +} + +async function prepareWorktrees(options: MergeOptions, input: worktree.RefInput, baseName: string) { + if (!options.worktrees) return null + + logger.info("Preparing reference worktrees...") + const refs = await worktree.prepare(input) + logWorktrees(refs, input, baseName) + return refs +} + async function getAuthor(): Promise<string> { const result = await $`git config user.name`.text() return result @@ -375,6 +402,7 @@ async function main() { // Create backup branch await git.checkout(config.baseBranch) await git.pull(config.originRemote) + const baseSha = await git.getCommitHash("HEAD") const backupBranch = await createBackupBranch(config.baseBranch) logger.info(`Created backup branch: ${backupBranch}`) @@ -741,6 +769,17 @@ async function main() { await report.saveReport(conflictReport, reportPath) logger.success(`Report saved to ${reportPath}`) + await prepareWorktrees( + options, + { + tag: targetVersion.tag, + upstream: targetVersion.commit, + base: await git.getCommitHash("HEAD"), + merge: await git.getCommitHash(opencodeBranch), + }, + config.baseBranch, + ) + logger.divider() logger.info("Next steps:") logger.info(" 1. Resolve remaining conflicts manually") @@ -768,6 +807,8 @@ async function main() { } } + const autoSha = await git.getCommitHash("HEAD") + // Step 8: Regenerate lock files and finalize logger.step(8, 8, "Regenerating lock files and finalizing...") @@ -824,6 +865,18 @@ async function main() { logger.info(`Backup branch: ${backupBranch}`) logger.info(`Report: ${reportPath}`) + await prepareWorktrees( + options, + { + tag: targetVersion.tag, + upstream: targetVersion.commit, + base: baseSha, + merge: opencodeBranch, + snapshot: autoSha, + }, + config.baseBranch, + ) + const remainingConflicts = await git.getConflictedFiles() if (remainingConflicts.length > 0) { logger.warn(`${remainingConflicts.length} conflicts need manual resolution`) diff --git a/script/upstream/utils/worktree.ts b/script/upstream/utils/worktree.ts new file mode 100644 index 00000000000..6a2f55e787e --- /dev/null +++ b/script/upstream/utils/worktree.ts @@ -0,0 +1,124 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import { randomUUID } from "node:crypto" +import { mkdir, realpath, rm, stat } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +export interface RefInput { + tag: string + upstream: string + base: string + merge: string + snapshot?: string +} + +export interface RefInfo { + opencode: string + main: string + auto: string + branch: string + snapshot: string +} + +async function exists(path: string) { + return stat(path) + .then((s) => s.isDirectory()) + .catch(() => false) +} + +async function root() { + return (await $`git rev-parse --show-toplevel`.text()).trim() +} + +function slug(value: string) { + return value.replace(/[^a-zA-Z0-9._-]+/g, "-") +} + +async function assertWorktree(path: string) { + const ok = await exists(path) + if (!ok) return false + + const result = await $`git -C ${path} rev-parse --show-toplevel`.quiet().nothrow() + if (result.exitCode !== 0) { + throw new Error(`${path} exists but is not a git worktree`) + } + + const top = result.stdout.toString().trim() + if ((await realpath(top)) !== (await realpath(path))) { + throw new Error(`${path} exists inside a git worktree but is not the worktree root`) + } + + return true +} + +async function assertClean(path: string) { + const status = await $`git -C ${path} status --porcelain`.text() + if (status.trim()) { + throw new Error(`${path} has local changes; clean it before refreshing opencode merge references`) + } +} + +async function checkout(path: string, ref: string) { + const found = await assertWorktree(path) + if (found) { + await assertClean(path) + await $`git -C ${path} checkout --detach ${ref}`.quiet() + return + } + + await $`git worktree add --detach ${path} ${ref}`.quiet() +} + +async function detach(path: string) { + const found = await assertWorktree(path) + if (!found) return + + await assertClean(path) + await $`git -C ${path} checkout --detach`.quiet() +} + +async function snapshot(input: RefInput) { + if (input.snapshot) return input.snapshot + + const idx = join(tmpdir(), `kilo-opencode-merge-${randomUUID()}.index`) + try { + await $`env GIT_INDEX_FILE=${idx} git read-tree ${input.base}`.quiet() + await $`env GIT_INDEX_FILE=${idx} git add -A`.quiet() + const tree = (await $`env GIT_INDEX_FILE=${idx} git write-tree`.text()).trim() + const msg = `chore: snapshot automated opencode merge for ${input.tag}` + const commit = await $`git commit-tree ${tree} -p ${input.base} -p ${input.merge} -m ${msg}`.text() + return commit.trim() + } finally { + await rm(idx, { force: true }) + } +} + +export async function prepare(input: RefInput): Promise<RefInfo> { + const repo = await root() + const dir = join(repo, ".worktrees", "opencode-merge") + const opencode = join(dir, "opencode") + const main = join(dir, "kilo-main") + const auto = join(dir, "auto-merge") + const branch = `opencode-merge/auto-${slug(input.tag)}` + + await mkdir(dir, { recursive: true }) + await $`git worktree prune`.quiet() + + const snap = await snapshot(input) + await detach(auto) + await $`git branch -f ${branch} ${snap}`.quiet() + + await checkout(opencode, input.upstream) + await checkout(main, input.base) + await checkout(auto, branch) + + return { + opencode, + main, + auto, + branch, + snapshot: snap, + } +} From 1adb8213ff60357b8201c88e86fcd5c9d6314e14 Mon Sep 17 00:00:00 2001 From: Marius <marius@kilocode.ai> Date: Wed, 29 Apr 2026 19:46:46 +0200 Subject: [PATCH 59/96] docs: include annotation check in quality gates (#9709) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 2c726e9a1cf..74dc60eceb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ Before saying an implementation is ready, run the smallest relevant checks that | CLI | From `packages/opencode/`: `bun run typecheck`, `bun test` or targeted `bun test ./path/to/file.test.ts` | | VS Code extension | From `packages/kilo-vscode/`: `bun run typecheck`, `bun run lint`, `bun run test:unit` or `bun run test` | | Extension build/package | From `packages/kilo-vscode/`: `bun run compile` or `bun run package` when touching build, packaging, SDK, or webview integration paths | -| CI-only guards | Run affected guards documented above, such as `bun run knip`, `bun run check-kilocode-change`, source link extraction, or opencode annotation checks | +| CI-only guards | Run affected guards documented above, such as `bun run knip`, `bun run check-kilocode-change`, `bun run script/check-opencode-annotations.ts`, or source link extraction | Never run root `bun test`; the root script prints `do not run tests from root` and exits with code 1. Use package-level tests instead. From d02c4a34c44fe628c902160cf97806add5007da2 Mon Sep 17 00:00:00 2001 From: Marius <marius@kilocode.ai> Date: Wed, 29 Apr 2026 19:59:40 +0200 Subject: [PATCH 60/96] docs(kilo-docs): refresh Agent Manager docs (#9710) * docs(kilo-docs): refresh agent manager docs * docs(kilo-docs): clarify agent manager worktree deletion --- .../pages/automate/agent-manager-workflows.md | 2 +- .../kilo-docs/pages/automate/agent-manager.md | 186 ++++-------------- packages/kilo-docs/pages/automate/index.md | 9 +- .../code-with-ai/platforms/vscode/index.md | 6 +- .../kilo-docs/pages/getting-started/byok.md | 4 +- .../pages/getting-started/settings/index.md | 6 +- .../getting-started/setup-authentication.md | 8 +- 7 files changed, 53 insertions(+), 168 deletions(-) diff --git a/packages/kilo-docs/pages/automate/agent-manager-workflows.md b/packages/kilo-docs/pages/automate/agent-manager-workflows.md index abf6f34327c..6ad8da4fe7c 100644 --- a/packages/kilo-docs/pages/automate/agent-manager-workflows.md +++ b/packages/kilo-docs/pages/automate/agent-manager-workflows.md @@ -16,7 +16,7 @@ If you already use the sidebar chat and want to start running multiple agents in Rule of thumb: if you would stash or switch branches to do the work, create a worktree instead. {% callout type="info" %} -All Agent Manager sessions share a single `kilo serve` process. What each worktree isolates is the filesystem and git state — the branch, the directory, the terminal. API keys, models, and configuration are shared. +All Agent Manager sessions use the extension's embedded runtime. What each worktree isolates is the filesystem and git state: the branch, the directory, and the terminal. Providers, BYOK keys, custom providers, models, and extension settings are shared with the sidebar. {% /callout %} ## What parallelizes well diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index 92114863421..e40e237e1df 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -7,10 +7,7 @@ description: "Manage and orchestrate multiple AI agents" The Agent Manager is a control panel for running and orchestrating multiple Kilo Code agents, with support for parallel worktree-isolated sessions. -{% tabs %} -{% tab label="VSCode" %} - -The Agent Manager is a **full-panel editor tab** built directly into the extension. All sessions share the single `kilo serve` backend process. It supports: +The Agent Manager is a **full-panel editor tab** built directly into the extension. It uses the extension's embedded runtime, so no separate Kilo CLI installation or CLI authentication setup is required. It supports: - Multiple parallel sessions, each in its own git worktree - A diff/review panel showing changes vs. the parent branch @@ -18,6 +15,7 @@ The Agent Manager is a **full-panel editor tab** built directly into the extensi - Setup scripts and `.env` auto-copy on worktree creation - Session import from existing branches, external worktrees, or GitHub PR URLs - "Continue in Worktree" to promote a sidebar session to the Agent Manager +- The same providers, BYOK keys, custom providers, and extension features supported in the sidebar {% callout type="tip" %} New to running multiple agents in parallel? The [Agent Manager Workflows](/docs/automate/agent-manager-workflows) guide walks through when to use the sidebar vs. the Agent Manager, how to pick tasks that parallelize well, and the common patterns for testing, reviewing, and integrating changes across worktrees. @@ -31,10 +29,26 @@ New to running multiple agents in parallel? The [Agent Manager Workflows](/docs/ The panel opens as an editor tab and stays active across focus changes. +## Requirements + +- Open a VS Code workspace folder +- Use a git repository for worktree features +- Open the main repository, not an existing worktree checkout, when creating new worktrees + +## Providers and Authentication + +Agent Manager uses the same sign-in, provider settings, models, BYOK keys, custom providers, MCP servers, and permission rules as the extension sidebar. Configure them from extension Settings and they apply to Agent Manager as well. + +See [Setup & Authentication](/docs/getting-started/setup-authentication), [AI Providers](/docs/ai-providers), and [Bring Your Own Key](/docs/getting-started/byok) for setup details. + ## Working with Worktrees Each Agent Manager session runs in an isolated git worktree on a separate branch, keeping your main branch clean. +### Worktree Location + +Managed worktrees are created under `.kilo/worktrees/` in your project. Kilo also stores Agent Manager UI state in `.kilo/agent-manager.json`. + ### PR Status Badges Each worktree item displays a **PR status badge** when its branch has an associated pull request. The badge shows the PR number (e.g. `#142`) and is color-coded to reflect the current state at a glance. Click the badge to open the PR in your browser. @@ -109,6 +123,15 @@ You can run up to 4 parallel implementations of the same prompt across separate - **From an external worktree:** Import a worktree that already exists on disk - **Continue in Worktree:** From the sidebar chat, promote the current session to a new Agent Manager worktree +Imported work stays associated with its branch or worktree and can be continued from Agent Manager. + +### Sessions and History + +- Create a worktree session to start a new agent in an isolated branch +- Press `Cmd+T` (macOS) / `Ctrl+T` (Windows/Linux) to start another session in the selected worktree +- Use session history to reopen local sessions or preview cloud sessions +- Continue a cloud session locally from Agent Manager using the same extension sign-in and provider settings + ## Sections Sections let you group worktrees into collapsible, color-coded folders in the sidebar. Use them to organize your workflow however you like — by status ("Review Pending", "In Progress"), by project area ("Frontend", "Backend"), priority, or any other scheme that fits. @@ -222,7 +245,9 @@ Two extra variables are injected into the script's environment: ## Session State and Persistence -Agent Manager state is persisted in `.kilo/agent-manager.json`. Sessions, worktrees, and their order are restored on reload. +Agent Manager state is persisted in `.kilo/agent-manager.json`. It stores worktrees, sections, session tabs, ordering, collapsed state, diff preferences, and cached PR metadata. Git branches and worktree directories remain on disk separately. + +Closing a managed worktree removes it from Agent Manager, deletes its `.kilo/worktrees/` directory, and deletes the local branch. Closing an imported external worktree removes the Agent Manager entry but leaves the external directory and branch untouched. ## Keyboard Shortcuts (Agent Manager Panel) @@ -247,154 +272,13 @@ Agent Manager state is persisted in `.kilo/agent-manager.json`. Sessions, worktr - **"Please open a folder…" error** — the Agent Manager requires a VS Code workspace folder - **Worktree creation fails** — ensure Git is installed and the workspace is a valid git repository. Open the main repository (where `.git` is a directory), not an existing worktree checkout. - -{% /tab %} -{% tab label="VSCode (Legacy)" %} - -The Agent Manager is a dedicated control panel for running and supervising Kilo Code agents as interactive CLI processes. It supports: - -- Local sessions -- Resuming existing sessions -- Parallel Mode (with support for Git worktree) for safe, isolated changes -- Viewing and continuing cloud-synced sessions filtered to your current repository - -This page reflects the actual implementation in the extension. - -## Prerequisites - -- Install/update the Kilo Code CLI (latest) — see [CLI setup](/docs/code-with-ai/platforms/cli) -- Open a project in VS Code (workspace required) -- Authentication: You must be logged in via the extension settings OR use CLI with kilocode as provider (see [Authentication Requirements](#authentication-requirements)) - -## Opening the Agent Manager - -- Command Palette: "Kilo Code: Open Agent Manager" -- Or use the title/menu entry if available in your Kilo Code UI - -The panel opens as a webview and stays active across focus changes. - -## Sending messages, approvals, and control - -- Continue the conversation: Send a follow-up message to the running agent -- Approvals: If the agent asks to use a tool, run a command, launch the browser, or connect to an MCP server, the UI shows an approval prompt - - Approve or reject, optionally adding a short note -- Cancel vs Stop - - Cancel sends a structured cancel message to the running process (clean cooperative stop) - - Stop force-terminates the underlying CLI process, updating status to "stopped" - -## Resuming an existing session - -You can continue a session later (local or remote): - -- If a session is not currently running, the Agent Manager will spawn a new CLI process attached to that session's ID -- Labels from the original session are preserved whenever possible -- Your first follow-up message becomes the continuation input - -## Parallel Mode - -Parallel Mode runs the agent in an isolated Git worktree branch, keeping your main branch clean. - -- Enable the "Parallel Mode" toggle before starting -- The extension prevents using Parallel Mode inside an existing worktree - - Open the main repository (where .git is a directory) to use this feature - -### Worktree Location - -Worktrees are created in `.kilocode/worktrees/` within your project directory. This folder is automatically excluded from git via `.git/info/exclude` (a local-only ignore file that doesn't require a commit). - -``` -your-project/ -├── .git/ -│ └── info/ -│ └── exclude # local ignore rules (includes .kilocode/worktrees/) -├── .kilocode/ -│ └── worktrees/ -│ └── feature-branch-1234567890/ # isolated working directory -└── ... -``` - -### While Running - -The Agent Manager surfaces: - -- Branch name created/used -- Worktree path -- A completion/merge instruction message when the agent finishes - -### After Completion - -- The worktree is cleaned up automatically, but the branch is preserved -- Review the branch in your VCS UI -- Merge or cherry-pick the changes as desired - -### Resuming Sessions - -If you resume a Parallel Mode session later, the extension will: - -1. Reuse the existing worktree if it still exists -2. Or recreate it from the session's branch - -## Authentication Requirements - -The Agent Manager requires proper authentication for full functionality, including session syncing and cloud features. - -### Supported Authentication Methods - -1. **Kilo Code Extension (Recommended)** - - Sign in through the extension settings - - Provides seamless authentication for the Agent Manager - - Enables session syncing and cloud features - -2. **CLI with Kilo Code Provider** - - Use the CLI configured with `kilocode` as the provider - - Run `kilocode config` to set up authentication - - See [CLI setup](/docs/code-with-ai/platforms/cli) for details - -### BYOK Limitations - -**Important:** Bring Your Own Key (BYOK) is not fully supported with the Agent Manager. - -If you're using BYOK with providers like Anthropic, OpenAI, or OpenRouter: - -- The Agent Manager will not have access to cloud-synced sessions -- Session syncing features will be unavailable -- You must use one of the supported authentication methods above for full functionality - -To use the Agent Manager with all features enabled, switch to the Kilo Code provider or sign in through the extension. - -## Remote sessions (Cloud) - -When signed in (Kilo Cloud), the Agent Manager lists your recent cloud-synced sessions: - -- Up to 50 sessions are fetched -- Sessions are filtered to the current repository via normalized Git remote URL - - If the current workspace has no remote, only sessions without a git_url are shown -- Selecting a remote session loads its message transcript -- To continue the work locally, send a message — the Agent Manager will spawn a local process bound to that session - -Message transcripts are fetched from a signed blob and exclude internal checkpoint "save" markers as chat rows (checkpoints still appear as dedicated entries in the UI). - -## Troubleshooting - -- CLI not found or outdated - - Install/update the CLI: [CLI setup](/docs/code-with-ai/platforms/cli) - - If you see an "unknown option --json-io" error, update to the latest CLI -- "Please open a folder…" error - - The Agent Manager requires a VS Code workspace folder -- "Cannot use parallel mode from within a git worktree" - - Open the main repository (where .git is a directory), not a worktree checkout -- Remote sessions not visible - - Ensure you're signed in and the repo's remote URL matches the sessions you expect to see - - If using BYOK, session syncing is not available — switch to Kilo Code provider or sign in through the extension -- Authentication errors - - Verify you're logged in via extension settings or using CLI with kilocode provider - - BYOK configurations do not support Agent Manager authentication - -{% /tab %} -{% /tabs %} +- **Provider or authentication errors** — open extension Settings and verify your sign-in, provider, model, or BYOK configuration. Agent Manager uses the same settings as the sidebar. +- **Session history missing cloud sessions** — sign in through the extension and confirm the repository remote matches the sessions you expect to see. +- **PR badges or PR import missing** — install and authenticate the GitHub CLI (`gh`). This is only required for GitHub PR features. ## Related features - [Sessions](/docs/collaborate/sessions-sharing) - [Auto-approving Actions](/docs/getting-started/settings/auto-approving-actions) -- [CLI](/docs/code-with-ai/platforms/cli) +- [AI Providers](/docs/ai-providers) +- [Bring Your Own Key](/docs/getting-started/byok) diff --git a/packages/kilo-docs/pages/automate/index.md b/packages/kilo-docs/pages/automate/index.md index d71a6dce541..5231c01833f 100644 --- a/packages/kilo-docs/pages/automate/index.md +++ b/packages/kilo-docs/pages/automate/index.md @@ -23,9 +23,10 @@ Automated AI code reviews for every pull request: Manage and orchestrate multiple AI agents: - [**Agent Manager**](/docs/automate/agent-manager) — Control panel for running agents -- Local and cloud-synced sessions -- Parallel Mode with Git worktree isolation -- Resume existing sessions +- Same providers, settings, and extension features as the sidebar +- Local and cloud session history +- Worktree-isolated and multi-version sessions +- Continue work from branches, PRs, external worktrees, or sidebar sessions ## MCP (Model Context Protocol) @@ -63,7 +64,7 @@ Customize and extend Kilo Code's capabilities: ## Get Started -1. Set up the [Agent Manager](/docs/automate/agent-manager) for local automation +1. Open the [Agent Manager](/docs/automate/agent-manager) for local automation 2. Configure [MCP servers](/docs/automate/mcp/using-in-kilo-code) for external integrations 3. Enable [Code Reviews](/docs/automate/code-reviews) for your repositories 4. Explore [integrations](/docs/automate/integrations) to connect your toolchain diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md b/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md index 2db22d89a61..e539edf26b4 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md @@ -5,7 +5,7 @@ description: "Using Kilo Code in Visual Studio Code" # VS Code Extension -Kilo Code is available as two VS Code extensions: the **VSCode (Legacy)** extension and the current **VSCode** version built on the Kilo CLI core. +Kilo Code is available as two VS Code extensions: the **VSCode (Legacy)** extension and the current **VSCode** version built on Kilo's shared agent runtime. {% tabs %} {% tab label="VSCode" %} @@ -17,7 +17,7 @@ Kilo Code is available as two VS Code extensions: the **VSCode (Legacy)** extens 3. Search for "Kilo Code" 4. Click the dropdown arrow next to **Install** and select **Install Pre-Release Version** -The extension bundles its own CLI binary and spawns `kilo serve` as a background process. All communication happens over HTTP + SSE. +The extension includes its own embedded runtime. No separate Kilo CLI installation is required. ## Key Features @@ -38,7 +38,7 @@ Key features include: ## Shared Settings -The extension shares its configuration with the CLI. Settings in `~/.config/kilo/kilo.jsonc` (global) and `./kilo.jsonc` (project) apply to both the CLI and the extension. +Settings apply across extension surfaces, including the sidebar and Agent Manager. The standalone CLI uses the same `~/.config/kilo/kilo.jsonc` (global) and `./kilo.jsonc` (project) files when used directly. {% /tab %} {% tab label="VSCode (Legacy)" %} diff --git a/packages/kilo-docs/pages/getting-started/byok.md b/packages/kilo-docs/pages/getting-started/byok.md index 3fd344f85d3..cc985022c77 100644 --- a/packages/kilo-docs/pages/getting-started/byok.md +++ b/packages/kilo-docs/pages/getting-started/byok.md @@ -70,6 +70,6 @@ Your IAM user or role must have the following permissions: - Select a model from a provider configured for BYOK, for example Claude Sonnet 4.5 if you configured BYOK for Anthropic. - (Optional) Validate with the provider that traffic is being served by that key. -## Limitations +## Use with Agent Manager -- BYOK is not fully supported by Agent Manager. See [Agent Manager](/docs/automate/agent-manager) for details. +Agent Manager supports the same BYOK and custom provider configurations as the extension sidebar. diff --git a/packages/kilo-docs/pages/getting-started/settings/index.md b/packages/kilo-docs/pages/getting-started/settings/index.md index f72cbd0e0aa..563d0a237ca 100644 --- a/packages/kilo-docs/pages/getting-started/settings/index.md +++ b/packages/kilo-docs/pages/getting-started/settings/index.md @@ -5,7 +5,7 @@ description: "Configure Kilo Code settings and preferences" # Settings -The VS Code extension can be configured through the Settings window, opened by pressing the gear icon. Both the CLI and the extension can also be configured through interactions with the agent. The current VS Code extension and CLI share the same underlying settings, so changes in one are reflected in the other. +The VS Code extension can be configured through the Settings window, opened by pressing the gear icon in Kilo Code. Changes apply across extension surfaces, including the sidebar and Agent Manager. The CLI can also use the same JSONC config files when you use it directly. ## Configuring with the Agent @@ -30,9 +30,9 @@ This is especially useful for complex configuration like custom model definition {% tabs %} {% tab label="VSCode" %} -The VS Code extension provides a **Settings webview UI** accessible from the extension sidebar by clicking the gear icon ({% codicon name="gear" /%}). The UI is organized into tabs including Providers, Auto-Approve, Models, and more. +The VS Code extension provides a **Settings webview UI** accessible from Kilo Code by clicking the gear icon ({% codicon name="gear" /%}). The UI is organized into tabs including Providers, Auto-Approve, Models, and more. -This UI reads and writes to the same underlying JSONC config files used by the CLI, so changes made in either place are reflected in both. +This UI reads and writes to the same underlying JSONC config files used across extension surfaces. Changes apply to the sidebar, Agent Manager, and the CLI when used directly. ### Config File Locations diff --git a/packages/kilo-docs/pages/getting-started/setup-authentication.md b/packages/kilo-docs/pages/getting-started/setup-authentication.md index 975d456e013..4b364aaf73e 100644 --- a/packages/kilo-docs/pages/getting-started/setup-authentication.md +++ b/packages/kilo-docs/pages/getting-started/setup-authentication.md @@ -12,7 +12,7 @@ When you install Kilo Code, you'll be prompted to sign in or create a free accou {% tabs %} {% tab label="VSCode" %} -The extension prompts you to sign in when you first open the sidebar. Click **Sign In** and complete the browser-based flow. The extension communicates with the CLI backend, so authentication is shared between the CLI and extension. +The extension prompts you to sign in when you first open Kilo Code in VS Code. Click **Sign In** and complete the browser-based flow. Sign-in applies across extension surfaces, including the sidebar and Agent Manager. {% /tab %} {% tab label="CLI" %} @@ -106,13 +106,13 @@ Already have a ChatGPT subscription? You can use it with Kilo Code through the [ {% tabs %} {% tab label="VSCode" %} -1. Open the Kilo Code sidebar in VS Code -2. Click the gear icon ({% codicon name="gear" /%}) to open **Settings** +1. Open Kilo Code in VS Code +2. Click the gear icon ({% codicon name="gear" /%}) in the extension UI to open **Settings** 3. Go to the **Providers** tab 4. Select your provider and enter your API key 5. Choose your model -You can also use `kilo auth login` for providers that support OAuth (like GitHub Copilot). The extension reads from the same underlying config files as the CLI, so provider settings are shared. +These settings apply across the extension, including the sidebar and Agent Manager. Agent Manager does not need separate provider or Kilo CLI authentication setup. {% /tab %} {% tab label="CLI" %} From 5b9a6268cdfb0e26c492f1593c82ac636cd9b288 Mon Sep 17 00:00:00 2001 From: Marius <marius@kilocode.ai> Date: Wed, 29 Apr 2026 20:12:38 +0200 Subject: [PATCH 61/96] docs(kilo-docs): remove redundant byok agent manager note (#9711) --- packages/kilo-docs/pages/getting-started/byok.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/kilo-docs/pages/getting-started/byok.md b/packages/kilo-docs/pages/getting-started/byok.md index cc985022c77..e9b33847366 100644 --- a/packages/kilo-docs/pages/getting-started/byok.md +++ b/packages/kilo-docs/pages/getting-started/byok.md @@ -69,7 +69,3 @@ Your IAM user or role must have the following permissions: - BYOK works with the Kilo Gateway provider. Users should ensure that is set as the active [provider](/docs/ai-providers). - Select a model from a provider configured for BYOK, for example Claude Sonnet 4.5 if you configured BYOK for Anthropic. - (Optional) Validate with the provider that traffic is being served by that key. - -## Use with Agent Manager - -Agent Manager supports the same BYOK and custom provider configurations as the extension sidebar. From 3728b9081a15d31c559fa6c114a47e7317afcd52 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" <kilo-maintainer[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:20:48 +0000 Subject: [PATCH 62/96] release: v7.2.31 --- .changeset/compact-todo-updates.md | 6 ---- .changeset/permission-apply-patch-diffs.md | 5 ---- bun.lock | 34 +++++++++++----------- package.json | 2 +- packages/app/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++---- packages/kilo-docs/package.json | 2 +- packages/kilo-gateway/package.json | 2 +- packages/kilo-i18n/package.json | 2 +- packages/kilo-indexing/package.json | 2 +- packages/kilo-telemetry/package.json | 2 +- packages/kilo-ui/package.json | 2 +- packages/kilo-vscode/CHANGELOG.md | 8 +++++ packages/kilo-vscode/package.json | 2 +- packages/opencode/CHANGELOG.md | 6 ++++ packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/script/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/shared/package.json | 2 +- packages/storybook/package.json | 2 +- packages/ui/package.json | 2 +- script/upstream/package.json | 2 +- sdks/vscode/package.json | 2 +- 26 files changed, 57 insertions(+), 54 deletions(-) delete mode 100644 .changeset/compact-todo-updates.md delete mode 100644 .changeset/permission-apply-patch-diffs.md diff --git a/.changeset/compact-todo-updates.md b/.changeset/compact-todo-updates.md deleted file mode 100644 index 6ef497be78d..00000000000 --- a/.changeset/compact-todo-updates.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"kilo-code": patch -"@kilocode/cli": patch ---- - -Show compact todo update cards when checking off items in long todo lists. diff --git a/.changeset/permission-apply-patch-diffs.md b/.changeset/permission-apply-patch-diffs.md deleted file mode 100644 index 2d115ae7c20..00000000000 --- a/.changeset/permission-apply-patch-diffs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Show apply_patch diffs in the permission prompt before approval. diff --git a/bun.lock b/bun.lock index be06087afee..a2f32c6dca1 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "7.2.30", + "version": "7.2.31", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -88,7 +88,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "7.2.30", + "version": "7.2.31", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -121,7 +121,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "7.2.30", + "version": "7.2.31", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -173,7 +173,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.30", + "version": "7.2.31", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -202,7 +202,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.30", + "version": "7.2.31", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -238,7 +238,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.2.30", + "version": "7.2.31", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -248,7 +248,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.2.30", + "version": "7.2.31", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -282,7 +282,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.2.30", + "version": "7.2.31", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "@opentelemetry/api": "1.9.0", @@ -302,7 +302,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.30", + "version": "7.2.31", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/shared": "workspace:*", @@ -337,7 +337,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.30", + "version": "7.2.31", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", @@ -398,7 +398,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.30", + "version": "7.2.31", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -556,7 +556,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.30", + "version": "7.2.31", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -581,7 +581,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.30", + "version": "7.2.31", "dependencies": { "semver": "^7.6.3", }, @@ -592,7 +592,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.30", + "version": "7.2.31", "dependencies": { "cross-spawn": "catalog:", }, @@ -607,7 +607,7 @@ }, "packages/shared": { "name": "@opencode-ai/shared", - "version": "7.2.30", + "version": "7.2.31", "bin": { "opencode": "./bin/opencode", }, @@ -631,7 +631,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.30", + "version": "7.2.31", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -654,7 +654,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.30", + "version": "7.2.31", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", diff --git a/package.json b/package.json index 254cc89838c..e7e8ad0d992 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,6 @@ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch" }, - "version": "7.2.30", + "version": "7.2.31", "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index 9311d276b20..ee1e6b515b4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "7.2.30", + "version": "7.2.31", "description": "", "type": "module", "exports": { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index bde4ac24ed5..435ad7e702a 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "7.2.30", + "version": "7.2.31", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 05310cc3de3..dc6a47790a6 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "7.2.30", + "version": "7.2.31", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 260340f1e3a..4ed1c6ffa95 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.2.30" +version = "7.2.31" 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.2.30/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.31/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.2.30/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.31/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.30/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.31/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.2.30/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.31/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.2.30/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.31/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 10097f5e6a2..710c331b5a1 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.2.30", + "version": "7.2.31", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 68ed19044c4..ada00533ded 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.2.30", + "version": "7.2.31", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index bcfd9604fea..a3b881e4362 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.2.30", + "version": "7.2.31", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 2c7531121a5..b223470dc63 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.2.30", + "version": "7.2.31", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 1d29bba187d..c7ff88d9c78 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.2.30", + "version": "7.2.31", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index fc122b26d99..5dcb8a72fcf 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.2.30", + "version": "7.2.31", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index d3e2e0d3ea6..d008b0ab0f6 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,13 @@ # kilo-code +## 7.2.31 + +### Patch Changes + +- [#9687](https://github.com/Kilo-Org/kilocode/pull/9687) [`9028174`](https://github.com/Kilo-Org/kilocode/commit/9028174cfd5fdd0cf2f3dd87d5ace7cfa780cc4d) - Show compact todo update cards when checking off items in long todo lists. + +- [#9691](https://github.com/Kilo-Org/kilocode/pull/9691) [`ae726cd`](https://github.com/Kilo-Org/kilocode/commit/ae726cdc45317a6bc6d2a77066c191dd674d0aa2) - Show apply_patch diffs in the permission prompt before approval. + ## 7.2.30 ### Patch Changes diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 968ab4b14f5..2e046f4743b 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.2.30", + "version": "7.2.31", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index b473911caf4..25bf1737aae 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,11 @@ # @kilocode/cli +## 7.2.31 + +### Patch Changes + +- [#9687](https://github.com/Kilo-Org/kilocode/pull/9687) [`9028174`](https://github.com/Kilo-Org/kilocode/commit/9028174cfd5fdd0cf2f3dd87d5ace7cfa780cc4d) - Show compact todo update cards when checking off items in long todo lists. + ## 7.2.30 ### Patch Changes diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 05d66a9dbbd..ca6d07b6c67 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.30", + "version": "7.2.31", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index a964fe2a8e5..2bf0a195d91 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.2.30", + "version": "7.2.31", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index 4263df29fea..8608af0b4e0 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.2.30", + "version": "7.2.31", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 47270dfe3a6..2a27ff2af9a 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.2.30", + "version": "7.2.31", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/shared/package.json b/packages/shared/package.json index 12dbbf082ff..fc22a67e365 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.30", + "version": "7.2.31", "name": "@opencode-ai/shared", "type": "module", "license": "MIT", diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 3595515b1db..4d2f3428dce 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.2.30", + "version": "7.2.31", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 736a20baeaa..5e22b13a264 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.2.30", + "version": "7.2.31", "type": "module", "license": "MIT", "exports": { diff --git a/script/upstream/package.json b/script/upstream/package.json index 268c04fdc51..375c778121a 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.2.30", + "version": "7.2.31", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 9e3ec8fc9e3..3d03f7330e1 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "7.2.30", + "version": "7.2.31", "publisher": "sst-dev", "repository": { "type": "git", From fa02c562e40accef8291f0293afdc5b5a78d1805 Mon Sep 17 00:00:00 2001 From: kirillk <kirillk@kilocode.ai> Date: Wed, 29 Apr 2026 14:55:09 -0400 Subject: [PATCH 63/96] docs: fix JetBrains UI skill tables --- .kilo/skills/jetbrains-ui-style/SKILL.md | 28 ++++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.kilo/skills/jetbrains-ui-style/SKILL.md b/.kilo/skills/jetbrains-ui-style/SKILL.md index 92a660d1865..5536a2c4459 100644 --- a/.kilo/skills/jetbrains-ui-style/SKILL.md +++ b/.kilo/skills/jetbrains-ui-style/SKILL.md @@ -38,7 +38,7 @@ This plugin is a split-mode JetBrains plugin. UI code belongs in `frontend` unle - Do not use JCEF. | Need | API | -| --- | --- | +|---|---| | Dialogs, settings pages, forms, any layout with components | Preferred: Kotlin UI DSL v2 (`com.intellij.ui.dsl.builder`) | | Tool window panels, action-driven UI, custom components | Standard Swing with IntelliJ Platform component replacements | | Menus and toolbars | Action System | @@ -131,7 +131,7 @@ panel { Every row uses one of three layouts. Default is `LABEL_ALIGNED` when a label is provided for the row, `INDEPENDENT` otherwise. | Layout | Behavior | -| --- | --- | +|---|---| | `LABEL_ALIGNED` | Label column and content columns, aligned across rows | | `INDEPENDENT` | All cells are independent, no cross-row alignment | | `PARENT_GRID` | Cells align with the parent grid columns across rows | @@ -164,7 +164,7 @@ panel { All cell factory methods available inside `row { }`: | Method | Description | -| --- | --- | +|---|---| | `checkBox("text")` | Checkbox | | `threeStateCheckBox("text")` | Three-state checkbox | | `radioButton("text", value)` | Radio button, must be inside `buttonsGroup {}` | @@ -256,7 +256,7 @@ When a row contains a `checkBox` or `radioButton`, the DSL automatically increas Three types of comments, each with different placement and semantics: | Type | Method | Placement | -| --- | --- | --- | +|---|---|---| | Cell comment, bottom | `cell.comment("text")` | Below the cell | | Cell comment, right | `cell.commentRight("text")` | Right of the cell | | Cell context help | `cell.contextHelp("text", "title")` | Help icon with popup | @@ -289,7 +289,7 @@ Comments support HTML with clickable links, bundled icons via `<icon src='...'>` ### Groups and Structure | Method | Grid | Description | -| --- | --- | --- | +|---|---|---| | `panel {}` | Own grid | Sub-panel occupying full width | | `rowsRange {}` | Parent grid | Grouped rows sharing parent grid, useful with `enabledIf` | | `group("Title") {}` | Own grid | Titled section with vertical spacing before/after | @@ -406,7 +406,7 @@ panel { Bind component values to model properties. Values are applied on `DialogPanel.apply()`, checked with `.isModified()`, and reverted with `.reset()`. | Method | Component | -| --- | --- | +|---|---| | `bindSelected(model::prop)` | checkBox | | `bindText(model::prop)` | textField | | `bindIntText(model::prop)` | intTextField | @@ -480,7 +480,7 @@ Activate validators by calling `dialogPanel.registerValidators(disposable)` afte ### Tips and Common Patterns | Pattern | Usage | -| --- | --- | +|---|---| | `.bold()` | Bold text on any cell | | `.columns(COLUMNS_MEDIUM)` | Set preferred width of textField / comboBox / textArea | | `.text("initial")` | Set initial text on text components | @@ -533,7 +533,7 @@ Preferred manual Swing rules: Use semantic spacing APIs before inventing numbers. Do not copy fallback values from IntelliJ source into generated UI code; those values are defaults behind theme keys and may change by UI theme, New UI, OS, or IDE version. | Need | Preferred source | -| --- | --- | +|---|---| | Dialogs, forms, settings | Kotlin UI DSL row/group/indent/gap APIs | | Component gaps in Kotlin UI DSL | `RightGap.SMALL`, `RightGap.COLUMNS`, `TopGap.MEDIUM`, `BottomGap.MEDIUM`, `.customize(UnscaledGaps(...))` as a last resort | | Manual Swing empty padding | `JBUI.Borders.empty(...)` | @@ -548,7 +548,7 @@ Use semantic spacing APIs before inventing numbers. Do not copy fallback values Use `JBUI.CurrentTheme` for context-specific spacing and dimensions: | UI area | Preferred source | -| --- | --- | +|---|---| | Action list rows | `JBUI.CurrentTheme.ActionsList.cellPadding()` | | Action icon/text gaps | `JBUI.CurrentTheme.ActionsList.elementIconGap()` | | Action mnemonic gaps | `JBUI.CurrentTheme.ActionsList.mnemonicIconGap()` / `mnemonicInsets()` | @@ -603,7 +603,7 @@ component.preferredSize = JBDimension(0, JBUI.CurrentTheme.Tree.rowHeight()) Use existing platform utility classes instead of hand-rolled labels, renderers, borders, colors, fonts, and validation behavior. | Need | Preferred API | -| --- | --- | +|---|---| | Concise border layout | `BorderLayoutPanel`, `JBUI.Panels.simplePanel(...)` | | Platform panel helpers | `JBPanel.withBorder(...)`, `.andTransparent()`, `.andOpaque()` | | Platform label behavior | `JBLabel` | @@ -651,7 +651,7 @@ append(link, SimpleTextAttributes.LINK_ATTRIBUTES) Always use IntelliJ platform components instead of raw Swing where an equivalent exists. | Instead of | Use | Package | -| --- | --- | --- | +|---|---|---| | `JLabel` | `JBLabel` | `com.intellij.ui.components` | | `JTextField` | `JBTextField` | `com.intellij.ui.components` | | `JTextArea` | `JBTextArea` | `com.intellij.ui.components` | @@ -671,7 +671,7 @@ Inspection `Plugin DevKit | Code | Undesirable class usage` highlights raw Swing ## Multi-line and Rich Text | Need | Component | -| --- | --- | +|---|---| | Rich HTML with modern CSS, icons, shortcuts | `JBHtmlPane` (`com.intellij.ui.components.JBHtmlPane`) | | Simple multi-line label with HTML | `JBLabel` + `XmlStringUtil.wrapInHtml()` | | Scrollable / wrapping HTML panel | `SwingHelper.createHtmlViewer()` | @@ -692,7 +692,7 @@ Do not hardcode runtime colors. IntelliJ UI colors must come from the current th Prefer semantic helpers for common UI roles: | Need | Preferred API | -| --- | --- | +|---|---| | Ordinary label text | `UIUtil.getLabelForeground()` | | Secondary/help text | `UIUtil.getContextHelpForeground()` | | Info label text | `UIUtil.getLabelInfoForeground()` | @@ -711,7 +711,7 @@ Prefer semantic helpers for common UI roles: Prefer `JBUI.CurrentTheme` for component-area colors and borders: | Need | Preferred API | -| --- | --- | +|---|---| | Popup background | `JBUI.CurrentTheme.Popup.BACKGROUND` | | Complex popup header | `JBUI.CurrentTheme.ComplexPopup.HEADER_BACKGROUND` | | Separators | `JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground()` | From a6b78e833ee459585f14baf3c82b3d2592d82104 Mon Sep 17 00:00:00 2001 From: Josh Lambert <josh@kilocode.ai> Date: Thu, 30 Apr 2026 00:39:02 -0400 Subject: [PATCH 64/96] docs: add per-message feedback proposal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial high-level design proposal for thumbs up/down feedback on assistant messages. Covers the problem motivation, scope, payload differences between Kilo Gateway and direct providers, and open questions. Implementation details intentionally kept out — those belong in the PR diff. --- packages/kilo-docs/lib/nav/contributing.ts | 4 + .../architecture/per-message-feedback.md | 76 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md diff --git a/packages/kilo-docs/lib/nav/contributing.ts b/packages/kilo-docs/lib/nav/contributing.ts index 74e75c5ede6..14f4dae5e63 100644 --- a/packages/kilo-docs/lib/nav/contributing.ts +++ b/packages/kilo-docs/lib/nav/contributing.ts @@ -70,6 +70,10 @@ export const ContributingNav: NavSection[] = [ href: "/contributing/architecture/voice-transcription", children: "Voice Transcription", }, + { + href: "/contributing/architecture/per-message-feedback", + children: "Per-Message Feedback", + }, ], }, ], diff --git a/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md b/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md new file mode 100644 index 00000000000..fadfc7d3efc --- /dev/null +++ b/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md @@ -0,0 +1,76 @@ +--- +title: "Per-Message Feedback" +description: "Thumbs up/down feedback on assistant messages sent to Kilo via telemetry" +--- + +# Per-Message Feedback (Thumbs Up / Down) + +## Problem + +We have no signal on which assistant responses are helpful and which aren't. Without per-response feedback, we can't: + +- Correlate model or prompt changes to user-perceived quality +- Identify specific bad responses in the Kilo Gateway logs for investigation +- Detect patterns where certain providers, models, or prompt paths consistently underperform + +Aggregate metrics like session completion rate or token cost are too coarse to understand individual response quality. A lightweight thumbs-up/down on each message gives us the missing feedback loop. + +## Proposal + +Add a thumbs-up / thumbs-down widget next to the existing copy button on every assistant message. Ratings are sent to Kilo via the existing PostHog telemetry pipeline. The UI is hidden entirely when telemetry is disabled. + +### Scope + +| Surface | Approach | +|---|---| +| VS Code extension | Thumbs buttons inline next to the copy button | +| TUI | Keybinds (`<leader>+` / `<leader>-`) on the last assistant message | +| Desktop / `kilo web` | Out of scope for the first pass | + +### Telemetry Payload + +We deliberately collect fewer identifiers for non-Kilo providers, since those IDs can't be correlated to upstream data and add tracking surface without product benefit. + +**Direct providers (Anthropic, OpenAI, etc.):** +`providerID`, `modelID`, `variant?`, `rating`, `previousRating?` + +**Kilo Gateway turns (`providerID` starts with `"kilo"`):** +Same fields plus `sessionID`, `messageID`, and `parentMessageID` (= the `x-kilo-request` header the gateway already saw). This lets backend analysts join feedback against gateway logs to diagnose specific bad responses. + +Event name: `"Feedback Submitted"` — a single event string in both telemetry enum registries so PostHog sees one event regardless of source. + +### UX + +- **Toggleable**: click the same button again to clear, or click the opposite to switch. Each change fires a new event with `rating` and `previousRating`. +- **In-memory state**: ratings are keyed by message ID and held in the webview/TUI session. Persisting across reloads is deferred to a follow-up. +- **Gated on telemetry**: if the user has VS Code telemetry disabled, the buttons don't render at all. The TUI keybinds are no-ops with a brief toast. + +### Architecture (high level) + +``` +[webview button / TUI keybind] + → existing telemetry proxy or Telemetry.track() + → POST /telemetry/capture (webview path) + → Telemetry.track("Feedback Submitted", {…}) + → PostHog +``` + +No new server endpoints, no SDK regeneration, no PostHog-side changes. The `/telemetry/capture` route and both telemetry proxy paths already exist and accept arbitrary event names. + +### Kilo Gateway Detection + +The webview uses `providerID.startsWith("kilo")` to decide whether to include correlation IDs — this matches the outbound header gating in `packages/opencode/src/session/llm.ts`. The TUI can use the more precise `model.api.npm === "@kilocode/kilo-gateway"` check since it has access to the full provider resolution in-process. + +## What's Out of Scope + +- Free-text comments on thumbs-down +- 1–5 scale or star rating +- Persisting ratings across page reloads / session switches +- Changing prior-message actions (copy + thumbs) to hover-only +- Shared web UI / desktop surface + +## Open Questions + +- Should ratings persist on the `MessageV2.Assistant` schema so they survive reloads? +- Confirm with the PostHog dashboard owner that the proposed event + property names fit existing conventions. +- Whether to add free-text comments for thumbs-down in a follow-up. From 82254696ec351ccaf1ebee2c3595ae5a353f13de Mon Sep 17 00:00:00 2001 From: Joshua Lambert <25085430+lambertjosh@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:02:58 -0400 Subject: [PATCH 65/96] docs(kilo-docs): document codebase indexing for CLI and new VS Code extension (#9714) * docs(kilo-docs): document codebase indexing for CLI and new VS Code extension The existing Codebase Indexing page was flagged as legacy-only. Rewrite it to cover the new shared implementation in the CLI and VS Code extension, including the `/indexing` command, the `indexing` config section, LanceDB, and the full list of supported embedding providers. * docs(kilo-docs): address review feedback on codebase indexing - Add a top-level warning that indexing is experimental in the CLI and new VS Code extension - Add a new 'Enabling the feature' section that documents the `experimental.semantic_indexing` flag and where to toggle it (CLI `kilo.jsonc`, VS Code Experimental settings, or legacy direct config) - Split Embedding Providers and Vector Stores into new-platform vs VSCode (Legacy) tabs so the legacy section only lists providers it actually supports (OpenAI, Gemini, Ollama; Qdrant only) - Fix the Vercel AI Gateway link to point at the product docs (https://vercel.com/docs/ai-gateway) instead of the raw API endpoint - Clarify that the Mistral indexing provider uses a La Plateforme API key, not the Codestral key from the autocomplete setup guide - Call out the experimental flag prerequisite at each configuration entry point * docs(kilo-docs): rename combined tab to 'VSCode & CLI' * chore: revert unrelated package-lock changes * docs(kilo-docs): order tabs VSCode, CLI, VSCode (Legacy) * docs(kilo-docs): consolidate platform-specific indexing docs into one tabs block --- packages/kilo-docs/lib/nav/customize.ts | 1 - .../customize/context/codebase-indexing.md | 351 +++++++++++++----- 2 files changed, 256 insertions(+), 96 deletions(-) diff --git a/packages/kilo-docs/lib/nav/customize.ts b/packages/kilo-docs/lib/nav/customize.ts index 7687bad119f..83006b9e1bd 100644 --- a/packages/kilo-docs/lib/nav/customize.ts +++ b/packages/kilo-docs/lib/nav/customize.ts @@ -37,7 +37,6 @@ export const CustomizeNav: NavSection[] = [ { href: "/customize/context/codebase-indexing", children: "Codebase Indexing", - platform: "legacy", }, { href: "/customize/context/context-condensing", diff --git a/packages/kilo-docs/pages/customize/context/codebase-indexing.md b/packages/kilo-docs/pages/customize/context/codebase-indexing.md index 163afeaa00c..f577678670b 100644 --- a/packages/kilo-docs/pages/customize/context/codebase-indexing.md +++ b/packages/kilo-docs/pages/customize/context/codebase-indexing.md @@ -1,14 +1,15 @@ --- title: "Codebase Indexing" description: "Index your codebase for improved AI understanding" -platform: legacy --- # Codebase Indexing Codebase Indexing enables semantic code search across your entire project using AI embeddings. Instead of searching for exact text matches, it understands the _meaning_ of your queries, helping Kilo Code find relevant code even when you don't know specific function names or file locations. -{% image src="/docs/img/codebase-indexing/codebase-indexing.png" alt="Codebase Indexing Settings" width="800" caption="Codebase Indexing Settings" /%} +{% callout type="warning" title="Experimental" %} +Codebase Indexing is currently **experimental** in the CLI and the new VS Code extension. You must explicitly opt in before the feature becomes available — see the **Setup** section below. Behavior, configuration, and defaults may change in future releases. +{% /callout %} ## What It Does @@ -16,7 +17,7 @@ When enabled, the indexing system: 1. **Parses your code** using Tree-sitter to identify semantic blocks (functions, classes, methods) 2. **Creates embeddings** of each code block using AI models -3. **Stores vectors** in a Qdrant database for fast similarity search +3. **Stores vectors** in a vector database for fast similarity search 4. **Provides the [`semantic_search`](/docs/automate/tools/semantic-search) tool** to Kilo Code for intelligent code discovery This enables natural language queries like "user authentication logic" or "database connection handling" to find relevant code across your entire project. @@ -28,43 +29,217 @@ This enables natural language queries like "user authentication logic" or "datab - **Cross-Project Discovery**: Search across all files, not just what's open - **Pattern Recognition**: Locate similar implementations and code patterns -## Setup Requirements +## Setup -### Embedding Provider +{% tabs %} +{% tab label="VSCode" %} -Choose one of these options for generating embeddings: +### 1. Enable the experimental flag -**OpenAI (Recommended)** +Codebase Indexing is gated behind an experimental flag. Until the flag is on, the Indexing UI is hidden and `semantic_search` is unavailable. -- Requires OpenAI API key -- Supports all OpenAI embedding models -- Default: `text-embedding-3-small` -- Processes up to 100,000 tokens per batch +1. Open Kilo Code **Settings** → **Experimental**. +2. Toggle **Semantic Indexing** on. +3. The **Indexing** tab will appear in Settings and the indexing status indicator will appear at the bottom of the prompt input panel. -**Gemini** +Alternatively, set `experimental.semantic_indexing` to `true` in your `kilo.jsonc`: -- Requires Google AI API key -- Supports Gemini embedding models including `gemini-embedding-001` -- Cost-effective alternative to OpenAI -- High-quality embeddings for code understanding +```json +{ + "experimental": { + "semantic_indexing": true + } +} +``` -**Ollama (Local)** +### 2. Configure indexing -- Requires local Ollama installation -- No API costs or internet dependency -- Supports any Ollama-compatible embedding model -- Requires Ollama base URL configuration +1. Open Kilo Code **Settings** → **Indexing**, or click the indexing indicator at the bottom of the prompt input panel. +2. Toggle **Enable Indexing** on. +3. Pick an **Embedding Provider** and fill in its required fields. +4. Pick a **Vector Store** (`Qdrant` or `LanceDB`) and configure it. +5. Optionally adjust **Tuning Parameters** (search score, batch size, retries, max results). +6. Save to start the initial scan. -### Vector Database +You can also edit the `indexing` section in `kilo.jsonc` directly: -**Qdrant** is required for storing and searching embeddings: +```json +{ + "indexing": { + "enabled": true, + "provider": "openai", + "model": "text-embedding-3-small", + "vectorStore": "lancedb", + "openai": { "apiKey": "sk-..." }, + "lancedb": {} + } +} +``` -- **Local**: `http://localhost:6333` (recommended for testing) -- **Cloud**: Qdrant Cloud or self-hosted instance -- **Authentication**: Optional API key for secured deployments +### Embedding providers + +| Provider | How to use | Notes | +|---|---|---| +| **OpenAI** | API key | Default model: `text-embedding-3-small`. `text-embedding-3-large` for higher accuracy. | +| **Ollama** | Local base URL | No API costs. Runs fully offline. | +| **OpenAI-Compatible** | Base URL + API key | For self-hosted or third-party OpenAI-compatible endpoints. | +| **Gemini** | Google AI API key | Supports `gemini-embedding-001` and other Gemini embedding models. | +| **Mistral** | API key from [La Plateforme](https://console.mistral.ai/api-keys/) | Use a standard Mistral API key. The Codestral-specific keys from the [Mistral autocomplete setup guide](/docs/code-with-ai/features/autocomplete/mistral-setup) are **not** interchangeable — those only work for completion. | +| **Vercel AI Gateway** | API key | Routes requests through [Vercel AI Gateway](https://vercel.com/docs/ai-gateway). | +| **AWS Bedrock** | AWS region + profile | Uses the AWS SDK credential chain. | +| **OpenRouter** | API key (optional specific provider) | Routes through [OpenRouter](https://openrouter.ai/). | +| **Voyage** | API key | Voyage `voyage-code-3` is tuned for code. | + +### Vector stores + +- **Qdrant** (default) — external server. Recommended for team deployments and larger codebases. See [Setting Up Qdrant](#setting-up-qdrant). +- **LanceDB** — embedded, file-based. No server to run. Stores data under your Kilo data directory by default. + +{% callout type="tip" %} +For a fully local, zero-cost setup, combine **Ollama** (embeddings) with **LanceDB** (vector store — no separate server needed). +{% /callout %} + +### Status indicator + +The prompt input panel shows a compact indexing status indicator that reflects the current state (Standby / In Progress / Complete / Error) along with progress when scanning or embedding. + +{% /tab %} +{% tab label="CLI" %} + +### 1. Enable the experimental flag + +Codebase Indexing is gated behind an experimental flag. Until the flag is on, the `/indexing` command is hidden and `semantic_search` is unavailable. + +Set the flag in your `kilo.jsonc`: + +```json +{ + "experimental": { + "semantic_indexing": true + } +} +``` + +Restart the CLI for the change to take effect. The `/indexing` command (and aliases `/index`, `/embedding`) will appear in the command palette once the flag is active. + +### 2. Configure indexing + +Open a Kilo TUI session and run: + +```text +/indexing +``` + +(aliases: `/index`, `/embedding`) + +This opens an interactive configuration dialog where you can: + +- **Toggle** indexing on/off +- Choose an **Embedding Provider** and fill in provider settings (API key, base URL, AWS region, etc.) +- Set the **Embedding Model** (blank = provider default) +- Set the **Vector Dimension** (blank = auto-detect from the model) +- Choose a **Vector Store** (`Qdrant` or `LanceDB`) and configure its connection +- Adjust **Tuning Parameters** (search threshold, batch size, retries, max results) + +All changes are written to your `kilo.jsonc` config and take effect immediately. + +You can also edit the `indexing` section directly. This is the full shape of the section: + +```json +{ + "indexing": { + "enabled": true, + "provider": "voyage", + "model": "voyage-code-3", + "dimension": 1024, + "vectorStore": "qdrant", + "voyage": { + "apiKey": "pa-..." + }, + "qdrant": { + "url": "http://localhost:6333", + "apiKey": "" + }, + "searchMinScore": 0.4, + "searchMaxResults": 50, + "embeddingBatchSize": 60, + "scannerMaxBatchRetries": 3 + } +} +``` + +### Embedding providers + +| Provider | Config key | Settings | Notes | +|---|---|---|---| +| **OpenAI** | `openai` | `{ apiKey }` | Default: `text-embedding-3-small`. | +| **Ollama** | `ollama` | `{ baseUrl }` | No API costs. Runs fully offline. | +| **OpenAI-Compatible** | `openai-compatible` | `{ baseUrl, apiKey }` | For self-hosted or third-party endpoints. | +| **Gemini** | `gemini` | `{ apiKey }` | Supports `gemini-embedding-001`. | +| **Mistral** | `mistral` | `{ apiKey }` | Use a [La Plateforme](https://console.mistral.ai/api-keys/) key — the Codestral-specific keys from the [autocomplete setup guide](/docs/code-with-ai/features/autocomplete/mistral-setup) don't work for embeddings. | +| **Vercel AI Gateway** | `vercel-ai-gateway` | `{ apiKey }` | Routes through [Vercel AI Gateway](https://vercel.com/docs/ai-gateway). | +| **AWS Bedrock** | `bedrock` | `{ region, profile }` | Uses AWS SDK credential chain. | +| **OpenRouter** | `openrouter` | `{ apiKey, specificProvider? }` | Routes through [OpenRouter](https://openrouter.ai/). | +| **Voyage** | `voyage` | `{ apiKey }` | `voyage-code-3` is tuned for code. | + +### Vector stores + +- `qdrant` — `{ url?, apiKey? }` (default). See [Setting Up Qdrant](#setting-up-qdrant). +- `lancedb` — `{ directory? }` — embedded, file-based. No server to run. Uses a default Kilo data directory when omitted. + +{% callout type="tip" %} +For a fully local, zero-cost setup, combine **Ollama** (embeddings) with **LanceDB** (vector store — no separate server needed). +{% /callout %} + +### Status indicator + +When indexing is enabled, the CLI shows an indexing status badge at the bottom of the TUI in the form `IDX <state>` (for example `IDX In Progress 40% 120/300`, `IDX Complete`, `IDX Standby`, or `IDX Error <message>`). + +{% /tab %} +{% tab label="VSCode (Legacy)" %} + +The legacy extension does not require an experimental flag. + +### Open Codebase Indexing Settings + +1. In the chat header, click the database icon (indexing status). +2. The Codebase Indexing settings panel opens. +3. If you don't see the icon, open Kilo Code settings ({% codicon name="gear" /%}) and search for **Codebase Indexing**. + +{% image src="/docs/img/codebase-indexing/codebase-indexing.png" alt="Codebase Indexing Settings" width="800" caption="Codebase Indexing Settings (legacy)" /%} + +### Configure Settings + +1. Enable **"Enable Codebase Indexing"** using the toggle switch. +2. Configure your embedding provider: + - **OpenAI**: Enter API key and select model + - **Gemini**: Enter Google AI API key and select embedding model + - **Ollama**: Enter base URL and select model +3. Set Qdrant URL and optional API key. +4. Configure **Max Search Results** (default: 20, range: 1-100). +5. Click **Save** to start initial indexing. + +### Embedding providers + +The legacy extension supports a smaller set of providers: + +| Provider | How to use | Notes | +|---|---|---| +| **OpenAI** | API key | Default: `text-embedding-3-small`. | +| **Gemini** | Google AI API key | Supports Gemini embedding models including `gemini-embedding-001`. | +| **Ollama (local)** | Local base URL | No API costs. | + +### Vector store + +The legacy extension only supports **Qdrant**. See [Setting Up Qdrant](#setting-up-qdrant). + +{% /tab %} +{% /tabs %} ## Setting Up Qdrant +If you choose **Qdrant** as your vector store, you need a running Qdrant server. + ### Quick Local Setup **Using Docker:** @@ -92,55 +267,27 @@ volumes: For team or production use: -- [Qdrant Cloud](https://cloud.qdrant.io/) - Managed service +- [Qdrant Cloud](https://cloud.qdrant.io/) — managed service - Self-hosted on AWS, GCP, or Azure - Local server with network access for team sharing -## Configuration - -### Open Codebase Indexing Settings - -1. In the chat header, click the database icon (indexing status) -2. The Codebase Indexing settings panel opens -3. If you don't see the icon, open Kilo Code settings (<Codicon name="gear" />) and search for **Codebase Indexing** - -### Configure Settings - -1. Enable **"Enable Codebase Indexing"** using the toggle switch -2. Configure your embedding provider: - - **OpenAI**: Enter API key and select model - - **Gemini**: Enter Google AI API key and select embedding model - - **Ollama**: Enter base URL and select model -3. Set Qdrant URL and optional API key -4. Configure **Max Search Results** (default: 20, range: 1-100) -5. Click **Save** to start initial indexing - -### Enable/Disable Toggle - -The codebase indexing feature includes a convenient toggle switch that allows you to: - -- **Enable**: Start indexing your codebase and make the search tool available -- **Disable**: Stop indexing, pause file watching, and disable the search functionality -- **Preserve Settings**: Your configuration remains saved when toggling off - -This toggle is useful for temporarily disabling indexing during intensive development work or when working with sensitive codebases. - ## Understanding Index Status -The interface shows real-time status with color indicators: +The interface shows real-time status: -- **Standby** (Gray): Not running, awaiting configuration -- **Indexing** (Yellow): Currently processing files -- **Indexed** (Green): Up-to-date and ready for searches -- **Error** (Red): Failed state requiring attention +- **Standby**: Not running, awaiting configuration or paused +- **In Progress**: Currently processing files (with a progress percentage and `processed/total` count) +- **Complete**: Up-to-date and ready for searches +- **Error**: Failed state, with an error message +- **Disabled**: Indexing is turned off or not yet configured ## How Files Are Processed ### Smart Code Parsing - **Tree-sitter Integration**: Uses AST parsing to identify semantic code blocks -- **Language Support**: All languages supported by Tree-sitter -- **Markdown Support**: Full support for markdown files and documentation +- **Language Support**: Broad language coverage via Tree-sitter — C, C#, C++, CSS, Elisp, Elixir, Go, HTML, Java, JavaScript, Kotlin, Lua, OCaml, PHP, Python, Ruby, Rust, Scala, Solidity, Swift, SystemRDL, TLA+, TOML, TSX, TypeScript, Vue, Zig, and more +- **Markdown Support**: Dedicated parser for markdown and documentation - **Fallback**: Line-based chunking for unsupported file types - **Block Sizing**: - Minimum: 100 characters @@ -159,40 +306,56 @@ The indexer automatically excludes: ### Incremental Updates -- **File Watching**: Monitors workspace for changes +- **File Watching**: Monitors the workspace for changes and re-indexes in the background - **Smart Updates**: Only reprocesses modified files - **Hash-based Caching**: Avoids reprocessing unchanged content - **Branch Switching**: Automatically handles Git branch changes +## Tuning Parameters + +These advanced settings live under the `indexing` key and are exposed in the CLI's `/indexing → Tuning Parameters` menu and the VS Code extension's Indexing settings: + +| Setting | Default | Description | +|---|---|---| +| `searchMinScore` | `0.4` | Minimum cosine similarity (0-1) for a result to be returned. | +| `searchMaxResults` | `50` | Maximum number of results returned per search. | +| `embeddingBatchSize` | `60` | Number of code segments per embedding batch. Lower this if your embedding endpoint has strict rate limits. | +| `scannerMaxBatchRetries` | `3` | Maximum retry attempts for a failed embedding batch. | + ## Best Practices ### Model Selection -**For OpenAI:** +**OpenAI:** - **`text-embedding-3-small`**: Best balance of performance and cost - **`text-embedding-3-large`**: Higher accuracy, 5x more expensive - **`text-embedding-ada-002`**: Legacy model, lower cost -**For Ollama:** +**Ollama:** -- **`mxbai-embed-large`**: The largest and highest-quality embedding model. -- **`nomic-embed-text`**: Best balance of performance and embedding quality. -- **`all-minilm`**: Compact model with lower quality but faster performance. +- **`mxbai-embed-large`**: The largest and highest-quality embedding model +- **`nomic-embed-text`**: Best balance of performance and embedding quality +- **`all-minilm`**: Compact model with lower quality but faster performance + +**Voyage:** + +- **`voyage-code-3`**: Code-tuned embeddings; strong default for source-heavy repos ### Security Considerations -- **API Keys**: Stored securely in VS Code's encrypted storage -- **Code Privacy**: Only small code snippets sent for embedding (not full files) -- **Local Processing**: All parsing happens locally -- **Qdrant Security**: Use authentication for production deployments +- **API Keys**: Stored in your `kilo.jsonc` config. Treat that file as a secret in shared environments. +- **Code Privacy**: Only small code snippets are sent for embedding — never whole files. +- **Local Processing**: All parsing (Tree-sitter) happens locally. +- **Fully Local Option**: Pair **Ollama** (embeddings) with **LanceDB** (local vector store) for a setup that never leaves your machine. +- **Qdrant Security**: Use authentication for production deployments. ## Current Limitations - **File Size**: 1MB maximum per file - **Single Workspace**: One workspace at a time -- **Dependencies**: Requires external services (embedding provider + Qdrant) -- **Language Coverage**: Limited to Tree-sitter supported languages for optimal parsing +- **Dependencies**: Requires an embedding provider, and — for Qdrant — a running Qdrant instance +- **Language Coverage**: Optimal parsing is limited to Tree-sitter supported languages ## Troubleshooting @@ -200,6 +363,16 @@ The indexer automatically excludes: If your local embedding server is based on llama.cpp (including Ollama), indexing can fail with errors about `n_ubatch` or `GGML_ASSERT`. Ensure both batch size (`-b`) and micro-batch size (`-ub`) are set to the same value for embedding models, then restart the server. For Ollama, configure `num_batch` in your Modelfile or request options to match the same effective value. +### Indexing status stays on "Disabled" + +- Check that `indexing.enabled` is `true` in your `kilo.jsonc` +- Verify that the selected provider has all required credentials set +- If using Qdrant, make sure the Qdrant server is reachable at the configured URL + +### Rate-limit or batch errors with a hosted provider + +Lower `embeddingBatchSize` under `indexing` (default `60`). Smaller batches send fewer segments per request and are less likely to hit per-request or per-minute rate limits. + ## Using the Search Feature Once indexed, Kilo Code can use the [`semantic_search`](/docs/automate/tools/semantic-search) tool to find relevant code: @@ -213,34 +386,22 @@ Once indexed, Kilo Code can use the [`semantic_search`](/docs/automate/tools/sem The tool provides Kilo Code with: -- Relevant code snippets (up to your configured max results limit) +- Relevant code snippets (up to your configured `searchMaxResults`) - File paths and line numbers - Similarity scores - Contextual information ### Search Results Configuration -You can control the number of search results returned by adjusting the **Max Search Results** setting: +Tune result volume and quality via: -- **Default**: 20 results -- **Range**: 1-100 results -- **Performance**: Lower values improve response speed -- **Comprehensiveness**: Higher values provide more context but may slow responses +- **`searchMaxResults`** — default `50`. Lower for faster, more focused responses; higher for more context. +- **`searchMinScore`** — default `0.4`. Raise to require closer matches; lower to include more tangentially related code. ## Privacy & Security -- **Code stays local**: Only small code snippets sent for embedding +- **Code stays local**: Only small code snippets are sent for embedding - **Embeddings are numeric**: Not human-readable representations -- **Secure storage**: API keys encrypted in VS Code storage -- **Local option**: Use Ollama for completely local processing -- **Access control**: Respects existing file permissions - -## Future Enhancements - -Planned improvements: - -- Additional embedding providers -- Multi-workspace indexing -- Enhanced filtering and configuration options -- Team sharing capabilities -- Integration with VS Code's native search +- **Secure storage**: API keys are stored in your local `kilo.jsonc` configuration +- **Fully local option**: Use **Ollama + LanceDB** for completely local processing +- **Access control**: Respects existing file permissions and `.kilocodeignore` From 1493d656c9afcafd41a13b45bdf734fb881536df Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov <eshurakov@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:44:29 +0200 Subject: [PATCH 66/96] fix(cli): keep remote badge visible while connecting --- .changeset/remote-status-badge.md | 5 +++ .../src/cli/cmd/tui/routes/session/footer.tsx | 6 ++- .../src/kilo-sessions/kilo-sessions.ts | 21 ++++++++--- .../opencode/src/kilo-sessions/remote-ws.ts | 1 + .../src/kilocode/plugins/home-footer.tsx | 25 +++++++------ packages/opencode/src/kilocode/remote-tui.tsx | 37 ++++++++++++------- 6 files changed, 64 insertions(+), 31 deletions(-) create mode 100644 .changeset/remote-status-badge.md diff --git a/.changeset/remote-status-badge.md b/.changeset/remote-status-badge.md new file mode 100644 index 00000000000..40b2a70936a --- /dev/null +++ b/.changeset/remote-status-badge.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep Remote status visible in the TUI while remote control is connecting. diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index b8fe76ff1d5..ced435f15cc 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -6,6 +6,7 @@ import { useConnected } from "../../component/dialog-model" import { useSDK } from "../../context/sdk" // kilocode_change import { createStore } from "solid-js/store" import { useRoute } from "../../context/route" +import { useEvent } from "../../context/event" // kilocode_change import { RemoteIndicator } from "@/kilocode/remote-tui" // kilocode_change import { formatIndexingLabel } from "@/kilocode/indexing-label" // kilocode_change import type { IndexingStatusState } from "@kilocode/kilo-indexing/status" // kilocode_change @@ -39,6 +40,7 @@ export function Footer() { const directory = useDirectory() const connected = useConnected() const sdk = useSDK() // kilocode_change + const event = useEvent() // kilocode_change const indexing = createMemo(() => sync.data.indexing) // kilocode_change const [store, setStore] = createStore({ @@ -74,7 +76,9 @@ export function Footer() { <box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}> <text fg={theme.textMuted}>{directory()}</text> <box gap={2} flexDirection="row" flexShrink={0}> - <RemoteIndicator sdk={sdk} theme={theme} kilo={sync.data.provider_next.connected.includes("kilo")} /> + {/* kilocode_change start */} + <RemoteIndicator sdk={sdk} theme={theme} kilo={sync.data.provider_next.connected.includes("kilo")} event={event} /> + {/* kilocode_change end */} <Switch> <Match when={store.welcome}> <text fg={theme.text}> diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index b754a21b5a4..481f39944d4 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -308,6 +308,7 @@ export namespace KiloSessions { if (ingestDisabled) return if (enabling) return enabling const seq = ++remoteSeq + void Bus.publish(Event.RemoteStatusChanged, { enabled: true, connected: false }) enabling = (async () => { const token = await kilocodeToken() if (!token) { @@ -399,17 +400,27 @@ export namespace KiloSessions { log.info("remote connection enabled", { connected: conn.connected }) Telemetry.trackRemoteConnectionOpened() void Bus.publish(Event.RemoteStatusChanged, { enabled: true, connected: conn.connected }) - })().finally(() => { - if (remoteSeq === seq) enabling = undefined - }) + })() + .catch((err) => { + if (remoteSeq === seq && !remote) + void Bus.publish(Event.RemoteStatusChanged, { enabled: false, connected: false }) + throw err + }) + .finally(() => { + if (remoteSeq === seq) enabling = undefined + }) return enabling } export function disableRemote() { remoteSeq += 1 + const pending = !!enabling enabling = undefined - if (!remote) return + if (!remote) { + if (pending) void Bus.publish(Event.RemoteStatusChanged, { enabled: false, connected: false }) + return + } remote.sender.dispose() remote.conn.close() remote = undefined @@ -419,7 +430,7 @@ export namespace KiloSessions { export function remoteStatus() { return { - enabled: !!remote, + enabled: !!remote || !!enabling, connected: remote?.conn.connected ?? false, } } diff --git a/packages/opencode/src/kilo-sessions/remote-ws.ts b/packages/opencode/src/kilo-sessions/remote-ws.ts index 6587e292e46..e78dbcd430b 100644 --- a/packages/opencode/src/kilo-sessions/remote-ws.ts +++ b/packages/opencode/src/kilo-sessions/remote-ws.ts @@ -86,6 +86,7 @@ export namespace RemoteWS { async function open() { if (closed) return const token = await options.getToken() + if (closed) return if (!token) { options.log.warn("remote-ws no token, will retry") schedule() diff --git a/packages/opencode/src/kilocode/plugins/home-footer.tsx b/packages/opencode/src/kilocode/plugins/home-footer.tsx index 244fabc789b..599e28e2b43 100644 --- a/packages/opencode/src/kilocode/plugins/home-footer.tsx +++ b/packages/opencode/src/kilocode/plugins/home-footer.tsx @@ -15,25 +15,28 @@ import { useSync } from "@/cli/cmd/tui/context/sync" const id = "internal:kilo-home-footer" +type Status = { + enabled: boolean + connected: boolean +} + // --------------------------------------------------------------------------- // RemoteIndicator – adapted from @/kilocode/remote-tui for plugin API usage // --------------------------------------------------------------------------- function RemoteIndicator(props: { api: TuiPluginApi; kilo: boolean }) { const theme = () => props.api.theme.current - const [status, setStatus] = createSignal<{ - enabled: boolean - connected: boolean - } | null>(null) + const [status, setStatus] = createSignal<Status | null>(null) onMount(() => { - const poll = async () => { - const res = await props.api.client.remote.status().catch(() => null) - if (res?.data) setStatus(res.data) - } - poll() - const timer = setInterval(poll, 5000) - onCleanup(() => clearInterval(timer)) + void props.api.client.remote + .status() + .then((res: { data?: Status }) => { + if (res.data) setStatus(res.data) + }) + .catch(() => undefined) + const off = props.api.event.on("kilo-sessions.remote-status-changed", (evt) => setStatus(evt.properties)) + onCleanup(off) }) return ( diff --git a/packages/opencode/src/kilocode/remote-tui.tsx b/packages/opencode/src/kilocode/remote-tui.tsx index 08d97f8f54b..4b6d06f605a 100644 --- a/packages/opencode/src/kilocode/remote-tui.tsx +++ b/packages/opencode/src/kilocode/remote-tui.tsx @@ -4,26 +4,35 @@ * RemoteIndicator component for the footer status bar. */ -import { createSignal, onMount, onCleanup, Show } from "solid-js" +import type { Event } from "@kilocode/sdk/v2" +import { createSignal, onCleanup, onMount, Show } from "solid-js" + +type Status = { + enabled: boolean + connected: boolean +} /** * Footer indicator showing remote connection status. - * Polls every 5 seconds. Only renders when kilo gateway is connected and remote is enabled. + * Uses the TUI event stream after an initial status fetch. */ -export function RemoteIndicator(props: { sdk: any; theme: any; kilo: boolean }) { - const [status, setStatus] = createSignal<{ - enabled: boolean - connected: boolean - } | null>(null) +export function RemoteIndicator(props: { + sdk: any + theme: any + kilo: boolean + event: { on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => () => void } +}) { + const [status, setStatus] = createSignal<Status | null>(null) onMount(() => { - const poll = async () => { - const res = await props.sdk.client.remote.status().catch(() => null) - if (res?.data) setStatus(res.data) - } - poll() - const timer = setInterval(poll, 5000) - onCleanup(() => clearInterval(timer)) + void props.sdk.client.remote + .status() + .then((res: { data?: Status }) => { + if (res.data) setStatus(res.data) + }) + .catch(() => undefined) + const off = props.event.on("kilo-sessions.remote-status-changed", (evt) => setStatus(evt.properties)) + onCleanup(off) }) return ( From f2db4d165cacc8b52148e7fe77fd4af7ff71b7bc Mon Sep 17 00:00:00 2001 From: Marius <marius@kilocode.ai> Date: Thu, 30 Apr 2026 10:46:59 +0200 Subject: [PATCH 67/96] feat(vscode): add terminal command display setting (#9708) * feat(vscode): add terminal command display setting * fix: annotate terminal display toggle change * fix: annotate terminal display jsx block * fix(vscode): format terminal display renderer * fix(vscode): preserve bash error rendering * chore: update kilo-vscode visual regression baselines --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> --- .changeset/terminal-command-display.md | 5 ++ .../pages/getting-started/settings/index.md | 12 ++++ ...-to-queued-user-spacing-chromium-linux.png | 4 +- .../kilo-ui/src/components/message-part.tsx | 3 +- .../src/components/chat/AssistantMessage.tsx | 68 +++++++++++++++---- .../components/chat/VscodeToolOverrides.tsx | 2 +- .../src/components/settings/DisplayTab.tsx | 29 +++++++- .../src/components/settings/settings-io.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ar.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/br.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/bs.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/da.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/de.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/en.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/es.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/fr.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/ja.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/ko.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/nl.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/no.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/pl.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/ru.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/th.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/tr.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/uk.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/zh.ts | 4 ++ .../kilo-vscode/webview-ui/src/i18n/zht.ts | 4 ++ .../webview-ui/src/types/messages/config.ts | 3 + packages/opencode/src/config/config.ts | 4 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++ packages/ui/src/components/basic-tool.tsx | 7 +- 31 files changed, 199 insertions(+), 19 deletions(-) create mode 100644 .changeset/terminal-command-display.md diff --git a/.changeset/terminal-command-display.md b/.changeset/terminal-command-display.md new file mode 100644 index 00000000000..bb2473f51bb --- /dev/null +++ b/.changeset/terminal-command-display.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add a Display setting to collapse terminal command blocks by default instead of keeping them expanded. diff --git a/packages/kilo-docs/pages/getting-started/settings/index.md b/packages/kilo-docs/pages/getting-started/settings/index.md index 563d0a237ca..4735ff358a2 100644 --- a/packages/kilo-docs/pages/getting-started/settings/index.md +++ b/packages/kilo-docs/pages/getting-started/settings/index.md @@ -45,6 +45,18 @@ There are two primary config files: If you check config files into version control, make sure they do not contain API keys or other secrets (e.g., `provider.*.options.apiKey`). Use environment variables for credentials instead. {% /callout %} +### Terminal Command Blocks + +Terminal command blocks stay expanded by default in the VS Code chat UI. Choose **Collapsed** for **Terminal Command Blocks** in the Display tab, or set `terminal_command_display` in `kilo.jsonc`, to start them collapsed: + +```json +{ + "terminal_command_display": "collapsed" +} +``` + +Valid values are `expanded` and `collapsed`. + ### Export and Import You can export and import settings from the **About Kilo Code** tab in the Settings UI: diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png index f1ada1dd34e..ff195b4d918 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:579c1817866f19735c86ec00d4d74bf15149f37f8ce27cfe7873bdf79f1d103f -size 12066 +oid sha256:c3f37193389ad7937c7b2103ad2fe3dd63123a0d4f60a0fbfd65b31d3982c092 +size 12105 diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 18dc83834bf..a41c950863f 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -1982,7 +1982,8 @@ ToolRegistry.register({ {...props} icon="console" animated - defaultOpen + defaultOpen={props.defaultOpen ?? true} + allowPendingToggle trigger={ <div data-slot="basic-tool-tool-info-structured"> <div data-slot="basic-tool-tool-info-main"> diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 0b4a5347d82..31e8ec4c378 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -18,6 +18,7 @@ import type { } from "@kilocode/sdk/v2" import { useData } from "@kilocode/kilo-ui/context/data" import { useSession } from "../../context/session" +import { useConfig } from "../../context/config" import { QuestionDock } from "./QuestionDock" import { SuggestBar } from "./SuggestBar" @@ -83,9 +84,36 @@ function TodoToolCard(props: { part: ToolPart }) { ) } +function BashToolCard(props: { part: ToolPart; defaultOpen: boolean }) { + const render = ToolRegistry.render(props.part.tool) + const state = props.part.state as any + return ( + <Show when={render}> + {(card) => ( + <Dynamic + component={card() as unknown as Component<Record<string, unknown>>} + input={state?.input ?? {}} + metadata={state?.metadata ?? {}} + partMetadata={props.part.metadata ?? {}} + tool={props.part.tool} + partID={props.part.id} + callID={props.part.callID} + output={state?.output} + status={state?.status} + defaultOpen={props.defaultOpen} + animate + reveal={state?.status === "pending" || state?.status === "running"} + /> + )} + </Show> + ) +} + export const AssistantMessage: Component<AssistantMessageProps> = (props) => { const data = useData() const session = useSession() + const { config } = useConfig() + const open = createMemo(() => config().terminal_command_display !== "collapsed") const parts = createMemo(() => { const stored = data.store.part?.[props.message.id] @@ -107,9 +135,18 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => { // Active suggestion tool parts render the interactive SuggestBar inline const activeSuggestion = createMemo(() => matchToolRequest(part, "suggest", session.suggestions())) + const bash = createMemo(() => { + if (part.type !== "tool") return + const tool = part as unknown as ToolPart + if (tool.tool !== "bash") return + if (tool.state?.status === "error") return + return part + }) return ( - <Show when={isUpstreamSuppressed || activeQuestion() || activeSuggestion() || PART_MAPPING[part.type]}> + <Show + when={isUpstreamSuppressed || activeQuestion() || activeSuggestion() || bash() || PART_MAPPING[part.type]} + > <div data-component="tool-part-wrapper" data-part-type={part.type}> <Show when={activeQuestion()} @@ -118,21 +155,28 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => { when={activeSuggestion()} fallback={ <Show - when={isUpstreamSuppressed} + when={bash()} fallback={ - <Part - part={part} - message={props.message as SDKMessage} - showAssistantCopyPartID={props.showAssistantCopyPartID} - animate={ - part.type === "tool" && - ((part as unknown as ToolPart).state?.status === "pending" || - (part as unknown as ToolPart).state?.status === "running") + <Show + when={isUpstreamSuppressed} + fallback={ + <Part + part={part} + message={props.message as SDKMessage} + showAssistantCopyPartID={props.showAssistantCopyPartID} + animate={ + part.type === "tool" && + ((part as unknown as ToolPart).state?.status === "pending" || + (part as unknown as ToolPart).state?.status === "running") + } + /> } - /> + > + <TodoToolCard part={part as unknown as ToolPart} /> + </Show> } > - <TodoToolCard part={part as unknown as ToolPart} /> + {(tool) => <BashToolCard part={tool() as unknown as ToolPart} defaultOpen={open()} />} </Show> } > diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeToolOverrides.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeToolOverrides.tsx index 55ddd9d102a..7c262d26e3d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeToolOverrides.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeToolOverrides.tsx @@ -22,7 +22,7 @@ export function registerVscodeToolOverrides() { ToolRegistry.register({ name, - render: (props) => <Dynamic component={upstream} {...props} defaultOpen />, + render: (props) => <Dynamic component={upstream} {...props} defaultOpen={props.defaultOpen ?? true} />, }) registered.add(name) } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/DisplayTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/DisplayTab.tsx index 812209e7c91..a300c57cb21 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/DisplayTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/DisplayTab.tsx @@ -4,6 +4,7 @@ import { TextField } from "@kilocode/kilo-ui/text-field" import { Card } from "@kilocode/kilo-ui/card" import { useConfig } from "../../context/config" import { useLanguage } from "../../context/language" +import type { TerminalCommandDisplay } from "../../types/messages" import SettingsRow from "./SettingsRow" interface LayoutOption { @@ -16,6 +17,11 @@ const LAYOUT_OPTIONS: LayoutOption[] = [ { value: "stretch", labelKey: "settings.display.layout.stretch" }, ] +const TERMINAL_OPTIONS: LayoutOption[] = [ + { value: "expanded", labelKey: "settings.display.terminalCommand.expanded" }, + { value: "collapsed", labelKey: "settings.display.terminalCommand.collapsed" }, +] + const DisplayTab: Component = () => { const { config, updateConfig } = useConfig() const language = useLanguage() @@ -39,7 +45,6 @@ const DisplayTab: Component = () => { <SettingsRow title={language.t("settings.display.layout.title")} description={language.t("settings.display.layout.description")} - last > <Select options={LAYOUT_OPTIONS} @@ -57,6 +62,28 @@ const DisplayTab: Component = () => { triggerVariant="settings" /> </SettingsRow> + + <SettingsRow + title={language.t("settings.display.terminalCommand.title")} + description={language.t("settings.display.terminalCommand.description")} + last + > + <Select + options={TERMINAL_OPTIONS} + current={TERMINAL_OPTIONS.find((o) => o.value === (config().terminal_command_display ?? "expanded"))} + value={(o) => o.value} + label={(o) => language.t(o.labelKey)} + onSelect={(o) => { + if (!o) return + const next = o.value as TerminalCommandDisplay + if (next === (config().terminal_command_display ?? "expanded")) return + updateConfig({ terminal_command_display: next }) + }} + variant="secondary" + size="small" + triggerVariant="settings" + /> + </SettingsRow> </Card> </div> ) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts b/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts index 4c1f4a87cee..e03b8b247f0 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts @@ -32,6 +32,7 @@ export const KNOWN_KEYS: ReadonlyArray<string> = [ "commit_message", "tools", "layout", + "terminal_command_display", "indexing", "experimental", ] diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 3adaf31b12b..589b61b2431 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1372,6 +1372,10 @@ export const dict = { "settings.display.layout.description": "وضع التخطيط لواجهة الدردشة", "settings.display.layout.auto": "تلقائي", "settings.display.layout.stretch": "تمديد", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "النموذج الافتراضي", "settings.providers.defaultModel.description": "النموذج الأساسي للمحادثات", "settings.providers.smallModel.title": "نموذج صغير", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 4911f47f40f..9624a7d3ff7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1411,6 +1411,10 @@ export const dict = { "settings.display.layout.description": "Modo de layout para a interface de chat", "settings.display.layout.auto": "Automático", "settings.display.layout.stretch": "Esticar", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "Modelo padrão", "settings.providers.defaultModel.description": "Modelo principal para conversas", "settings.providers.smallModel.title": "Modelo pequeno", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index a32e0d8ae2e..f196331e1c0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1408,6 +1408,10 @@ export const dict = { "settings.display.layout.description": "Način rasporeda za sučelje chata", "settings.display.layout.auto": "Automatski", "settings.display.layout.stretch": "Rastegni", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "Zadani model", "settings.providers.defaultModel.description": "Primarni model za razgovore", "settings.providers.smallModel.title": "Mali model", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 9277bafb45b..8917aa34dfa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1396,6 +1396,10 @@ export const dict = { "settings.display.layout.description": "Layouttilstand for chatgrænsefladen", "settings.display.layout.auto": "Automatisk", "settings.display.layout.stretch": "Stræk", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "Standardmodel", "settings.providers.defaultModel.description": "Primær model til samtaler", "settings.providers.smallModel.title": "Lille model", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 1dbbcc542b0..a5ca1efa8a0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1426,6 +1426,10 @@ export const dict = { "settings.display.layout.description": "Layout-Modus für die Chat-Oberfläche", "settings.display.layout.auto": "Automatisch", "settings.display.layout.stretch": "Gestreckt", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "Standardmodell", "settings.providers.defaultModel.description": "Primäres Modell für Gespräche", "settings.providers.smallModel.title": "Kleines Modell", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 694bfe50b55..f97d9ed93f3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1390,6 +1390,10 @@ export const dict = { "settings.display.layout.description": "Layout mode for the chat interface", "settings.display.layout.auto": "Auto", "settings.display.layout.stretch": "Stretch", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "Default Model", "settings.providers.defaultModel.description": "Primary model for conversations", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index c75ef7fdc4f..d6fe1b8ce5c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1417,6 +1417,10 @@ export const dict = { "settings.display.layout.description": "Modo de diseño para la interfaz de chat", "settings.display.layout.auto": "Automático", "settings.display.layout.stretch": "Estirar", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "Modelo predeterminado", "settings.providers.defaultModel.description": "Modelo principal para conversaciones", "settings.providers.smallModel.title": "Modelo pequeño", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 6bf03394b0f..2d876869499 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1432,6 +1432,10 @@ export const dict = { "settings.display.layout.description": "Mode de disposition pour l'interface de chat", "settings.display.layout.auto": "Automatique", "settings.display.layout.stretch": "Étiré", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "Modèle par défaut", "settings.providers.defaultModel.description": "Modèle principal pour les conversations", "settings.providers.smallModel.title": "Petit modèle", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 6ed1bccd224..aab61f3dfaa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1394,6 +1394,10 @@ export const dict = { "settings.display.layout.description": "チャットインターフェースのレイアウトモード", "settings.display.layout.auto": "自動", "settings.display.layout.stretch": "ストレッチ", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "デフォルトモデル", "settings.providers.defaultModel.description": "会話のプライマリモデル", "settings.providers.smallModel.title": "小型モデル", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 40b9fdc69b3..afb1a148dd4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1378,6 +1378,10 @@ export const dict = { "settings.display.layout.description": "채팅 인터페이스의 레이아웃 모드", "settings.display.layout.auto": "자동", "settings.display.layout.stretch": "늘리기", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "기본 모델", "settings.providers.defaultModel.description": "대화의 기본 모델", "settings.providers.smallModel.title": "소형 모델", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index efdab4a19fd..ca3380ea7bf 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1380,6 +1380,10 @@ export const dict = { "settings.display.layout.description": "Lay-outmodus voor de chatinterface", "settings.display.layout.auto": "Auto", "settings.display.layout.stretch": "Uitrekken", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "Standaard Model", "settings.providers.defaultModel.description": "Primair model voor gesprekken", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 180a75a2ee6..cc1d2de885c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1395,6 +1395,10 @@ export const dict = { "settings.display.layout.description": "Layoutmodus for chatgrensesnittet", "settings.display.layout.auto": "Automatisk", "settings.display.layout.stretch": "Strekk", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "Standardmodell", "settings.providers.defaultModel.description": "Primær modell for samtaler", "settings.providers.smallModel.title": "Liten modell", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 05d942f0f0b..e71f9233571 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1404,6 +1404,10 @@ export const dict = { "settings.display.layout.description": "Tryb układu interfejsu czatu", "settings.display.layout.auto": "Automatyczny", "settings.display.layout.stretch": "Rozciągnij", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "Domyślny model", "settings.providers.defaultModel.description": "Główny model do rozmów", "settings.providers.smallModel.title": "Mały model", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 307fe08dcb5..5129da6362e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1404,6 +1404,10 @@ export const dict = { "settings.display.layout.description": "Режим макета для интерфейса чата", "settings.display.layout.auto": "Авто", "settings.display.layout.stretch": "Растянуть", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "Модель по умолчанию", "settings.providers.defaultModel.description": "Основная модель для разговоров", "settings.providers.smallModel.title": "Малая модель", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 28145efb914..ce75e9baef6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1377,6 +1377,10 @@ export const dict = { "settings.display.layout.description": "โหมดเค้าโครงสำหรับอินเทอร์เฟซแชท", "settings.display.layout.auto": "อัตโนมัติ", "settings.display.layout.stretch": "ยืด", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "โมเดลเริ่มต้น", "settings.providers.defaultModel.description": "โมเดลหลักสำหรับบทสนทนา", "settings.providers.smallModel.title": "โมเดลขนาดเล็ก", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index e5a1a30083c..88a6ff675e4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1369,6 +1369,10 @@ export const dict = { "settings.display.layout.description": "Sohbet arayüzü için düzen modu", "settings.display.layout.auto": "Otomatik", "settings.display.layout.stretch": "Genişlet", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "Varsayılan Model", "settings.providers.defaultModel.description": "Sohbetler için birincil model", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 59a090c3d5d..6db47099df1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1371,6 +1371,10 @@ export const dict = { "settings.display.layout.description": "Режим макету для інтерфейсу чату", "settings.display.layout.auto": "Автоматично", "settings.display.layout.stretch": "Розтягнути", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "Модель за замовчуванням", "settings.providers.defaultModel.description": "Основна модель для чатів", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index c6ff4ede0d9..e3eb3b562f9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1348,6 +1348,10 @@ export const dict = { "settings.display.layout.description": "聊天界面的布局模式", "settings.display.layout.auto": "自动", "settings.display.layout.stretch": "拉伸", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "默认模型", "settings.providers.defaultModel.description": "对话的主要模型", "settings.providers.smallModel.title": "小模型", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 147cef9da74..ac816ffc9e2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1327,6 +1327,10 @@ export const dict = { "settings.display.layout.description": "聊天介面的佈局模式", "settings.display.layout.auto": "自動", "settings.display.layout.stretch": "延伸", + "settings.display.terminalCommand.title": "Terminal Command Blocks", + "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", + "settings.display.terminalCommand.expanded": "Expanded", + "settings.display.terminalCommand.collapsed": "Collapsed", "settings.providers.defaultModel.title": "預設模型", "settings.providers.defaultModel.description": "對話的主要模型", "settings.providers.smallModel.title": "小模型", diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts index 3b77f6769b0..3d5851b33c9 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -92,6 +92,8 @@ export interface BrowserSettings { headless: boolean } +export type TerminalCommandDisplay = "expanded" | "collapsed" + export interface Config { permission?: PermissionConfig model?: string | null @@ -107,6 +109,7 @@ export interface Config { skills?: SkillsConfig snapshot?: boolean remote_control?: boolean + terminal_command_display?: TerminalCommandDisplay share?: "manual" | "auto" | "disabled" username?: string watcher?: WatcherConfig diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f63fd523a3e..e7691ccc590 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -168,6 +168,10 @@ export const Info = Schema.Struct({ description: "Enable remote control of sessions via Kilo Cloud. Equivalent to running /remote on startup.", }), indexing: Schema.optional(IndexingRef).annotate({ description: "Codebase indexing configuration" }), // kilocode_change + terminal_command_display: Schema.optional(Schema.Literals(["expanded", "collapsed"])).annotate({ + description: + "Controls whether terminal command blocks are expanded or collapsed by default in the VS Code chat UI", + }), // kilocode_change end // kilocode_change start - nullable for delete sentinel model: Schema.optional(Schema.NullOr(ConfigModelID)).annotate({ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b9d92659382..9922e354a38 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1856,6 +1856,10 @@ export type Config = { */ remote_control?: boolean indexing?: IndexingConfig + /** + * Controls whether terminal command blocks are expanded or collapsed by default in the VS Code chat UI + */ + terminal_command_display?: "expanded" | "collapsed" /** * Model to use in the format of provider/model, eg anthropic/claude-2 */ diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 5bbc78f86cf..e1527387f9c 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -33,6 +33,7 @@ export interface BasicToolProps { defer?: boolean locked?: boolean animated?: boolean + allowPendingToggle?: boolean // kilocode_change onSubtitleClick?: () => void onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent> triggerHref?: string @@ -119,7 +120,7 @@ export function BasicTool(props: BasicToolProps) { }) const handleOpenChange = (value: boolean) => { - if (pending()) return + if (pending() && !props.allowPendingToggle) return // kilocode_change if (props.hideDetails) return // kilocode_change if (props.locked && !value) return setState("open", value) @@ -195,9 +196,11 @@ export function BasicTool(props: BasicToolProps) { </Switch> </div> </div> - <Show when={props.children && !props.hideDetails && !props.locked && !pending()}> + {/* kilocode_change start */} + <Show when={props.children && !props.hideDetails && !props.locked && (!pending() || props.allowPendingToggle)}> <Collapsible.Arrow /> </Show> + {/* kilocode_change end */} </div> ) From 1338266da954453dea61ebdf32b7151547edc9af Mon Sep 17 00:00:00 2001 From: Marius <marius@kilocode.ai> Date: Thu, 30 Apr 2026 11:37:14 +0200 Subject: [PATCH 68/96] docs(agent-manager): document worktree setup behavior (#9731) --- .../pages/automate/agent-manager-workflows.md | 46 +++++++++++ .../kilo-docs/pages/automate/agent-manager.md | 76 ++++++++++++++++++- .../agent-manager/setup-script-template.ts | 28 ++++--- 3 files changed, 139 insertions(+), 11 deletions(-) diff --git a/packages/kilo-docs/pages/automate/agent-manager-workflows.md b/packages/kilo-docs/pages/automate/agent-manager-workflows.md index 6ad8da4fe7c..1def0846199 100644 --- a/packages/kilo-docs/pages/automate/agent-manager-workflows.md +++ b/packages/kilo-docs/pages/automate/agent-manager-workflows.md @@ -19,6 +19,10 @@ Rule of thumb: if you would stash or switch branches to do the work, create a wo All Agent Manager sessions use the extension's embedded runtime. What each worktree isolates is the filesystem and git state: the branch, the directory, and the terminal. Providers, BYOK keys, custom providers, models, and extension settings are shared with the sidebar. {% /callout %} +{% callout type="warning" %} +Git worktrees are lightweight compared with cloning the repository several times, but they are not free. Each worktree has its own checked-out files, and any dependencies, build artifacts, caches, local databases, or generated files created inside that directory count separately on disk. +{% /callout %} + ## What parallelizes well Parallel work pays off when sessions are **independent** — neither one's output depends on the other, and they are unlikely to edit the same files. @@ -102,6 +106,47 @@ Two fixes, in order of preference: The same applies to caches (avoid pointing `CARGO_TARGET_DIR` at a shared path), emulators (create a named simulator per worktree), and containers (use unique container names or `COMPOSE_PROJECT_NAME`). +A practical run script pattern is to derive a stable port from `WORKTREE_PATH`, then start the app with that value: + +```sh +#!/bin/sh +set -e + +sum=$(cksum <<EOF | cut -d ' ' -f 1 +$WORKTREE_PATH +EOF +) +export PORT=$((4000 + (sum % 1000))) + +npm run dev +``` + +For Docker Compose, do the same with `COMPOSE_PROJECT_NAME` so parallel worktrees get separate container names and volumes: + +```sh +#!/bin/sh +set -e + +name=$(basename "$WORKTREE_PATH" | tr -cd '[:alnum:]_-') +export COMPOSE_PROJECT_NAME="kilo_${name}" + +docker compose up +``` + +If your app or framework supports `PORT=0`, that can be even simpler for local-only work because the OS chooses a free port. The tradeoff is that the URL changes each run. + +### Setup script and copied files + +Use `.kilo/setup-script` to make new worktrees runnable without manual setup. It runs after Kilo copies root-level `.env` and `.env.*` files, and before the agent starts in the new worktree. + +Kilo's env copy is intentionally narrow: + +- It copies root-level plain files named `.env` or `.env.*` +- It skips existing files instead of overwriting them +- It does not copy nested env files, `.envrc`, `.environment`, `.env-cmdrc`, local certificates, local databases, or ignored tool-specific config + +Put the remaining project-specific setup in `.kilo/setup-script`, for example copying `apps/web/.env.local`, creating a per-worktree database, or installing dependencies. The setup script receives `WORKTREE_PATH` and `REPO_PATH` in the environment. + ## Reviewing changes Layer review in before asking a teammate: @@ -161,6 +206,7 @@ Merge the most foundational one first. Then, in each remaining worktree, ask the - Merge within a day or two. Past that, pull the parent branch into the worktree rather than letting it drift. - After a branch merges, close the worktree from its context menu. The branch is preserved; the directory is removed. +- Periodically clean up dependencies, build output, containers, volumes, simulators, and databases created by old worktrees. Closing a managed worktree removes the checkout, not external resources. - Do not run more than four or five agents at once. The practical limit is review and integration cost, not memory. ## Common mistakes diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index e40e237e1df..79dd33c2afb 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -49,6 +49,10 @@ Each Agent Manager session runs in an isolated git worktree on a separate branch Managed worktrees are created under `.kilo/worktrees/` in your project. Kilo also stores Agent Manager UI state in `.kilo/agent-manager.json`. +{% callout type="info" %} +Worktrees share Git object storage with the main repository, but each worktree is still a separate checkout on disk. Files created inside each worktree, such as `node_modules`, build output, local databases, generated files, and package-manager caches, can multiply disk usage across parallel agents. Closing a managed worktree removes its checkout directory, but it does not remove external caches, containers, volumes, simulators, or databases that your scripts created outside the worktree. +{% /callout %} + ### PR Status Badges Each worktree item displays a **PR status badge** when its branch has an associated pull request. The badge shows the PR number (e.g. `#142`) and is color-coded to reflect the current state at a glance. Click the badge to open the PR in your browser. @@ -200,7 +204,58 @@ A common workflow is letting the agent work, then switching to the terminal to r ## Setup Scripts -Place an executable script at `.kilo/setup-script` in your project root. It runs automatically whenever a new worktree is created (useful for `npm install`, env setup, etc.). Root-level `.env` and `.env.*` files are also auto-copied from the main repo before the setup script runs. +Setup scripts let you prepare each new worktree before the agent starts, for example by installing dependencies, linking local config, copying non-standard env files, or creating per-worktree databases. + +Create a script file in `.kilo/` using the appropriate filename for your platform: + +| Platform | Filename (checked in order) | +|---|---| +| macOS / Linux | `.kilo/setup-script`, `.kilo/setup-script.sh` | +| Windows | `.kilo/setup-script.ps1`, `.kilo/setup-script.cmd`, `.kilo/setup-script.bat` | + +Kilo runs the script automatically whenever a new worktree is created. It uses `sh` for POSIX scripts, PowerShell for `.ps1`, and `cmd.exe` for `.cmd` / `.bat`, so executable permissions are not required. + +Two extra variables are injected into the setup script's environment: + +| Variable | Value | +|---|---| +| `WORKTREE_PATH` | Absolute path to the new worktree directory | +| `REPO_PATH` | Repository root | + +For example, on macOS / Linux: + +```sh +#!/bin/sh +set -e + +cd "$WORKTREE_PATH" +npm install + +# Copy a nested env file that Kilo does not auto-copy. +if [ -f "$REPO_PATH/apps/web/.env.local" ] && [ ! -f "$WORKTREE_PATH/apps/web/.env.local" ]; then + cp "$REPO_PATH/apps/web/.env.local" "$WORKTREE_PATH/apps/web/.env.local" +fi +``` + +If the setup script fails, Agent Manager shows the failure and keeps the worktree available so you can inspect it, fix the script, or run setup steps manually. + +### Environment File Copying + +Before the setup script runs, Kilo automatically copies root-level `.env` files from the main repo into the new worktree. + +Copied automatically: + +- Root-level plain files named exactly `.env` +- Root-level plain files matching `.env.*`, such as `.env.local` or `.env.development` + +Not copied automatically: + +- Nested env files, such as `apps/web/.env.local` +- Non-dotenv files, such as `.envrc`, `.environment`, or `.env-cmdrc` +- Directories named `.env` or `.env.local` +- Files that already exist in the worktree, because Kilo never overwrites them + +Use `.kilo/setup-script` for anything outside the automatic copy rules, including nested env files, ignored local config, local certificates, local database files, generated config directories, or tool-specific files required to run the project. ## Run Script @@ -222,6 +277,25 @@ For example, on macOS / Linux create `.kilo/run-script`: npm run dev ``` +For projects that need a unique dev-server port per worktree, assign the port in the run script and make your app read it from `PORT`: + +```sh +#!/bin/sh +set -e + +# Pick a deterministic port from the worktree path so each worktree keeps the same URL. +sum=$(cksum <<EOF | cut -d ' ' -f 1 +$WORKTREE_PATH +EOF +) +export PORT=$((4000 + (sum % 1000))) + +echo "Starting dev server on http://localhost:$PORT" +npm run dev +``` + +If your stack supports `PORT=0`, you can also let the OS pick a free port instead. Prefer app-side env support where possible, then use the run script to provide per-worktree defaults. + The next time you click the run button (or press `Cmd+E` / `Ctrl+E`), the script runs in the selected worktree's directory. {% callout type="tip" %} diff --git a/packages/kilo-vscode/src/agent-manager/setup-script-template.ts b/packages/kilo-vscode/src/agent-manager/setup-script-template.ts index 64224d16137..01ec67fa7ff 100644 --- a/packages/kilo-vscode/src/agent-manager/setup-script-template.ts +++ b/packages/kilo-vscode/src/agent-manager/setup-script-template.ts @@ -7,8 +7,12 @@ export const SETUP_SCRIPT_TEMPLATE = `#!/bin/sh # WORKTREE_PATH - Absolute path to the worktree directory # REPO_PATH - Absolute path to the main repository # +# Kilo already copies root-level .env and .env.* files before this script runs. +# Use this script for dependencies, nested env files, local config, databases, +# certificates, or other project-specific setup that is not committed to git. +# # Example tasks: -# - Copy .env files from main repo +# - Copy nested env files from main repo # - Install dependencies # - Run database migrations # - Set up local configuration @@ -19,10 +23,10 @@ echo "Setting up worktree: $WORKTREE_PATH" # Uncomment and modify as needed: -# Copy environment files -# if [ -f "$REPO_PATH/.env" ]; then -# cp "$REPO_PATH/.env" "$WORKTREE_PATH/.env" -# echo "Copied .env" +# Copy a nested environment file +# if [ -f "$REPO_PATH/apps/web/.env.local" ] && [ ! -f "$WORKTREE_PATH/apps/web/.env.local" ]; then +# cp "$REPO_PATH/apps/web/.env.local" "$WORKTREE_PATH/apps/web/.env.local" +# echo "Copied apps/web/.env.local" # fi # Install dependencies (Node.js) @@ -48,8 +52,12 @@ export const SETUP_SCRIPT_TEMPLATE_POWERSHELL = `# Kilo Code Worktree Setup Scri # $env:WORKTREE_PATH - Absolute path to the worktree directory # $env:REPO_PATH - Absolute path to the main repository # +# Kilo already copies root-level .env and .env.* files before this script runs. +# Use this script for dependencies, nested env files, local config, databases, +# certificates, or other project-specific setup that is not committed to git. +# # Example tasks: -# - Copy .env files from main repo +# - Copy nested env files from main repo # - Install dependencies # - Run database migrations # - Set up local configuration @@ -60,10 +68,10 @@ Write-Host "Setting up worktree: $env:WORKTREE_PATH" # Uncomment and modify as needed: -# Copy environment files -# if (Test-Path "$env:REPO_PATH/.env") { -# Copy-Item "$env:REPO_PATH/.env" "$env:WORKTREE_PATH/.env" -Force -# Write-Host "Copied .env" +# Copy a nested environment file +# if ((Test-Path "$env:REPO_PATH/apps/web/.env.local") -and !(Test-Path "$env:WORKTREE_PATH/apps/web/.env.local")) { +# Copy-Item "$env:REPO_PATH/apps/web/.env.local" "$env:WORKTREE_PATH/apps/web/.env.local" +# Write-Host "Copied apps/web/.env.local" # } # Install dependencies (Node.js) From 2eec02452b1a9606405e373d00d5da10f1775be7 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:08:23 +0000 Subject: [PATCH 69/96] chore(cli): add script to find merge conflict markers in a file Provides a small helper for upstream merge workflows so agents can be allowlisted to search for conflict markers without needing general shell access. Wraps the canonical ripgrep invocation with a POSIX grep fallback. --- script/upstream/find-conflict-markers.sh | 48 ++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100755 script/upstream/find-conflict-markers.sh diff --git a/script/upstream/find-conflict-markers.sh b/script/upstream/find-conflict-markers.sh new file mode 100755 index 00000000000..69fa65bd79e --- /dev/null +++ b/script/upstream/find-conflict-markers.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Find git merge conflict markers in a file. +# +# Prints the line number and the marker for each of: +# <<<<<<< (ours start) +# ||||||| (base / diff3 separator) +# ======= (separator) +# >>>>>>> (theirs end) +# +# Usage: +# script/upstream/find-conflict-markers.sh <file> +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "Usage: $0 <file>" >&2 + exit 2 +fi + +file=$1 + +if [ ! -f "$file" ]; then + echo "error: not a file: $file" >&2 + exit 2 +fi + +# Match the four conflict marker line shapes. `=======` must be the whole line; +# the others may have trailing content (branch name, commit hash, etc.). +# +# Prefer ripgrep when available (matches the historical invocation), fall back +# to POSIX grep so the script works in minimal environments. +if command -v rg >/dev/null 2>&1; then + rg -n '^(<{7}|\|{7}|={7}$|>{7})' "$file" || { + status=$? + # rg exits 1 when no matches are found; treat that as success (clean file). + if [ "$status" -eq 1 ]; then + exit 0 + fi + exit "$status" + } +else + grep -nE '^(<{7}|\|{7}|={7}$|>{7})' "$file" || { + status=$? + if [ "$status" -eq 1 ]; then + exit 0 + fi + exit "$status" + } +fi From 8185b01e51cd364caad3b04ad6859edf1ea9e548 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:18:35 +0000 Subject: [PATCH 70/96] chore(cli): require ripgrep for find-conflict-markers Drop the POSIX grep fallback; bail with a clear error when rg is missing. --- script/upstream/find-conflict-markers.sh | 36 ++++++++++-------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/script/upstream/find-conflict-markers.sh b/script/upstream/find-conflict-markers.sh index 69fa65bd79e..076f5b9e68b 100755 --- a/script/upstream/find-conflict-markers.sh +++ b/script/upstream/find-conflict-markers.sh @@ -7,6 +7,8 @@ # ======= (separator) # >>>>>>> (theirs end) # +# Requires ripgrep (rg). +# # Usage: # script/upstream/find-conflict-markers.sh <file> set -euo pipefail @@ -23,26 +25,18 @@ if [ ! -f "$file" ]; then exit 2 fi +if ! command -v rg >/dev/null 2>&1; then + echo "error: ripgrep (rg) is required but not installed" >&2 + exit 2 +fi + # Match the four conflict marker line shapes. `=======` must be the whole line; # the others may have trailing content (branch name, commit hash, etc.). -# -# Prefer ripgrep when available (matches the historical invocation), fall back -# to POSIX grep so the script works in minimal environments. -if command -v rg >/dev/null 2>&1; then - rg -n '^(<{7}|\|{7}|={7}$|>{7})' "$file" || { - status=$? - # rg exits 1 when no matches are found; treat that as success (clean file). - if [ "$status" -eq 1 ]; then - exit 0 - fi - exit "$status" - } -else - grep -nE '^(<{7}|\|{7}|={7}$|>{7})' "$file" || { - status=$? - if [ "$status" -eq 1 ]; then - exit 0 - fi - exit "$status" - } -fi +# rg exits 1 when no matches are found; treat that as success (clean file). +rg -n '^(<{7}|\|{7}|={7}$|>{7})' "$file" || { + status=$? + if [ "$status" -eq 1 ]; then + exit 0 + fi + exit "$status" +} From 22b5283f79e4dd8461764904b908001df969cce9 Mon Sep 17 00:00:00 2001 From: Marius <marius@kilocode.ai> Date: Thu, 30 Apr 2026 12:23:00 +0200 Subject: [PATCH 71/96] feat(vscode): make reasoning blocks stay expanded (#9704) * feat(vscode): make reasoning blocks stay expanded * chore: group Kilo config entries --- .changeset/expanded-reasoning-blocks.md | 5 ++ .../pages/getting-started/settings/index.md | 10 +++ .../kilo-ui/src/components/message-part.css | 7 +- .../kilo-ui/src/components/message-part.tsx | 74 ++++++++++-------- .../agent-manager/AgentManagerApp.tsx | 25 ++++--- packages/kilo-vscode/webview-ui/src/App.tsx | 21 +++--- .../src/components/chat/AssistantMessage.tsx | 3 + .../src/components/settings/DisplayTab.tsx | 20 ++++- .../src/components/settings/settings-io.ts | 1 + .../webview-ui/src/context/display.tsx | 33 ++++++++ .../kilo-vscode/webview-ui/src/i18n/ar.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 3 + .../webview-ui/src/stories/StoryProviders.tsx | 75 ++++++++++--------- .../webview-ui/src/types/messages/config.ts | 1 + packages/opencode/src/config/config.ts | 3 + packages/sdk/js/src/v2/gen/types.gen.ts | 4 + packages/sdk/openapi.json | 4 + 34 files changed, 250 insertions(+), 93 deletions(-) create mode 100644 .changeset/expanded-reasoning-blocks.md create mode 100644 packages/kilo-vscode/webview-ui/src/context/display.tsx diff --git a/.changeset/expanded-reasoning-blocks.md b/.changeset/expanded-reasoning-blocks.md new file mode 100644 index 00000000000..953662fa162 --- /dev/null +++ b/.changeset/expanded-reasoning-blocks.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Keep reasoning blocks expanded by default and add a setting to auto-collapse them after completion. diff --git a/packages/kilo-docs/pages/getting-started/settings/index.md b/packages/kilo-docs/pages/getting-started/settings/index.md index 4735ff358a2..69e1953dbc9 100644 --- a/packages/kilo-docs/pages/getting-started/settings/index.md +++ b/packages/kilo-docs/pages/getting-started/settings/index.md @@ -45,6 +45,16 @@ There are two primary config files: If you check config files into version control, make sure they do not contain API keys or other secrets (e.g., `provider.*.options.apiKey`). Use environment variables for credentials instead. {% /callout %} +### Reasoning Blocks + +Reasoning blocks stay expanded by default in the VS Code chat UI. Enable **Auto-Collapse Reasoning** in the Display tab, or set `auto_collapse_reasoning` in `kilo.jsonc`, to collapse them after the agent finishes writing them: + +```json +{ + "auto_collapse_reasoning": true +} +``` + ### Terminal Command Blocks Terminal command blocks stay expanded by default in the VS Code chat UI. Choose **Collapsed** for **Terminal Command Blocks** in the Display tab, or set `terminal_command_display` in `kilo.jsonc`, to start them collapsed: diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index 5b90068c9c6..dbad2443f10 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -261,8 +261,7 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { } /* Reasoning block: visually distinct from normal agent output. - While streaming: open with fixed-height scrollable window. - When done: auto-collapses; user can click to reopen. */ + Reasoning stays open unless the user explicitly collapses it. */ [data-component="reasoning-part"] { width: 100%; border-left: 2px solid var(--border-weak-base); @@ -345,9 +344,9 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { } } - /* Streaming state: fixed-height window with auto-scroll */ + /* Streaming state: keep the block open while content grows. */ &[data-streaming] { - [data-slot="reasoning-content"] { + &[data-auto-collapse] [data-slot="reasoning-content"] { max-height: 120px; overflow-y: auto; scroll-behavior: smooth; diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index a41c950863f..934ef5cb11f 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -5,7 +5,6 @@ import { createSignal, For, Match, - on, onCleanup, Show, Switch, @@ -131,6 +130,7 @@ export interface MessagePartProps { message: MessageType hideDetails?: boolean defaultOpen?: boolean + reasoningAutoCollapse?: boolean showAssistantCopyPartID?: string | null showTurnDiffSummary?: boolean turnDiffSummary?: () => JSX.Element @@ -393,6 +393,7 @@ export function AssistantParts(props: { turnDiffSummary?: () => JSX.Element working?: boolean showReasoningSummaries?: boolean + reasoningAutoCollapse?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean animate?: boolean @@ -656,6 +657,7 @@ export function AssistantParts(props: { props.shellToolDefaultOpen, props.editToolDefaultOpen, )} + reasoningAutoCollapse={props.reasoningAutoCollapse} hideDetails={false} animate={props.animate} working={props.working} @@ -963,6 +965,7 @@ export function Part(props: MessagePartProps) { message={props.message} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} + reasoningAutoCollapse={props.reasoningAutoCollapse} showAssistantCopyPartID={props.showAssistantCopyPartID} showTurnDiffSummary={props.showTurnDiffSummary} turnDiffSummary={props.turnDiffSummary} @@ -1336,19 +1339,25 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { ) } -// Track part IDs that have been rendered while streaming. -// Persists across component instances so that when reasoning-end replaces the -// store object (causing <For> to recreate the component) the new instance -// knows the part was just streaming and can animate the collapse. +// Expanded mode tracks explicit user collapses so reactive or virtualized +// remounts do not reopen a block the user closed. +const userCollapsed = new Set<string>() +// Auto-collapse mode preserves the original flow: streaming blocks open, +// completed blocks collapse once, and manual opens survive later remounts. const streamed = new Set<string>() -// Tracks parts that have already been auto-collapsed once, so component -// recreation (from store updates while other parts stream) won't collapse again. const autocollapsed = new Set<string>() -// Tracks parts that the user has explicitly opened, so auto-collapse won't -// override the user's intent when reasoning finishes or a tool call starts. const userOpened = new Set<string>() +const MAX_REASONING_STATE = 1000 -// Overrides upstream flat markdown render with streaming reasoning block + auto-collapse. +function rememberReasoningState(set: Set<string>, id: string) { + if (set.has(id)) set.delete(id) + set.add(id) + if (set.size <= MAX_REASONING_STATE) return + const first = set.values().next().value + if (first !== undefined) set.delete(first) +} + +// Overrides upstream flat markdown render with streaming reasoning block. // Also filters encrypted reasoning data from OpenRouter that appears as [REDACTED]. PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProps) { const i18n = useI18n() @@ -1369,42 +1378,39 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp } const id = (props.part as any).id as string - - // Check before adding — order matters const was = streamed.has(id) - if (!done()) streamed.add(id) + if (!done()) rememberReasoningState(streamed, id) - // Streaming → open. Just finished (was streaming, now done) → open briefly - // then collapse. Historical → collapsed from the start. - // Restore user's explicit open preference across component recreations. - const [open, setOpen] = createSignal(!done() || was || userOpened.has(id)) + // Auto-collapse mode: streaming -> open, just-finished -> open briefly then + // collapse, historical -> collapsed. Expanded mode: open unless the user + // explicitly collapsed this reasoning part. + const initial = props.reasoningAutoCollapse ? !done() || was || userOpened.has(id) : !userCollapsed.has(id) + const [open, setOpen] = createSignal(initial) - // Propagate user intent to the module-level set so it survives component - // recreations (e.g. when a tool call arrives while reading reasoning). const track = (value: boolean) => { - if (value) userOpened.add(id) - else userOpened.delete(id) + if (props.reasoningAutoCollapse) { + if (value) rememberReasoningState(userOpened, id) + else userOpened.delete(id) + setOpen(value) + return + } + + if (value) userCollapsed.delete(id) + else rememberReasoningState(userCollapsed, id) setOpen(value) } - // Auto-collapse once when reasoning finishes (streaming → done transition). - // Collapses immediately so the grid transition runs in sync with the - // streaming-height removal. Module-level Set prevents re-triggering on - // component recreation. Skipped entirely if the user has explicitly opened - // the block, so reading is not interrupted by a subsequent tool call. createEffect(() => { + if (!props.reasoningAutoCollapse) return + // Skip auto-collapse for blocks the user explicitly opened. if (done() && open() && !autocollapsed.has(id) && !userOpened.has(id)) { - autocollapsed.add(id) + rememberReasoningState(autocollapsed, id) setOpen(false) } }) onCleanup(() => { if (done()) streamed.delete(id) - // userOpened is intentionally NOT deleted here. The component recreates - // frequently while other parts stream (same as autocollapsed), so removing - // the entry on unmount would discard the user's explicit preference and - // re-collapse the block on the next remount. }) // Auto-scroll the content container while streaming. @@ -1433,7 +1439,11 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp return ( <Show when={display()}> - <div data-component="reasoning-part" data-streaming={!done() ? "" : undefined}> + <div + data-component="reasoning-part" + data-streaming={!done() ? "" : undefined} + data-auto-collapse={props.reasoningAutoCollapse ? "" : undefined} + > <Collapsible open={open()} onOpenChange={track} class="tool-collapsible"> <Collapsible.Trigger> <div data-slot="reasoning-header"> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index af24000a882..45852bfb92f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -75,6 +75,7 @@ import { VSCodeProvider, useVSCode } from "../src/context/vscode" import { ServerProvider } from "../src/context/server" import { ProviderProvider } from "../src/context/provider" import { ConfigProvider } from "../src/context/config" +import { DisplayProvider } from "../src/context/display" import { NotificationsProvider } from "../src/context/notifications" import { SessionProvider, useSession } from "../src/context/session" import { WorktreeModeProvider } from "../src/context/worktree-mode" @@ -3164,17 +3165,19 @@ export const AgentManagerApp: Component = () => { <FileComponentProvider component={File}> <ProviderProvider> <ConfigProvider> - <NotificationsProvider> - <SessionProvider> - <IndexingProvider> - <WorktreeModeProvider> - <DataBridge> - <AgentManagerContent /> - </DataBridge> - </WorktreeModeProvider> - </IndexingProvider> - </SessionProvider> - </NotificationsProvider> + <DisplayProvider> + <NotificationsProvider> + <SessionProvider> + <IndexingProvider> + <WorktreeModeProvider> + <DataBridge> + <AgentManagerContent /> + </DataBridge> + </WorktreeModeProvider> + </IndexingProvider> + </SessionProvider> + </NotificationsProvider> + </DisplayProvider> </ConfigProvider> </ProviderProvider> </FileComponentProvider> diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index c774defdac5..8665a763fa0 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -16,6 +16,7 @@ import { VSCodeProvider, useVSCode } from "./context/vscode" import { ServerProvider, useServer } from "./context/server" import { ProviderProvider, useProvider } from "./context/provider" import { ConfigProvider } from "./context/config" +import { DisplayProvider } from "./context/display" import { IndexingProvider } from "./context/indexing" import { SessionProvider, useSession } from "./context/session" import { LanguageProvider } from "./context/language" @@ -349,15 +350,17 @@ const App: Component = () => { <FileComponentProvider component={File}> <ProviderProvider> <ConfigProvider> - <IndexingProvider> - <NotificationsProvider> - <SessionProvider> - <DataBridge> - <AppContent /> - </DataBridge> - </SessionProvider> - </NotificationsProvider> - </IndexingProvider> + <DisplayProvider> + <IndexingProvider> + <NotificationsProvider> + <SessionProvider> + <DataBridge> + <AppContent /> + </DataBridge> + </SessionProvider> + </NotificationsProvider> + </IndexingProvider> + </DisplayProvider> </ConfigProvider> </ProviderProvider> </FileComponentProvider> diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 31e8ec4c378..01d03b0a5b3 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -18,6 +18,7 @@ import type { } from "@kilocode/sdk/v2" import { useData } from "@kilocode/kilo-ui/context/data" import { useSession } from "../../context/session" +import { useDisplay } from "../../context/display" import { useConfig } from "../../context/config" import { QuestionDock } from "./QuestionDock" import { SuggestBar } from "./SuggestBar" @@ -112,6 +113,7 @@ function BashToolCard(props: { part: ToolPart; defaultOpen: boolean }) { export const AssistantMessage: Component<AssistantMessageProps> = (props) => { const data = useData() const session = useSession() + const display = useDisplay() const { config } = useConfig() const open = createMemo(() => config().terminal_command_display !== "collapsed") @@ -164,6 +166,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => { part={part} message={props.message as SDKMessage} showAssistantCopyPartID={props.showAssistantCopyPartID} + reasoningAutoCollapse={display.reasoningAutoCollapse()} animate={ part.type === "tool" && ((part as unknown as ToolPart).state?.status === "pending" || diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/DisplayTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/DisplayTab.tsx index a300c57cb21..9be295e5b2d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/DisplayTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/DisplayTab.tsx @@ -1,8 +1,10 @@ -import { Component } from "solid-js" +import { type Component } from "solid-js" import { Select } from "@kilocode/kilo-ui/select" import { TextField } from "@kilocode/kilo-ui/text-field" import { Card } from "@kilocode/kilo-ui/card" +import { Switch } from "@kilocode/kilo-ui/switch" import { useConfig } from "../../context/config" +import { useDisplay } from "../../context/display" import { useLanguage } from "../../context/language" import type { TerminalCommandDisplay } from "../../types/messages" import SettingsRow from "./SettingsRow" @@ -24,6 +26,7 @@ const TERMINAL_OPTIONS: LayoutOption[] = [ const DisplayTab: Component = () => { const { config, updateConfig } = useConfig() + const display = useDisplay() const language = useLanguage() return ( @@ -63,6 +66,21 @@ const DisplayTab: Component = () => { /> </SettingsRow> + <SettingsRow + title={language.t("settings.display.reasoningAutoCollapse.title")} + description={language.t("settings.display.reasoningAutoCollapse.description")} + > + <Switch + checked={display.reasoningAutoCollapse()} + onChange={(checked: boolean) => { + display.setReasoningAutoCollapse(checked) + }} + hideLabel + > + {language.t("settings.display.reasoningAutoCollapse.title")} + </Switch> + </SettingsRow> + <SettingsRow title={language.t("settings.display.terminalCommand.title")} description={language.t("settings.display.terminalCommand.description")} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts b/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts index e03b8b247f0..d5b1a3abee7 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts @@ -32,6 +32,7 @@ export const KNOWN_KEYS: ReadonlyArray<string> = [ "commit_message", "tools", "layout", + "auto_collapse_reasoning", "terminal_command_display", "indexing", "experimental", diff --git a/packages/kilo-vscode/webview-ui/src/context/display.tsx b/packages/kilo-vscode/webview-ui/src/context/display.tsx new file mode 100644 index 00000000000..eb74fa9f003 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/display.tsx @@ -0,0 +1,33 @@ +import { createContext, createMemo, useContext, type Accessor, type ParentComponent } from "solid-js" +import { useConfig } from "./config" + +interface DisplayContextValue { + reasoningAutoCollapse: Accessor<boolean> + setReasoningAutoCollapse: (collapse: boolean) => void +} + +export const DisplayContext = createContext<DisplayContextValue>() + +export const DisplayProvider: ParentComponent = (props) => { + const { config, updateConfig } = useConfig() + const reasoningAutoCollapse = createMemo(() => config().auto_collapse_reasoning ?? false) + + return ( + <DisplayContext.Provider + value={{ + reasoningAutoCollapse, + setReasoningAutoCollapse: (collapse) => updateConfig({ auto_collapse_reasoning: collapse }), + }} + > + {props.children} + </DisplayContext.Provider> + ) +} + +export function useDisplay(): DisplayContextValue { + const context = useContext(DisplayContext) + if (!context) { + throw new Error("useDisplay must be used within a DisplayProvider") + } + return context +} diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 589b61b2431..d9e5a938cf8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1372,6 +1372,9 @@ export const dict = { "settings.display.layout.description": "وضع التخطيط لواجهة الدردشة", "settings.display.layout.auto": "تلقائي", "settings.display.layout.stretch": "تمديد", + "settings.display.reasoningAutoCollapse.title": "طي الاستدلال تلقائيًا", + "settings.display.reasoningAutoCollapse.description": + "يطوي كتل الاستدلال بعد أن ينتهي الوكيل من كتابتها. اتركه معطلاً لإبقاء الاستدلال موسعًا ما لم تطوه يدويًا.", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 9624a7d3ff7..3edc850ff20 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1411,6 +1411,9 @@ export const dict = { "settings.display.layout.description": "Modo de layout para a interface de chat", "settings.display.layout.auto": "Automático", "settings.display.layout.stretch": "Esticar", + "settings.display.reasoningAutoCollapse.title": "Recolher raciocínio automaticamente", + "settings.display.reasoningAutoCollapse.description": + "Recolhe os blocos de raciocínio depois que o agente termina de escrevê-los. Deixe desativado para manter o raciocínio expandido, a menos que você o recolha manualmente.", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index f196331e1c0..abf5b438460 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1408,6 +1408,9 @@ export const dict = { "settings.display.layout.description": "Način rasporeda za sučelje chata", "settings.display.layout.auto": "Automatski", "settings.display.layout.stretch": "Rastegni", + "settings.display.reasoningAutoCollapse.title": "Automatski sažmi razmišljanje", + "settings.display.reasoningAutoCollapse.description": + "Sažima blokove razmišljanja nakon što ih agent završi pisati. Ostavite isključeno da razmišljanje ostane prošireno, osim ako ga ručno sažmete.", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 8917aa34dfa..9644b6732d6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1396,6 +1396,9 @@ export const dict = { "settings.display.layout.description": "Layouttilstand for chatgrænsefladen", "settings.display.layout.auto": "Automatisk", "settings.display.layout.stretch": "Stræk", + "settings.display.reasoningAutoCollapse.title": "Skjul ræsonnement automatisk", + "settings.display.reasoningAutoCollapse.description": + "Skjuler ræsonnementsblokke, når agenten er færdig med at skrive dem. Lad den være slået fra for at holde ræsonnement udvidet, medmindre du skjuler det manuelt.", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index a5ca1efa8a0..3387f97481a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1426,6 +1426,9 @@ export const dict = { "settings.display.layout.description": "Layout-Modus für die Chat-Oberfläche", "settings.display.layout.auto": "Automatisch", "settings.display.layout.stretch": "Gestreckt", + "settings.display.reasoningAutoCollapse.title": "Reasoning automatisch einklappen", + "settings.display.reasoningAutoCollapse.description": + "Klappt Reasoning-Blöcke ein, nachdem der Agent sie fertig geschrieben hat. Deaktiviert lassen, damit Reasoning erweitert bleibt, sofern du es nicht manuell einklappst.", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index f97d9ed93f3..2e0ae4175cf 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1390,6 +1390,9 @@ export const dict = { "settings.display.layout.description": "Layout mode for the chat interface", "settings.display.layout.auto": "Auto", "settings.display.layout.stretch": "Stretch", + "settings.display.reasoningAutoCollapse.title": "Auto-Collapse Reasoning", + "settings.display.reasoningAutoCollapse.description": + "Collapse reasoning blocks after the agent finishes writing them. Leave off to keep reasoning expanded unless you collapse it manually.", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index d6fe1b8ce5c..97ac805d866 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1417,6 +1417,9 @@ export const dict = { "settings.display.layout.description": "Modo de diseño para la interfaz de chat", "settings.display.layout.auto": "Automático", "settings.display.layout.stretch": "Estirar", + "settings.display.reasoningAutoCollapse.title": "Contraer razonamiento automáticamente", + "settings.display.reasoningAutoCollapse.description": + "Contrae los bloques de razonamiento después de que el agente termine de escribirlos. Déjalo desactivado para mantener el razonamiento expandido, a menos que lo contraigas manualmente.", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 2d876869499..f806a08d89b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1432,6 +1432,9 @@ export const dict = { "settings.display.layout.description": "Mode de disposition pour l'interface de chat", "settings.display.layout.auto": "Automatique", "settings.display.layout.stretch": "Étiré", + "settings.display.reasoningAutoCollapse.title": "Réduire automatiquement le raisonnement", + "settings.display.reasoningAutoCollapse.description": + "Réduit les blocs de raisonnement une fois que l'agent a fini de les écrire. Laissez désactivé pour garder le raisonnement développé, sauf si vous le réduisez manuellement.", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index aab61f3dfaa..d7af89dcaa5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1394,6 +1394,9 @@ export const dict = { "settings.display.layout.description": "チャットインターフェースのレイアウトモード", "settings.display.layout.auto": "自動", "settings.display.layout.stretch": "ストレッチ", + "settings.display.reasoningAutoCollapse.title": "推論を自動で折りたたむ", + "settings.display.reasoningAutoCollapse.description": + "エージェントが推論の書き込みを終えた後に推論ブロックを自動で折りたたみます。手動で折りたたむまでは推論を展開したままにするには、オフのままにしてください。", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index afb1a148dd4..e53fe132605 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1378,6 +1378,9 @@ export const dict = { "settings.display.layout.description": "채팅 인터페이스의 레이아웃 모드", "settings.display.layout.auto": "자동", "settings.display.layout.stretch": "늘리기", + "settings.display.reasoningAutoCollapse.title": "추론 자동 접기", + "settings.display.reasoningAutoCollapse.description": + "에이전트가 추론 작성을 마친 뒤 추론 블록을 자동으로 접습니다. 수동으로 접기 전까지 추론을 펼친 상태로 두려면 끄세요.", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index ca3380ea7bf..290dc1747c8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1380,6 +1380,9 @@ export const dict = { "settings.display.layout.description": "Lay-outmodus voor de chatinterface", "settings.display.layout.auto": "Auto", "settings.display.layout.stretch": "Uitrekken", + "settings.display.reasoningAutoCollapse.title": "Redenering automatisch inklappen", + "settings.display.reasoningAutoCollapse.description": + "Klapt redeneerblokken in nadat de agent klaar is met schrijven. Laat uitgeschakeld om redenering uitgeklapt te houden, tenzij je die handmatig inklapt.", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index cc1d2de885c..23b0bc744e9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1395,6 +1395,9 @@ export const dict = { "settings.display.layout.description": "Layoutmodus for chatgrensesnittet", "settings.display.layout.auto": "Automatisk", "settings.display.layout.stretch": "Strekk", + "settings.display.reasoningAutoCollapse.title": "Skjul resonnement automatisk", + "settings.display.reasoningAutoCollapse.description": + "Skjuler resonnementblokker etter at agenten er ferdig med å skrive dem. La være av for å holde resonnement utvidet med mindre du skjuler det manuelt.", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index e71f9233571..66099445e87 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1404,6 +1404,9 @@ export const dict = { "settings.display.layout.description": "Tryb układu interfejsu czatu", "settings.display.layout.auto": "Automatyczny", "settings.display.layout.stretch": "Rozciągnij", + "settings.display.reasoningAutoCollapse.title": "Automatycznie zwijaj rozumowanie", + "settings.display.reasoningAutoCollapse.description": + "Zwija bloki rozumowania po zakończeniu ich pisania przez agenta. Pozostaw wyłączone, aby rozumowanie pozostało rozwinięte, chyba że zwiniesz je ręcznie.", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 5129da6362e..6a0d2d7ee6e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1404,6 +1404,9 @@ export const dict = { "settings.display.layout.description": "Режим макета для интерфейса чата", "settings.display.layout.auto": "Авто", "settings.display.layout.stretch": "Растянуть", + "settings.display.reasoningAutoCollapse.title": "Автоматически сворачивать рассуждение", + "settings.display.reasoningAutoCollapse.description": + "Сворачивает блоки рассуждения после того, как агент закончит их писать. Оставьте выключенным, чтобы рассуждение оставалось раскрытым, пока вы не свернете его вручную.", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index ce75e9baef6..5bf3f531313 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1377,6 +1377,9 @@ export const dict = { "settings.display.layout.description": "โหมดเค้าโครงสำหรับอินเทอร์เฟซแชท", "settings.display.layout.auto": "อัตโนมัติ", "settings.display.layout.stretch": "ยืด", + "settings.display.reasoningAutoCollapse.title": "ยุบเหตุผลอัตโนมัติ", + "settings.display.reasoningAutoCollapse.description": + "ยุบ block เหตุผลหลังจากเอเจนต์เขียนเสร็จ ปิดไว้เพื่อให้เหตุผลยังคงขยายอยู่ เว้นแต่คุณจะยุบเอง", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 88a6ff675e4..57046c1e309 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1369,6 +1369,9 @@ export const dict = { "settings.display.layout.description": "Sohbet arayüzü için düzen modu", "settings.display.layout.auto": "Otomatik", "settings.display.layout.stretch": "Genişlet", + "settings.display.reasoningAutoCollapse.title": "Akıl yürütmeyi otomatik daralt", + "settings.display.reasoningAutoCollapse.description": + "Ajan yazmayı bitirdikten sonra akıl yürütme bloklarını daraltır. Manuel olarak daraltmadığınız sürece akıl yürütmenin geniş kalması için kapalı bırakın.", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 6db47099df1..d2556d1fbc5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1371,6 +1371,9 @@ export const dict = { "settings.display.layout.description": "Режим макету для інтерфейсу чату", "settings.display.layout.auto": "Автоматично", "settings.display.layout.stretch": "Розтягнути", + "settings.display.reasoningAutoCollapse.title": "Автоматично згортати міркування", + "settings.display.reasoningAutoCollapse.description": + "Згортає блоки міркувань після того, як агент закінчить їх писати. Залиште вимкненим, щоб міркування залишалися розгорнутими, доки ви не згорнете їх вручну.", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index e3eb3b562f9..36ae44edda3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1348,6 +1348,9 @@ export const dict = { "settings.display.layout.description": "聊天界面的布局模式", "settings.display.layout.auto": "自动", "settings.display.layout.stretch": "拉伸", + "settings.display.reasoningAutoCollapse.title": "自动折叠推理", + "settings.display.reasoningAutoCollapse.description": + "在智能体写完推理后折叠推理块。保持关闭可让推理保持展开,除非你手动折叠它。", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index ac816ffc9e2..d0427beeb1e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1327,6 +1327,9 @@ export const dict = { "settings.display.layout.description": "聊天介面的佈局模式", "settings.display.layout.auto": "自動", "settings.display.layout.stretch": "延伸", + "settings.display.reasoningAutoCollapse.title": "自動收合推理", + "settings.display.reasoningAutoCollapse.description": + "在代理寫完推理後收合推理區塊。保持關閉可讓推理保持展開,除非你手動收合它。", "settings.display.terminalCommand.title": "Terminal Command Blocks", "settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.", "settings.display.terminalCommand.expanded": "Expanded", diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 211339cac7d..ea309a6a669 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -16,6 +16,7 @@ import { ServerProvider } from "../context/server" import { ProviderContext } from "../context/provider" import { flattenModels, findModel as _findModel } from "../context/provider-utils" import { ConfigProvider, ConfigContext } from "../context/config" +import { DisplayProvider } from "../context/display" import { DataProvider } from "@kilocode/kilo-ui/context/data" import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff" import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" @@ -317,42 +318,44 @@ export const StoryProviders: ParentComponent<StoryProvidersProps> = (props) => { <VSCodeProvider> <ServerProvider> <ConfigWrapper config={props.config} onConfigChange={props.onConfigChange}> - <MockProviderProvider> - <DialogProvider> - <LanguageContext.Provider - value={{ - locale, - setLocale: noop, - userOverride: () => "" as any, - t, - }} - > - <I18nProvider value={{ locale: () => "en", t }}> - <NotificationsContext.Provider value={notifications}> - <SessionContext.Provider value={session as any}> - <IndexingProvider> - <DataProvider data={data()} directory="/project/"> - <DiffComponentProvider component={Diff}> - <CodeComponentProvider component={Code}> - <FileComponentProvider component={File}> - <MarkedProvider> - {props.noPadding ? ( - props.children - ) : ( - <div style={{ padding: "12px" }}>{props.children}</div> - )} - </MarkedProvider> - </FileComponentProvider> - </CodeComponentProvider> - </DiffComponentProvider> - </DataProvider> - </IndexingProvider> - </SessionContext.Provider> - </NotificationsContext.Provider> - </I18nProvider> - </LanguageContext.Provider> - </DialogProvider> - </MockProviderProvider> + <DisplayProvider> + <MockProviderProvider> + <DialogProvider> + <LanguageContext.Provider + value={{ + locale, + setLocale: noop, + userOverride: () => "" as any, + t, + }} + > + <I18nProvider value={{ locale: () => "en", t }}> + <NotificationsContext.Provider value={notifications}> + <SessionContext.Provider value={session as any}> + <IndexingProvider> + <DataProvider data={data()} directory="/project/"> + <DiffComponentProvider component={Diff}> + <CodeComponentProvider component={Code}> + <FileComponentProvider component={File}> + <MarkedProvider> + {props.noPadding ? ( + props.children + ) : ( + <div style={{ padding: "12px" }}>{props.children}</div> + )} + </MarkedProvider> + </FileComponentProvider> + </CodeComponentProvider> + </DiffComponentProvider> + </DataProvider> + </IndexingProvider> + </SessionContext.Provider> + </NotificationsContext.Provider> + </I18nProvider> + </LanguageContext.Provider> + </DialogProvider> + </MockProviderProvider> + </DisplayProvider> </ConfigWrapper> </ServerProvider> </VSCodeProvider> diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts index 3d5851b33c9..928bac4c374 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -119,6 +119,7 @@ export interface Config { commit_message?: CommitMessageConfig tools?: Record<string, boolean> layout?: "auto" | "stretch" + auto_collapse_reasoning?: boolean experimental?: ExperimentalConfig indexing?: IndexingConfig } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index e7691ccc590..74c6e643fa0 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -167,6 +167,9 @@ export const Info = Schema.Struct({ remote_control: Schema.optional(Schema.Boolean).annotate({ description: "Enable remote control of sessions via Kilo Cloud. Equivalent to running /remote on startup.", }), + auto_collapse_reasoning: Schema.optional(Schema.Boolean).annotate({ + description: "Automatically collapse reasoning blocks after the agent finishes writing them", + }), indexing: Schema.optional(IndexingRef).annotate({ description: "Codebase indexing configuration" }), // kilocode_change terminal_command_display: Schema.optional(Schema.Literals(["expanded", "collapsed"])).annotate({ description: diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9922e354a38..08034506608 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1855,6 +1855,10 @@ export type Config = { * Enable remote control of sessions via Kilo Cloud. Equivalent to running /remote on startup. */ remote_control?: boolean + /** + * Automatically collapse reasoning blocks after the agent finishes writing them + */ + auto_collapse_reasoning?: boolean indexing?: IndexingConfig /** * Controls whether terminal command blocks are expanded or collapsed by default in the VS Code chat UI diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 75779e397fa..55e5ff29f57 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -15579,6 +15579,10 @@ "description": "Enable remote control of sessions via Kilo Cloud. Equivalent to running /remote on startup.", "type": "boolean" }, + "auto_collapse_reasoning": { + "description": "Automatically collapse reasoning blocks after the agent finishes writing them", + "type": "boolean" + }, "indexing": { "$ref": "#/components/schemas/IndexingConfig" }, From a076e336b34f897a6b9463b48d2181d90f9dd997 Mon Sep 17 00:00:00 2001 From: Marius <marius@kilocode.ai> Date: Thu, 30 Apr 2026 14:02:53 +0200 Subject: [PATCH 72/96] fix(vscode): preserve agent manager local sessions (#9742) --- .changeset/local-sessions-restart.md | 5 ++ .../kilo-vscode/tests/unit/navigate.test.ts | 88 +++++++++++++++++++ .../agent-manager/AgentManagerApp.tsx | 20 +++-- .../webview-ui/agent-manager/navigate.ts | 29 ++++++ 4 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 .changeset/local-sessions-restart.md diff --git a/.changeset/local-sessions-restart.md b/.changeset/local-sessions-restart.md new file mode 100644 index 00000000000..3332d103ff9 --- /dev/null +++ b/.changeset/local-sessions-restart.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve Agent Manager local sessions across panel restarts when session refreshes complete out of order. diff --git a/packages/kilo-vscode/tests/unit/navigate.test.ts b/packages/kilo-vscode/tests/unit/navigate.test.ts index 61d7110eb60..819a6c15f89 100644 --- a/packages/kilo-vscode/tests/unit/navigate.test.ts +++ b/packages/kilo-vscode/tests/unit/navigate.test.ts @@ -4,6 +4,7 @@ import { validateLocalSession, adjacentHint, restoreLocalSessions, + reconcileLocalSessions, LOCAL, } from "../../webview-ui/agent-manager/navigate" @@ -294,3 +295,90 @@ describe("restoreLocalSessions", () => { expect(result).toBeUndefined() }) }) + +describe("reconcileLocalSessions", () => { + const isPending = (id: string) => id.startsWith("pending-") + + it("keeps restored local sessions through a partial restart refresh", () => { + const managed = [ + { id: "local-1", worktreeId: null }, + { id: "worktree-1", worktreeId: "wt-1" }, + ] + const restored = restoreLocalSessions(managed, [], undefined, isPending, (items) => items)?.filter(Boolean) ?? [] + + const result = reconcileLocalSessions(restored, ["worktree-1"], managed, isPending) + + expect(restored).toEqual(["local-1"]) + expect(result).toBeUndefined() + }) + + it("preserves restored local sessions before sessionsLoaded includes them", () => { + const result = reconcileLocalSessions( + ["s1", "s2"], + [], + [ + { id: "s1", worktreeId: null }, + { id: "s2", worktreeId: null }, + ], + isPending, + ) + + expect(result).toBeUndefined() + }) + + it("does not forget persisted local sessions when only worktree sessions loaded", () => { + const result = reconcileLocalSessions( + ["local-1"], + ["worktree-1"], + [ + { id: "local-1", worktreeId: null }, + { id: "worktree-1", worktreeId: "wt-1" }, + ], + isPending, + ) + + expect(result).toBeUndefined() + }) + + it("waits for managed state before removing sessions restored from webview state", () => { + const beforeState = reconcileLocalSessions(["local-1"], ["worktree-1"], [], isPending) + const afterState = reconcileLocalSessions( + ["local-1"], + ["worktree-1"], + [ + { id: "local-1", worktreeId: null }, + { id: "worktree-1", worktreeId: "wt-1" }, + ], + isPending, + ) + + expect(beforeState).toEqual({ ids: [], forget: ["local-1"] }) + expect(afterState).toBeUndefined() + }) + + it("forgets stale local sessions missing from loaded and managed state", () => { + const result = reconcileLocalSessions(["s1", "gone"], ["s1"], [{ id: "s1", worktreeId: null }], isPending) + + expect(result).toEqual({ ids: ["s1"], forget: ["gone"] }) + }) + + it("evicts worktree sessions that raced into local state without forgetting them", () => { + const result = reconcileLocalSessions( + ["local-1", "worktree-1"], + ["local-1", "worktree-1"], + [ + { id: "local-1", worktreeId: null }, + { id: "worktree-1", worktreeId: "wt-1" }, + ], + isPending, + ) + + expect(result).toEqual({ ids: ["local-1"], forget: [] }) + }) + + it("keeps pending local tabs during reconciliation", () => { + const result = reconcileLocalSessions(["pending-1", "gone"], [], [], isPending) + + expect(result).toEqual({ ids: ["pending-1"], forget: ["gone"] }) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 45852bfb92f..b5d270f87e2 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -85,7 +85,7 @@ import { NewWorktreeDialog } from "./NewWorktreeDialog" import { LanguageBridge, DataBridge } from "../src/App" import { useLanguage } from "../src/context/language" import { formatRelativeDate } from "../src/utils/date" -import { validateLocalSession, nextSelectionAfterDelete, adjacentHint, restoreLocalSessions, LOCAL } from "./navigate" +import { nextSelectionAfterDelete, adjacentHint, restoreLocalSessions, reconcileLocalSessions, LOCAL } from "./navigate" import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order" import { createTabOrderSync } from "./tab-order-sync" import { ConstrainDragYAxis } from "./sortable-tab" @@ -721,16 +721,18 @@ const AgentManagerContent: Component = () => { // Invalidate local session IDs if they no longer exist (preserve pending tabs) createEffect(() => { + if (!worktreesLoaded()) return const all = session.sessions() if (all.length === 0) return // sessions not loaded yet - const ids = all.map((s) => s.id) - const prev = localSessionIDs() - const valid = prev.filter((lid) => isPending(lid) || validateLocalSession(lid, ids)) - if (valid.length !== prev.length) { - const removed = prev.filter((lid) => !isPending(lid) && !valid.includes(lid)) - for (const id of removed) vscode.postMessage({ type: "agentManager.forgetSession", sessionId: id }) - setLocalSessionIDs(valid) - } + const next = reconcileLocalSessions( + localSessionIDs(), + all.map((s) => s.id), + managedSessions(), + isPending, + ) + if (!next) return + for (const id of next.forget) vscode.postMessage({ type: "agentManager.forgetSession", sessionId: id }) + setLocalSessionIDs(next.ids) }) // Drop in-memory review state for worktrees that no longer exist. createEffect(() => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts b/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts index b9098a99c4a..d61a8265efb 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts @@ -129,6 +129,35 @@ export function restoreLocalSessions( return changed ? merged : undefined } +export function reconcileLocalSessions( + current: string[], + loaded: string[], + managed: { id: string; worktreeId: string | null }[], + isPending: (id: string) => boolean, +): { ids: string[]; forget: string[] } | undefined { + const seen = new Set(loaded) + const local = new Set(managed.filter((s) => !s.worktreeId).map((s) => s.id)) + const worktree = new Set(managed.filter((s) => s.worktreeId).map((s) => s.id)) + const ids: string[] = [] + const forget: string[] = [] + + for (const id of current) { + if (isPending(id)) { + ids.push(id) + continue + } + if (worktree.has(id)) continue + if (seen.has(id) || local.has(id)) { + ids.push(id) + continue + } + forget.push(id) + } + + if (ids.length === current.length && forget.length === 0) return undefined + return { ids, forget } +} + /** * After removing a worktree, pick the nearest remaining sidebar neighbor. * Order: the worktree just below → the one above → LOCAL. From 535b8d0d37b52c56963f2dcb93603e375cd5c1e2 Mon Sep 17 00:00:00 2001 From: Marius <marius@kilocode.ai> Date: Thu, 30 Apr 2026 14:07:58 +0200 Subject: [PATCH 73/96] docs(ui): explain reasoning state retention (#9738) --- packages/kilo-ui/src/components/message-part.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 934ef5cb11f..d0a405c446e 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -1350,6 +1350,9 @@ const userOpened = new Set<string>() const MAX_REASONING_STATE = 1000 function rememberReasoningState(set: Set<string>, id: string) { + // Remember the most recent manual display choices without growing forever. + // If an old id is evicted, only its open/collapsed override is forgotten; + // the reasoning block still renders normally if it appears again. if (set.has(id)) set.delete(id) set.add(id) if (set.size <= MAX_REASONING_STATE) return From 006600b69d55f8b8acb61e2d97f40f7a111142fb Mon Sep 17 00:00:00 2001 From: Imanol Maiztegui <imanol.mzd@gmail.com> Date: Thu, 30 Apr 2026 14:39:18 +0200 Subject: [PATCH 74/96] feat(kilo-ui): add hover-revealed diff viewer button to edit tool header Introduce an icon button that appears on hover within the edit-tool trigger, allowing users to open the diff viewer directly from the tool header without clicking the file path. - Add "Open in Diff Viewer" IconButton with tooltip inside edit trigger - Extract openDiff logic into reusable handler with dedicated click handler - Style the action button with opacity transition on parent hover/focus - Add i18n string for "Open in Diff Viewer" label --- .../kilo-ui/src/components/message-part.css | 33 ++++++++++++++++ .../kilo-ui/src/components/message-part.tsx | 38 +++++++++++++++---- packages/ui/src/i18n/en.ts | 1 + 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index 5b90068c9c6..ba79eec6359 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -33,6 +33,39 @@ } } +/* Hover-revealed "Open in Diff Viewer" icon button in the edit-tool header */ +[data-component="tool-trigger"] [data-slot="basic-tool-tool-info"]:has([data-component="edit-trigger"]) { + flex: 1 1 auto; +} + +[data-component="edit-trigger"] { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + + [data-slot="message-part-title-area"] { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + } + + [data-slot="edit-trigger-actions"] { + flex-shrink: 0; + margin-left: auto; + display: inline-flex; + align-items: center; + opacity: 0; + transition: opacity 0.15s ease; + } +} + +[data-component="tool-trigger"]:hover [data-slot="edit-trigger-actions"], +[data-slot="edit-trigger-actions"]:focus-within { + opacity: 1; +} + /* Prevent long title/path from hiding the collapsible expand arrow */ [data-component="edit-trigger"], [data-component="write-trigger"] { diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index a41c950863f..3382afade68 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -2039,17 +2039,22 @@ ToolRegistry.register({ const canOpenDiff = () => !!data.openDiff && !!path() && (before() !== "" || after() !== "") const canOpenFile = () => !!data.openFile && !!path() + const openDiff = () => { + if (!canOpenDiff()) return + data.openDiff!({ + file: path(), + before: before(), + after: after(), + additions: props.metadata?.filediff?.additions ?? 0, + deletions: props.metadata?.filediff?.deletions ?? 0, + }) + } + const handleFileClick = (e: MouseEvent) => { e.stopPropagation() if (canOpenDiff()) { - data.openDiff!({ - file: path(), - before: before(), - after: after(), - additions: props.metadata?.filediff?.additions ?? 0, - deletions: props.metadata?.filediff?.deletions ?? 0, - }) + openDiff() return } @@ -2058,6 +2063,11 @@ ToolRegistry.register({ } } + const handleOpenDiffClick = (e: MouseEvent) => { + e.stopPropagation() + openDiff() + } + return ( <div data-component="edit-tool"> <BasicTool @@ -2084,6 +2094,20 @@ ToolRegistry.register({ </Show> </div> </div> + <Show when={canOpenDiff()}> + <span data-slot="edit-trigger-actions"> + <Tooltip value={i18n.t("ui.messagePart.openInDiffViewer")} placement="top" gutter={4}> + <IconButton + icon="square-arrow-top-right" + size="small" + variant="ghost" + onMouseDown={(e) => e.preventDefault()} + onClick={handleOpenDiffClick} + aria-label={i18n.t("ui.messagePart.openInDiffViewer")} + /> + </Tooltip> + </span> + </Show> </div> } > diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 4ed9e9c01b6..04f73ba4b93 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -73,6 +73,7 @@ export const dict: Record<string, string> = { "ui.messagePart.mcp.output": "Output", "ui.messagePart.title.edit": "Edit", "ui.messagePart.title.write": "Write", + "ui.messagePart.openInDiffViewer": "Open in Diff Viewer", "ui.messagePart.option.typeOwnAnswer": "Type your own answer", "ui.messagePart.review.title": "Review your answers", "ui.messagePart.questions.dismissed": "Questions dismissed", From 236fa2dd8802f4c064b8a11be0518fa41833f0ac Mon Sep 17 00:00:00 2001 From: Imanol Maiztegui <imanol.mzd@gmail.com> Date: Thu, 30 Apr 2026 14:45:33 +0200 Subject: [PATCH 75/96] chore(i18n): add translations for "Open in Diff Viewer" label across all locales Add the `ui.messagePart.openInDiffViewer` key to 18 language files to support the new diff viewer button introduced in the edit tool header. --- packages/ui/src/i18n/ar.ts | 1 + packages/ui/src/i18n/br.ts | 1 + packages/ui/src/i18n/bs.ts | 1 + packages/ui/src/i18n/da.ts | 1 + packages/ui/src/i18n/de.ts | 1 + packages/ui/src/i18n/es.ts | 1 + packages/ui/src/i18n/fr.ts | 1 + packages/ui/src/i18n/ja.ts | 1 + packages/ui/src/i18n/ko.ts | 1 + packages/ui/src/i18n/nl.ts | 1 + packages/ui/src/i18n/no.ts | 1 + packages/ui/src/i18n/pl.ts | 1 + packages/ui/src/i18n/ru.ts | 1 + packages/ui/src/i18n/th.ts | 1 + packages/ui/src/i18n/tr.ts | 1 + packages/ui/src/i18n/uk.ts | 1 + packages/ui/src/i18n/zh.ts | 1 + packages/ui/src/i18n/zht.ts | 1 + 18 files changed, 18 insertions(+) diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index f31f108cb13..23ea7e7aed2 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -80,6 +80,7 @@ export const dict = { "ui.messagePart.mcp.output": "الإخراج", "ui.messagePart.title.edit": "تحرير", "ui.messagePart.title.write": "كتابة", + "ui.messagePart.openInDiffViewer": "فتح في عارض الفروقات", "ui.messagePart.option.typeOwnAnswer": "اكتب إجابتك الخاصة", "ui.messagePart.review.title": "مراجعة إجاباتك", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index 6d4a826bcde..8464ce2e4a6 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -80,6 +80,7 @@ export const dict = { "ui.messagePart.mcp.output": "Saída", "ui.messagePart.title.edit": "Editar", "ui.messagePart.title.write": "Escrever", + "ui.messagePart.openInDiffViewer": "Abrir no Visualizador de Diferenças", "ui.messagePart.option.typeOwnAnswer": "Digite sua própria resposta", "ui.messagePart.review.title": "Revise suas respostas", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 62970c7279f..2406e87f6fa 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -84,6 +84,7 @@ export const dict = { "ui.messagePart.mcp.output": "Izlaz", "ui.messagePart.title.edit": "Uredi", "ui.messagePart.title.write": "Napiši", + "ui.messagePart.openInDiffViewer": "Otvori u pregledniku razlika", "ui.messagePart.option.typeOwnAnswer": "Unesi svoj odgovor", "ui.messagePart.review.title": "Pregledaj svoje odgovore", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 20303221038..66619290876 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -79,6 +79,7 @@ export const dict = { "ui.messagePart.mcp.output": "Output", "ui.messagePart.title.edit": "Rediger", "ui.messagePart.title.write": "Skriv", + "ui.messagePart.openInDiffViewer": "Åbn i diff-viser", "ui.messagePart.option.typeOwnAnswer": "Skriv dit eget svar", "ui.messagePart.review.title": "Gennemgå dine svar", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index bc0358ea056..d9304b724a3 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -85,6 +85,7 @@ export const dict = { "ui.messagePart.mcp.output": "Ausgabe", "ui.messagePart.title.edit": "Bearbeiten", "ui.messagePart.title.write": "Schreiben", + "ui.messagePart.openInDiffViewer": "Im Diff-Viewer öffnen", "ui.messagePart.option.typeOwnAnswer": "Eigene Antwort eingeben", "ui.messagePart.review.title": "Antworten überprüfen", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 90899358219..3dd92fedbc0 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -80,6 +80,7 @@ export const dict = { "ui.messagePart.mcp.output": "Salida", "ui.messagePart.title.edit": "Editar", "ui.messagePart.title.write": "Escribir", + "ui.messagePart.openInDiffViewer": "Abrir en el visor de diferencias", "ui.messagePart.option.typeOwnAnswer": "Escribe tu propia respuesta", "ui.messagePart.review.title": "Revisa tus respuestas", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index 35f6702c5d4..f86b8b83c79 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -80,6 +80,7 @@ export const dict = { "ui.messagePart.mcp.output": "Sortie", "ui.messagePart.title.edit": "Modifier", "ui.messagePart.title.write": "Écrire", + "ui.messagePart.openInDiffViewer": "Ouvrir dans la visionneuse de différences", "ui.messagePart.option.typeOwnAnswer": "Tapez votre propre réponse", "ui.messagePart.review.title": "Passez en revue vos réponses", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 2daf8cf2443..d6f84aca929 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -79,6 +79,7 @@ export const dict = { "ui.messagePart.mcp.output": "出力", "ui.messagePart.title.edit": "編集", "ui.messagePart.title.write": "作成", + "ui.messagePart.openInDiffViewer": "差分ビューアで開く", "ui.messagePart.option.typeOwnAnswer": "自分の回答を入力", "ui.messagePart.review.title": "回答を確認", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index ee6e1f83096..9ca45acc6b9 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -80,6 +80,7 @@ export const dict = { "ui.messagePart.mcp.output": "출력", "ui.messagePart.title.edit": "편집", "ui.messagePart.title.write": "작성", + "ui.messagePart.openInDiffViewer": "Diff 뷰어에서 열기", "ui.messagePart.option.typeOwnAnswer": "직접 답변 입력", "ui.messagePart.review.title": "답변 검토", diff --git a/packages/ui/src/i18n/nl.ts b/packages/ui/src/i18n/nl.ts index c3cab8540d2..d52eaa9b93e 100644 --- a/packages/ui/src/i18n/nl.ts +++ b/packages/ui/src/i18n/nl.ts @@ -73,6 +73,7 @@ export const dict: Record<string, string> = { "ui.messagePart.mcp.output": "Uitvoer", "ui.messagePart.title.edit": "Bewerken", "ui.messagePart.title.write": "Schrijven", + "ui.messagePart.openInDiffViewer": "Openen in diff-viewer", "ui.messagePart.option.typeOwnAnswer": "Typ je eigen antwoord", "ui.messagePart.review.title": "Controleer je antwoorden", "ui.messagePart.questions.dismissed": "Vragen genegeerd", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index 377fa7a95a8..d899d698fd4 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -83,6 +83,7 @@ export const dict: Record<Keys, string> = { "ui.messagePart.mcp.output": "Utdata", "ui.messagePart.title.edit": "Rediger", "ui.messagePart.title.write": "Skriv", + "ui.messagePart.openInDiffViewer": "Åpne i diff-viser", "ui.messagePart.option.typeOwnAnswer": "Skriv ditt eget svar", "ui.messagePart.review.title": "Gjennomgå svarene dine", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index 05a1a2e1662..afaa65aaaff 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -79,6 +79,7 @@ export const dict = { "ui.messagePart.mcp.output": "Wyjście", "ui.messagePart.title.edit": "Edycja", "ui.messagePart.title.write": "Pisanie", + "ui.messagePart.openInDiffViewer": "Otwórz w podglądzie różnic", "ui.messagePart.option.typeOwnAnswer": "Wpisz własną odpowiedź", "ui.messagePart.review.title": "Przejrzyj swoje odpowiedzi", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 067cbd0e67b..f01883bd9f9 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -79,6 +79,7 @@ export const dict = { "ui.messagePart.mcp.output": "Вывод", "ui.messagePart.title.edit": "Редактировать", "ui.messagePart.title.write": "Написать", + "ui.messagePart.openInDiffViewer": "Открыть в просмотрщике различий", "ui.messagePart.option.typeOwnAnswer": "Введите свой ответ", "ui.messagePart.review.title": "Проверьте ваши ответы", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 92299360185..8e62bc0ea70 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -81,6 +81,7 @@ export const dict = { "ui.messagePart.mcp.output": "เอาต์พุต", "ui.messagePart.title.edit": "แก้ไข", "ui.messagePart.title.write": "เขียน", + "ui.messagePart.openInDiffViewer": "เปิดในตัวดูความแตกต่าง", "ui.messagePart.option.typeOwnAnswer": "พิมพ์คำตอบของคุณเอง", "ui.messagePart.review.title": "ตรวจสอบคำตอบของคุณ", diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index f7b55f6ad8e..8e9eefb9a4e 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -86,6 +86,7 @@ export const dict = { "ui.messagePart.mcp.output": "Çıkış", "ui.messagePart.title.edit": "Düzenle", "ui.messagePart.title.write": "Yaz", + "ui.messagePart.openInDiffViewer": "Fark görüntüleyicide aç", "ui.messagePart.option.typeOwnAnswer": "Kendi cevabınızı yazın", "ui.messagePart.review.title": "Cevapları inceleyin", diff --git a/packages/ui/src/i18n/uk.ts b/packages/ui/src/i18n/uk.ts index 3ebf9ce3396..189fa392170 100644 --- a/packages/ui/src/i18n/uk.ts +++ b/packages/ui/src/i18n/uk.ts @@ -86,6 +86,7 @@ export const dict = { "ui.messagePart.mcp.output": "Вихід", "ui.messagePart.title.edit": "Редагувати", "ui.messagePart.title.write": "Записати", + "ui.messagePart.openInDiffViewer": "Відкрити у переглядачі відмінностей", "ui.messagePart.option.typeOwnAnswer": "Введіть власну відповідь", "ui.messagePart.review.title": "Перегляньте свої відповіді", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 48423ab232c..b21cd85ad8d 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -84,6 +84,7 @@ export const dict = { "ui.messagePart.mcp.output": "输出", "ui.messagePart.title.edit": "编辑", "ui.messagePart.title.write": "写入", + "ui.messagePart.openInDiffViewer": "在差异查看器中打开", "ui.messagePart.option.typeOwnAnswer": "输入自己的答案", "ui.messagePart.review.title": "检查你的答案", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 276db511540..96e1c8479dc 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -84,6 +84,7 @@ export const dict = { "ui.messagePart.mcp.output": "輸出", "ui.messagePart.title.edit": "編輯", "ui.messagePart.title.write": "寫入", + "ui.messagePart.openInDiffViewer": "在差異檢視器中開啟", "ui.messagePart.option.typeOwnAnswer": "輸入自己的答案", "ui.messagePart.review.title": "檢查你的答案", From a3ed2bb37fdd868a558b854c3ccb9b1636f2db3b Mon Sep 17 00:00:00 2001 From: Imanol Maiztegui <imanol.mzd@gmail.com> Date: Thu, 30 Apr 2026 14:50:11 +0200 Subject: [PATCH 76/96] test(kilo-ui): add storybook story for edit tool diff viewer button Introduce `WithEditToolOpenDiffAction` story and supporting `editCompletedPart` fixture with full filediff metadata to exercise the `canOpenDiff()` code path. Force-reveal the hover-only action slot via a scoped style override so visual regression snapshots capture the icon-button layout. - Add completed edit tool part with before/after file content - Wire `onOpenDiff` prop through `AllProviders` into `DataProvider` - Create dedicated `mockDataEdit` dataset for the new story --- .../src/stories/message-part.stories.tsx | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/packages/kilo-ui/src/stories/message-part.stories.tsx b/packages/kilo-ui/src/stories/message-part.stories.tsx index e67fe32b441..8c256487b12 100644 --- a/packages/kilo-ui/src/stories/message-part.stories.tsx +++ b/packages/kilo-ui/src/stories/message-part.stories.tsx @@ -163,6 +163,39 @@ const errorToolPart: ToolPart = { }, } +// Completed edit tool with full filediff metadata — exercises canOpenDiff() +// so the "Open in Diff Viewer" icon button renders in the trigger. +const editCompletedPart: ToolPart = { + id: "part-tool-edit-done", + sessionID: SESSION_ID, + messageID: ASST_MSG_ID, + type: "tool", + callID: "call-edit-done", + tool: "edit", + state: { + status: "completed", + input: { + filePath: "src/counter.tsx", + oldString: "const [count, setCount] = createSignal(0)", + newString: "const [count, setCount] = createSignal(1)", + }, + output: "File edited successfully", + title: "Edit file", + metadata: { + filediff: { + file: "src/counter.tsx", + before: + "import { createSignal } from 'solid-js'\n\nexport function Counter() {\n const [count, setCount] = createSignal(0)\n return <button onClick={() => setCount(count() + 1)}>{count()}</button>\n}\n", + after: + "import { createSignal } from 'solid-js'\n\nexport function Counter() {\n const [count, setCount] = createSignal(1)\n return <button onClick={() => setCount(count() + 1)}>{count()}</button>\n}\n", + additions: 1, + deletions: 1, + }, + }, + time: { start: now - 4000, end: now - 3500 }, + }, +} + // --- Reasoning part --- const reasoningPart: ReasoningPart = { @@ -208,10 +241,12 @@ const mockDataReasoning = createMockData([reasoningPart, textPart]) const mockDataBash = createMockData([bashCompleted]) // Three context-group tools — exercises ContextToolGroupHeader collapse path const mockDataContextGroup = createMockData([completedToolPart, grepCompleted, globCompleted, textPart]) +// Completed edit tool with filediff — exercises the "Open in Diff Viewer" button path +const mockDataEdit = createMockData([editCompletedPart]) -function AllProviders(props: { children: any; data?: MockData }) { +function AllProviders(props: { children: any; data?: MockData; onOpenDiff?: () => void }) { return ( - <DataProvider data={props.data ?? mockData} directory="/project"> + <DataProvider data={props.data ?? mockData} directory="/project" onOpenDiff={props.onOpenDiff}> <DiffComponentProvider component={Diff}> <CodeComponentProvider component={Code}> <FileComponentProvider component={File}> @@ -354,6 +389,25 @@ export const WithContextGroup: Story = { ), } +// --- Completed edit tool with filediff → "Open in Diff Viewer" icon visible --- +// +// CSS :hover cannot be triggered reliably from Storybook `play` functions, +// so the action slot is force-revealed via a scoped style override. This +// captures the layout with the icon present so the visual regression +// suite catches any regression in: +// - the `:has()` parent-grow rule on basic-tool-tool-info +// - the `margin-left: auto` pushing the action to the right +// - the icon-button ghost variant in the tool-trigger row +export const WithEditToolOpenDiffAction: Story = { + name: "WithEditTool (open-diff action visible)", + render: () => ( + <AllProviders data={mockDataEdit} onOpenDiff={() => {}}> + <style>{`[data-slot="edit-trigger-actions"] { opacity: 1 !important; }`}</style> + <AssistantParts messages={[mockAssistantMessage]} /> + </AllProviders> + ), +} + // --- All 5 tool hint error types in a single screenshot --- const hintErrors: ToolPart[] = [ From 80535d4ed6266888988a66ca28706260ee89e533 Mon Sep 17 00:00:00 2001 From: Marius <marius@kilocode.ai> Date: Thu, 30 Apr 2026 14:55:02 +0200 Subject: [PATCH 77/96] fix(cli): persist command permission approvals (#9746) --- .changeset/command-permissions-persist.md | 5 ++ packages/opencode/src/permission/index.ts | 53 ++++++++----------- .../permission/next.always-rules.test.ts | 42 +++++++++++++++ .../opencode/test/permission/next.test.ts | 39 +++++++++++++- 4 files changed, 107 insertions(+), 32 deletions(-) create mode 100644 .changeset/command-permissions-persist.md diff --git a/.changeset/command-permissions-persist.md b/.changeset/command-permissions-persist.md new file mode 100644 index 00000000000..8e8e16c82e4 --- /dev/null +++ b/.changeset/command-permissions-persist.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Avoid repeated command approval prompts when multiple sessions request the same saved command permission, without widening bash permission matching. diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 6e1516b4691..991b0a52f4e 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -159,6 +159,7 @@ interface PendingEntry { info: Request ruleset: Ruleset // kilocode_change hardRuleset?: Ruleset // kilocode_change + saved?: boolean // kilocode_change deferred: Deferred.Deferred<void, RejectedError | CorrectedError> } @@ -309,40 +310,31 @@ export const layer = Layer.effect( // kilocode_change end for (const pattern of existing.info.always) { - approved.push({ - permission: existing.info.permission, - pattern, - action: "allow", - }) + // kilocode_change start — saveAlwaysRules may have already persisted selected always-rules + if (!existing.saved) { + approved.push({ + permission: existing.info.permission, + pattern, + action: "allow", + }) + } + // kilocode_change end } - for (const [id, item] of pending.entries()) { - if (item.info.sessionID !== existing.info.sessionID) continue - if (ConfigProtection.isRequest(item.info)) continue // kilocode_change - // kilocode_change start - const ok = item.info.patterns.every((pattern) => { - if (veto(item.info.permission, pattern, item.hardRuleset)) return false - return evaluate(item.info.permission, pattern, item.ruleset, approved).action === "allow" - }) - // kilocode_change end - if (!ok) continue - pending.delete(id) - yield* bus.publish(Event.Replied, { - sessionID: item.info.sessionID, - requestID: item.info.id, - reply: "always", - }) - yield* Deferred.succeed(item.deferred, undefined) - } + // kilocode_change start — drain covered permissions across sibling Agent Manager sessions + yield* drainCovered(pending as unknown as Map<string, PendingEntry>, approved, DeniedError) + // kilocode_change end // kilocode_change start — persist always-rules to global config - const alwaysRules: Ruleset = existing.info.always.map((pattern) => ({ - permission: existing.info.permission, - pattern, - action: "allow" as const, - })) - if (alwaysRules.length > 0) { - yield* Effect.promise(() => Config.updateGlobal({ permission: toConfig(alwaysRules) }, { dispose: false })) + if (!existing.saved) { + const alwaysRules: Ruleset = existing.info.always.map((pattern) => ({ + permission: existing.info.permission, + pattern, + action: "allow" as const, + })) + if (alwaysRules.length > 0) { + yield* Effect.promise(() => Config.updateGlobal({ permission: toConfig(alwaysRules) }, { dispose: false })) + } } // kilocode_change end return true // kilocode_change @@ -377,6 +369,7 @@ export const layer = Layer.effect( if (deniedSet.has(pattern)) newRules.push({ permission, pattern, action: "deny" }) } s.approved.push(...newRules) + existing.saved = true // kilocode_change if (newRules.length > 0) { yield* Effect.promise(() => Config.updateGlobal({ permission: toConfig(newRules) }, { dispose: false })) diff --git a/packages/opencode/test/kilocode/permission/next.always-rules.test.ts b/packages/opencode/test/kilocode/permission/next.always-rules.test.ts index d1fafbbb36c..92386c76eff 100644 --- a/packages/opencode/test/kilocode/permission/next.always-rules.test.ts +++ b/packages/opencode/test/kilocode/permission/next.always-rules.test.ts @@ -625,6 +625,48 @@ describe("saveAlwaysRules", () => { ), ) + it.live("saveAlwaysRules then reply(always) does not duplicate saved rules", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const fiber = yield* ask({ + id: PermissionID.make("permission_saved_always"), + sessionID: SessionID.make("session_saved_always"), + permission: "bash", + patterns: ["kilo-permission-8353 test"], + metadata: { rules: ["kilo-permission-8353 *", "kilo-permission-8353 test"] }, + always: ["kilo-permission-8353 *", "kilo-permission-8353 test"], + ruleset: [], + }).pipe(Effect.forkScoped) + + yield* waitForPending(1) + yield* saveAlwaysRules({ + requestID: PermissionID.make("permission_saved_always"), + approvedAlways: ["kilo-permission-8353 test"], + }) + yield* reply({ requestID: PermissionID.make("permission_saved_always"), reply: "always" }) + yield* Fiber.join(fiber) + + const cfg = yield* Effect.promise(() => Config.get()) + expect(cfg.permission?.bash).toMatchObject({ "kilo-permission-8353 test": "allow" }) + expect(cfg.permission?.bash).not.toMatchObject({ "kilo-permission-8353 *": "allow" }) + + const broad = yield* ask({ + id: PermissionID.make("permission_saved_always_broad"), + sessionID: SessionID.make("session_saved_always"), + permission: "bash", + patterns: ["kilo-permission-8353 install"], + metadata: {}, + always: [], + ruleset: [], + }).pipe(Effect.forkScoped) + + yield* waitForPending(1) + yield* reply({ requestID: PermissionID.make("permission_saved_always_broad"), reply: "reject" }) + expectFailure(yield* Fiber.await(broad), Permission.RejectedError) + }), + ), + ) + it.live("auto-rejects pending permission from sibling session when denied", () => withDir({ git: true }, () => Effect.gen(function* () { diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 89a703d6daa..05e8682d056 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -949,7 +949,8 @@ it.live("reply - always resolves matching pending requests in same session", () ), ) -it.live("reply - always keeps other session pending", () => +// kilocode_change start +it.live("reply - always resolves matching pending requests from other sessions", () => withDir({ git: true }, () => Effect.gen(function* () { const a = yield* ask({ @@ -976,13 +977,47 @@ it.live("reply - always keeps other session pending", () => yield* reply({ requestID: PermissionID.make("per_test6a"), reply: "always" }) yield* Fiber.join(a) - expect((yield* list()).map((item) => item.id)).toEqual([PermissionID.make("per_test6b")]) + yield* Fiber.join(b) + expect(yield* list()).toHaveLength(0) + }), + ), +) + +it.live("reply - always does not resolve dangerous variants from other sessions", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const a = yield* ask({ + id: PermissionID.make("per_test_safe_a"), + sessionID: SessionID.make("session_a"), + permission: "bash", + patterns: ["npm test"], + metadata: {}, + always: ["npm test"], + ruleset: [], + }).pipe(Effect.forkScoped) + + const b = yield* ask({ + id: PermissionID.make("per_test_safe_b"), + sessionID: SessionID.make("session_b"), + permission: "bash", + patterns: ["npm test > ~/.ssh/authorized_keys"], + metadata: {}, + always: [], + ruleset: [], + }).pipe(Effect.forkScoped) + + yield* waitForPending(2) + yield* reply({ requestID: PermissionID.make("per_test_safe_a"), reply: "always" }) + + yield* Fiber.join(a) + expect((yield* list()).map((item) => item.id)).toEqual([PermissionID.make("per_test_safe_b")]) yield* rejectAll() yield* Fiber.await(b) }), ), ) +// kilocode_change end it.live("reply - publishes replied event", () => withDir({ git: true }, () => From 8e0987e8416b12c2446e4fcd7ac7531ed7fd68fe Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" <kilo-maintainer[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:55:16 +0000 Subject: [PATCH 78/96] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index c92c084061b..7e5e025b455 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-cAQ3LYGSSUAxDH77qouZcG4dPjTBIysPAkLwXHkRza4=", - "aarch64-linux": "sha256-49haI3uiwlTn4YYJsCyEEMTXmjGYyktm8qC5zQ0MAzg=", - "aarch64-darwin": "sha256-sdd37hufZNCHQuVkDxaHc6xVqovz/BuCPbBOoDbcuMQ=", - "x86_64-darwin": "sha256-Ndl68EHo83z9C3WFDNqRDni6fdKSX9nj4/5o+3wxzNs=" + "x86_64-linux": "sha256-4hLG583KyBLxAOYvIiVMh+xLtVAdR3QgWFEqnGlZLZw=", + "aarch64-linux": "sha256-+60DxQ/GKAAoMFuAQRFoYM0HlZMzpLpLrTD7qmweUUA=", + "aarch64-darwin": "sha256-3WCWVqaxu+lCIc51N7wOttM/hoXbbnmtw7EP2eGstQg=", + "x86_64-darwin": "sha256-K8iG+OHkgC/lwud6hQ91sXF3c7UbdQQYRU/KK2P+B/4=" } } From 5f5e55c3486c1916beb5498716c4b8b0e38ce398 Mon Sep 17 00:00:00 2001 From: Imanol Maiztegui <imanol.mzd@gmail.com> Date: Thu, 30 Apr 2026 15:05:52 +0200 Subject: [PATCH 79/96] refactor(i18n): move openInDiffViewer translation key from ui to kilo-i18n Relocate the `ui.messagePart.openInDiffViewer` string from the `packages/ui` locale dictionaries to `packages/kilo-i18n`, removing the duplicate entries in the UI package and consolidating ownership of the diff viewer label under a single i18n source. --- packages/kilo-i18n/src/ar.ts | 2 ++ packages/kilo-i18n/src/br.ts | 2 ++ packages/kilo-i18n/src/bs.ts | 2 ++ packages/kilo-i18n/src/da.ts | 2 ++ packages/kilo-i18n/src/de.ts | 2 ++ packages/kilo-i18n/src/en.ts | 3 +++ packages/kilo-i18n/src/es.ts | 2 ++ packages/kilo-i18n/src/fr.ts | 2 ++ packages/kilo-i18n/src/ja.ts | 2 ++ packages/kilo-i18n/src/ko.ts | 2 ++ packages/kilo-i18n/src/nl.ts | 2 ++ packages/kilo-i18n/src/no.ts | 2 ++ packages/kilo-i18n/src/pl.ts | 2 ++ packages/kilo-i18n/src/ru.ts | 2 ++ packages/kilo-i18n/src/th.ts | 2 ++ packages/kilo-i18n/src/tr.ts | 2 ++ packages/kilo-i18n/src/uk.ts | 2 ++ packages/kilo-i18n/src/zh.ts | 2 ++ packages/kilo-i18n/src/zht.ts | 2 ++ packages/ui/src/i18n/ar.ts | 1 - packages/ui/src/i18n/br.ts | 1 - packages/ui/src/i18n/bs.ts | 1 - packages/ui/src/i18n/da.ts | 1 - packages/ui/src/i18n/de.ts | 1 - packages/ui/src/i18n/en.ts | 1 - packages/ui/src/i18n/es.ts | 1 - packages/ui/src/i18n/fr.ts | 1 - packages/ui/src/i18n/ja.ts | 1 - packages/ui/src/i18n/ko.ts | 1 - packages/ui/src/i18n/nl.ts | 1 - packages/ui/src/i18n/no.ts | 1 - packages/ui/src/i18n/pl.ts | 1 - packages/ui/src/i18n/ru.ts | 1 - packages/ui/src/i18n/th.ts | 1 - packages/ui/src/i18n/tr.ts | 1 - packages/ui/src/i18n/uk.ts | 1 - packages/ui/src/i18n/zh.ts | 1 - packages/ui/src/i18n/zht.ts | 1 - 38 files changed, 39 insertions(+), 19 deletions(-) diff --git a/packages/kilo-i18n/src/ar.ts b/packages/kilo-i18n/src/ar.ts index 3a6374008bc..84be1f19d72 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -79,4 +79,6 @@ export const dict = { "plan.followup.answer.newSession.description": "نفّذ في جلسة جديدة بسياق نظيف", "plan.followup.answer.continue": "المتابعة هنا", "plan.followup.answer.continue.description": "نفّذ الخطة في هذه الجلسة", + + "ui.messagePart.openInDiffViewer": "فتح في عارض الفروقات", } diff --git a/packages/kilo-i18n/src/br.ts b/packages/kilo-i18n/src/br.ts index 5a9db6c39d0..06066b4b0c8 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -79,4 +79,6 @@ export const dict = { "plan.followup.answer.newSession.description": "Implementar em uma nova sessão com contexto limpo", "plan.followup.answer.continue": "Continuar aqui", "plan.followup.answer.continue.description": "Implementar o plano nesta sessão", + + "ui.messagePart.openInDiffViewer": "Abrir no Visualizador de Diferenças", } diff --git a/packages/kilo-i18n/src/bs.ts b/packages/kilo-i18n/src/bs.ts index 1b88c607dbc..c2bec932ad6 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -84,4 +84,6 @@ export const dict = { "plan.followup.answer.newSession.description": "Implementiraj u novoj sesiji s čistim kontekstom", "plan.followup.answer.continue": "Nastavi ovdje", "plan.followup.answer.continue.description": "Implementiraj plan u ovoj sesiji", + + "ui.messagePart.openInDiffViewer": "Otvori u pregledniku razlika", } diff --git a/packages/kilo-i18n/src/da.ts b/packages/kilo-i18n/src/da.ts index 912ecaaad72..04ce7845cd1 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -79,4 +79,6 @@ export const dict = { "plan.followup.answer.newSession.description": "Implementér i en ny session med ren kontekst", "plan.followup.answer.continue": "Fortsæt her", "plan.followup.answer.continue.description": "Implementér planen i denne session", + + "ui.messagePart.openInDiffViewer": "Åbn i diff-viser", } diff --git a/packages/kilo-i18n/src/de.ts b/packages/kilo-i18n/src/de.ts index 2e08b98b273..f3d057700fa 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -79,4 +79,6 @@ export const dict = { "plan.followup.answer.newSession.description": "In einer neuen Sitzung mit leerem Kontext umsetzen", "plan.followup.answer.continue": "Hier fortfahren", "plan.followup.answer.continue.description": "Den Plan in dieser Sitzung umsetzen", + + "ui.messagePart.openInDiffViewer": "Im Diff-Viewer öffnen", } diff --git a/packages/kilo-i18n/src/en.ts b/packages/kilo-i18n/src/en.ts index cacebb54f97..f0e6d02eee0 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -83,4 +83,7 @@ export const dict = { "plan.followup.answer.newSession.description": "Implement in a fresh session with a clean context", "plan.followup.answer.continue": "Continue here", "plan.followup.answer.continue.description": "Implement the plan in this session", + + // Edit-tool header: hover-revealed action opening the diff in a full tab. + "ui.messagePart.openInDiffViewer": "Open in Diff Viewer", } diff --git a/packages/kilo-i18n/src/es.ts b/packages/kilo-i18n/src/es.ts index 9325ae29a42..090de326f77 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -79,4 +79,6 @@ export const dict = { "plan.followup.answer.newSession.description": "Implementar en una sesión nueva con contexto limpio", "plan.followup.answer.continue": "Continuar aquí", "plan.followup.answer.continue.description": "Implementar el plan en esta sesión", + + "ui.messagePart.openInDiffViewer": "Abrir en el visor de diferencias", } diff --git a/packages/kilo-i18n/src/fr.ts b/packages/kilo-i18n/src/fr.ts index 51b5735b3e2..950a32abfe8 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -79,4 +79,6 @@ export const dict = { "plan.followup.answer.newSession.description": "Implémenter dans une nouvelle session avec un contexte vierge", "plan.followup.answer.continue": "Continuer ici", "plan.followup.answer.continue.description": "Implémenter le plan dans cette session", + + "ui.messagePart.openInDiffViewer": "Ouvrir dans la visionneuse de différences", } diff --git a/packages/kilo-i18n/src/ja.ts b/packages/kilo-i18n/src/ja.ts index bf1ae85ff55..649a1b17e94 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -77,4 +77,6 @@ export const dict = { "plan.followup.answer.newSession.description": "クリーンなコンテキストの新しいセッションで実装する", "plan.followup.answer.continue": "ここで続行", "plan.followup.answer.continue.description": "このセッションで計画を実装する", + + "ui.messagePart.openInDiffViewer": "差分ビューアで開く", } diff --git a/packages/kilo-i18n/src/ko.ts b/packages/kilo-i18n/src/ko.ts index f1260654972..122316fda38 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -77,4 +77,6 @@ export const dict = { "plan.followup.answer.newSession.description": "깨끗한 컨텍스트의 새 세션에서 구현", "plan.followup.answer.continue": "여기서 계속하기", "plan.followup.answer.continue.description": "이 세션에서 계획 구현", + + "ui.messagePart.openInDiffViewer": "Diff 뷰어에서 열기", } diff --git a/packages/kilo-i18n/src/nl.ts b/packages/kilo-i18n/src/nl.ts index 20e2dd6d1c9..305c2597a37 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -81,4 +81,6 @@ export const dict = { "plan.followup.answer.newSession.description": "Implementeren in een nieuwe sessie met een lege context", "plan.followup.answer.continue": "Hier doorgaan", "plan.followup.answer.continue.description": "Het plan in deze sessie implementeren", + + "ui.messagePart.openInDiffViewer": "Openen in diff-viewer", } diff --git a/packages/kilo-i18n/src/no.ts b/packages/kilo-i18n/src/no.ts index e1c1ac61bcf..53587770289 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -79,4 +79,6 @@ export const dict = { "plan.followup.answer.newSession.description": "Implementer i en ny økt med ren kontekst", "plan.followup.answer.continue": "Fortsett her", "plan.followup.answer.continue.description": "Implementer planen i denne økten", + + "ui.messagePart.openInDiffViewer": "Åpne i diff-viser", } diff --git a/packages/kilo-i18n/src/pl.ts b/packages/kilo-i18n/src/pl.ts index 0593170d9b0..4295d29c37d 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -79,4 +79,6 @@ export const dict = { "plan.followup.answer.newSession.description": "Wdróż w nowej sesji z czystym kontekstem", "plan.followup.answer.continue": "Kontynuuj tutaj", "plan.followup.answer.continue.description": "Wdróż plan w tej sesji", + + "ui.messagePart.openInDiffViewer": "Otwórz w podglądzie różnic", } diff --git a/packages/kilo-i18n/src/ru.ts b/packages/kilo-i18n/src/ru.ts index 74acae59272..3afd83fc562 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -79,4 +79,6 @@ export const dict = { "plan.followup.answer.newSession.description": "Реализовать в новой сессии с чистым контекстом", "plan.followup.answer.continue": "Продолжить здесь", "plan.followup.answer.continue.description": "Реализовать план в этой сессии", + + "ui.messagePart.openInDiffViewer": "Открыть в просмотрщике различий", } diff --git a/packages/kilo-i18n/src/th.ts b/packages/kilo-i18n/src/th.ts index 48b02146af5..1897ec9ce4a 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -79,4 +79,6 @@ export const dict = { "plan.followup.answer.newSession.description": "ดำเนินการในเซสชันใหม่ที่มีบริบทว่างเปล่า", "plan.followup.answer.continue": "ดำเนินการต่อที่นี่", "plan.followup.answer.continue.description": "ดำเนินการตามแผนในเซสชันนี้", + + "ui.messagePart.openInDiffViewer": "เปิดในตัวดูความแตกต่าง", } diff --git a/packages/kilo-i18n/src/tr.ts b/packages/kilo-i18n/src/tr.ts index e307f790772..df699abe396 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -79,4 +79,6 @@ export const dict = { "plan.followup.answer.newSession.description": "Temiz bir bağlamla yeni bir oturumda uygula", "plan.followup.answer.continue": "Burada devam et", "plan.followup.answer.continue.description": "Planı bu oturumda uygula", + + "ui.messagePart.openInDiffViewer": "Fark görüntüleyicide aç", } diff --git a/packages/kilo-i18n/src/uk.ts b/packages/kilo-i18n/src/uk.ts index 7d1707a3e51..34f35508bf8 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -79,4 +79,6 @@ export const dict = { "plan.followup.answer.newSession.description": "Реалізувати в новій сесії з чистим контекстом", "plan.followup.answer.continue": "Продовжити тут", "plan.followup.answer.continue.description": "Реалізувати план у цій сесії", + + "ui.messagePart.openInDiffViewer": "Відкрити у переглядачі відмінностей", } diff --git a/packages/kilo-i18n/src/zh.ts b/packages/kilo-i18n/src/zh.ts index d5cd78e8778..c89fe57ef6c 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -76,4 +76,6 @@ export const dict = { "plan.followup.answer.newSession.description": "在具有干净上下文的新会话中实现", "plan.followup.answer.continue": "在此继续", "plan.followup.answer.continue.description": "在本会话中实现计划", + + "ui.messagePart.openInDiffViewer": "在差异查看器中打开", } diff --git a/packages/kilo-i18n/src/zht.ts b/packages/kilo-i18n/src/zht.ts index 5cdb8dff670..0904259276b 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -76,4 +76,6 @@ export const dict = { "plan.followup.answer.newSession.description": "在具有乾淨上下文的新工作階段中實作", "plan.followup.answer.continue": "在此繼續", "plan.followup.answer.continue.description": "在本工作階段中實作計畫", + + "ui.messagePart.openInDiffViewer": "在差異檢視器中開啟", } diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 23ea7e7aed2..f31f108cb13 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -80,7 +80,6 @@ export const dict = { "ui.messagePart.mcp.output": "الإخراج", "ui.messagePart.title.edit": "تحرير", "ui.messagePart.title.write": "كتابة", - "ui.messagePart.openInDiffViewer": "فتح في عارض الفروقات", "ui.messagePart.option.typeOwnAnswer": "اكتب إجابتك الخاصة", "ui.messagePart.review.title": "مراجعة إجاباتك", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index 8464ce2e4a6..6d4a826bcde 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -80,7 +80,6 @@ export const dict = { "ui.messagePart.mcp.output": "Saída", "ui.messagePart.title.edit": "Editar", "ui.messagePart.title.write": "Escrever", - "ui.messagePart.openInDiffViewer": "Abrir no Visualizador de Diferenças", "ui.messagePart.option.typeOwnAnswer": "Digite sua própria resposta", "ui.messagePart.review.title": "Revise suas respostas", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 2406e87f6fa..62970c7279f 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -84,7 +84,6 @@ export const dict = { "ui.messagePart.mcp.output": "Izlaz", "ui.messagePart.title.edit": "Uredi", "ui.messagePart.title.write": "Napiši", - "ui.messagePart.openInDiffViewer": "Otvori u pregledniku razlika", "ui.messagePart.option.typeOwnAnswer": "Unesi svoj odgovor", "ui.messagePart.review.title": "Pregledaj svoje odgovore", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 66619290876..20303221038 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -79,7 +79,6 @@ export const dict = { "ui.messagePart.mcp.output": "Output", "ui.messagePart.title.edit": "Rediger", "ui.messagePart.title.write": "Skriv", - "ui.messagePart.openInDiffViewer": "Åbn i diff-viser", "ui.messagePart.option.typeOwnAnswer": "Skriv dit eget svar", "ui.messagePart.review.title": "Gennemgå dine svar", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index d9304b724a3..bc0358ea056 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -85,7 +85,6 @@ export const dict = { "ui.messagePart.mcp.output": "Ausgabe", "ui.messagePart.title.edit": "Bearbeiten", "ui.messagePart.title.write": "Schreiben", - "ui.messagePart.openInDiffViewer": "Im Diff-Viewer öffnen", "ui.messagePart.option.typeOwnAnswer": "Eigene Antwort eingeben", "ui.messagePart.review.title": "Antworten überprüfen", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 04f73ba4b93..4ed9e9c01b6 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -73,7 +73,6 @@ export const dict: Record<string, string> = { "ui.messagePart.mcp.output": "Output", "ui.messagePart.title.edit": "Edit", "ui.messagePart.title.write": "Write", - "ui.messagePart.openInDiffViewer": "Open in Diff Viewer", "ui.messagePart.option.typeOwnAnswer": "Type your own answer", "ui.messagePart.review.title": "Review your answers", "ui.messagePart.questions.dismissed": "Questions dismissed", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 3dd92fedbc0..90899358219 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -80,7 +80,6 @@ export const dict = { "ui.messagePart.mcp.output": "Salida", "ui.messagePart.title.edit": "Editar", "ui.messagePart.title.write": "Escribir", - "ui.messagePart.openInDiffViewer": "Abrir en el visor de diferencias", "ui.messagePart.option.typeOwnAnswer": "Escribe tu propia respuesta", "ui.messagePart.review.title": "Revisa tus respuestas", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index f86b8b83c79..35f6702c5d4 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -80,7 +80,6 @@ export const dict = { "ui.messagePart.mcp.output": "Sortie", "ui.messagePart.title.edit": "Modifier", "ui.messagePart.title.write": "Écrire", - "ui.messagePart.openInDiffViewer": "Ouvrir dans la visionneuse de différences", "ui.messagePart.option.typeOwnAnswer": "Tapez votre propre réponse", "ui.messagePart.review.title": "Passez en revue vos réponses", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index d6f84aca929..2daf8cf2443 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -79,7 +79,6 @@ export const dict = { "ui.messagePart.mcp.output": "出力", "ui.messagePart.title.edit": "編集", "ui.messagePart.title.write": "作成", - "ui.messagePart.openInDiffViewer": "差分ビューアで開く", "ui.messagePart.option.typeOwnAnswer": "自分の回答を入力", "ui.messagePart.review.title": "回答を確認", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index 9ca45acc6b9..ee6e1f83096 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -80,7 +80,6 @@ export const dict = { "ui.messagePart.mcp.output": "출력", "ui.messagePart.title.edit": "편집", "ui.messagePart.title.write": "작성", - "ui.messagePart.openInDiffViewer": "Diff 뷰어에서 열기", "ui.messagePart.option.typeOwnAnswer": "직접 답변 입력", "ui.messagePart.review.title": "답변 검토", diff --git a/packages/ui/src/i18n/nl.ts b/packages/ui/src/i18n/nl.ts index d52eaa9b93e..c3cab8540d2 100644 --- a/packages/ui/src/i18n/nl.ts +++ b/packages/ui/src/i18n/nl.ts @@ -73,7 +73,6 @@ export const dict: Record<string, string> = { "ui.messagePart.mcp.output": "Uitvoer", "ui.messagePart.title.edit": "Bewerken", "ui.messagePart.title.write": "Schrijven", - "ui.messagePart.openInDiffViewer": "Openen in diff-viewer", "ui.messagePart.option.typeOwnAnswer": "Typ je eigen antwoord", "ui.messagePart.review.title": "Controleer je antwoorden", "ui.messagePart.questions.dismissed": "Vragen genegeerd", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index d899d698fd4..377fa7a95a8 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -83,7 +83,6 @@ export const dict: Record<Keys, string> = { "ui.messagePart.mcp.output": "Utdata", "ui.messagePart.title.edit": "Rediger", "ui.messagePart.title.write": "Skriv", - "ui.messagePart.openInDiffViewer": "Åpne i diff-viser", "ui.messagePart.option.typeOwnAnswer": "Skriv ditt eget svar", "ui.messagePart.review.title": "Gjennomgå svarene dine", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index afaa65aaaff..05a1a2e1662 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -79,7 +79,6 @@ export const dict = { "ui.messagePart.mcp.output": "Wyjście", "ui.messagePart.title.edit": "Edycja", "ui.messagePart.title.write": "Pisanie", - "ui.messagePart.openInDiffViewer": "Otwórz w podglądzie różnic", "ui.messagePart.option.typeOwnAnswer": "Wpisz własną odpowiedź", "ui.messagePart.review.title": "Przejrzyj swoje odpowiedzi", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index f01883bd9f9..067cbd0e67b 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -79,7 +79,6 @@ export const dict = { "ui.messagePart.mcp.output": "Вывод", "ui.messagePart.title.edit": "Редактировать", "ui.messagePart.title.write": "Написать", - "ui.messagePart.openInDiffViewer": "Открыть в просмотрщике различий", "ui.messagePart.option.typeOwnAnswer": "Введите свой ответ", "ui.messagePart.review.title": "Проверьте ваши ответы", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 8e62bc0ea70..92299360185 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -81,7 +81,6 @@ export const dict = { "ui.messagePart.mcp.output": "เอาต์พุต", "ui.messagePart.title.edit": "แก้ไข", "ui.messagePart.title.write": "เขียน", - "ui.messagePart.openInDiffViewer": "เปิดในตัวดูความแตกต่าง", "ui.messagePart.option.typeOwnAnswer": "พิมพ์คำตอบของคุณเอง", "ui.messagePart.review.title": "ตรวจสอบคำตอบของคุณ", diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index 8e9eefb9a4e..f7b55f6ad8e 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -86,7 +86,6 @@ export const dict = { "ui.messagePart.mcp.output": "Çıkış", "ui.messagePart.title.edit": "Düzenle", "ui.messagePart.title.write": "Yaz", - "ui.messagePart.openInDiffViewer": "Fark görüntüleyicide aç", "ui.messagePart.option.typeOwnAnswer": "Kendi cevabınızı yazın", "ui.messagePart.review.title": "Cevapları inceleyin", diff --git a/packages/ui/src/i18n/uk.ts b/packages/ui/src/i18n/uk.ts index 189fa392170..3ebf9ce3396 100644 --- a/packages/ui/src/i18n/uk.ts +++ b/packages/ui/src/i18n/uk.ts @@ -86,7 +86,6 @@ export const dict = { "ui.messagePart.mcp.output": "Вихід", "ui.messagePart.title.edit": "Редагувати", "ui.messagePart.title.write": "Записати", - "ui.messagePart.openInDiffViewer": "Відкрити у переглядачі відмінностей", "ui.messagePart.option.typeOwnAnswer": "Введіть власну відповідь", "ui.messagePart.review.title": "Перегляньте свої відповіді", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index b21cd85ad8d..48423ab232c 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -84,7 +84,6 @@ export const dict = { "ui.messagePart.mcp.output": "输出", "ui.messagePart.title.edit": "编辑", "ui.messagePart.title.write": "写入", - "ui.messagePart.openInDiffViewer": "在差异查看器中打开", "ui.messagePart.option.typeOwnAnswer": "输入自己的答案", "ui.messagePart.review.title": "检查你的答案", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 96e1c8479dc..276db511540 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -84,7 +84,6 @@ export const dict = { "ui.messagePart.mcp.output": "輸出", "ui.messagePart.title.edit": "編輯", "ui.messagePart.title.write": "寫入", - "ui.messagePart.openInDiffViewer": "在差異檢視器中開啟", "ui.messagePart.option.typeOwnAnswer": "輸入自己的答案", "ui.messagePart.review.title": "檢查你的答案", From b864338855835b07d0ffbcb1920b9aac02db8365 Mon Sep 17 00:00:00 2001 From: Marius <marius@kilocode.ai> Date: Thu, 30 Apr 2026 16:16:15 +0200 Subject: [PATCH 80/96] docs: add sqlite corruption troubleshooting (#9631) --- .../troubleshooting-extension.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/kilo-docs/pages/getting-started/troubleshooting/troubleshooting-extension.md b/packages/kilo-docs/pages/getting-started/troubleshooting/troubleshooting-extension.md index 9c28050cf41..f813ed95674 100644 --- a/packages/kilo-docs/pages/getting-started/troubleshooting/troubleshooting-extension.md +++ b/packages/kilo-docs/pages/getting-started/troubleshooting/troubleshooting-extension.md @@ -47,6 +47,64 @@ Once you have the Developer Tools console open: 3. **Check for errors**: Look at the Console tab for error messages (usually shown in red). If you suspect connection issues, also check the **Network** tab 4. **Copy the logs**: Right-click in the console and select "Save as..." or copy the relevant error messages +## SQLite database is malformed + +If every prompt fails with `SQLiteError: database disk image is malformed`, Kilo Code's local SQLite database may be corrupted. This database stores local Kilo state such as sessions and history. + +### Find the database + +The database location depends on where Kilo Code is running: + +| Environment | Database path | +|---|---| +| Windows | `%LOCALAPPDATA%\kilo\kilo.db` | +| macOS | `~/Library/Application Support/kilo/kilo.db` | +| Linux | `~/.local/share/kilo/kilo.db` | +| VS Code Remote SSH | `~/.local/share/kilo/kilo.db` on the remote machine | + +{% callout type="warning" %} +When using VS Code Remote SSH, check the remote Linux machine, not your local Windows or macOS computer. +{% /callout %} + +### Reset the database + +Close VS Code or stop the Kilo backend first. On Linux or Remote SSH, run: + +```bash +pkill -f "kilo serve" +mkdir -p ~/.local/share/kilo +mv ~/.local/share/kilo/kilo.db ~/.local/share/kilo/kilo.db.bak +mv ~/.local/share/kilo/kilo.db-wal ~/.local/share/kilo/kilo.db-wal.bak 2>/dev/null +mv ~/.local/share/kilo/kilo.db-shm ~/.local/share/kilo/kilo.db-shm.bak 2>/dev/null +``` + +Then reload VS Code or reconnect Remote SSH. Kilo Code recreates the database the next time it starts. + +On Windows or macOS, rename the database file and any `kilo.db-wal` or `kilo.db-shm` files in the same folder, then restart the IDE. + +{% callout type="warning" %} +Renaming this database resets local Kilo Code sessions and history for that machine. Keep the `.bak` files if you need to share them with support or attempt recovery later. +{% /callout %} + +### Fully reset local Kilo data + +If resetting the database does not fix the issue, you can fully reset Kilo Code's local data. This also removes local configuration and cache files, so use it only after trying the database reset above. + +On Linux or VS Code Remote SSH, run this on the machine where Kilo Code is running: + +```bash +pkill -f "kilo serve" +mv ~/.local/share/kilo ~/.local/share/kilo.bak 2>/dev/null +mv ~/.config/kilo ~/.config/kilo.bak 2>/dev/null +mv ~/.cache/kilo ~/.cache/kilo.bak 2>/dev/null +``` + +Then reload VS Code or reconnect Remote SSH. Kilo Code recreates these directories the next time it starts. + +{% callout type="warning" %} +This resets local sessions, history, settings, and cached data. Prefer renaming the directories instead of deleting them so you can recover files. Remove secrets such as API keys or tokens before sharing any backup with support. +{% /callout %} + ## Contact Support If you're unable to resolve the issue, please inspect the console logs, remove any secrets, and send the logs to **[hi@kilocode.ai](mailto:hi@kilocode.ai)** along with the following: From 7bda97de99dafd354bb2caf205029b8aa2a376cc Mon Sep 17 00:00:00 2001 From: Marius <marius@kilocode.ai> Date: Thu, 30 Apr 2026 16:21:11 +0200 Subject: [PATCH 81/96] ux(vscode): clarify indexing setting scope (#9747) Co-authored-by: Josh Holmer <jholmer.in@gmail.com> --- .../settings/indexing-provider-blur-race-chromium-linux.png | 4 ++-- packages/kilo-vscode/webview-ui/src/i18n/ar.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/br.ts | 3 +-- packages/kilo-vscode/webview-ui/src/i18n/bs.ts | 3 +-- packages/kilo-vscode/webview-ui/src/i18n/da.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/de.ts | 3 +-- packages/kilo-vscode/webview-ui/src/i18n/en.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/es.ts | 3 +-- packages/kilo-vscode/webview-ui/src/i18n/fr.ts | 3 +-- packages/kilo-vscode/webview-ui/src/i18n/ja.ts | 3 +-- packages/kilo-vscode/webview-ui/src/i18n/ko.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/nl.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/no.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/pl.ts | 3 +-- packages/kilo-vscode/webview-ui/src/i18n/ru.ts | 3 +-- packages/kilo-vscode/webview-ui/src/i18n/th.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/tr.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/uk.ts | 3 +-- packages/kilo-vscode/webview-ui/src/i18n/zh.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/zht.ts | 2 +- 20 files changed, 21 insertions(+), 30 deletions(-) diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png index 9535764bbbb..af2c78e53c1 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6edf67819cd2115c4c85a15b16f7b948a2cb48f19c15ee66ea606f3d0e1971aa -size 58183 +oid sha256:ed6e2cb028e565af2022447c99651bdab8874183a06e64d6ecab6187acffeb92 +size 57319 diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index d9e5a938cf8..880dad10157 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1025,7 +1025,7 @@ export const dict = { "settings.context.title": "السياق", "settings.indexing.title": "الفهرسة", "settings.indexing.enable.title": "تمكين الفهرسة", - "settings.indexing.enable.description": "تشغيل أو إيقاف فهرسة قاعدة الكود الدلالية لمساحة العمل هذه.", + "settings.indexing.enable.description": "تشغيل أو إيقاف فهرسة قاعدة الكود الدلالية.", "settings.indexing.provider.title": "موفر التضمين", "settings.indexing.provider.description": "اختر الموفر المستخدم لإنشاء التضمينات للبحث الدلالي.", "settings.indexing.model.title": "نموذج التضمين", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 3edc850ff20..a1e5d96371b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1044,8 +1044,7 @@ export const dict = { "settings.language.title": "Idioma", "settings.indexing.title": "Indexação", "settings.indexing.enable.title": "Ativar indexação", - "settings.indexing.enable.description": - "Ativar ou desativar a indexação semântica da base de código para este espaço de trabalho.", + "settings.indexing.enable.description": "Ativar ou desativar a indexação semântica da base de código.", "settings.indexing.provider.title": "Provedor de embedding", "settings.indexing.provider.description": "Escolha o provedor usado para gerar embeddings para busca semântica.", "settings.indexing.model.title": "Modelo de embedding", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index abf5b438460..10e1a7343db 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -757,8 +757,7 @@ export const dict = { "Ostavite prazno za automatsko prepoznavanje dimenzije embeddinga iz modela.", "settings.indexing.dimension.placeholder": "Auto", "settings.indexing.dimension.title": "Dimenzija vektora", - "settings.indexing.enable.description": - "Uključite ili isključite semantičko indeksiranje baze koda za ovaj radni prostor.", + "settings.indexing.enable.description": "Uključite ili isključite semantičko indeksiranje baze koda.", "settings.indexing.enable.title": "Omogući indeksiranje", "settings.indexing.lancedbDirectory.description": "Opcionalni direktorij za lokalno LanceDB skladište.", "settings.indexing.lancedbDirectory.placeholder": "Ostavite prazno za zadano", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 9644b6732d6..a37c411f361 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -767,7 +767,7 @@ export const dict = { "settings.indexing.title": "Indeksering", "settings.indexing.enable.title": "Aktivér indeksering", - "settings.indexing.enable.description": "Slå semantisk kodebase-indeksering til eller fra for dette arbejdsområde.", + "settings.indexing.enable.description": "Slå semantisk kodebase-indeksering til eller fra.", "settings.indexing.provider.title": "Embedding-udbyder", "settings.indexing.provider.description": "Vælg udbyderen, der bruges til at generere embeddings til semantisk søgning.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 3387f97481a..686564a778b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -763,8 +763,7 @@ export const dict = { "Leer lassen, um die Embedding-Dimension automatisch aus dem Modell zu erkennen.", "settings.indexing.dimension.placeholder": "Auto", "settings.indexing.dimension.title": "Vektordimension", - "settings.indexing.enable.description": - "Semantische Codebasis-Indizierung für diesen Arbeitsbereich ein- oder ausschalten.", + "settings.indexing.enable.description": "Semantische Codebasis-Indizierung ein- oder ausschalten.", "settings.indexing.enable.title": "Indizierung aktivieren", "settings.indexing.lancedbDirectory.description": "Optionaler Ordner für den lokalen LanceDB-Speicher.", "settings.indexing.lancedbDirectory.placeholder": "Leer lassen für Standard", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 2e0ae4175cf..bdac4d6d58e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1003,7 +1003,7 @@ export const dict = { "settings.indexing.title": "Indexing", "settings.indexing.status.title": "Status", "settings.indexing.enable.title": "Enable indexing", - "settings.indexing.enable.description": "Turn semantic codebase indexing on or off for this workspace.", + "settings.indexing.enable.description": "Turn semantic codebase indexing on or off.", "settings.indexing.provider.title": "Embedding provider", "settings.indexing.provider.description": "Choose the provider used to generate embeddings for semantic search.", "settings.indexing.model.title": "Embedding model", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 97ac805d866..111722750cd 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -754,8 +754,7 @@ export const dict = { "settings.indexing.title": "Indexación", "settings.indexing.enable.title": "Habilitar indexación", - "settings.indexing.enable.description": - "Activar o desactivar la indexación semántica de la base de código para este espacio de trabajo.", + "settings.indexing.enable.description": "Activar o desactivar la indexación semántica de la base de código.", "settings.indexing.provider.title": "Proveedor de embeddings", "settings.indexing.provider.description": "Elige el proveedor utilizado para generar embeddings para búsqueda semántica.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index f806a08d89b..cd8d8854cdf 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -682,8 +682,7 @@ export const dict = { "Laissez vide pour détecter automatiquement la dimension d'embedding à partir du modèle.", "settings.indexing.dimension.placeholder": "Auto", "settings.indexing.dimension.title": "Dimension vectorielle", - "settings.indexing.enable.description": - "Activer ou désactiver l'indexation sémantique de la base de code pour cet espace de travail.", + "settings.indexing.enable.description": "Activer ou désactiver l'indexation sémantique de la base de code.", "settings.indexing.enable.title": "Activer l'indexation", "settings.indexing.lancedbDirectory.description": "Répertoire optionnel pour le stockage local LanceDB.", "settings.indexing.lancedbDirectory.placeholder": "Laissez vide pour la valeur par défaut", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index d7af89dcaa5..2cbadcf5df9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -748,8 +748,7 @@ export const dict = { "settings.indexing.dimension.description": "空のままにすると、モデルから埋め込み次元を自動検出します。", "settings.indexing.dimension.placeholder": "自動", "settings.indexing.dimension.title": "ベクトル次元", - "settings.indexing.enable.description": - "このワークスペースのセマンティックコードベースインデックスをオンまたはオフにします。", + "settings.indexing.enable.description": "セマンティックコードベースインデックスをオンまたはオフにします。", "settings.indexing.enable.title": "インデックスを有効にする", "settings.indexing.lancedbDirectory.description": "ローカルLanceDBストアのオプションのディレクトリ。", "settings.indexing.lancedbDirectory.placeholder": "デフォルトの場合は空のままにする", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index e53fe132605..391bab1b4a4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1037,7 +1037,7 @@ export const dict = { "settings.indexing.dimension.description": "비워두면 모델에서 임베딩 차원을 자동으로 감지합니다.", "settings.indexing.dimension.placeholder": "자동", "settings.indexing.dimension.title": "벡터 차원", - "settings.indexing.enable.description": "이 작업 공간의 의미적 코드베이스 인덱싱을 켜거나 끕니다.", + "settings.indexing.enable.description": "의미적 코드베이스 인덱싱을 켜거나 끕니다.", "settings.indexing.enable.title": "인덱싱 활성화", "settings.indexing.lancedbDirectory.description": "로컬 LanceDB 저장소의 선택적 디렉터리입니다.", "settings.indexing.lancedbDirectory.placeholder": "기본값 사용을 위해 비워두세요", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 290dc1747c8..0a28dfb67ea 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1041,7 +1041,7 @@ export const dict = { "settings.indexing.title": "Indexering", "settings.indexing.status.title": "Status", "settings.indexing.enable.title": "Indexering inschakelen", - "settings.indexing.enable.description": "Schakel semantische codebase-indexering in of uit voor deze werkruimte.", + "settings.indexing.enable.description": "Schakel semantische codebase-indexering in of uit.", "settings.indexing.provider.title": "Embedding-provider", "settings.indexing.provider.description": "Kies de provider die wordt gebruikt om embeddings te genereren voor semantisch zoeken.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 23b0bc744e9..b28b572d2cd 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1195,7 +1195,7 @@ export const dict = { "settings.experimental.toolToggles": "Verktøybrytere", "settings.indexing.title": "Indeksering", "settings.indexing.enable.title": "Aktiver indeksering", - "settings.indexing.enable.description": "Slå semantisk kodebaseindeksering på eller av for dette arbeidsområdet.", + "settings.indexing.enable.description": "Slå semantisk kodebaseindeksering på eller av.", "settings.indexing.provider.title": "Embedding-leverandør", "settings.indexing.provider.description": "Velg leverandøren som brukes til å generere embeddings for semantisk søk.", "settings.indexing.model.title": "Embedding-modell", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 66099445e87..cb6a3187228 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1197,8 +1197,7 @@ export const dict = { "settings.indexing.dimension.description": "Pozostaw puste, aby automatycznie wykryć wymiar osadzania z modelu.", "settings.indexing.dimension.placeholder": "Automatycznie", "settings.indexing.dimension.title": "Wymiar wektora", - "settings.indexing.enable.description": - "Włącz lub wyłącz semantyczne indeksowanie bazy kodu dla tego obszaru roboczego.", + "settings.indexing.enable.description": "Włącz lub wyłącz semantyczne indeksowanie bazy kodu.", "settings.indexing.enable.title": "Włącz indeksowanie", "settings.indexing.lancedbDirectory.description": "Opcjonalny katalog dla lokalnego magazynu LanceDB.", "settings.indexing.lancedbDirectory.placeholder": "Pozostaw puste dla domyślnego", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 6a0d2d7ee6e..2b6b88ea151 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -753,8 +753,7 @@ export const dict = { "settings.indexing.title": "Индексация", "settings.indexing.enable.title": "Включить индексацию", - "settings.indexing.enable.description": - "Включить или отключить семантическую индексацию кодовой базы для этого рабочего пространства.", + "settings.indexing.enable.description": "Включить или отключить семантическую индексацию кодовой базы.", "settings.indexing.status.title": "Статус", "settings.indexing.provider.title": "Провайдер эмбеддингов", "settings.indexing.provider.description": "Выберите провайдера для генерации эмбеддингов при семантическом поиске.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 5bf3f531313..52c13e329e1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -744,7 +744,7 @@ export const dict = { "settings.indexing.title": "การสร้างดัชนี", "settings.indexing.enable.title": "เปิดใช้งานการสร้างดัชนี", - "settings.indexing.enable.description": "เปิดหรือปิดการสร้างดัชนีโค้ดเบสเชิงความหมายสำหรับพื้นที่ทำงานนี้", + "settings.indexing.enable.description": "เปิดหรือปิดการสร้างดัชนีโค้ดเบสเชิงความหมาย", "settings.indexing.provider.title": "ผู้ให้บริการการฝัง", "settings.indexing.provider.description": "เลือกผู้ให้บริการที่ใช้สร้างการฝังสำหรับการค้นหาเชิงความหมาย", "settings.indexing.model.title": "โมเดลการฝัง", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 57046c1e309..99b8f499465 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1040,7 +1040,7 @@ export const dict = { "settings.indexing.title": "İndeksleme", "settings.indexing.status.title": "Durum", "settings.indexing.enable.title": "İndekslemeyi etkinleştir", - "settings.indexing.enable.description": "Bu çalışma alanı için anlamsal kod tabanı indekslemeyi açın veya kapatın.", + "settings.indexing.enable.description": "Anlamsal kod tabanı indekslemeyi açın veya kapatın.", "settings.indexing.provider.title": "Yerleştirme sağlayıcısı", "settings.indexing.provider.description": "Anlamsal arama için yerleştirmeleri oluşturmak amacıyla kullanılacak sağlayıcıyı seçin.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index d2556d1fbc5..5eccca715b4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1040,8 +1040,7 @@ export const dict = { "settings.indexing.title": "Індексування", "settings.indexing.status.title": "Статус", "settings.indexing.enable.title": "Увімкнути індексування", - "settings.indexing.enable.description": - "Увімкніть або вимкніть семантичне індексування кодової бази для цього робочого простору.", + "settings.indexing.enable.description": "Увімкніть або вимкніть семантичне індексування кодової бази.", "settings.indexing.provider.title": "Провайдер ембедингів", "settings.indexing.provider.description": "Виберіть провайдера для генерації ембедингів для семантичного пошуку.", "settings.indexing.model.title": "Модель ембедингів", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 36ae44edda3..ad149040f86 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -734,7 +734,7 @@ export const dict = { "settings.indexing.title": "索引", "settings.indexing.enable.title": "启用索引", - "settings.indexing.enable.description": "为此工作区打开或关闭语义代码库索引。", + "settings.indexing.enable.description": "打开或关闭语义代码库索引。", "settings.indexing.provider.title": "嵌入提供商", "settings.indexing.provider.description": "选择用于生成语义搜索嵌入的提供商。", "settings.indexing.model.title": "嵌入模型", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index d0427beeb1e..ab068ea6ff8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1352,7 +1352,7 @@ export const dict = { "settings.indexing.dimension.description": "留空以自動從模型偵測嵌入維度。", "settings.indexing.dimension.placeholder": "自動", "settings.indexing.dimension.title": "向量維度", - "settings.indexing.enable.description": "為此工作區開啟或關閉語意程式碼庫索引。", + "settings.indexing.enable.description": "開啟或關閉語意程式碼庫索引。", "settings.indexing.enable.title": "啟用索引", "settings.indexing.lancedbDirectory.description": "本機 LanceDB 儲存的可選目錄。", "settings.indexing.lancedbDirectory.placeholder": "留空使用預設值", From 26e4c1148f4e7a734bb8e535e02a1a9ad75be584 Mon Sep 17 00:00:00 2001 From: Marius <marius@kilocode.ai> Date: Thu, 30 Apr 2026 16:22:54 +0200 Subject: [PATCH 82/96] fix: scope commit prompt config to project (#9460) * fix: scope commit prompt config to project * fix: address commit prompt review feedback * fix: address project config review feedback * fix: adapt project config update to schema statics * fix(vscode): keep provider under line cap --- .changeset/commit-prompt-project-scope.md | 6 + packages/kilo-vscode/src/KiloProvider.ts | 40 +++---- .../webview-ui/src/context/config.tsx | 21 +++- .../src/types/messages/webview-messages.ts | 3 + packages/opencode/src/config/config.ts | 22 ++-- .../opencode/src/kilocode/config/config.ts | 53 ++++++++- packages/opencode/test/config/config.test.ts | 14 --- .../kilocode/project-config-update.test.ts | 110 ++++++++++++++++++ 8 files changed, 223 insertions(+), 46 deletions(-) create mode 100644 .changeset/commit-prompt-project-scope.md create mode 100644 packages/opencode/test/kilocode/project-config-update.test.ts diff --git a/.changeset/commit-prompt-project-scope.md b/.changeset/commit-prompt-project-scope.md new file mode 100644 index 00000000000..eeb0978f269 --- /dev/null +++ b/.changeset/commit-prompt-project-scope.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Scope the custom commit message prompt to the current project. Setting it in the VS Code settings now writes to the workspace's `kilo.json` so different repositories can have different conventions, instead of silently applying globally. Also fixes the project-level config update endpoint, which previously wrote to a file that wasn't loaded. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index bf9d8872c80..3b0ebc44b29 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -827,7 +827,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) break case "updateConfig": - await this.handleUpdateConfig(message.config) + await this.handleUpdateConfig(message.config, message.projectConfig) break case "openSettingsTab": if (message.tab === "indexing") { @@ -2303,12 +2303,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return getBusySessionCount(this.sessionStatusMap) } - /** - * Handle config update request from the webview. - * Applies a partial config update via the global config endpoint, then pushes - * the full merged config back to the webview. - */ - private async handleUpdateConfig(partial: Partial<Config>): Promise<void> { + private async handleUpdateConfig(partial: Partial<Config>, project: Partial<Config> = {}): Promise<void> { if (!this.client || this.connectionState !== "connected") { this.postMessage({ type: "configUpdateFailed", message: "Not connected to CLI backend" }) return @@ -2318,40 +2313,34 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper partial.provider !== undefined || partial.disabled_providers !== undefined || partial.enabled_providers !== undefined + const hasGlobal = Object.keys(partial).length > 0 + const hasProject = Object.keys(project).length > 0 - // Guard against fetchAndSendConfig pushing stale data while the write is in flight. this.pending++ + const dir = this.getWorkspaceDirectory() - // Phase 1: write. Errors here = real save failures the user can fix + retry. try { await this.connectionService.drainPendingPrompts() - await this.client.global.config.update({ config: partial }, { throwOnError: true }) + if (hasGlobal) await this.client.global.config.update({ config: partial }, { throwOnError: true }) + if (hasProject) await this.client.config.update({ config: project, directory: dir }, { throwOnError: true }) } catch (error) { - console.error("[Kilo New] KiloProvider: Failed to update config:", error) - this.postMessage({ - type: "configUpdateFailed", - message: getErrorMessage(error) || "Failed to update config", - details: getConfigErrorDetails(error), - }) + this.postConfigFailure(error) this.pending-- return } - // Phase 2: refresh. Config is already on disk — post-write errors are - // transient, so send an optimistic configUpdated to clear the webview's - // saving/draft state. SSE global.config.updated pushes the real data next. try { - const dir = this.getWorkspaceDirectory() const { data: merged } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })) this.cachedConfigMessage = { type: "configLoaded", config: merged, features: configFeatures(merged) } this.postMessage({ type: "configUpdated", config: merged, features: configFeatures(merged) }) if (refreshProviders) await this.fetchAndSendProviders() } catch (error) { console.error("[Kilo New] KiloProvider: Config write succeeded but post-write refresh failed:", error) + const patch = { ...partial, ...project } const cached = (this.cachedConfigMessage as { config?: unknown } | null)?.config const features = (this.cachedConfigMessage as { features?: unknown } | null)?.features const optimistic = - cached && typeof cached === "object" ? { ...(cached as Record<string, unknown>), ...partial } : partial + cached && typeof cached === "object" ? { ...(cached as Record<string, unknown>), ...patch } : patch this.postMessage({ type: "configUpdated", config: optimistic, @@ -2362,6 +2351,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + private postConfigFailure(error: unknown): void { + console.error("[Kilo New] KiloProvider: Failed to update config:", error) + this.postMessage({ + type: "configUpdateFailed", + message: getErrorMessage(error) || "Failed to update config", + details: getConfigErrorDetails(error), + }) + } + private async resolveSession(sessionID?: string, draftID?: string, context?: string) { if (!this.client) return undefined diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index 071b467be04..29a61b8c708 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -14,6 +14,21 @@ import { useVSCode } from "./vscode" import type { Config, ExtensionMessage, FeatureFlags } from "../types/messages" import { deepMerge, stripNulls, resolveConfig } from "../utils/config-utils" +// Top-level config keys that persist to the project's kilo.json rather than the +// global one. Settings that are inherently per-repository (e.g. commit message +// conventions) belong here so they don't leak across workspaces. +const PROJECT_SCOPED_KEYS: ReadonlySet<string> = new Set(["commit_message"]) + +function splitByScope(draft: Partial<Config>) { + const global: Record<string, unknown> = {} + const project: Record<string, unknown> = {} + for (const [key, value] of Object.entries(draft)) { + if (PROJECT_SCOPED_KEYS.has(key)) project[key] = value + else global[key] = value + } + return { global: global as Partial<Config>, project: project as Partial<Config> } +} + export interface SaveError { message: string details?: string @@ -137,7 +152,11 @@ export const ConfigProvider: ParentComponent = (props) => { // If the write fails, the save bar stays visible so the user can retry. setSaving(true) setSaveError(null) - vscode.postMessage({ type: "updateConfig", config: changes }) + // Split so per-project settings (e.g. commit_message.prompt) land in the + // workspace's kilo.json instead of the global one. Send one message so the + // extension confirms only after both scopes are saved. + const split = splitByScope(changes) + vscode.postMessage({ type: "updateConfig", config: split.global, projectConfig: split.project }) } function discardConfig() { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 3b8cc5a6043..7c91a2a3488 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -378,7 +378,10 @@ export interface OpenSettingsTabRequest { export interface UpdateConfigMessage { type: "updateConfig" + /** Global config patch written to ~/.config/kilo/kilo.json. */ config: Partial<Config> + /** Project config patch written to the workspace's .kilo/kilo.json or existing project config. */ + projectConfig?: Partial<Config> } export interface RequestNotificationSettingsMessage { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 74c6e643fa0..13704749fea 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -925,15 +925,19 @@ export const layer = Layer.effect( // kilocode_change end const update = Effect.fn("Config.update")(function* (config: Info) { - const dir = yield* InstanceState.directory - const file = path.join(dir, "config.json") - const existing = yield* loadFile(file) - yield* fs - .writeFileString( - file, - JSON.stringify(KilocodeConfig.mergeConfig(writable(existing), writable(config)), null, 2), - ) - .pipe(Effect.orDie) // kilocode_change + // kilocode_change start - delegate Kilo project config update behavior. + const ctx = yield* InstanceState.context + yield* KilocodeConfig.updateProjectConfig({ + fs, + directory: ctx.directory, + worktree: ctx.worktree, + config, + read: readConfigFile, + parse: (input, file) => ConfigParse.schema(Info.zod, ConfigParse.jsonc(input, file), file), + patch: (input, patch) => patchJsonc(input, patch), + writable, + }) + // kilocode_change end yield* Effect.promise(() => Instance.dispose()) }) diff --git a/packages/opencode/src/kilocode/config/config.ts b/packages/opencode/src/kilocode/config/config.ts index 36254870f4b..73edee399eb 100644 --- a/packages/opencode/src/kilocode/config/config.ts +++ b/packages/opencode/src/kilocode/config/config.ts @@ -3,12 +3,13 @@ import path from "path" import { pathToFileURL } from "url" import { existsSync } from "fs" import z from "zod" -import { Schema } from "effect" +import { Effect, Schema } from "effect" import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser" import { mergeDeep } from "remeda" import { Log } from "../../util" import { Global } from "../../global" import { NamedError } from "@opencode-ai/shared/util/error" +import type { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Bus } from "@/bus" import { isRecord } from "@/util/record" import { ConfigError } from "../../config/error" @@ -48,6 +49,9 @@ export namespace KilocodeConfig { /** Directory suffixes that Kilo recognizes in addition to .opencode. */ export const KILO_DIR_SUFFIXES = [".kilo", ".kilocode"] as const + /** All config directory suffixes Kilo can update, including upstream .opencode. */ + export const ALL_CONFIG_DIR_SUFFIXES = [".kilo", ".kilocode", ".opencode"] as const + /** Path patterns for resolving kilo agent names from file paths. */ export const AGENT_PATTERNS = ["/.kilo/agent/", "/.kilo/agents/", "/.kilocode/agent/", "/.kilocode/agents/"] as const @@ -59,6 +63,53 @@ export namespace KilocodeConfig { "/.kilocode/commands/", ] as const + /** + * Choose the project config file that Config.update should patch. + * + * This mirrors the Kilo project-config load chain: prefer existing config files + * in ancestor config directories, then existing root config files, and create + * `.kilo/kilo.json` when no project config exists yet. + */ + export const projectConfigUpdateTarget = Effect.fn("KilocodeConfig.projectConfigUpdateTarget")(function* (input: { + fs: AppFileSystem.Interface + directory: string + worktree?: string + }) { + const dirs = yield* input.fs + .up({ targets: [...ALL_CONFIG_DIR_SUFFIXES], start: input.directory, stop: input.worktree }) + .pipe(Effect.orDie) + const roots = yield* input.fs + .up({ targets: [...ALL_CONFIG_FILES], start: input.directory, stop: input.worktree }) + .pipe(Effect.orDie) + const files = [...dirs.flatMap((dir) => ALL_CONFIG_FILES.map((file) => path.join(dir, file))), ...roots] + return files.find((file) => existsSync(file)) ?? path.join(input.directory, ".kilo", "kilo.json") + }) + + export const updateProjectConfig = Effect.fn("KilocodeConfig.updateProjectConfig")(function* (input: { + fs: AppFileSystem.Interface + directory: string + worktree?: string + config: Config.Info + read: (file: string) => Effect.Effect<string | undefined> + parse: (input: string, file: string) => Config.Info + patch: (input: string, config: Config.Info) => string + writable: (config: Config.Info) => Config.Info + }) { + const file = yield* projectConfigUpdateTarget(input) + const before = (yield* input.read(file)) ?? "{}" + const patch = input.writable(input.config) + + if (file.endsWith(".jsonc")) { + const updated = input.patch(before, patch) + yield* input.fs.writeWithDirs(file, updated).pipe(Effect.orDie) + return + } + + const existing = input.parse(before, file) + const merged = mergeConfig(input.writable(existing), patch) + yield* input.fs.writeWithDirs(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) + }) + // ── Warning helpers ────────────────────────────────────────────────── /** Convert known config-loading error types into a Warning. Returns undefined for unknown errors. */ diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index ba8345aefec..5101ed574ae 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -853,20 +853,6 @@ Hello from new command`, }) }) -test("updates config and writes to file", async () => { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const newConfig = { model: "updated/model" } - await save(newConfig as any) - - const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json")) - expect(writtenConfig.model).toBe("updated/model") - }, - }) -}) - test("gets config directories", async () => { await using tmp = await tmpdir() await Instance.provide({ diff --git a/packages/opencode/test/kilocode/project-config-update.test.ts b/packages/opencode/test/kilocode/project-config-update.test.ts new file mode 100644 index 00000000000..c6b58b7555f --- /dev/null +++ b/packages/opencode/test/kilocode/project-config-update.test.ts @@ -0,0 +1,110 @@ +// kilocode_change - new file + +import { expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Effect, Layer, Option } from "effect" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" +import { Config } from "../../src/config" +import { Auth } from "../../src/auth" +import { Account } from "../../src/account/account" +import { Env } from "../../src/env" +import { Npm } from "../../src/npm" +import { Instance } from "../../src/project/instance" +import { Filesystem } from "../../src/util" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { tmpdir } from "../fixture/fixture" + +const infra = CrossSpawnSpawner.defaultLayer.pipe( + Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), +) + +const emptyAccount = Layer.mock(Account.Service)({ + active: () => Effect.succeed(Option.none()), + activeOrg: () => Effect.succeed(Option.none()), +}) + +const emptyAuth = Layer.mock(Auth.Service)({ + all: () => Effect.succeed({}), +}) + +const noopNpm = Layer.mock(Npm.Service)({ + install: () => Effect.void, + add: () => Effect.die("not implemented"), + outdated: () => Effect.succeed(false), + which: () => Effect.succeed(Option.none()), +}) + +const layer = Config.layer.pipe( + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(emptyAuth), + Layer.provide(emptyAccount), + Layer.provideMerge(infra), + Layer.provide(noopNpm), +) + +const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer))) +const save = (config: Config.Info) => + Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer))) + +async function writeConfig(dir: string, config: unknown) { + await Filesystem.write(path.join(dir, "kilo.json"), JSON.stringify(config, null, 2)) +} + +test("project config update creates .kilo/kilo.json and reloads it", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await save({ model: "updated/model" } as any) + + const written = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, ".kilo", "kilo.json")) + expect(written.model).toBe("updated/model") + + const loaded = await load() + expect(loaded.model).toBe("updated/model") + }, + }) +}) + +test("project config update prefers existing root kilo.json", async () => { + await using tmp = await tmpdir() + await writeConfig(tmp.path, { username: "alice" }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await save({ model: "updated/model" } as any) + + const merged = await Filesystem.readJson<{ model: string; username: string }>(path.join(tmp.path, "kilo.json")) + expect(merged.model).toBe("updated/model") + expect(merged.username).toBe("alice") + }, + }) +}) + +test("project config update patches ancestor .kilo/kilo.json from nested directory", async () => { + await using tmp = await tmpdir() + const child = path.join(tmp.path, "nested", "workspace") + await fs.mkdir(child, { recursive: true }) + await fs.mkdir(path.join(tmp.path, ".kilo"), { recursive: true }) + await writeConfig(path.join(tmp.path, ".kilo"), { username: "alice" }) + + await Instance.provide({ + directory: child, + fn: async () => { + await save({ model: "updated/model" } as any) + + const merged = await Filesystem.readJson<{ model: string; username: string }>( + path.join(tmp.path, ".kilo", "kilo.json"), + ) + expect(merged.model).toBe("updated/model") + expect(merged.username).toBe("alice") + await expect(fs.access(path.join(child, ".kilo", "kilo.json"))).rejects.toThrow() + }, + }) +}) From d5fb9eb2265c03127e776c99020b03bb770255a1 Mon Sep 17 00:00:00 2001 From: Marius <marius@kilocode.ai> Date: Thu, 30 Apr 2026 16:32:28 +0200 Subject: [PATCH 83/96] Add experimental Agent Manager tool (#9737) * feat: add experimental agent manager tool * fix(agent-manager): sanitize tool worktree names * fix(cli): validate agent manager tool tasks --- .changeset/agent-manager-tool.md | 6 + .../src/agent-manager/AgentManagerProvider.ts | 50 +++- .../src/agent-manager/tool-start.ts | 257 ++++++++++++++++++ .../unit/agent-manager-tool-start.test.ts | 183 +++++++++++++ .../components/settings/ExperimentalTab.tsx | 13 + .../kilo-vscode/webview-ui/src/i18n/ar.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 3 + .../webview-ui/src/types/messages/config.ts | 1 + packages/opencode/src/config/config.ts | 3 + packages/opencode/src/config/permission.ts | 1 + packages/opencode/src/global/index.ts | 7 +- .../src/kilocode/agent-manager/event.ts | 26 ++ .../src/kilocode/tool/agent-manager.ts | 75 +++++ .../src/kilocode/tool/agent-manager.txt | 13 + .../opencode/src/kilocode/tool/registry.ts | 17 +- .../test/kilocode/agent-manager-tool.test.ts | 71 +++++ packages/sdk/js/src/v2/gen/types.gen.ts | 31 +++ packages/sdk/openapi.json | 134 ++++++--- packages/shared/src/global.ts | 13 +- 36 files changed, 908 insertions(+), 49 deletions(-) create mode 100644 .changeset/agent-manager-tool.md create mode 100644 packages/kilo-vscode/src/agent-manager/tool-start.ts create mode 100644 packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts create mode 100644 packages/opencode/src/kilocode/agent-manager/event.ts create mode 100644 packages/opencode/src/kilocode/tool/agent-manager.ts create mode 100644 packages/opencode/src/kilocode/tool/agent-manager.txt create mode 100644 packages/opencode/test/kilocode/agent-manager-tool.test.ts diff --git a/.changeset/agent-manager-tool.md b/.changeset/agent-manager-tool.md new file mode 100644 index 00000000000..0d332f7b8ad --- /dev/null +++ b/.changeset/agent-manager-tool.md @@ -0,0 +1,6 @@ +--- +"kilo-code": minor +"@kilocode/cli": minor +--- + +Support starting Agent Manager local sessions and worktree sessions from an experimental agent tool. diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 788248d17db..2bce7ae94d8 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -29,6 +29,7 @@ import { continueInWorktree } from "./continue-in-worktree" import { WorktreeDiffController } from "./worktree-diff-controller" import { WorktreeImporter } from "./worktree-importer" import { diffSummary as localDiffSummary, diffFile as localDiffFile } from "./local-diff" +import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start" import { buildKeybindingMap } from "./format-keybinding" import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version" @@ -65,6 +66,7 @@ export class AgentManagerProvider implements Disposable { private staleWorktreeIds = new Set<string>() private cachedWorktreeStats: { type: "agentManager.worktreeStats"; stats: WorktreeStats[] } | undefined private cachedLocalStats: { type: "agentManager.localStats"; stats: LocalStats } | undefined + private unsubTool: (() => void) | undefined /** Session ID most recently loaded via a `loadMessages` message from the webview. * Updated synchronously — unlike the session provider's currentSession which depends on @@ -152,6 +154,10 @@ export class AgentManagerProvider implements Disposable { log: (...a) => this.log(...a), semaphore, }) + this.unsubTool = this.connectionService.onEventFiltered( + (event) => (event as { type?: string }).type === "kilocode.agent_manager.start", + (event, directory) => this.onToolEvent(event, directory), + ) } private log(...args: unknown[]) { @@ -159,11 +165,11 @@ export class AgentManagerProvider implements Disposable { this.outputChannel.appendLine(`${new Date().toISOString()} ${msg}`) } - public openPanel(): void { + public openPanel(preserveFocus?: boolean): void { if (this.panel) { this.log("Panel already open, revealing") - this.panel.reveal() - this.postToWebview({ type: "action", action: "focusInput" }) + this.panel.reveal(preserveFocus) + if (!preserveFocus) this.postToWebview({ type: "action", action: "focusInput" }) return } this.log("Opening Agent Manager panel") @@ -792,6 +798,43 @@ export class AgentManagerProvider implements Disposable { await this.stateReady.catch((err) => this.log(`${context}: stateReady rejected, continuing:`, err)) } + private onToolEvent(event: unknown, directory?: string): void { + const properties = (event as { properties?: unknown }).properties + const req = parseToolRequest(properties) + if (!req) return + if (directory) req.directory = directory + void this.startToolRequest(req) + } + + private async startToolRequest(req: ToolRequest): Promise<void> { + await startFromTool( + { + getClient: () => this.connectionService.getClient(), + getRoot: () => this.getRoot(), + getState: () => this.getStateManager(), + getPanel: () => this.panel, + openPanel: (preserveFocus) => this.openPanel(preserveFocus), + waitReady: (context) => this.waitForStateReady(context), + createWorktree: (opts) => this.createWorktreeOnDisk(opts), + cleanupWorktree: async (wid, dir) => { + this.getStateManager()?.removeWorktree(wid) + await this.getWorktreeManager()?.removeWorktree(dir) + this.pushState() + }, + setup: (dir, branch, id) => this.runSetupScriptForWorktree(dir, branch, id), + createSessionInWorktree: (dir, branch, id) => this.createSessionInWorktree(dir, branch, id), + registerWorktreeSession: (sid, dir) => this.registerWorktreeSession(sid, dir), + notifyReady: (sid, result, wid) => this.notifyWorktreeReady(sid, result, wid), + push: () => this.pushState(), + post: (msg) => this.postToWebview(msg as AgentManagerOutMessage), + capture: (event, props) => this.host.capture(event, props), + log: (...args) => this.log(...args), + error: (msg) => this.host.showError(msg), + }, + req, + ) + } + // --------------------------------------------------------------------------- // Worktree actions // --------------------------------------------------------------------------- @@ -1565,6 +1608,7 @@ export class AgentManagerProvider implements Disposable { } public dispose(): void { + this.unsubTool?.() this.connectionService.unregisterFocused("agent-manager") this.connectionService.registerOpen("agent-manager", []) this.diffs.stop() diff --git a/packages/kilo-vscode/src/agent-manager/tool-start.ts b/packages/kilo-vscode/src/agent-manager/tool-start.ts new file mode 100644 index 00000000000..51b9b0bdcb4 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/tool-start.ts @@ -0,0 +1,257 @@ +import type { KiloClient, Session } from "@kilocode/sdk/v2/client" +import { sanitizeBranchName, versionedName } from "./branch-name" +import type { CreateWorktreeResult } from "./WorktreeManager" +import type { WorktreeStateManager } from "./WorktreeStateManager" +import type { PanelContext } from "./host" +import { PLATFORM } from "./constants" + +const LABEL_MAX = 28 +const PREFIX = new Set(["feat", "fix", "chore", "bug", "issue", "task", "branch"]) + +export interface ToolTask { + prompt?: string + name?: string + branchName?: string +} + +export interface ToolRequest { + requestID: string + sessionID?: string + directory?: string + mode: "worktree" | "local" + versions?: boolean + tasks: ToolTask[] +} + +interface WorktreeCreated { + worktree: ReturnType<WorktreeStateManager["addWorktree"]> + result: CreateWorktreeResult +} + +export interface ToolDeps { + getClient: () => KiloClient + getRoot: () => string | undefined + getState: () => WorktreeStateManager | undefined + getPanel: () => PanelContext | undefined + openPanel: (preserveFocus?: boolean) => void + waitReady: (context: string) => Promise<void> + createWorktree: (opts: { + groupId?: string + branchName?: string + name?: string + label?: string + }) => Promise<WorktreeCreated | null> + cleanupWorktree: (wid: string, dir: string) => Promise<void> + setup: (dir: string, branch?: string, id?: string) => Promise<void> + createSessionInWorktree: (dir: string, branch: string, id?: string) => Promise<Session | null> + registerWorktreeSession: (sid: string, dir: string) => void + notifyReady: (sid: string, result: CreateWorktreeResult, wid?: string) => void + push: () => void + post: (msg: unknown) => void + capture: (event: string, props?: Record<string, unknown>) => void + log: (...args: unknown[]) => void + error: (msg: string) => void +} + +function text(task: ToolTask): string | undefined { + return task.prompt?.trim() || undefined +} + +function clean(value: string | undefined): string | undefined { + return value?.trim() || undefined +} + +function label(value: string | undefined): string | undefined { + const raw = clean(value) + if (!raw) return undefined + const words = raw + .toLowerCase() + .replace(/[/_.-]+/g, " ") + .replace(/[^a-z0-9 ]+/g, "") + .replace(/\s+/g, " ") + .trim() + .split(" ") + .filter(Boolean) + const meaningful = words.filter((word) => !PREFIX.has(word)) + const picked: string[] = [] + for (const word of meaningful.length > 0 ? meaningful : words) { + const next = [...picked, word].join(" ") + if (next.length > LABEL_MAX) break + picked.push(word) + if (picked.length >= 3) break + } + return picked.join(" ") || words[0]?.slice(0, LABEL_MAX) || undefined +} + +function branch(value: string | undefined): string | undefined { + const raw = clean(value) + if (!raw) return undefined + return sanitizeBranchName(raw) || undefined +} + +function versionedLabel(base: string | undefined, index: number, total: number): string | undefined { + if (!base) return undefined + if (total > 1 && index > 0) return `${base} v${index + 1}` + return base +} + +async function prompt(client: KiloClient, sid: string, dir: string, task: ToolTask) { + const body = text(task) + if (!body) return + await client.session.promptAsync( + { + sessionID: sid, + directory: dir, + parts: [{ type: "text", text: body }], + }, + { throwOnError: true }, + ) +} + +async function local(deps: ToolDeps, client: KiloClient, task: ToolTask, directory?: string) { + const root = deps.getRoot() + const state = deps.getState() + if (!root || !state) return false + + const dir = clean(directory) ?? root + const wt = dir === root ? undefined : state.findWorktreeByPath(dir) + if (dir !== root && !wt) { + deps.log("Agent Manager tool local request ignored unknown directory", dir) + deps.post({ + type: "error", + message: `Agent Manager tool cannot start a local session for unknown directory: ${dir}`, + }) + return false + } + const target = wt?.path ?? root + const { data } = await client.session.create({ directory: target, platform: PLATFORM }, { throwOnError: true }) + const session = data + state.addSession(session.id, wt?.id ?? null) + if (wt) deps.registerWorktreeSession(session.id, wt.path) + deps.push() + deps.getPanel()?.sessions.registerSession(session) + if (wt) deps.post({ type: "agentManager.sessionAdded", sessionId: session.id, worktreeId: wt.id }) + await prompt(client, session.id, target, task) + deps.capture("Agent Manager Session Started", { + source: PLATFORM, + sessionId: session.id, + tool: true, + mode: "local", + worktreeId: wt?.id, + }) + return true +} + +async function worktree( + deps: ToolDeps, + client: KiloClient, + task: ToolTask, + index: number, + total: number, + groupId?: string, + versions?: boolean, +) { + const baseBranch = branch(task.branchName) ?? branch(task.name) + const baseLabel = label(task.name) ?? label(task.branchName) ?? label(task.prompt) + const version = versionedName(baseBranch, versions ? index : 0, versions ? total : 1) + const created = await deps.createWorktree({ + groupId, + branchName: version.branch, + name: version.branch, + label: versionedLabel(baseLabel, versions ? index : 0, versions ? total : 1), + }) + if (!created) return false + + await deps.setup(created.result.path, created.result.branch, created.worktree.id) + const session = await deps.createSessionInWorktree(created.result.path, created.result.branch, created.worktree.id) + if (!session) { + await deps.cleanupWorktree(created.worktree.id, created.result.path) + return false + } + + const state = deps.getState() + if (!state) { + await deps.cleanupWorktree(created.worktree.id, created.result.path) + return false + } + state.addSession(session.id, created.worktree.id) + deps.registerWorktreeSession(session.id, created.result.path) + deps.notifyReady(session.id, created.result, created.worktree.id) + deps.getPanel()?.sessions.registerSession(session) + await prompt(client, session.id, created.result.path, task) + deps.capture("Agent Manager Session Started", { + source: PLATFORM, + sessionId: session.id, + worktreeId: created.worktree.id, + branch: created.result.branch, + tool: true, + }) + return true +} + +export async function startFromTool(deps: ToolDeps, req: ToolRequest): Promise<void> { + deps.openPanel(true) + await deps.getPanel()?.waitForReady() + await deps.waitReady("startFromTool") + const client = deps.getClient() + const total = req.tasks.length + const versions = req.mode === "worktree" && req.versions === true && total > 1 + const groupId = versions ? `grp-${Date.now()}` : undefined + const state = { ok: 0 } + + deps.post({ type: "agentManager.multiVersionProgress", status: "creating", total, completed: 0, groupId }) + for (let i = 0; i < req.tasks.length; i++) { + const task = req.tasks[i]! + try { + const done = + req.mode === "local" + ? await local(deps, client, task, req.directory) + : await worktree(deps, client, task, i, total, groupId, versions) + if (done) state.ok++ + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + deps.log("Agent Manager tool task failed", msg) + deps.post({ type: "error", message: `Agent Manager tool task failed: ${msg}` }) + } + deps.post({ type: "agentManager.multiVersionProgress", status: "creating", total, completed: state.ok, groupId }) + } + + deps.post({ type: "agentManager.multiVersionProgress", status: "done", total, completed: state.ok, groupId }) + if (state.ok === 0) deps.error(`Failed to start any Agent Manager sessions for request ${req.requestID}.`) + deps.log(`Agent Manager tool request ${req.requestID} complete: ${state.ok}/${total}`) +} + +function record(value: unknown): value is Record<string, unknown> { + return !!value && typeof value === "object" +} + +function task(value: unknown): ToolTask | undefined { + if (!record(value)) return undefined + const out: ToolTask = {} + for (const key of ["prompt", "name", "branchName"] as const) { + if (typeof value[key] === "string" && value[key].trim()) out[key] = value[key] + } + if (!out.prompt && !out.name && !out.branchName) return undefined + return out +} + +export function parseToolRequest(value: unknown): ToolRequest | undefined { + if (!record(value)) return undefined + const mode = value.mode + const tasks = value.tasks + if (mode !== "worktree" && mode !== "local") return undefined + if (!Array.isArray(tasks) || tasks.length === 0) return undefined + const parsed = tasks + .slice(0, 20) + .map(task) + .filter((item): item is ToolTask => !!item) + if (parsed.length === 0) return undefined + return { + requestID: typeof value.requestID === "string" ? value.requestID : `am-${Date.now()}`, + sessionID: typeof value.sessionID === "string" ? value.sessionID : undefined, + directory: typeof value.directory === "string" ? value.directory : undefined, + mode, + versions: typeof value.versions === "boolean" ? value.versions : undefined, + tasks: parsed, + } +} diff --git a/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts new file mode 100644 index 00000000000..8742ab2776a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it, mock } from "bun:test" +import { parseToolRequest, startFromTool, type ToolDeps, type ToolRequest } from "../../src/agent-manager/tool-start" +import type { CreateWorktreeResult } from "../../src/agent-manager/WorktreeManager" +import type { Session } from "@kilocode/sdk/v2/client" + +function session(id: string): Session { + return { id, title: id, createdAt: "", updatedAt: "" } as Session +} + +function result(path: string): CreateWorktreeResult { + return { path, branch: "kilo/test", parentBranch: "main", startPointSource: "fallback" } as CreateWorktreeResult +} + +function deps(overrides: Partial<ToolDeps> = {}): ToolDeps { + const calls: unknown[] = [] + const panel = { + waitForReady: mock(async () => calls.push("waitForReady")), + sessions: { registerSession: mock(() => calls.push("registerSession")) }, + } + return { + getClient: () => + ({ + session: { + create: mock(async () => ({ data: session("s-local") })), + promptAsync: mock(async () => ({})), + }, + }) as never, + getRoot: () => "/repo", + getState: () => ({ addSession: mock(() => calls.push("addSession")) }) as never, + getPanel: () => panel as never, + openPanel: mock(() => calls.push("openPanel")), + waitReady: mock(async () => calls.push("waitReady")), + createWorktree: mock(async () => ({ worktree: { id: "wt-1" }, result: result("/repo/.kilo/worktrees/wt-1") })), + cleanupWorktree: mock(async () => calls.push("cleanupWorktree")), + setup: mock(async () => calls.push("setup")), + createSessionInWorktree: mock(async () => session("s-wt")), + registerWorktreeSession: mock(() => calls.push("registerWorktreeSession")), + notifyReady: mock(() => calls.push("notifyReady")), + push: mock(() => calls.push("push")), + post: mock((msg: unknown) => calls.push(msg)), + capture: mock(() => calls.push("capture")), + log: mock(() => {}), + error: mock(() => {}), + ...overrides, + } +} + +describe("agent manager tool start", () => { + it("parses tool start events defensively", () => { + const parsed = parseToolRequest({ mode: "local", tasks: [{ prompt: "one" }] }) + expect(parsed?.requestID.startsWith("am-")).toBe(true) + expect(parsed?.sessionID).toBeUndefined() + expect(parsed?.directory).toBeUndefined() + expect(parsed?.mode).toBe("local") + expect(parsed?.versions).toBeUndefined() + expect(parsed?.tasks).toEqual([{ prompt: "one" }]) + expect(parseToolRequest({ mode: "bad", tasks: [{ prompt: "one" }] })).toBeUndefined() + expect(parseToolRequest({ mode: "local", tasks: [] })).toBeUndefined() + expect(parseToolRequest({ mode: "local", tasks: [{}] })).toBeUndefined() + }) + + it("starts local sessions and sends the initial prompt", async () => { + const client = { + session: { + create: mock(async () => ({ data: session("s-local") })), + promptAsync: mock(async () => ({})), + }, + } + const c = deps({ getClient: () => client as never }) + const req: ToolRequest = { + requestID: "am-1", + mode: "local", + tasks: [{ prompt: "Do work" }], + } + + await startFromTool(c, req) + + expect(c.openPanel).toHaveBeenCalledWith(true) + const panel = c.getPanel() + expect(panel?.waitForReady).toHaveBeenCalled() + expect(client.session.create).toHaveBeenCalledWith( + { directory: "/repo", platform: "agent-manager" }, + { throwOnError: true }, + ) + expect(client.session.promptAsync).toHaveBeenCalledWith( + expect.objectContaining({ + sessionID: "s-local", + directory: "/repo", + parts: [{ type: "text", text: "Do work" }], + }), + { throwOnError: true }, + ) + }) + + it("starts worktree sessions through existing hooks", async () => { + const c = deps() + await startFromTool(c, { requestID: "am-2", mode: "worktree", tasks: [{ prompt: "Fix", branchName: "fix/one" }] }) + + expect(c.createWorktree).toHaveBeenCalledWith( + expect.objectContaining({ branchName: "fix-one", name: "fix-one", label: "one" }), + ) + expect(c.setup).toHaveBeenCalled() + expect(c.createSessionInWorktree).toHaveBeenCalled() + expect(c.registerWorktreeSession).toHaveBeenCalledWith("s-wt", "/repo/.kilo/worktrees/wt-1") + expect(c.notifyReady).toHaveBeenCalled() + }) + + it("only applies version suffixes when versions is true", async () => { + const normal = deps() + await startFromTool(normal, { + requestID: "am-normal", + mode: "worktree", + tasks: [ + { prompt: "Fix one", branchName: "fix/one" }, + { prompt: "Fix two", branchName: "fix/two" }, + ], + }) + expect(normal.createWorktree).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ branchName: "fix-two", label: "two" }), + ) + + const grouped = deps() + await startFromTool(grouped, { + requestID: "am-versions", + mode: "worktree", + versions: true, + tasks: [ + { prompt: "Try one", branchName: "try/work" }, + { prompt: "Try two", branchName: "try/work" }, + ], + }) + expect(grouped.createWorktree).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ branchName: "try-work_v2", label: "try work v2" }), + ) + }) + + it("sanitizes branch names and keeps card labels short", async () => { + const c = deps() + await startFromTool(c, { + requestID: "am-name", + mode: "worktree", + tasks: [ + { + prompt: "Fix command permissions persistence regression", + name: "Fix command permissions persistence regression that is too long", + branchName: "fix command permissions @#$ persistence", + }, + ], + }) + + expect(c.createWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + branchName: "fix-command-permissions-persistence", + label: "command permissions", + }), + ) + }) + + it("rejects local sessions for unknown worktree directories", async () => { + const client = { + session: { + create: mock(async () => ({ data: session("s-local") })), + promptAsync: mock(async () => ({})), + }, + } + const c = deps({ + getClient: () => client as never, + getState: () => ({ addSession: mock(), findWorktreeByPath: mock(() => undefined) }) as never, + }) + + await startFromTool(c, { + requestID: "am-dir", + mode: "local", + directory: "/repo/other", + tasks: [{ prompt: "Do work" }], + }) + + expect(client.session.create).not.toHaveBeenCalled() + expect(c.error).toHaveBeenCalled() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx index 3c27729ef52..95966dbe020 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx @@ -180,6 +180,19 @@ const ExperimentalTab: Component = () => { </Switch> </SettingsRow> + <SettingsRow + title={language.t("settings.experimental.agentManagerTool.title")} + description={language.t("settings.experimental.agentManagerTool.description")} + > + <Switch + checked={experimental().agent_manager_tool ?? false} + onChange={(checked) => updateExperimental("agent_manager_tool", checked)} + hideLabel + > + {language.t("settings.experimental.agentManagerTool.title")} + </Switch> + </SettingsRow> + <SettingsRow title={language.t("settings.experimental.continueOnDeny.title")} description={language.t("settings.experimental.continueOnDeny.description")} diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 880dad10157..8e8f66afff1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1184,6 +1184,9 @@ export const dict = { "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "بحث في قاعدة الكود", "settings.experimental.codebaseSearch.description": "تمكين البحث بالذكاء الاصطناعي باللغة الطبيعية عبر قاعدة الكود", + "settings.experimental.agentManagerTool.title": "أداة Agent Manager", + "settings.experimental.agentManagerTool.description": + "السماح للوكلاء ببدء جلسات Agent Manager المحلية وجلسات worktree من استدعاء أداة", "settings.experimental.continueOnDeny.title": "المتابعة عند الرفض", "settings.experimental.continueOnDeny.description": "متابعة حلقة الوكيل عند رفض الإذن", "settings.experimental.mcpTimeout.title": "مهلة MCP (مللي ثانية)", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index a1e5d96371b..0287962c2d4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1210,6 +1210,9 @@ export const dict = { "settings.experimental.codebaseSearch.title": "Pesquisa de código", "settings.experimental.codebaseSearch.description": "Ativar pesquisa por linguagem natural com IA em toda a base de código", + "settings.experimental.agentManagerTool.title": "Ferramenta Agent Manager", + "settings.experimental.agentManagerTool.description": + "Permitir que agentes iniciem sessões locais e sessões worktree do Agent Manager a partir de uma chamada de ferramenta", "settings.experimental.continueOnDeny.title": "Continuar ao negar", "settings.experimental.continueOnDeny.description": "Continuar o loop do agente quando uma permissão é negada", "settings.experimental.mcpTimeout.title": "Tempo limite MCP (ms)", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 10e1a7343db..fbbc30ebb02 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1210,6 +1210,9 @@ export const dict = { "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Pretraga koda", "settings.experimental.codebaseSearch.description": "Omogući AI pretragu prirodnim jezikom kroz bazu koda", + "settings.experimental.agentManagerTool.title": "Agent Manager alat", + "settings.experimental.agentManagerTool.description": + "Dozvoli agentima da pokreću lokalne Agent Manager sesije i worktree sesije iz poziva alata", "settings.experimental.continueOnDeny.title": "Nastavi pri odbijanju", "settings.experimental.continueOnDeny.description": "Nastavi petlju agenta kada je dozvola odbijena", "settings.experimental.mcpTimeout.title": "MCP istek vremena (ms)", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index a37c411f361..77072bc4d4d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1203,6 +1203,9 @@ export const dict = { "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Kodesøgning", "settings.experimental.codebaseSearch.description": "Aktiver AI-drevet naturlig sprogsøgning på tværs af kodebasen", + "settings.experimental.agentManagerTool.title": "Agent Manager-værktøj", + "settings.experimental.agentManagerTool.description": + "Tillad agenter at starte lokale Agent Manager-sessioner og worktree-sessioner fra et værktøjskald", "settings.experimental.continueOnDeny.title": "Fortsæt ved afvisning", "settings.experimental.continueOnDeny.description": "Fortsæt agentløkken, når en tilladelse afvises", "settings.experimental.mcpTimeout.title": "MCP-timeout (ms)", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 686564a778b..c5a2a719ab8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1225,6 +1225,9 @@ export const dict = { "settings.experimental.codebaseSearch.title": "Codebase-Suche", "settings.experimental.codebaseSearch.description": "KI-gestützte Suche in natürlicher Sprache über die gesamte Codebasis aktivieren", + "settings.experimental.agentManagerTool.title": "Agent Manager-Werkzeug", + "settings.experimental.agentManagerTool.description": + "Agenten erlauben, lokale Agent Manager-Sitzungen und Worktree-Sitzungen per Werkzeugaufruf zu starten", "settings.experimental.continueOnDeny.title": "Bei Ablehnung fortfahren", "settings.experimental.continueOnDeny.description": "Agent-Schleife fortsetzen, wenn eine Berechtigung abgelehnt wird", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index bdac4d6d58e..4fa6c6a1f19 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1197,6 +1197,9 @@ export const dict = { "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Codebase Search", "settings.experimental.codebaseSearch.description": "Enable AI-powered natural language search across your codebase", + "settings.experimental.agentManagerTool.title": "Agent Manager Tool", + "settings.experimental.agentManagerTool.description": + "Allow agents to start Agent Manager local sessions and worktree sessions from a tool call", "settings.experimental.continueOnDeny.title": "Continue on Deny", "settings.experimental.continueOnDeny.description": "Continue the agent loop when a permission is denied", "settings.experimental.mcpTimeout.title": "MCP Timeout (ms)", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 111722750cd..1b7e0224927 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1216,6 +1216,9 @@ export const dict = { "settings.experimental.codebaseSearch.title": "Búsqueda de código", "settings.experimental.codebaseSearch.description": "Habilitar búsqueda por lenguaje natural con IA en toda la base de código", + "settings.experimental.agentManagerTool.title": "Herramienta Agent Manager", + "settings.experimental.agentManagerTool.description": + "Permitir que los agentes inicien sesiones locales y sesiones de worktree de Agent Manager desde una llamada de herramienta", "settings.experimental.continueOnDeny.title": "Continuar al denegar", "settings.experimental.continueOnDeny.description": "Continuar el bucle del agente cuando se deniega un permiso", "settings.experimental.mcpTimeout.title": "Tiempo de espera MCP (ms)", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index cd8d8854cdf..ff841abcd45 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1228,6 +1228,9 @@ export const dict = { "settings.experimental.codebaseSearch.title": "Recherche de code", "settings.experimental.codebaseSearch.description": "Activer la recherche en langage naturel par IA dans toute la base de code", + "settings.experimental.agentManagerTool.title": "Outil Agent Manager", + "settings.experimental.agentManagerTool.description": + "Autoriser les agents à démarrer des sessions locales Agent Manager et des sessions worktree depuis un appel d'outil", "settings.experimental.continueOnDeny.title": "Continuer en cas de refus", "settings.experimental.continueOnDeny.description": "Continuer la boucle de l'agent lorsqu'une autorisation est refusée", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 2cbadcf5df9..eaa615e650a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1199,6 +1199,9 @@ export const dict = { "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "コードベース検索", "settings.experimental.codebaseSearch.description": "コードベース全体でAIによる自然言語検索を有効にする", + "settings.experimental.agentManagerTool.title": "Agent Manager ツール", + "settings.experimental.agentManagerTool.description": + "エージェントがツール呼び出しから Agent Manager のローカルセッションとワークツリーセッションを開始できるようにする", "settings.experimental.continueOnDeny.title": "拒否時に続行", "settings.experimental.continueOnDeny.description": "権限が拒否された場合にエージェントループを続行", "settings.experimental.mcpTimeout.title": "MCPタイムアウト(ミリ秒)", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 391bab1b4a4..525fa5a4f0b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1193,6 +1193,9 @@ export const dict = { "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "코드베이스 검색", "settings.experimental.codebaseSearch.description": "코드베이스 전체에서 AI 기반 자연어 검색 활성화", + "settings.experimental.agentManagerTool.title": "Agent Manager 도구", + "settings.experimental.agentManagerTool.description": + "에이전트가 도구 호출로 Agent Manager 로컬 세션 및 워크트리 세션을 시작하도록 허용", "settings.experimental.continueOnDeny.title": "거부 시 계속", "settings.experimental.continueOnDeny.description": "권한이 거부되면 에이전트 루프 계속", "settings.experimental.mcpTimeout.title": "MCP 타임아웃 (ms)", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 0a28dfb67ea..9dec7f8c7c0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1213,6 +1213,9 @@ export const dict = { "settings.experimental.codebaseSearch.title": "Codebase Zoeken", "settings.experimental.codebaseSearch.description": "Schakel AI-aangedreven zoeken in natuurlijke taal door je codebase in", + "settings.experimental.agentManagerTool.title": "Agent Manager-tool", + "settings.experimental.agentManagerTool.description": + "Sta agents toe om lokale Agent Manager-sessies en worktree-sessies te starten vanuit een tool call", "settings.experimental.continueOnDeny.title": "Doorgaan bij weigering", "settings.experimental.continueOnDeny.description": "Ga door met de agent loop wanneer een toestemming wordt geweigerd", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index b28b572d2cd..7cdf1472245 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1180,6 +1180,9 @@ export const dict = { "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Kodesøk", "settings.experimental.codebaseSearch.description": "Aktiver AI-drevet naturlig språksøk på tvers av kodebasen", + "settings.experimental.agentManagerTool.title": "Agent Manager-verktøy", + "settings.experimental.agentManagerTool.description": + "Tillat agenter å starte lokale Agent Manager-økter og worktree-økter fra et verktøykall", "settings.experimental.continueOnDeny.title": "Fortsett ved avvisning", "settings.experimental.continueOnDeny.description": "Fortsett agentløkken når en tillatelse avvises", "settings.experimental.mcpTimeout.title": "MCP-tidsavbrudd (ms)", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index cb6a3187228..1df02292787 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1180,6 +1180,9 @@ export const dict = { "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Wyszukiwanie kodu", "settings.experimental.codebaseSearch.description": "Włącz wyszukiwanie w języku naturalnym z AI w całej bazie kodu", + "settings.experimental.agentManagerTool.title": "Narzędzie Agent Manager", + "settings.experimental.agentManagerTool.description": + "Zezwól agentom na uruchamianie lokalnych sesji Agent Manager i sesji worktree z wywołania narzędzia", "settings.experimental.continueOnDeny.title": "Kontynuuj przy odmowie", "settings.experimental.continueOnDeny.description": "Kontynuuj pętlę agenta po odmowie uprawnienia", "settings.experimental.mcpTimeout.title": "Limit czasu MCP (ms)", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 2b6b88ea151..dc5d61053df 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1207,6 +1207,9 @@ export const dict = { "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Поиск по коду", "settings.experimental.codebaseSearch.description": "Включить поиск на естественном языке с ИИ по всей кодовой базе", + "settings.experimental.agentManagerTool.title": "Инструмент Agent Manager", + "settings.experimental.agentManagerTool.description": + "Разрешить агентам запускать локальные сеансы Agent Manager и сеансы worktree через вызов инструмента", "settings.experimental.continueOnDeny.title": "Продолжить при отказе", "settings.experimental.continueOnDeny.description": "Продолжить цикл агента при отказе в разрешении", "settings.experimental.mcpTimeout.title": "Таймаут MCP (мс)", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 52c13e329e1..0d60a88a242 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1191,6 +1191,9 @@ export const dict = { "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "ค้นหาโค้ดเบส", "settings.experimental.codebaseSearch.description": "เปิดใช้งานการค้นหาด้วยภาษาธรรมชาติโดย AI ทั่วทั้งโค้ดเบส", + "settings.experimental.agentManagerTool.title": "เครื่องมือ Agent Manager", + "settings.experimental.agentManagerTool.description": + "อนุญาตให้เอเจนต์เริ่มเซสชัน Agent Manager ในเครื่องและเซสชัน worktree จากการเรียกเครื่องมือ", "settings.experimental.continueOnDeny.title": "ดำเนินต่อเมื่อถูกปฏิเสธ", "settings.experimental.continueOnDeny.description": "ดำเนินลูปเอเจนต์ต่อเมื่อสิทธิ์ถูกปฏิเสธ", "settings.experimental.mcpTimeout.title": "หมดเวลา MCP (มิลลิวินาที)", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 99b8f499465..dee0b5de847 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1206,6 +1206,9 @@ export const dict = { "settings.experimental.codebaseSearch.title": "Kod Tabanı Araması", "settings.experimental.codebaseSearch.description": "Kod tabanınız genelinde yapay zeka destekli doğal dil aramasını etkinleştir", + "settings.experimental.agentManagerTool.title": "Agent Manager Aracı", + "settings.experimental.agentManagerTool.description": + "Ajanların bir araç çağrısından Agent Manager yerel oturumları ve worktree oturumları başlatmasına izin ver", "settings.experimental.continueOnDeny.title": "Reddetme Durumunda Devam Et", "settings.experimental.continueOnDeny.description": "Bir izin reddedildiğinde ajan döngüsüne devam et", "settings.experimental.mcpTimeout.title": "MCP Zaman Aşımı (ms)", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 5eccca715b4..49572144389 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1208,6 +1208,9 @@ export const dict = { "settings.experimental.codebaseSearch.title": "Пошук по кодовій базі", "settings.experimental.codebaseSearch.description": "Увімкнути пошук природною мовою на основі ШІ по всій кодовій базі", + "settings.experimental.agentManagerTool.title": "Інструмент Agent Manager", + "settings.experimental.agentManagerTool.description": + "Дозволити агентам запускати локальні сесії Agent Manager і сесії worktree через виклик інструмента", "settings.experimental.continueOnDeny.title": "Продовжувати при відхиленні", "settings.experimental.continueOnDeny.description": "Продовжувати цикл агента, коли дозвіл відхилено", "settings.experimental.mcpTimeout.title": "Тайм-аут MCP (мс)", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index ad149040f86..76cdb98a446 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1176,6 +1176,8 @@ export const dict = { "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "代码库搜索", "settings.experimental.codebaseSearch.description": "启用 AI 驱动的自然语言代码库搜索", + "settings.experimental.agentManagerTool.title": "Agent Manager 工具", + "settings.experimental.agentManagerTool.description": "允许智能体通过工具调用启动 Agent Manager 本地会话和工作树会话", "settings.experimental.continueOnDeny.title": "拒绝后继续", "settings.experimental.continueOnDeny.description": "权限被拒绝时继续智能体循环", "settings.experimental.mcpTimeout.title": "MCP 超时(毫秒)", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index ab068ea6ff8..3d246ac6382 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1153,6 +1153,9 @@ export const dict = { "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "程式碼庫搜尋", "settings.experimental.codebaseSearch.description": "啟用 AI 驅動的自然語言程式碼庫搜尋", + "settings.experimental.agentManagerTool.title": "Agent Manager 工具", + "settings.experimental.agentManagerTool.description": + "允許 Agent 透過工具呼叫啟動 Agent Manager 本機工作階段和工作樹工作階段", "settings.experimental.continueOnDeny.title": "拒絕後繼續", "settings.experimental.continueOnDeny.description": "權限被拒絕時繼續 Agent 迴圈", "settings.experimental.mcpTimeout.title": "MCP 逾時(毫秒)", diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts index 928bac4c374..34548232584 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -41,6 +41,7 @@ export interface ExperimentalConfig { batch_tool?: boolean semantic_indexing?: boolean codebase_search?: boolean + agent_manager_tool?: boolean primary_tools?: string[] continue_loop_on_deny?: boolean mcp_timeout?: number diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 13704749fea..8091b49dfe3 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -291,6 +291,9 @@ export const Info = Schema.Struct({ semantic_indexing: Schema.optional(Schema.Boolean).annotate({ description: "Enable semantic codebase indexing and the semantic_search tool", }), + agent_manager_tool: Schema.optional(Schema.Boolean).annotate({ + description: "Enable the VS Code Agent Manager orchestration tool", + }), // kilocode_change end // kilocode_change start - enable telemetry by default openTelemetry: Schema.Boolean.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(true))).annotate({ diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index f2ced3112e2..4c49b1c179e 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -47,6 +47,7 @@ const InputObject = Schema.StructWithRest( lsp: Schema.optional(Rule), doom_loop: Schema.optional(Action), skill: Schema.optional(Rule), + agent_manager: Schema.optional(Rule), // kilocode_change }), [Schema.Record(Schema.String, Rule)], ) diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index c48fb57af2c..fb44048c0a5 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -8,15 +8,14 @@ import { Flock } from "@opencode-ai/shared/util/flock" const app = "kilo" // kilocode_change // kilocode_change start -// Defensively strip surrounding whitespace from the resolved XDG paths. +// Defensively strip newline characters from the resolved XDG paths. // If `$HOME` (or any `$XDG_*_HOME` override) has a trailing newline in // the user's shell — e.g. because a shell snippet did `export HOME=$(cmd)` // against a command with an implicit newline — the unsanitised path // makes `fs.mkdir` try to create `/Users/<name>\n` and fail with EACCES, // which breaks every `kilo` invocation at startup (including the SDK -// regen that runs during `bun run extension`). A trim is cheap and -// trailing whitespace is never legitimate in a filesystem path. -const clean = (p: string | undefined) => p?.trim() +// regen that runs during `bun run extension`). +const clean = (p: string | undefined) => p?.replace(/[\r\n]+/g, "") const data = path.join(clean(xdgData)!, app) const cache = path.join(clean(xdgCache)!, app) const config = path.join(clean(xdgConfig)!, app) diff --git a/packages/opencode/src/kilocode/agent-manager/event.ts b/packages/opencode/src/kilocode/agent-manager/event.ts new file mode 100644 index 00000000000..df432dd2184 --- /dev/null +++ b/packages/opencode/src/kilocode/agent-manager/event.ts @@ -0,0 +1,26 @@ +// kilocode_change - new file +import { BusEvent } from "@/bus/bus-event" +import { SessionID } from "@/session/schema" +import { Schema } from "effect" + +export const AgentManagerTask = Schema.Struct({ + prompt: Schema.optional(Schema.String).annotate({ description: "Initial prompt to send to the new session" }), + name: Schema.optional(Schema.String).annotate({ description: "Short display name for the Agent Manager card" }), + branchName: Schema.optional(Schema.String).annotate({ description: "Git branch name seed for worktree mode" }), +}) + +export const AgentManagerMode = Schema.Literals(["worktree", "local"]) + +export const AgentManagerStart = Schema.Struct({ + requestID: Schema.String, + sessionID: SessionID, + mode: AgentManagerMode, + versions: Schema.optional(Schema.Boolean), + tasks: Schema.Array(AgentManagerTask).check(Schema.isMinLength(1), Schema.isMaxLength(20)), +}) + +export type AgentManagerStart = Schema.Schema.Type<typeof AgentManagerStart> + +export const AgentManagerEvent = { + Start: BusEvent.define("kilocode.agent_manager.start", AgentManagerStart), +} diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts new file mode 100644 index 00000000000..270429a1f3a --- /dev/null +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -0,0 +1,75 @@ +// kilocode_change - new file +import { Bus } from "@/bus" +import { AgentManagerEvent } from "@/kilocode/agent-manager/event" +import { Tool } from "@/tool" +import { Effect, Schema } from "effect" +import DESCRIPTION from "./agent-manager.txt" + +const Task = Schema.Struct({ + prompt: Schema.optional(Schema.String).annotate({ description: "Initial prompt to send to the new session" }), + name: Schema.optional(Schema.String).annotate({ description: "Short display name for the Agent Manager card" }), + branchName: Schema.optional(Schema.String).annotate({ description: "Git branch name seed for worktree mode" }), +}).check( + Schema.makeFilter((task: { prompt?: string; name?: string; branchName?: string }) => + task.prompt?.trim() || task.name?.trim() || task.branchName?.trim() + ? undefined + : "Each task must include prompt, name, or branchName", + ), +) + +export const Params = Schema.Struct({ + mode: Schema.Literals(["worktree", "local"]).annotate({ + description: "Use worktree for isolated git worktrees, or local for same-directory Agent Manager sessions", + }), + versions: Schema.optional(Schema.Boolean).annotate({ + description: + "Set true only when tasks are alternative versions of the same work to compare. Omit or false for independent sessions.", + }), + tasks: Schema.Array(Task) + .check(Schema.isMinLength(1), Schema.isMaxLength(20)) + .annotate({ description: "Agent Manager sessions to start" }), +}) + +export const AgentManagerTool = Tool.define< + typeof Params, + { requestID: string; count: number }, + Bus.Service, + "agent_manager" +>( + "agent_manager", + Effect.gen(function* () { + const bus = yield* Bus.Service + return { + description: DESCRIPTION, + parameters: Params, + execute: (params, ctx) => + Effect.gen(function* () { + yield* ctx.ask({ + permission: "agent_manager", + patterns: [params.mode], + always: [params.mode], + metadata: { mode: params.mode, count: params.tasks.length }, + }) + + const requestID = `am-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + yield* bus.publish(AgentManagerEvent.Start, { + requestID, + sessionID: ctx.sessionID, + mode: params.mode, + versions: params.versions, + tasks: params.tasks, + }) + + return { + title: `Requested ${params.tasks.length} Agent Manager ${params.mode === "worktree" ? "worktree" : "local"} session${params.tasks.length === 1 ? "" : "s"}`, + output: [ + `Requested ${params.tasks.length} Agent Manager ${params.mode === "worktree" ? "worktree" : "local"} session${params.tasks.length === 1 ? "" : "s"}.`, + `request_id: ${requestID}`, + "The VS Code extension will create the sessions asynchronously and show progress in Agent Manager.", + ].join("\n"), + metadata: { requestID, count: params.tasks.length }, + } + }), + } + }), +) diff --git a/packages/opencode/src/kilocode/tool/agent-manager.txt b/packages/opencode/src/kilocode/tool/agent-manager.txt new file mode 100644 index 00000000000..d1a85145af5 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/agent-manager.txt @@ -0,0 +1,13 @@ +Start Agent Manager sessions in the VS Code extension. + +Use this tool when the user explicitly asks you to fan out work into Agent Manager, create Agent Manager worktrees, or start multiple Agent Manager sessions for independent tasks. + +Modes: +- `worktree`: creates a new Agent Manager git worktree for each task, like the New Worktree dialog. +- `local`: creates Agent Manager sessions in the current workspace directory without git worktree isolation. + +Each task may provide a prompt, a short display name, and a branch name. Keep display names short because Agent Manager cards are narrow. Branch names are sanitized before worktree creation. The agent, model, reasoning, and base branch settings always use the normal defaults. + +By default, multiple tasks are started as independent Agent Manager sessions. Set `versions` to true only when all tasks are alternate versions of the same work that should be compared together. Versioned worktrees are grouped in Agent Manager and branch names may receive version suffixes. + +Do not use this for ordinary subagent research. Use the `task` tool for internal subagents, and use this only when the user wants visible Agent Manager sessions in the extension. diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index a57dc60a6b6..e1a15b0f89e 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -1,6 +1,7 @@ // kilocode_change - new file import { CodebaseSearchTool } from "../../tool/warpgrep" import { RecallTool } from "../../tool/recall" +import { AgentManagerTool } from "./agent-manager" import * as Tool from "../../tool/tool" import { Flag } from "@/flag/flag" import { Effect } from "effect" @@ -18,17 +19,19 @@ export namespace KiloToolRegistry { return Effect.gen(function* () { const codebase = yield* CodebaseSearchTool const recall = yield* RecallTool - return { codebase, recall } + const manager = yield* AgentManagerTool + return { codebase, recall, manager } }) } /** Finalize Kilo-specific tools into Tool.Defs. Call this inside the InstanceState state Effect — * it has no Service deps beyond what Tool.init itself needs. */ - export function build(tools: { codebase: Tool.Info; recall: Tool.Info }, deps: Deps) { + export function build(tools: { codebase: Tool.Info; recall: Tool.Info; manager: Tool.Info }, deps: Deps) { return Effect.gen(function* () { const base = yield* Effect.all({ codebase: Tool.init(tools.codebase), recall: Tool.init(tools.recall), + manager: Tool.init(tools.manager), }) const semantic = yield* semanticTool(deps) return { ...base, semantic } @@ -37,7 +40,9 @@ export namespace KiloToolRegistry { function semanticTool(deps: Deps) { return Effect.gen(function* () { - const ready = yield* Effect.tryPromise(() => import("@/kilocode/indexing").then((mod) => mod.KiloIndexing.ready())).pipe( + const ready = yield* Effect.tryPromise(() => + import("@/kilocode/indexing").then((mod) => mod.KiloIndexing.ready()), + ).pipe( Effect.catch((err) => Effect.sync(() => { log.warn("semantic search unavailable", { err }) @@ -83,13 +88,15 @@ export namespace KiloToolRegistry { /** Kilo-specific tools to append to the builtin list */ export function extra( - tools: { codebase: Tool.Def; semantic?: Tool.Def; recall: Tool.Def }, - cfg: { experimental?: { codebase_search?: boolean } }, + tools: { codebase: Tool.Def; semantic?: Tool.Def; recall: Tool.Def; manager: Tool.Def }, + cfg: { experimental?: { codebase_search?: boolean; agent_manager_tool?: boolean } }, ): Tool.Def[] { return [ ...(cfg.experimental?.codebase_search === true ? [tools.codebase] : []), ...(tools.semantic ? [tools.semantic] : []), tools.recall, + // The extension is the only client that can consume the Agent Manager start event. + ...(Flag.KILO_CLIENT === "vscode" && cfg.experimental?.agent_manager_tool === true ? [tools.manager] : []), ] } diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts new file mode 100644 index 00000000000..21a3cda013d --- /dev/null +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Layer, ManagedRuntime } from "effect" +import { MessageID, SessionID } from "../../src/session/schema" +import { provideTmpdirInstance } from "../fixture/fixture" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { AgentManagerTool } from "../../src/kilocode/tool/agent-manager" +import { Bus } from "../../src/bus" +import { Tool } from "../../src/tool" +import { Truncate } from "../../src/tool" +import { Agent } from "../../src/agent/agent" + +const runtime = ManagedRuntime.make( + Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer, Bus.defaultLayer, CrossSpawnSpawner.defaultLayer), +) + +async function init() { + return runtime.runPromise( + Effect.gen(function* () { + const info = yield* AgentManagerTool + return yield* Tool.init(info) + }), + ) +} + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "call_agent_manager", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +describe("agent_manager tool", () => { + test("asks for agent_manager permission", async () => { + const tool = await init() + const calls: unknown[] = [] + + await runtime.runPromise( + provideTmpdirInstance(() => + tool.execute( + { mode: "local", tasks: [{ prompt: "Fix issue" }] }, + { ...ctx, ask: (input: unknown) => Effect.sync(() => calls.push(input)) }, + ), + ).pipe(Effect.scoped), + ) + + expect(calls).toEqual([ + { + permission: "agent_manager", + patterns: ["local"], + always: ["local"], + metadata: { mode: "local", count: 1 }, + }, + ]) + }) + + test("rejects empty tasks", async () => { + const tool = await init() + + await expect( + runtime.runPromise( + provideTmpdirInstance(() => + tool.execute({ mode: "local", tasks: [{}] }, { ...ctx, ask: () => Effect.void }), + ).pipe(Effect.scoped), + ), + ).rejects.toThrow("Each task must include prompt, name, or branchName") + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 08034506608..15b487ff97f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -590,6 +590,30 @@ export type EventVcsBranchUpdated = { } } +export type EventKilocodeAgentManagerStart = { + type: "kilocode.agent_manager.start" + properties: { + requestID: string + sessionID: string + mode: "worktree" | "local" + versions?: boolean + tasks: Array<{ + /** + * Initial prompt to send to the new session + */ + prompt?: string + /** + * Short display name for the Agent Manager card + */ + name?: string + /** + * Git branch name seed for worktree mode + */ + branchName?: string + }> + } +} + export type EventSessionCompacted = { type: "session.compacted" properties: { @@ -1331,6 +1355,7 @@ export type GlobalEvent = { | EventSessionIdle | EventTodoUpdated | EventVcsBranchUpdated + | EventKilocodeAgentManagerStart | EventSessionCompacted | EventKiloSessionsRemoteStatusChanged | EventWorktreeReady @@ -1539,6 +1564,7 @@ export type PermissionConfig = lsp?: PermissionRuleConfig doom_loop?: PermissionActionConfig skill?: PermissionRuleConfig + agent_manager?: PermissionRuleConfig [key: string]: PermissionRuleConfig | PermissionActionConfig | undefined } @@ -2025,6 +2051,10 @@ export type Config = { * Enable semantic codebase indexing and the semantic_search tool */ semantic_indexing?: boolean + /** + * Enable the VS Code Agent Manager orchestration tool + */ + agent_manager_tool?: boolean /** * Enable telemetry. Set to false to opt-out. */ @@ -2457,6 +2487,7 @@ export type Event = | EventSessionIdle | EventTodoUpdated | EventVcsBranchUpdated + | EventKilocodeAgentManagerStart | EventSessionCompacted | EventKiloSessionsRemoteStatusChanged | EventWorktreeReady diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 55e5ff29f57..bf127ae3ac2 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -12225,46 +12225,54 @@ }, "required": ["type", "properties"] }, - "IndexingStatusState": { - "type": "string", - "enum": ["Disabled", "In Progress", "Complete", "Error", "Standby"] - }, - "IndexingStatus": { - "type": "object", - "properties": { - "state": { - "$ref": "#/components/schemas/IndexingStatusState" - }, - "message": { - "type": "string" - }, - "processedFiles": { - "type": "number" - }, - "totalFiles": { - "type": "number" - }, - "percent": { - "type": "number" - } - }, - "required": ["state", "message", "processedFiles", "totalFiles", "percent"] - }, - "Event.indexing.status": { + "Event.kilocode.agent_manager.start": { "type": "object", "properties": { "type": { "type": "string", - "const": "indexing.status" + "const": "kilocode.agent_manager.start" }, "properties": { "type": "object", "properties": { - "status": { - "$ref": "#/components/schemas/IndexingStatus" + "requestID": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "mode": { + "type": "string", + "enum": ["worktree", "local"] + }, + "versions": { + "type": "boolean" + }, + "tasks": { + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "prompt": { + "description": "Initial prompt to send to the new session", + "type": "string" + }, + "name": { + "description": "Short display name for the Agent Manager card", + "type": "string" + }, + "branchName": { + "description": "Git branch name seed for worktree mode", + "type": "string" + } + } + } } }, - "required": ["status"] + "required": ["requestID", "sessionID", "mode", "tasks"] } }, "required": ["type", "properties"] @@ -13968,6 +13976,50 @@ }, "required": ["type", "properties"] }, + "IndexingStatusState": { + "type": "string", + "enum": ["Disabled", "In Progress", "Complete", "Error", "Standby"] + }, + "IndexingStatus": { + "type": "object", + "properties": { + "state": { + "$ref": "#/components/schemas/IndexingStatusState" + }, + "message": { + "type": "string" + }, + "processedFiles": { + "type": "number" + }, + "totalFiles": { + "type": "number" + }, + "percent": { + "type": "number" + } + }, + "required": ["state", "message", "processedFiles", "totalFiles", "percent"] + }, + "Event.indexing.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "indexing.status" + }, + "properties": { + "type": "object", + "properties": { + "status": { + "$ref": "#/components/schemas/IndexingStatus" + } + }, + "required": ["status"] + } + }, + "required": ["type", "properties"] + }, "SyncEvent.message.updated": { "type": "object", "properties": { @@ -14578,7 +14630,7 @@ "$ref": "#/components/schemas/Event.vcs.branch.updated" }, { - "$ref": "#/components/schemas/Event.indexing.status" + "$ref": "#/components/schemas/Event.kilocode.agent_manager.start" }, { "$ref": "#/components/schemas/Event.session.compacted" @@ -14637,6 +14689,9 @@ { "$ref": "#/components/schemas/Event.session.deleted" }, + { + "$ref": "#/components/schemas/Event.indexing.status" + }, { "$ref": "#/components/schemas/SyncEvent.message.updated" }, @@ -14971,6 +15026,9 @@ }, "skill": { "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "agent_manager": { + "$ref": "#/components/schemas/PermissionRuleConfig" } }, "additionalProperties": { @@ -15586,6 +15644,11 @@ "indexing": { "$ref": "#/components/schemas/IndexingConfig" }, + "terminal_command_display": { + "description": "Controls whether terminal command blocks are expanded or collapsed by default in the VS Code chat UI", + "type": "string", + "enum": ["expanded", "collapsed"] + }, "model": { "description": "Model to use in the format of provider/model, eg anthropic/claude-2", "anyOf": [ @@ -15925,6 +15988,10 @@ "description": "Enable semantic codebase indexing and the semantic_search tool", "type": "boolean" }, + "agent_manager_tool": { + "description": "Enable the VS Code Agent Manager orchestration tool", + "type": "boolean" + }, "openTelemetry": { "description": "Enable telemetry. Set to false to opt-out.", "default": true, @@ -17206,7 +17273,7 @@ "$ref": "#/components/schemas/Event.vcs.branch.updated" }, { - "$ref": "#/components/schemas/Event.indexing.status" + "$ref": "#/components/schemas/Event.kilocode.agent_manager.start" }, { "$ref": "#/components/schemas/Event.session.compacted" @@ -17264,6 +17331,9 @@ }, { "$ref": "#/components/schemas/Event.session.deleted" + }, + { + "$ref": "#/components/schemas/Event.indexing.status" } ] }, diff --git a/packages/shared/src/global.ts b/packages/shared/src/global.ts index 4bc0ec93bf4..455021baa40 100644 --- a/packages/shared/src/global.ts +++ b/packages/shared/src/global.ts @@ -20,11 +20,14 @@ export namespace Global { Service, Effect.gen(function* () { const app = "opencode" - const home = process.env.KILO_TEST_HOME ?? os.homedir() - const data = path.join(xdgData!, app) - const cache = path.join(xdgCache!, app) - const cfg = path.join(xdgConfig!, app) - const state = path.join(xdgState!, app) + // kilocode_change start - guard against newline-contaminated HOME/XDG paths + const clean = (p: string | undefined) => p?.replace(/[\r\n]+/g, "") + const home = clean(process.env.KILO_TEST_HOME ?? os.homedir())! + const data = path.join(clean(xdgData)!, app) + const cache = path.join(clean(xdgCache)!, app) + const cfg = path.join(clean(xdgConfig)!, app) + const state = path.join(clean(xdgState)!, app) + // kilocode_change end const bin = path.join(cache, "bin") const log = path.join(data, "log") From e9f2760d8e9a93d4b71dba6ef5aae522cb4fe263 Mon Sep 17 00:00:00 2001 From: Marius <marius@kilocode.ai> Date: Thu, 30 Apr 2026 16:33:51 +0200 Subject: [PATCH 84/96] Expose custom agent permissions in VS Code settings (#9733) * feat(vscode): expose custom agent permissions * style(vscode): format agent permission settings * chore: update kilo-vscode visual regression baselines --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> --- .changeset/per-agent-permissions.md | 5 + .../mode-edit-permissions-chromium-linux.png | 3 + .../tests/unit/config-utils.test.ts | 68 +++ .../tests/unit/permission-editor.test.ts | 33 + .../components/settings/AutoApproveTab.tsx | 514 +--------------- .../src/components/settings/ModeEditView.tsx | 49 +- .../components/settings/PermissionEditor.tsx | 571 ++++++++++++++++++ .../components/settings/permission-utils.ts | 17 + .../src/stories/settings.stories.tsx | 53 ++ .../src/types/messages/permissions.ts | 2 +- 10 files changed, 806 insertions(+), 509 deletions(-) create mode 100644 .changeset/per-agent-permissions.md create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-permissions-chromium-linux.png create mode 100644 packages/kilo-vscode/tests/unit/permission-editor.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/components/settings/PermissionEditor.tsx create mode 100644 packages/kilo-vscode/webview-ui/src/components/settings/permission-utils.ts diff --git a/.changeset/per-agent-permissions.md b/.changeset/per-agent-permissions.md new file mode 100644 index 00000000000..7667cd139a5 --- /dev/null +++ b/.changeset/per-agent-permissions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Support configuring custom agent tool permissions from the VS Code agent editor. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-permissions-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-permissions-chromium-linux.png new file mode 100644 index 00000000000..f6e37711f27 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-permissions-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7dc43983d81b4cc88568dd71b7b312e73336df029f445a9c00175c9393f4331c +size 56736 diff --git a/packages/kilo-vscode/tests/unit/config-utils.test.ts b/packages/kilo-vscode/tests/unit/config-utils.test.ts index 15ae52b443e..16fba3a660d 100644 --- a/packages/kilo-vscode/tests/unit/config-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/config-utils.test.ts @@ -204,4 +204,72 @@ describe("ConfigState", () => { expect(Object.keys(s.draft).length).toBe(0) }) }) + + describe("agent permission patches", () => { + it("merges nested per-agent permission patches into existing rules", () => { + const s = new ConfigState() + s.handleConfigLoaded({ + agent: { + reviewer: { + permission: { + read: "allow", + edit: "deny", + }, + }, + }, + }) + + s.updateConfig({ agent: { reviewer: { permission: { bash: "ask" } } } }) + + expect(s.config.agent?.reviewer?.permission).toEqual({ + read: "allow", + edit: "deny", + bash: "ask", + }) + expect(s.draft.agent?.reviewer?.permission).toEqual({ bash: "ask" }) + }) + + it("keeps nested permission delete sentinels in the draft", () => { + const s = new ConfigState() + s.handleConfigLoaded({ + agent: { + docs: { + permission: { + edit: { "*": "deny", "**/*.md": "allow" }, + }, + }, + }, + }) + + s.updateConfig({ agent: { docs: { permission: { edit: { "**/*.md": null } } } } }) + + expect(s.config.agent?.docs?.permission).toEqual({ edit: { "*": "deny" } }) + expect(s.draft.agent?.docs?.permission).toEqual({ edit: { "**/*.md": null } }) + expect(JSON.parse(JSON.stringify(s.draft))).toEqual({ + agent: { docs: { permission: { edit: { "**/*.md": null } } } }, + }) + }) + + it("keeps tool-level permission delete sentinels in the draft", () => { + const s = new ConfigState() + s.handleConfigLoaded({ + agent: { + reviewer: { + permission: { + read: "allow", + bash: "deny", + }, + }, + }, + }) + + s.updateConfig({ agent: { reviewer: { permission: { bash: null } } } }) + + expect(s.config.agent?.reviewer?.permission).toEqual({ read: "allow" }) + expect(s.draft.agent?.reviewer?.permission).toEqual({ bash: null }) + expect(JSON.parse(JSON.stringify(s.draft))).toEqual({ + agent: { reviewer: { permission: { bash: null } } }, + }) + }) + }) }) diff --git a/packages/kilo-vscode/tests/unit/permission-editor.test.ts b/packages/kilo-vscode/tests/unit/permission-editor.test.ts new file mode 100644 index 00000000000..f8df3576677 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/permission-editor.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test" +import { effectiveRuleLevel } from "../../webview-ui/src/components/settings/permission-utils" +import type { PermissionRuleItem } from "../../webview-ui/src/types/messages" + +describe("effectiveRuleLevel", () => { + it("uses the last matching wildcard rule from resolved agent rules", () => { + const rules: PermissionRuleItem[] = [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "*", pattern: "*", action: "deny" }, + ] + + expect(effectiveRuleLevel(rules, "bash")).toBe("deny") + expect(effectiveRuleLevel(rules, "external_directory")).toBe("deny") + }) + + it("uses specific tool rules after wildcard rules", () => { + const rules: PermissionRuleItem[] = [ + { permission: "*", pattern: "*", action: "deny" }, + { permission: "read", pattern: "*", action: "allow" }, + { permission: "grep", pattern: "*", action: "allow" }, + ] + + expect(effectiveRuleLevel(rules, "read")).toBe("allow") + expect(effectiveRuleLevel(rules, "grep")).toBe("allow") + expect(effectiveRuleLevel(rules, "edit")).toBe("deny") + }) + + it("falls back to ask when no resolved wildcard rule is available", () => { + expect(effectiveRuleLevel(undefined, "bash")).toBe("ask") + expect(effectiveRuleLevel([], "bash")).toBe("ask") + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/AutoApproveTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/AutoApproveTab.tsx index 5bc297fd9bd..c63cb533504 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AutoApproveTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AutoApproveTab.tsx @@ -1,153 +1,8 @@ -import { Component, For, Show, createEffect, createMemo, createSignal } from "solid-js" -import { Select } from "@kilocode/kilo-ui/select" -import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { Component, createMemo } from "solid-js" + import { useConfig } from "../../context/config" import { useLanguage } from "../../context/language" -import type { PermissionLevel, PermissionRule } from "../../types/messages" - -interface LevelOption { - value: PermissionLevel - labelKey: string -} - -const LEVEL_OPTIONS: LevelOption[] = [ - { value: "allow", labelKey: "settings.autoApprove.level.allow" }, - { value: "ask", labelKey: "settings.autoApprove.level.ask" }, - { value: "deny", labelKey: "settings.autoApprove.level.deny" }, -] - -interface GranularConfig { - wildcardKey: string - addKey: string - placeholderKey: string -} - -interface ToolDef { - id: string - descriptionKey: string - granular?: GranularConfig -} - -interface GranularToolDef extends ToolDef { - granular: GranularConfig -} - -/** Grouped tool: maps a single UI row to multiple config keys */ -interface GroupedToolDef { - ids: string[] - label: string - descriptionKey: string -} - -const GRANULAR_TOOLS: GranularToolDef[] = [ - { - id: "external_directory", - descriptionKey: "settings.autoApprove.tool.external_directory", - granular: { - wildcardKey: "settings.autoApprove.wildcardLabel.paths", - addKey: "settings.autoApprove.addPath", - placeholderKey: "settings.autoApprove.placeholder.path", - }, - }, - { - id: "bash", - descriptionKey: "settings.autoApprove.tool.bash", - granular: { - wildcardKey: "settings.autoApprove.wildcardLabel.commands", - addKey: "settings.autoApprove.addCommand", - placeholderKey: "settings.autoApprove.placeholder.command", - }, - }, - { - id: "read", - descriptionKey: "settings.autoApprove.tool.read", - granular: { - wildcardKey: "settings.autoApprove.wildcardLabel.paths", - addKey: "settings.autoApprove.addPath", - placeholderKey: "settings.autoApprove.placeholder.path", - }, - }, - { - id: "edit", - descriptionKey: "settings.autoApprove.tool.edit", - granular: { - wildcardKey: "settings.autoApprove.wildcardLabel.paths", - addKey: "settings.autoApprove.addPath", - placeholderKey: "settings.autoApprove.placeholder.path", - }, - }, -] - -const SIMPLE_TOOLS: ToolDef[] = [ - { id: "glob", descriptionKey: "settings.autoApprove.tool.glob" }, - { id: "grep", descriptionKey: "settings.autoApprove.tool.grep" }, - { id: "list", descriptionKey: "settings.autoApprove.tool.list" }, - { id: "task", descriptionKey: "settings.autoApprove.tool.task" }, - { id: "skill", descriptionKey: "settings.autoApprove.tool.skill" }, - { id: "lsp", descriptionKey: "settings.autoApprove.tool.lsp" }, -] - -const GROUPED_TOOLS: GroupedToolDef[] = [ - { - ids: ["todoread", "todowrite"], - label: "todoread / todowrite", - descriptionKey: "settings.autoApprove.tool.todoreadwrite", - }, - { - ids: ["websearch", "codesearch"], - label: "websearch / codesearch", - descriptionKey: "settings.autoApprove.tool.websearchcodesearch", - }, -] - -const TRAILING_TOOLS: ToolDef[] = [ - { id: "webfetch", descriptionKey: "settings.autoApprove.tool.webfetch" }, - { id: "doom_loop", descriptionKey: "settings.autoApprove.tool.doom_loop" }, -] - -/** - * Backend default permission levels — mirrors the base defaults defined in - * packages/opencode/src/agent/agent.ts (lines 61-78). The global default - * is "*": "allow"; these are the per-tool overrides. If the backend defaults - * change, this map must be updated to match. - */ -const TOOL_DEFAULTS: Partial<Record<string, PermissionLevel>> = { - doom_loop: "ask", - external_directory: "ask", -} - -const RESTRICTION_ORDER: Record<PermissionLevel, number> = { allow: 0, ask: 1, deny: 2 } - -/** For grouped tools, return the most restrictive level across all IDs. */ -function mostRestrictive(levels: PermissionLevel[]): PermissionLevel { - return levels.reduce<PermissionLevel>( - (best, l) => (RESTRICTION_ORDER[l] > RESTRICTION_ORDER[best] ? l : best), - levels[0] ?? "allow", - ) -} - -function wildcardAction(rule: PermissionRule | undefined, fallback: PermissionLevel): PermissionLevel { - if (!rule) return fallback - if (typeof rule === "string") return rule - return rule["*"] ?? fallback -} - -function exceptions(rule: PermissionRule | undefined): Array<{ pattern: string; action: PermissionLevel }> { - if (!rule || typeof rule === "string") return [] - return Object.entries(rule) - .filter(([key, action]) => key !== "*" && action !== null) - .map(([pattern, action]) => ({ pattern, action: action as PermissionLevel })) -} - -function toolTitle(id: string): string { - return id - .split("_") - .map((word) => word[0].toUpperCase() + word.slice(1)) - .join(" ") - .split(" / ") - .map((part) => part[0].toUpperCase() + part.slice(1)) - .join(" / ") -} +import PermissionEditor from "./PermissionEditor" const AutoApproveTab: Component = () => { const { config, updateConfig } = useConfig() @@ -155,366 +10,11 @@ const AutoApproveTab: Component = () => { const permissions = createMemo(() => config().permission ?? {}) - const globalFallback = createMemo((): PermissionLevel => { - const star = permissions()["*"] - if (typeof star === "string") return star - return "allow" // backend default: "*": "allow" (agent.ts) - }) - - const defaultFor = (tool: string): PermissionLevel => TOOL_DEFAULTS[tool] ?? globalFallback() - - const levelFor = (tool: string): PermissionLevel => wildcardAction(permissions()[tool], defaultFor(tool)) - - const ruleFor = (tool: string): PermissionRule | undefined => permissions()[tool] - - const setSimple = (tool: string, level: PermissionLevel) => { - updateConfig({ permission: { [tool]: level } }) - } - - const setGrouped = (ids: string[], level: PermissionLevel) => { - const patch: Record<string, PermissionLevel> = {} - for (const id of ids) patch[id] = level - updateConfig({ permission: patch }) - } - - const setWildcard = (tool: string, level: PermissionLevel) => { - const current = ruleFor(tool) - const excs = exceptions(current) - if (excs.length === 0) { - updateConfig({ permission: { [tool]: level } }) - return - } - const obj: Record<string, PermissionLevel | null> = { "*": level } - for (const exc of excs) obj[exc.pattern] = exc.action - updateConfig({ permission: { [tool]: obj } }) - } - - const setException = (tool: string, pattern: string, level: PermissionLevel) => { - const current = ruleFor(tool) - const base: Record<string, PermissionLevel | null> = - typeof current === "string" ? { "*": current } : { ...(current ?? {}) } - base[pattern] = level - updateConfig({ permission: { [tool]: base } }) - } - - const addException = (tool: string, pattern: string) => { - const current = ruleFor(tool) - const base: Record<string, PermissionLevel | null> = - typeof current === "string" ? { "*": current } : { ...(current ?? {}) } - base[pattern] = "allow" - updateConfig({ permission: { [tool]: base } }) - } - - const removeException = (tool: string, pattern: string) => { - const current = ruleFor(tool) - if (!current || typeof current === "string") return - // Send a single patch with null for the deleted key. - // null is a delete sentinel: patchJsonc removes the key from the JSONC file, - // stripNulls removes it from the optimistic UI. - updateConfig({ permission: { [tool]: { [pattern]: null } } }) - } - return ( - <div data-component="auto-approve-settings"> - <div - style={{ - "font-size": "12px", - color: "var(--text-weak-base, var(--vscode-descriptionForeground))", - "padding-bottom": "12px", - "border-bottom": "1px solid var(--border-weak-base)", - }} - > - {language.t("settings.autoApprove.description")} - </div> - - <For each={GRANULAR_TOOLS}> - {(tool) => ( - <GranularToolRow - tool={tool} - rule={ruleFor(tool.id)} - fallback={defaultFor(tool.id)} - onWildcardChange={(level) => setWildcard(tool.id, level)} - onExceptionChange={(pattern, level) => setException(tool.id, pattern, level)} - onExceptionAdd={(pattern) => addException(tool.id, pattern)} - onExceptionRemove={(pattern) => removeException(tool.id, pattern)} - /> - )} - </For> - - <For each={SIMPLE_TOOLS}> - {(tool) => ( - <SimpleToolRow - id={tool.id} - descriptionKey={tool.descriptionKey} - level={levelFor(tool.id)} - onChange={(level) => setSimple(tool.id, level)} - /> - )} - </For> - - <For each={GROUPED_TOOLS}> - {(group) => ( - <SimpleToolRow - id={group.label} - descriptionKey={group.descriptionKey} - level={mostRestrictive(group.ids.map(levelFor))} - onChange={(level) => setGrouped(group.ids, level)} - /> - )} - </For> - - <For each={TRAILING_TOOLS}> - {(tool) => ( - <SimpleToolRow - id={tool.id} - descriptionKey={tool.descriptionKey} - level={levelFor(tool.id)} - onChange={(level) => setSimple(tool.id, level)} - /> - )} - </For> - </div> - ) -} - -const SimpleToolRow: Component<{ - id: string - descriptionKey: string - level: PermissionLevel - onChange: (level: PermissionLevel) => void -}> = (props) => { - const language = useLanguage() - return ( - <div - style={{ - display: "flex", - gap: "24px", - "align-items": "flex-start", - "justify-content": "space-between", - padding: "12px 0", - "border-bottom": "1px solid var(--border-weak-base)", - }} - > - <div style={{ flex: 1, "min-width": 0 }}> - <div style={{ "font-size": "13px", color: "var(--text-base, var(--vscode-foreground))" }}> - {toolTitle(props.id)} - </div> - <div - style={{ - "font-size": "12px", - color: "var(--text-weak-base, var(--vscode-descriptionForeground))", - "margin-top": "6px", - }} - > - {language.t(props.descriptionKey)} - </div> - </div> - <ActionSelect level={props.level} onChange={props.onChange} /> - </div> - ) -} - -const GranularToolRow: Component<{ - tool: GranularToolDef - rule: PermissionRule | undefined - fallback: PermissionLevel - onWildcardChange: (level: PermissionLevel) => void - onExceptionChange: (pattern: string, level: PermissionLevel) => void - onExceptionAdd: (pattern: string) => void - onExceptionRemove: (pattern: string) => void -}> = (props) => { - const language = useLanguage() - const [adding, setAdding] = createSignal(false) - const [input, setInput] = createSignal("") - let inputRef: HTMLInputElement | undefined - - createEffect(() => { - if (adding()) inputRef?.focus() - }) - - const excs = createMemo(() => exceptions(props.rule)) - const level = createMemo(() => wildcardAction(props.rule, props.fallback)) - - const submit = () => { - const val = input().trim() - if (val) { - props.onExceptionAdd(val) - setInput("") - } - setAdding(false) - } - - const cancel = () => { - setInput("") - setAdding(false) - } - - return ( - <div style={{ padding: "12px 0", "border-bottom": "1px solid var(--border-weak-base)" }}> - {/* Tool header with name and description */} - <div style={{ display: "flex", gap: "24px", "align-items": "flex-start", "justify-content": "space-between" }}> - <div style={{ flex: 1, "min-width": 0 }}> - <div style={{ "font-size": "13px", color: "var(--text-base, var(--vscode-foreground))" }}> - {toolTitle(props.tool.id)} - </div> - <div - style={{ - "font-size": "12px", - color: "var(--text-weak-base, var(--vscode-descriptionForeground))", - "margin-top": "6px", - }} - > - {language.t(props.tool.descriptionKey)} - </div> - </div> - </div> - - {/* Wildcard row */} - <div - style={{ - display: "flex", - gap: "24px", - "align-items": "center", - "justify-content": "space-between", - padding: "8px 0", - }} - > - <div style={{ flex: 1, "min-width": 0 }}> - <div style={{ "font-size": "12px", color: "var(--text-base, #ccc)" }}> - {language.t(props.tool.granular.wildcardKey)} - </div> - </div> - <ActionSelect level={level()} onChange={props.onWildcardChange} /> - </div> - - {/* Exceptions */} - <Show when={excs().length > 0}> - <div style={{ "margin-top": "4px" }}> - <div - style={{ - "font-size": "12px", - color: "var(--text-weak-base, var(--vscode-descriptionForeground))", - "margin-bottom": "4px", - }} - > - {language.t("settings.autoApprove.exceptions")} - </div> - <For each={excs()}> - {(exc) => ( - <div - style={{ - display: "flex", - gap: "8px", - "align-items": "center", - padding: "4px 0", - "padding-left": "12px", - "border-top": "1px solid var(--border-weak-base)", - }} - > - <div - style={{ - flex: "1 1 0%", - "min-width": 0, - "font-size": "13px", - "font-family": "var(--vscode-editor-font-family, monospace)", - color: "var(--text-base, #ccc)", - overflow: "hidden", - "text-overflow": "ellipsis", - "white-space": "nowrap", - }} - title={exc.pattern} - > - {exc.pattern} - </div> - <div style={{ display: "flex", gap: "4px", "align-items": "center", "flex-shrink": 0 }}> - <ActionSelect level={exc.action} onChange={(level) => props.onExceptionChange(exc.pattern, level)} /> - <IconButton - variant="ghost" - size="small" - icon="close" - onClick={() => props.onExceptionRemove(exc.pattern)} - /> - </div> - </div> - )} - </For> - </div> - </Show> - - {/* Add button / inline input */} - <Show - when={adding()} - fallback={ - <button - style={{ - display: "flex", - gap: "4px", - "align-items": "center", - padding: "4px 0", - background: "none", - border: "none", - cursor: "pointer", - "font-size": "12px", - color: "var(--text-link-base, #3794ff)", - "font-family": "inherit", - "margin-top": "4px", - }} - onClick={() => setAdding(true)} - > - <span style={{ "font-size": "14px" }}>+</span> - {language.t(props.tool.granular.addKey)} - </button> - } - > - <div style={{ display: "flex", gap: "8px", "align-items": "center", "margin-top": "4px" }}> - <input - ref={(el) => (inputRef = el)} - type="text" - value={input()} - onInput={(e) => setInput(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter") submit() - if (e.key === "Escape") cancel() - }} - onBlur={() => { - if (!input().trim()) cancel() - }} - placeholder={language.t(props.tool.granular.placeholderKey)} - style={{ - flex: 1, - "min-width": 0, - background: "var(--surface-strong-base, #252526)", - border: "1px solid var(--border-base, #434443)", - "border-radius": "2px", - color: "var(--text-base, #ccc)", - "font-size": "13px", - "font-family": "var(--vscode-editor-font-family, monospace)", - padding: "4px 8px", - outline: "none", - }} - /> - <IconButton variant="ghost" size="small" icon="close" onClick={cancel} /> - </div> - </Show> - </div> - ) -} - -const ActionSelect: Component<{ - level: PermissionLevel - onChange: (level: PermissionLevel) => void -}> = (props) => { - const language = useLanguage() - return ( - <Select - options={LEVEL_OPTIONS} - current={LEVEL_OPTIONS.find((o) => o.value === props.level)} - value={(o) => o.value} - label={(o) => language.t(o.labelKey)} - onSelect={(option) => option && props.onChange(option.value)} - variant="secondary" - size="small" - triggerVariant="settings" + <PermissionEditor + permissions={permissions()} + description={language.t("settings.autoApprove.description")} + onChange={(patch) => updateConfig({ permission: patch })} /> ) } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ModeEditView.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ModeEditView.tsx index 2c436efd8ef..434a92b27af 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ModeEditView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ModeEditView.tsx @@ -8,9 +8,10 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button" import { useConfig } from "../../context/config" import { useSession } from "../../context/session" import { useLanguage } from "../../context/language" -import type { AgentConfig, AgentInfo, PermissionRuleItem } from "../../types/messages" +import type { AgentConfig, AgentInfo, PermissionConfig, PermissionRuleItem } from "../../types/messages" import SettingsRow from "./SettingsRow" import { buildExport } from "./mode-io" +import PermissionEditor from "./PermissionEditor" interface Props { name: string @@ -43,6 +44,10 @@ const ModeEditView: Component<Props> = (props) => { }) } + const updatePermission = (patch: PermissionConfig) => { + updateConfig({ agent: { [props.name]: { permission: patch } } }) + } + const exportMode = () => { const data = buildExport(props.name, cfg()) const json = JSON.stringify(data, null, 2) @@ -231,6 +236,48 @@ const ModeEditView: Component<Props> = (props) => { </SettingsRow> </Card> + <Show when={!native()}> + <Card + style={{ + "margin-bottom": "12px", + padding: "0", + overflow: "hidden", + border: "1px solid var(--border-base, var(--vscode-panel-border))", + }} + > + <div + style={{ + padding: "14px 16px 12px", + "border-bottom": "1px solid var(--border-weak-base, var(--vscode-panel-border))", + background: "var(--bg-subtle-base, var(--vscode-editorWidget-background))", + }} + > + <div data-slot="settings-row-label-title" style={{ "margin-bottom": "6px" }}> + Per-Agent Permissions + </div> + <div + style={{ + "font-size": "12px", + color: "var(--text-weak-base, var(--vscode-descriptionForeground))", + "line-height": "1.45", + }} + > + These settings only apply to this custom agent. Change a dropdown from Default to create an override, or + use Add path/Add command for tool-specific exceptions. + </div> + </div> + <div style={{ padding: "0 16px 4px" }}> + <PermissionEditor + permissions={cfg().permission} + rules={agent()?.permission} + component="agent-permission-settings" + inherited + onChange={updatePermission} + /> + </div> + </Card> + </Show> + {/* Calculated permissions (read-only, collapsible) */} <Show when={agent()?.permission} keyed> {(rules) => ( diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/PermissionEditor.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/PermissionEditor.tsx new file mode 100644 index 00000000000..994090a1cbe --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/settings/PermissionEditor.tsx @@ -0,0 +1,571 @@ +import { Component, For, Show, createEffect, createMemo, createSignal } from "solid-js" +import { Select } from "@kilocode/kilo-ui/select" +import { IconButton } from "@kilocode/kilo-ui/icon-button" + +import { useLanguage } from "../../context/language" +import type { PermissionConfig, PermissionLevel, PermissionRule, PermissionRuleItem } from "../../types/messages" +import { effectiveRuleLevel } from "./permission-utils" + +type LevelValue = PermissionLevel | "inherit" + +interface LevelOption { + value: LevelValue + labelKey: string +} + +const LEVEL_OPTIONS: LevelOption[] = [ + { value: "allow", labelKey: "settings.autoApprove.level.allow" }, + { value: "ask", labelKey: "settings.autoApprove.level.ask" }, + { value: "deny", labelKey: "settings.autoApprove.level.deny" }, +] + +const INHERIT_OPTION: LevelOption = { value: "inherit", labelKey: "common.default" } + +interface GranularConfig { + wildcardKey: string + addKey: string + placeholderKey: string +} + +interface ToolDef { + id: string + descriptionKey: string + granular?: GranularConfig +} + +interface GranularToolDef extends ToolDef { + granular: GranularConfig +} + +interface GroupedToolDef { + ids: string[] + label: string + descriptionKey: string +} + +const GRANULAR_TOOLS: GranularToolDef[] = [ + { + id: "external_directory", + descriptionKey: "settings.autoApprove.tool.external_directory", + granular: { + wildcardKey: "settings.autoApprove.wildcardLabel.paths", + addKey: "settings.autoApprove.addPath", + placeholderKey: "settings.autoApprove.placeholder.path", + }, + }, + { + id: "bash", + descriptionKey: "settings.autoApprove.tool.bash", + granular: { + wildcardKey: "settings.autoApprove.wildcardLabel.commands", + addKey: "settings.autoApprove.addCommand", + placeholderKey: "settings.autoApprove.placeholder.command", + }, + }, + { + id: "read", + descriptionKey: "settings.autoApprove.tool.read", + granular: { + wildcardKey: "settings.autoApprove.wildcardLabel.paths", + addKey: "settings.autoApprove.addPath", + placeholderKey: "settings.autoApprove.placeholder.path", + }, + }, + { + id: "edit", + descriptionKey: "settings.autoApprove.tool.edit", + granular: { + wildcardKey: "settings.autoApprove.wildcardLabel.paths", + addKey: "settings.autoApprove.addPath", + placeholderKey: "settings.autoApprove.placeholder.path", + }, + }, +] + +const SIMPLE_TOOLS: ToolDef[] = [ + { id: "glob", descriptionKey: "settings.autoApprove.tool.glob" }, + { id: "grep", descriptionKey: "settings.autoApprove.tool.grep" }, + { id: "list", descriptionKey: "settings.autoApprove.tool.list" }, + { id: "task", descriptionKey: "settings.autoApprove.tool.task" }, + { id: "skill", descriptionKey: "settings.autoApprove.tool.skill" }, + { id: "lsp", descriptionKey: "settings.autoApprove.tool.lsp" }, +] + +const GROUPED_TOOLS: GroupedToolDef[] = [ + { + ids: ["todoread", "todowrite"], + label: "todoread / todowrite", + descriptionKey: "settings.autoApprove.tool.todoreadwrite", + }, + { + ids: ["websearch", "codesearch"], + label: "websearch / codesearch", + descriptionKey: "settings.autoApprove.tool.websearchcodesearch", + }, +] + +const TRAILING_TOOLS: ToolDef[] = [ + { id: "webfetch", descriptionKey: "settings.autoApprove.tool.webfetch" }, + { id: "doom_loop", descriptionKey: "settings.autoApprove.tool.doom_loop" }, +] + +const RESTRICTION_ORDER: Record<PermissionLevel, number> = { allow: 0, ask: 1, deny: 2 } + +type PermissionPatch = PermissionConfig + +function mostRestrictive(levels: PermissionLevel[]): PermissionLevel { + return levels.reduce<PermissionLevel>( + (best, level) => (RESTRICTION_ORDER[level] > RESTRICTION_ORDER[best] ? level : best), + levels[0] ?? "allow", + ) +} + +function wildcardAction(rule: PermissionRule | undefined, fallback: PermissionLevel): PermissionLevel { + if (!rule) return fallback + if (typeof rule === "string") return rule + if (rule === null) return fallback + return rule["*"] ?? fallback +} + +function inheritedWildcard(rule: PermissionRule | undefined): boolean { + if (!rule) return true + if (typeof rule === "string") return false + if (rule === null) return true + return rule["*"] === undefined || rule["*"] === null +} + +function exceptions(rule: PermissionRule | undefined): Array<{ pattern: string; action: PermissionLevel }> { + if (!rule || typeof rule === "string") return [] + return Object.entries(rule) + .filter(([key, action]) => key !== "*" && action !== null) + .map(([pattern, action]) => ({ pattern, action: action as PermissionLevel })) +} + +function toolTitle(id: string): string { + return id + .split("_") + .map((word) => word[0].toUpperCase() + word.slice(1)) + .join(" ") + .split(" / ") + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(" / ") +} + +const PermissionEditor: Component<{ + permissions?: PermissionConfig + rules?: PermissionRuleItem[] + description?: string + component?: string + inherited?: boolean + onChange: (patch: PermissionPatch) => void +}> = (props) => { + const perms = createMemo(() => props.permissions ?? {}) + + const levelFor = (tool: string): PermissionLevel => + wildcardAction(perms()[tool], effectiveRuleLevel(props.rules, tool)) + + const ruleFor = (tool: string): PermissionRule | undefined => perms()[tool] + + const setSimple = (tool: string, level: PermissionLevel) => { + props.onChange({ [tool]: level }) + } + + const clearSimple = (tool: string) => { + props.onChange({ [tool]: null }) + } + + const setGrouped = (ids: string[], level: PermissionLevel) => { + const patch: PermissionPatch = {} + for (const id of ids) patch[id] = level + props.onChange(patch) + } + + const clearGrouped = (ids: string[]) => { + const patch: PermissionPatch = {} + for (const id of ids) patch[id] = null + props.onChange(patch) + } + + const setWildcard = (tool: string, level: PermissionLevel) => { + const excs = exceptions(ruleFor(tool)) + if (excs.length === 0) { + props.onChange({ [tool]: level }) + return + } + const obj: Record<string, PermissionLevel | null> = { "*": level } + for (const exc of excs) obj[exc.pattern] = exc.action + props.onChange({ [tool]: obj }) + } + + const clearWildcard = (tool: string) => { + const excs = exceptions(ruleFor(tool)) + if (excs.length === 0) { + props.onChange({ [tool]: null }) + return + } + props.onChange({ [tool]: { "*": null } }) + } + + const setException = (tool: string, pattern: string, level: PermissionLevel) => { + const current = ruleFor(tool) + const base: Record<string, PermissionLevel | null> = + typeof current === "string" || current === null ? { "*": current } : { ...(current ?? {}) } + base[pattern] = level + props.onChange({ [tool]: base }) + } + + const addException = (tool: string, pattern: string) => { + const current = ruleFor(tool) + const base: Record<string, PermissionLevel | null> = + typeof current === "string" || current === null ? { "*": current } : { ...(current ?? {}) } + base[pattern] = "allow" + props.onChange({ [tool]: base }) + } + + const removeException = (tool: string, pattern: string) => { + const current = ruleFor(tool) + if (!current || typeof current === "string") return + props.onChange({ [tool]: { [pattern]: null } }) + } + + return ( + <div data-component={props.component ?? "auto-approve-settings"}> + <Show when={props.description}> + <div + style={{ + "font-size": "12px", + color: "var(--text-weak-base, var(--vscode-descriptionForeground))", + "padding-bottom": "12px", + "border-bottom": "1px solid var(--border-weak-base)", + }} + > + {props.description} + </div> + </Show> + + <For each={GRANULAR_TOOLS}> + {(tool) => ( + <GranularToolRow + tool={tool} + rule={ruleFor(tool.id)} + fallback={levelFor(tool.id)} + inherited={props.inherited && inheritedWildcard(ruleFor(tool.id))} + allowInherit={props.inherited} + onWildcardChange={(level) => setWildcard(tool.id, level)} + onWildcardInherit={() => clearWildcard(tool.id)} + onExceptionChange={(pattern, level) => setException(tool.id, pattern, level)} + onExceptionAdd={(pattern) => addException(tool.id, pattern)} + onExceptionRemove={(pattern) => removeException(tool.id, pattern)} + /> + )} + </For> + + <For each={SIMPLE_TOOLS}> + {(tool) => ( + <SimpleToolRow + id={tool.id} + descriptionKey={tool.descriptionKey} + level={levelFor(tool.id)} + inherited={props.inherited && ruleFor(tool.id) === undefined} + onChange={(level) => setSimple(tool.id, level)} + onInherit={() => clearSimple(tool.id)} + /> + )} + </For> + + <For each={GROUPED_TOOLS}> + {(group) => ( + <SimpleToolRow + id={group.label} + descriptionKey={group.descriptionKey} + level={mostRestrictive(group.ids.map(levelFor))} + inherited={props.inherited && group.ids.every((id) => ruleFor(id) === undefined)} + onChange={(level) => setGrouped(group.ids, level)} + onInherit={() => clearGrouped(group.ids)} + /> + )} + </For> + + <For each={TRAILING_TOOLS}> + {(tool) => ( + <SimpleToolRow + id={tool.id} + descriptionKey={tool.descriptionKey} + level={levelFor(tool.id)} + inherited={props.inherited && ruleFor(tool.id) === undefined} + onChange={(level) => setSimple(tool.id, level)} + onInherit={() => clearSimple(tool.id)} + /> + )} + </For> + </div> + ) +} + +const SimpleToolRow: Component<{ + id: string + descriptionKey: string + level: PermissionLevel + inherited?: boolean + onChange: (level: PermissionLevel) => void + onInherit?: () => void +}> = (props) => { + const language = useLanguage() + return ( + <div + style={{ + display: "flex", + gap: "24px", + "align-items": "flex-start", + "justify-content": "space-between", + padding: "12px 0", + "border-bottom": "1px solid var(--border-weak-base)", + }} + > + <div style={{ flex: 1, "min-width": 0 }}> + <div style={{ "font-size": "13px", color: "var(--text-base, var(--vscode-foreground))" }}> + {toolTitle(props.id)} + </div> + <div + style={{ + "font-size": "12px", + color: "var(--text-weak-base, var(--vscode-descriptionForeground))", + "margin-top": "6px", + }} + > + {language.t(props.descriptionKey)} + </div> + </div> + <ActionSelect + level={props.level} + inherited={props.inherited} + onChange={props.onChange} + onInherit={props.onInherit} + /> + </div> + ) +} + +const GranularToolRow: Component<{ + tool: GranularToolDef + rule: PermissionRule | undefined + fallback: PermissionLevel + inherited?: boolean + allowInherit?: boolean + onWildcardChange: (level: PermissionLevel) => void + onWildcardInherit: () => void + onExceptionChange: (pattern: string, level: PermissionLevel) => void + onExceptionAdd: (pattern: string) => void + onExceptionRemove: (pattern: string) => void +}> = (props) => { + const language = useLanguage() + const [adding, setAdding] = createSignal(false) + const [input, setInput] = createSignal("") + let ref: HTMLInputElement | undefined + + createEffect(() => { + if (adding()) ref?.focus() + }) + + const excs = createMemo(() => exceptions(props.rule)) + const level = createMemo(() => wildcardAction(props.rule, props.fallback)) + + const submit = () => { + const val = input().trim() + if (val) { + props.onExceptionAdd(val) + setInput("") + } + setAdding(false) + } + + const cancel = () => { + setInput("") + setAdding(false) + } + + return ( + <div style={{ padding: "12px 0", "border-bottom": "1px solid var(--border-weak-base)" }}> + <div style={{ display: "flex", gap: "24px", "align-items": "flex-start", "justify-content": "space-between" }}> + <div style={{ flex: 1, "min-width": 0 }}> + <div style={{ "font-size": "13px", color: "var(--text-base, var(--vscode-foreground))" }}> + {toolTitle(props.tool.id)} + </div> + <div + style={{ + "font-size": "12px", + color: "var(--text-weak-base, var(--vscode-descriptionForeground))", + "margin-top": "6px", + }} + > + {language.t(props.tool.descriptionKey)} + </div> + </div> + </div> + + <div + style={{ + display: "flex", + gap: "24px", + "align-items": "center", + "justify-content": "space-between", + padding: "8px 0", + }} + > + <div style={{ flex: 1, "min-width": 0 }}> + <div style={{ "font-size": "12px", color: "var(--text-base, #ccc)" }}> + {language.t(props.tool.granular.wildcardKey)} + </div> + </div> + <ActionSelect + level={level()} + inherited={props.inherited} + onChange={props.onWildcardChange} + onInherit={props.allowInherit ? props.onWildcardInherit : undefined} + /> + </div> + + <Show when={excs().length > 0}> + <div style={{ "margin-top": "4px" }}> + <div + style={{ + "font-size": "12px", + color: "var(--text-weak-base, var(--vscode-descriptionForeground))", + "margin-bottom": "4px", + }} + > + {language.t("settings.autoApprove.exceptions")} + </div> + <For each={excs()}> + {(exc) => ( + <div + style={{ + display: "flex", + gap: "8px", + "align-items": "center", + padding: "4px 0", + "padding-left": "12px", + "border-top": "1px solid var(--border-weak-base)", + }} + > + <div + style={{ + flex: "1 1 0%", + "min-width": 0, + "font-size": "13px", + "font-family": "var(--vscode-editor-font-family, monospace)", + color: "var(--text-base, #ccc)", + overflow: "hidden", + "text-overflow": "ellipsis", + "white-space": "nowrap", + }} + title={exc.pattern} + > + {exc.pattern} + </div> + <div style={{ display: "flex", gap: "4px", "align-items": "center", "flex-shrink": 0 }}> + <ActionSelect + level={exc.action} + onChange={(level: PermissionLevel) => props.onExceptionChange(exc.pattern, level)} + /> + <IconButton + variant="ghost" + size="small" + icon="close" + onClick={() => props.onExceptionRemove(exc.pattern)} + /> + </div> + </div> + )} + </For> + </div> + </Show> + + <Show + when={adding()} + fallback={ + <button + style={{ + display: "flex", + gap: "4px", + "align-items": "center", + padding: "4px 0", + background: "none", + border: "none", + cursor: "pointer", + "font-size": "12px", + color: "var(--text-link-base, #3794ff)", + "font-family": "inherit", + "margin-top": "4px", + }} + onClick={() => setAdding(true)} + > + <span style={{ "font-size": "14px" }}>+</span> + {language.t(props.tool.granular.addKey)} + </button> + } + > + <div style={{ display: "flex", gap: "8px", "align-items": "center", "margin-top": "4px" }}> + <input + ref={(el) => (ref = el)} + type="text" + value={input()} + onInput={(e) => setInput(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") submit() + if (e.key === "Escape") cancel() + }} + onBlur={() => { + if (!input().trim()) cancel() + }} + placeholder={language.t(props.tool.granular.placeholderKey)} + style={{ + flex: 1, + "min-width": 0, + background: "var(--surface-strong-base, #252526)", + border: "1px solid var(--border-base, #434443)", + "border-radius": "2px", + color: "var(--text-base, #ccc)", + "font-size": "13px", + "font-family": "var(--vscode-editor-font-family, monospace)", + padding: "4px 8px", + outline: "none", + }} + /> + <IconButton variant="ghost" size="small" icon="close" onClick={cancel} /> + </div> + </Show> + </div> + ) +} + +const ActionSelect: Component<{ + level: PermissionLevel + inherited?: boolean + onChange: (level: PermissionLevel) => void + onInherit?: () => void +}> = (props) => { + const language = useLanguage() + const opts = createMemo(() => (props.onInherit ? [INHERIT_OPTION, ...LEVEL_OPTIONS] : LEVEL_OPTIONS)) + return ( + <Select + options={opts()} + current={props.inherited ? INHERIT_OPTION : LEVEL_OPTIONS.find((option) => option.value === props.level)} + value={(option) => option.value} + label={(option) => language.t(option.labelKey)} + onSelect={(option) => { + if (!option) return + if (option.value === "inherit") { + props.onInherit?.() + return + } + props.onChange(option.value) + }} + variant="secondary" + size="small" + triggerVariant="settings" + /> + ) +} + +export default PermissionEditor diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/permission-utils.ts b/packages/kilo-vscode/webview-ui/src/components/settings/permission-utils.ts new file mode 100644 index 00000000000..ff9989907dc --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/settings/permission-utils.ts @@ -0,0 +1,17 @@ +import type { PermissionLevel, PermissionRuleItem } from "../../types/messages" + +function matchTool(tool: string, pattern: string): boolean { + if (pattern === tool || pattern === "*") return true + if (!pattern.includes("*")) return false + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + return new RegExp(`^${escaped}$`).test(tool) +} + +export function effectiveRuleLevel(rules: PermissionRuleItem[] | undefined, tool: string): PermissionLevel { + const list = rules ?? [] + for (let i = list.length - 1; i >= 0; i--) { + const item = list[i] + if (item.pattern === "*" && matchTool(tool, item.permission)) return item.action + } + return "ask" +} diff --git a/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx index 412f817e285..69f397e5fc6 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx @@ -102,6 +102,14 @@ export const AgentBehaviourEditCustomMode: Story = { prompt: "You are a code reviewer. Focus on code quality, best practices, and potential bugs.", model: "anthropic/claude-sonnet-4-20250514", temperature: 0.3, + permission: { + read: "allow", + grep: "allow", + glob: "allow", + edit: "deny", + bash: "deny", + task: "ask", + }, }, } return ( @@ -322,6 +330,51 @@ export const ModeEditExport: Story = { }, } +export const ModeEditPermissions: Story = { + name: "ModeEditView — per-agent permissions", + render: () => { + const cfg: Record<string, AgentConfig> = { + reviewer: { + description: "Review code without editing it", + prompt: "Find bugs, regressions, and missing tests.", + permission: { + "*": "deny", + read: "allow", + grep: "allow", + glob: "allow", + edit: { "*": "deny", "**/*.md": "allow" }, + bash: "deny", + task: "ask", + skill: "deny", + }, + }, + } + const session = { + ...mockSessionValue({ id: "permissions-story", status: "idle" }), + agents: () => MOCK_AGENTS, + allAgents: () => MOCK_AGENTS, + removeMode: noop, + removeMcp: noop, + skills: () => [], + refreshSkills: noop, + removeSkill: noop, + } + return ( + <StoryProviders + sessionID="permissions-story" + status="idle" + config={{ permission: { bash: "ask", external_directory: "ask" }, agent: cfg } as any} + > + <SessionContext.Provider value={session as any}> + <div style={{ width: "460px", height: "760px", overflow: "auto" }}> + <ModeEditView name="reviewer" onBack={noop} onRemove={noop} /> + </div> + </SessionContext.Provider> + </StoryProviders> + ) + }, +} + export const IndexingProviderBlurRace: Story = { name: "IndexingTab", render: () => { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/permissions.ts b/packages/kilo-vscode/webview-ui/src/types/messages/permissions.ts index fb7104a8f1a..ece6dfc0366 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/permissions.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/permissions.ts @@ -1,7 +1,7 @@ export type PermissionLevel = "allow" | "ask" | "deny" /** null in a PermissionRule object is a delete sentinel — removes the key from the config */ -export type PermissionRule = PermissionLevel | Record<string, PermissionLevel | null> +export type PermissionRule = PermissionLevel | null | Record<string, PermissionLevel | null> export type PermissionConfig = Partial<Record<string, PermissionRule>> From 6a32cb1e6f41c00affd420c4e0f07b79b35869a4 Mon Sep 17 00:00:00 2001 From: Josh Lambert <josh@kilocode.ai> Date: Mon, 27 Apr 2026 16:08:38 -0400 Subject: [PATCH 85/96] docs(kilo-docs): clarify plugin cache path --- packages/kilo-docs/pages/automate/extending/plugins.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kilo-docs/pages/automate/extending/plugins.md b/packages/kilo-docs/pages/automate/extending/plugins.md index f5d07ac2f15..00914b2bb0e 100644 --- a/packages/kilo-docs/pages/automate/extending/plugins.md +++ b/packages/kilo-docs/pages/automate/extending/plugins.md @@ -90,7 +90,7 @@ The command resolves the package, reads its `package.json` for plugin entrypoint ### How plugins are installed -- **npm plugins** are installed automatically at startup using Bun. Packages and their dependencies are cached in Kilo's XDG cache directory (`~/.cache/kilo/` on Linux, `~/Library/Caches/kilo/` on macOS, `%LOCALAPPDATA%\kilo\` on Windows). +- **npm plugins** are installed automatically at startup using Bun. Packages and their dependencies are cached under `packages/` in the current CLI XDG cache directory (`~/.cache/opencode/packages/` by default, or `$XDG_CACHE_HOME/opencode/packages/` when `XDG_CACHE_HOME` is set). - **Pinned npm versions** like `my-plugin@1.2.3` install that exact version and do not check for newer registry versions. Bare package names resolve to `latest` and can refresh when the cached copy becomes stale. - **Install scripts are disabled** for npm plugins. Kilo installs packages with lifecycle scripts such as `install` and `postinstall` blocked. - **Local plugins** are loaded directly from the plugin directory. If your plugin imports external packages, add a `package.json` to your config directory (see [Dependencies](#dependencies)) — Kilo runs `bun install` on startup so imports resolve. @@ -596,8 +596,8 @@ Host slots include `home_prompt_right`, `session_prompt`, `session_prompt_right` - **Package installed but not active in one runtime** — make sure the package exposes the matching entrypoint. Server plugins need `exports["./server"]` or `main`; TUI plugins need `exports["./tui"]` or valid `oc-themes`. Packages that only support the other runtime are skipped with a warning instead of causing a fatal load error. - **Local plugin can't find an npm import** — add a `package.json` in the config directory so `bun install` picks up the dependency (see [Dependencies](#dependencies)). -- **Plugin loads in dev but not in CI** — verify `KILO_PURE` is not set, and that npm-installed plugins are cached (Kilo's XDG cache directory — `~/.cache/kilo/` on Linux, `~/Library/Caches/kilo/` on macOS, `%LOCALAPPDATA%\kilo\` on Windows). Run with `--log-level DEBUG` to see install output. -- **Reset the plugin cache** — delete the `node_modules/` under Kilo's cache directory (or the `node_modules` cache under your config directory) and restart Kilo. +- **Plugin loads in dev but not in CI** — verify `KILO_PURE` is not set, and that npm-installed plugins are cached under `packages/` in the current CLI XDG cache directory (`~/.cache/opencode/packages/` by default, or `$XDG_CACHE_HOME/opencode/packages/` when `XDG_CACHE_HOME` is set). Run with `--log-level DEBUG` to see install output. +- **Reset the plugin cache** — delete the plugin package folder under the CLI's `packages/` cache directory (or the `node_modules` cache under your config directory) and restart Kilo. --- From da3d79a6886944b4ad311211e3f67c350958a6ca Mon Sep 17 00:00:00 2001 From: Marius <marius@kilocode.ai> Date: Thu, 30 Apr 2026 17:29:50 +0200 Subject: [PATCH 86/96] fix(cli): scope GPT-5.5 prompt behavior (#9745) * fix(cli): scope GPT-5.5 prompt behavior * fix(cli): route GPT-5.5 prompt by metadata * fix(cli): allow prompt metadata in provider config --- .changeset/gpt55-codex-prompt.md | 5 + packages/kilo-gateway/src/api/constants.ts | 11 ++- packages/opencode/src/config/provider.ts | 2 + .../src/session/prompt/kilocode-gpt-5.5.txt | 95 +++++++++++++++++++ packages/opencode/src/session/system.ts | 3 + .../test/kilocode/system-prompt.test.ts | 38 ++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 3 +- packages/sdk/openapi.json | 15 ++- 8 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 .changeset/gpt55-codex-prompt.md create mode 100644 packages/opencode/src/session/prompt/kilocode-gpt-5.5.txt diff --git a/.changeset/gpt55-codex-prompt.md b/.changeset/gpt55-codex-prompt.md new file mode 100644 index 00000000000..ee75ff945e6 --- /dev/null +++ b/.changeset/gpt55-codex-prompt.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Use a GPT-5.5-specific coding prompt that improves autonomous task handling while keeping older Codex generations on their existing prompt. diff --git a/packages/kilo-gateway/src/api/constants.ts b/packages/kilo-gateway/src/api/constants.ts index d7a1eb9b17b..75b518f1b24 100644 --- a/packages/kilo-gateway/src/api/constants.ts +++ b/packages/kilo-gateway/src/api/constants.ts @@ -70,6 +70,15 @@ export const HEADER_FEATURE = "X-KILOCODE-FEATURE" /** Environment variable name for feature override */ export const ENV_FEATURE = "KILOCODE_FEATURE" -export const PROMPTS = ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo", "ling"] as const +export const PROMPTS = [ + "codex", + "gemini", + "beast", + "anthropic", + "trinity", + "anthropic_without_todo", + "ling", + "gpt55", +] as const export const AI_SDK_PROVIDERS = ["alibaba", "anthropic", "openai", "openai-compatible", "openrouter"] as const diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 440904d7ce9..f49b155c37c 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { PROMPTS } from "@kilocode/kilo-gateway" // kilocode_change import { zod } from "@/util/effect-zod" import { PositiveInt, withStatics } from "@/util/schema" @@ -6,6 +7,7 @@ export const Model = Schema.Struct({ id: Schema.optional(Schema.String), name: Schema.optional(Schema.String), family: Schema.optional(Schema.String), + prompt: Schema.optional(Schema.Literals(PROMPTS)), // kilocode_change release_date: Schema.optional(Schema.String), attachment: Schema.optional(Schema.Boolean), reasoning: Schema.optional(Schema.Boolean), diff --git a/packages/opencode/src/session/prompt/kilocode-gpt-5.5.txt b/packages/opencode/src/session/prompt/kilocode-gpt-5.5.txt new file mode 100644 index 00000000000..8f5b8a1c537 --- /dev/null +++ b/packages/opencode/src/session/prompt/kilocode-gpt-5.5.txt @@ -0,0 +1,95 @@ +You are Kilo, an AI coding agent operating in the user's current project checkout. + +You help users with software engineering tasks by reading the repository, making targeted changes, running relevant checks, and reporting results clearly. Treat the workspace as shared with the user and other agents. + +## Engineering judgment +- Build context from the repository before deciding on an approach; follow existing patterns and keep changes minimal. +- Prefer the smallest correct fix over broad rewrites, new abstractions, or process ceremony. +- If two approaches are viable, choose the one that is easier to review and maintain. +- Ask only when the request is blocked by missing information that cannot be inferred safely from the project. + +## Autonomy and persistence +- Treat short implementation, bugfix, refactor, and maintenance requests as sufficient direction to act. +- Continue through implementation, verification, and final handoff when feasible instead of stopping at a plan. +- Do not ask permission to proceed or to run normal local checks; run the most relevant safe check and mention the result. +- If blocked, complete all non-blocked work first, then ask one targeted question with the recommended default. + +## Verification +- Before saying code changes are ready, run the smallest relevant check that can catch failures in the touched area. +- Fix failures you introduced before finalizing when practical. +- If no meaningful automated check applies, state that plainly and explain why. +- Do not claim a check passed unless you ran it in this workspace. + +## Editing constraints +- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. +- Only add comments if they are necessary to make a non-obvious block easier to understand. +- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). + +## Tool usage +- If the Task tool is available, use it proactively to delegate focused subtasks to a subagent instance. You can spawn multiple subagents in parallel. +- Prefer specialized tools over shell for file operations: + - Use Read to view files, Edit to modify files, and Write only when needed. + - Use Glob to find files by name and Grep to search file contents. +- Use Bash for terminal operations (git, bun, builds, tests, running scripts). +- Run tool calls in parallel when neither call needs the other's output; otherwise run sequentially. + +## Git and workspace hygiene +- You may be in a dirty git worktree. + * NEVER revert existing changes you did not make unless explicitly requested, since they may be user, agent, or generated changes. + * Ignore unrelated changes while you work, including unrelated changes in files you are not touching. + * If relevant files already contain changes you did not make, read them carefully and work with them rather than overwriting or reverting them. + * Ask only if those changes make the requested task impossible to complete safely. +- Do not amend commits unless explicitly requested. +- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user. + +## Frontend tasks +When doing frontend design tasks, avoid collapsing into bland, generic layouts. +Aim for interfaces that feel intentional and deliberate. +- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system). +- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias. +- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions. +- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere. +- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs. +- Ensure the page loads properly on both desktop and mobile. + +Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language. + +## Presenting your work and final message + +You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. + +- Default: be very concise; friendly coding teammate tone. +- Questions: only ask when you are truly blocked after checking relevant context AND you cannot safely pick a reasonable default. This usually means one of: + * The request is ambiguous in a way that materially changes the result and you cannot disambiguate by reading the repo. + * The action is destructive/irreversible, touches production, or changes billing/security posture. + * You need a secret/credential/value that cannot be inferred (API key, account id, etc.). +- For substantial work, summarize clearly; follow final-answer formatting. +- Skip heavy formatting for simple confirmations. +- Don't dump large files you've written; reference paths only. +- No "save/copy this file" - User is on the same machine. +- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something. +- For code changes: + * Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in. + * If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps. + * When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number. +- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result. + +## Final answer structure and style guidelines + +- Plain text; CLI handles styling. Use structure only when it helps scannability. +- Headers: optional; short Title Case (1-3 words) wrapped in **...**; no blank line before the first bullet; add only if they truly help. +- Bullets: use - ; merge related points; keep to one line when possible; 4-6 per list ordered by importance; keep phrasing consistent. +- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **. +- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible. +- Structure: group related bullets; order sections general -> specific -> supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task. +- Tone: collaborative, concise, factual; present tense, active voice; self-contained; no "above/below"; parallel wording. +- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short - wrap/reformat if long; avoid naming formatting styles in answers. +- Adaptation: code explanations -> precise, structured with code refs; simple tasks -> lead with outcome; big changes -> logical walkthrough + rationale + next actions; casual one-offs -> plain sentences, no headers/bullets. +- File References: When referencing files in your response follow the below rules: + * Use inline code to make file paths clickable. + * Each reference should have a stand alone path. Even if it's the same file. + * Accepted: absolute, workspace-relative, a/ or b/ diff prefixes, or bare filename/suffix. + * Optionally include line/column (1-based): :line[:column] or #Lline[Ccolumn] (column defaults to 1). + * Do not use URIs like file://, vscode://, or https://. + * Do not provide range of lines + * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5 diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index fefbd5bb3bb..8b610e48865 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -8,6 +8,7 @@ import PROMPT_DEFAULT from "./prompt/default.txt" import PROMPT_BEAST from "./prompt/beast.txt" import PROMPT_GEMINI from "./prompt/gemini.txt" import PROMPT_GPT from "./prompt/gpt.txt" +import PROMPT_GPT55 from "./prompt/kilocode-gpt-5.5.txt" // kilocode_change import PROMPT_KIMI from "./prompt/kimi.txt" import PROMPT_LING from "./prompt/ling.txt" // kilocode_change @@ -47,6 +48,8 @@ export function provider(model: Provider.Model) { return [PROMPT_CODEX] case "gemini": return [PROMPT_GEMINI] + case "gpt55": + return [PROMPT_GPT55] case "ling": return [PROMPT_LING] case "trinity": diff --git a/packages/opencode/test/kilocode/system-prompt.test.ts b/packages/opencode/test/kilocode/system-prompt.test.ts index 69b4b2ac1a5..d54b94dc03b 100644 --- a/packages/opencode/test/kilocode/system-prompt.test.ts +++ b/packages/opencode/test/kilocode/system-prompt.test.ts @@ -7,6 +7,8 @@ import PROMPT_DEFAULT from "../../src/session/prompt/default.txt" import PROMPT_BEAST from "../../src/session/prompt/beast.txt" import PROMPT_CODEX from "../../src/session/prompt/codex.txt" import PROMPT_GEMINI from "../../src/session/prompt/gemini.txt" +import PROMPT_GPT from "../../src/session/prompt/gpt.txt" +import PROMPT_GPT55 from "../../src/session/prompt/kilocode-gpt-5.5.txt" import PROMPT_TRINITY from "../../src/session/prompt/trinity.txt" describe("SystemPrompt.provider", () => { @@ -35,6 +37,15 @@ describe("SystemPrompt.provider", () => { expect(result).toEqual([PROMPT_CODEX]) }) + test("GPT-5.5 prompt is selected from prompt metadata", () => { + const model = ProviderTest.model({ + prompt: "gpt55", + api: { id: "provider-specific-model", url: "https://example.com", npm: "@ai-sdk/openai" }, + }) + const result = SystemPrompt.provider(model) + expect(result).toEqual([PROMPT_GPT55]) + }) + test("gemini prompt is selected when model.prompt is 'gemini'", () => { const model = ProviderTest.model({ prompt: "gemini" }) const result = SystemPrompt.provider(model) @@ -66,5 +77,32 @@ describe("SystemPrompt.provider", () => { const result = SystemPrompt.provider(model) expect(result).toEqual([PROMPT_ANTHROPIC]) }) + + test("GPT-5.5 model ids are not prompt-special without metadata", () => { + const model = ProviderTest.model({ + prompt: undefined, + api: { id: "gpt-5.5", url: "https://example.com", npm: "@ai-sdk/openai" }, + }) + const result = SystemPrompt.provider(model) + expect(result).toEqual([PROMPT_GPT]) + }) + + test("codex prompt metadata still wins for GPT-5.5 model ids", () => { + const model = ProviderTest.model({ + prompt: "codex", + api: { id: "gpt-5.5", url: "https://example.com", npm: "@ai-sdk/openai" }, + }) + const result = SystemPrompt.provider(model) + expect(result).toEqual([PROMPT_CODEX]) + }) + + test("older Codex model ids keep the Codex prompt", () => { + const model = ProviderTest.model({ + prompt: undefined, + api: { id: "gpt-5.1-codex", url: "https://example.com", npm: "@ai-sdk/openai" }, + }) + const result = SystemPrompt.provider(model) + expect(result).toEqual([PROMPT_CODEX]) + }) }) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 15b487ff97f..a5713b94be3 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1672,6 +1672,7 @@ export type ProviderConfig = { id?: string name?: string family?: string + prompt?: "codex" | "gemini" | "beast" | "anthropic" | "trinity" | "anthropic_without_todo" | "ling" | "gpt55" release_date?: string attachment?: boolean reasoning?: boolean @@ -2194,7 +2195,7 @@ export type Model = { } } recommendedIndex?: number - prompt?: "codex" | "gemini" | "beast" | "anthropic" | "trinity" | "anthropic_without_todo" | "ling" + prompt?: "codex" | "gemini" | "beast" | "anthropic" | "trinity" | "anthropic_without_todo" | "ling" | "gpt55" isFree?: boolean ai_sdk_provider?: "alibaba" | "anthropic" | "openai" | "openai-compatible" | "openrouter" } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index bf127ae3ac2..0cf4637d350 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -15218,6 +15218,19 @@ "family": { "type": "string" }, + "prompt": { + "type": "string", + "enum": [ + "codex", + "gemini", + "beast", + "anthropic", + "trinity", + "anthropic_without_todo", + "ling", + "gpt55" + ] + }, "release_date": { "type": "string" }, @@ -16393,7 +16406,7 @@ }, "prompt": { "type": "string", - "enum": ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo", "ling"] + "enum": ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo", "ling", "gpt55"] }, "isFree": { "type": "boolean" From fb11c7b56d49cdd52f8ee96fdfc9ce30d2c883b7 Mon Sep 17 00:00:00 2001 From: kirillk <kirillk@kilocode.ai> Date: Thu, 30 Apr 2026 13:39:49 -0400 Subject: [PATCH 87/96] fix(vscode): center wide welcome notifications --- .changeset/center-wide-notifications.md | 5 +++++ packages/kilo-vscode/webview-ui/src/styles/notifications.css | 3 +++ 2 files changed, 8 insertions(+) create mode 100644 .changeset/center-wide-notifications.md diff --git a/.changeset/center-wide-notifications.md b/.changeset/center-wide-notifications.md new file mode 100644 index 00000000000..115139e9bcc --- /dev/null +++ b/.changeset/center-wide-notifications.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Center welcome notifications in wide VS Code views. diff --git a/packages/kilo-vscode/webview-ui/src/styles/notifications.css b/packages/kilo-vscode/webview-ui/src/styles/notifications.css index 054bd69e5a2..ef77957e52b 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/notifications.css +++ b/packages/kilo-vscode/webview-ui/src/styles/notifications.css @@ -5,6 +5,9 @@ .kilo-notifications { display: flex; flex-direction: column; + align-self: center; + width: 100%; + max-width: 720px; border-radius: 6px; animation: kilo-notifications-slide-in 0.3s ease-out; overflow: hidden; From bd34334dd8e4cc61daa112062409ba904d36aed0 Mon Sep 17 00:00:00 2001 From: Joshua Lambert <25085430+lambertjosh@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:24:50 -0400 Subject: [PATCH 88/96] Apply suggestions from code review Co-authored-by: Joshua Lambert <25085430+lambertjosh@users.noreply.github.com> --- .../contributing/architecture/per-message-feedback.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md b/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md index fadfc7d3efc..900a68363d7 100644 --- a/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md +++ b/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md @@ -13,7 +13,7 @@ We have no signal on which assistant responses are helpful and which aren't. Wit - Identify specific bad responses in the Kilo Gateway logs for investigation - Detect patterns where certain providers, models, or prompt paths consistently underperform -Aggregate metrics like session completion rate or token cost are too coarse to understand individual response quality. A lightweight thumbs-up/down on each message gives us the missing feedback loop. +Aggregate metrics like session completion rate or token cost are too coarse to understand individual response quality. A lightweight thumbs-up/down on each message can help close the feedback loop. ## Proposal @@ -25,13 +25,12 @@ Add a thumbs-up / thumbs-down widget next to the existing copy button on every a |---|---| | VS Code extension | Thumbs buttons inline next to the copy button | | TUI | Keybinds (`<leader>+` / `<leader>-`) on the last assistant message | -| Desktop / `kilo web` | Out of scope for the first pass | ### Telemetry Payload We deliberately collect fewer identifiers for non-Kilo providers, since those IDs can't be correlated to upstream data and add tracking surface without product benefit. -**Direct providers (Anthropic, OpenAI, etc.):** +**Third party providers (Anthropic, OpenAI, local, etc.):** `providerID`, `modelID`, `variant?`, `rating`, `previousRating?` **Kilo Gateway turns (`providerID` starts with `"kilo"`):** @@ -43,7 +42,7 @@ Event name: `"Feedback Submitted"` — a single event string in both telemetry e - **Toggleable**: click the same button again to clear, or click the opposite to switch. Each change fires a new event with `rating` and `previousRating`. - **In-memory state**: ratings are keyed by message ID and held in the webview/TUI session. Persisting across reloads is deferred to a follow-up. -- **Gated on telemetry**: if the user has VS Code telemetry disabled, the buttons don't render at all. The TUI keybinds are no-ops with a brief toast. +- **Gated on telemetry**: if the user has VS Code telemetry disabled, the buttons don't render at all. For the CLI when telemetry is off, the keybinds are no-ops. ### Architecture (high level) From 702496318c8bcd04d220c2d0208b28679f5eaa9c Mon Sep 17 00:00:00 2001 From: Joshua Lambert <25085430+lambertjosh@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:25:42 -0400 Subject: [PATCH 89/96] Apply suggestion from @lambertjosh --- .../pages/contributing/architecture/per-message-feedback.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md b/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md index 900a68363d7..b638dba13d2 100644 --- a/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md +++ b/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md @@ -28,7 +28,7 @@ Add a thumbs-up / thumbs-down widget next to the existing copy button on every a ### Telemetry Payload -We deliberately collect fewer identifiers for non-Kilo providers, since those IDs can't be correlated to upstream data and add tracking surface without product benefit. +We deliberately collect fewer identifiers for non-Kilo providers, since those IDs can't be correlated to upstream data and add tracking surface without product benefit. Users of non-Kilo GW models would also not expect or want us to collect that information in Kilo GW from other providers. **Third party providers (Anthropic, OpenAI, local, etc.):** `providerID`, `modelID`, `variant?`, `rating`, `previousRating?` From d75973bfeda3da23971382779e334f83526be243 Mon Sep 17 00:00:00 2001 From: Jake Jia <87770044+Phoen1xCode@users.noreply.github.com> Date: Fri, 1 May 2026 05:10:27 +0800 Subject: [PATCH 90/96] fix(kilo-docs): rewrite zenmux doc and add to nav sidebar (#6142) * fix(kilo-docs): rewrite zenmux doc and add to nav sidebar Rewrite zenmux.md to match the standard provider doc format (openrouter, minimax, etc.) and add ZenMux entry to ai-providers nav. Changes: - Add sidebar_label frontmatter, Website link, consistent section names - Remove unrelated API code examples and product feature descriptions - Add Supported Transforms and Tips and Notes sections - Fix typo (Visi -> Visit) and codicon syntax - Add ZenMux to ai-providers nav config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update packages/kilo-docs/pages/ai-providers/zenmux.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: update nix node_modules hashes --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: kilo-maintainer[bot] <kilo-maintainer[bot]@users.noreply.github.com> --- packages/kilo-docs/lib/nav/ai-providers.ts | 1 + packages/kilo-docs/pages/ai-providers/zenmux.md | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/kilo-docs/lib/nav/ai-providers.ts b/packages/kilo-docs/lib/nav/ai-providers.ts index 92ad10f6504..408598f0f52 100644 --- a/packages/kilo-docs/lib/nav/ai-providers.ts +++ b/packages/kilo-docs/lib/nav/ai-providers.ts @@ -31,6 +31,7 @@ export const AiProvidersNav: NavSection[] = [ { href: "/ai-providers/glama", children: "Glama" }, { href: "/ai-providers/requesty", children: "Requesty" }, { href: "/ai-providers/unbound", children: "Unbound" }, + { href: "/ai-providers/zenmux", children: "ZenMux" }, { href: "/ai-providers/vercel-ai-gateway", children: "Vercel AI Gateway", diff --git a/packages/kilo-docs/pages/ai-providers/zenmux.md b/packages/kilo-docs/pages/ai-providers/zenmux.md index 0092201a526..03034211ee4 100644 --- a/packages/kilo-docs/pages/ai-providers/zenmux.md +++ b/packages/kilo-docs/pages/ai-providers/zenmux.md @@ -1,10 +1,9 @@ --- title: "Using ZenMux with Kilo Code | Unified AI Gateway" description: "Access OpenAI, Anthropic, Google, and other AI models through ZenMux in Kilo Code with automatic routing and cost optimization." +sidebar_label: ZenMux --- -import Codicon from "@site/src/components/Codicon"; - # Using ZenMux With Kilo Code [ZenMux](https://zenmux.ai) provides a unified API gateway to access multiple AI models from different providers through a single endpoint. It supports OpenAI, Anthropic, Google, and other major AI providers, automatically handling routing, fallbacks, and cost optimization. @@ -20,7 +19,7 @@ import Codicon from "@site/src/components/Codicon"; {% tabs %} {% tab label="VSCode (Legacy)" %} -1. **Open Kilo Code Settings:** Click the gear icon (<Codicon name="gear" />) in the Kilo Code panel. +1. **Open Kilo Code Settings:** Click the gear icon ({% codicon name="gear" /%}) in the Kilo Code panel. 2. **Select Provider:** Choose "ZenMux" from the "API Provider" dropdown. 3. **Enter API Key:** Paste your ZenMux API key into the "ZenMux API Key" field. 4. **Select Model:** Choose your desired model from the "Model" dropdown. @@ -71,7 +70,7 @@ Then set your default model: ZenMux supports a wide range of models from various providers: -Visi [zenmux.ai/models](https://zenmux.ai/models) to see the complete list of available models. +Visit [zenmux.ai/models](https://zenmux.ai/models) to see the complete list of available models. ### Other Providers From 0a3f674ea5a2252d056cab8d7fa494db4f47c26b Mon Sep 17 00:00:00 2001 From: Brendan O'Leary <github@olearycrew.com> Date: Thu, 30 Apr 2026 17:32:49 -0400 Subject: [PATCH 91/96] Add KiloClaw to docs man page (#9636) --- packages/kilo-docs/pages/index.tsx | 84 +++++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 12 deletions(-) diff --git a/packages/kilo-docs/pages/index.tsx b/packages/kilo-docs/pages/index.tsx index b3f89322e91..61452757875 100644 --- a/packages/kilo-docs/pages/index.tsx +++ b/packages/kilo-docs/pages/index.tsx @@ -65,6 +65,25 @@ const terminalContent = { <span className="terminal-prompt">$</span> kilo rules list </> ), + kiloclaw: ( + <> + <span className="terminal-comment"># Chat with your KiloClaw agent from the CLI</span> + {"\n"} + <span className="terminal-prompt">$</span> kilo /claw + {"\n"} + {"\n"} + <span className="terminal-comment"># Or connect via chat platforms</span> + {"\n"} + <span className="terminal-comment"># Telegram, Discord, Slack — no self-hosting required</span> + {"\n"} + {"\n"} + <span className="terminal-comment"># Trigger your agent via webhook</span> + {"\n"} + <span className="terminal-prompt">$</span> curl -X POST https://your-instance.kiloclaw.ai/webhook \{"\n"} + -H "Content-Type: application/json" \{"\n"} + {` -d '{"event":"deploy","repo":"my-app"}'`} + </> + ), } // Category card data based on the information architecture @@ -100,6 +119,24 @@ const categories = [ { title: "Custom Rules", href: "/code-with-ai" }, ], }, + { + title: "KiloClaw", + description: + "Hosted OpenClaw agents — deploy, manage, and integrate AI agents with chat platforms, dev tools, and triggers without self-hosting.", + href: "/kiloclaw/overview", + icon: ( + <svg className="category-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M12 2L2 7l10 5 10-5-10-5z" strokeLinecap="round" strokeLinejoin="round" /> + <path d="M2 17l10 5 10-5" strokeLinecap="round" strokeLinejoin="round" /> + <path d="M2 12l10 5 10-5" strokeLinecap="round" strokeLinejoin="round" /> + </svg> + ), + links: [ + { title: "Overview", href: "/kiloclaw/overview" }, + { title: "Chat Platforms", href: "/kiloclaw/chat-platforms" }, + { title: "Development Tools", href: "/kiloclaw/development-tools" }, + ], + }, { title: "Collaborate", description: "Work with teams, share sessions, and manage organizations", @@ -193,7 +230,9 @@ const categories = [ ] export default function HomePage() { - const [activeTab, setActiveTab] = useState<"installation" | "firstTask" | "customRules" | "gateway">("installation") + const [activeTab, setActiveTab] = useState<"installation" | "firstTask" | "customRules" | "gateway" | "kiloclaw">( + "installation", + ) return ( <div className="homepage"> @@ -203,16 +242,17 @@ export default function HomePage() { {/* Hero Section */} <section className="hero"> <div className="hero-content"> - <h1 className="hero-title">Kilo Code Documentation</h1> + <h1 className="hero-title">Kilo Documentation</h1> <p className="hero-subtitle"> - Explore our guides and examples to build with Kilo Code, the most popular open source coding agent. + Explore guides and examples for the Kilo platform — from coding agents and AI-powered development to hosted + agentic infrastructure with KiloClaw. </p> <div className="hero-buttons"> <Link href="/getting-started" className="btn btn-primary"> Get started with Kilo Code → </Link> - <Link href="/code-with-ai" className="btn btn-secondary"> - Explore all features + <Link href="/kiloclaw/overview" className="btn btn-secondary"> + Explore KiloClaw </Link> </div> </div> @@ -220,7 +260,7 @@ export default function HomePage() { {/* Quick Links Panel - Stripe style */} <div className="quick-panel"> <div className="quick-section"> - <h3 className="quick-title">POPULAR GUIDES</h3> + <h3 className="quick-title">KILO CODE</h3> <div className="quick-links"> <Link href="/getting-started/installing" className="quick-link"> Installation Guide @@ -233,6 +273,20 @@ export default function HomePage() { </Link> </div> </div> + <div className="quick-section"> + <h3 className="quick-title">KILOCLAW</h3> + <div className="quick-links"> + <Link href="/kiloclaw/overview" className="quick-link"> + Overview + </Link> + <Link href="/kiloclaw/chat-platforms" className="quick-link"> + Chat Platforms + </Link> + <Link href="/kiloclaw/development-tools" className="quick-link"> + Development Tools + </Link> + </div> + </div> <div className="quick-section"> <h3 className="quick-title">PLATFORMS</h3> <div className="quick-links"> @@ -250,10 +304,10 @@ export default function HomePage() { </div> </section> - {/* Browse by Category - Three Column Layout */} + {/* Browse by Category - Four Column Layout */} <section className="categories-section"> <div className="category-group"> - {categories.slice(0, 3).map((category) => ( + {categories.slice(0, 4).map((category) => ( <div key={category.title} className="category-column"> <h3 className="category-title">{category.title}</h3> <div className="category-links"> @@ -291,7 +345,7 @@ export default function HomePage() { <section className="terminal-section"> <div className="terminal-intro"> <h2 className="section-title">Try it out</h2> - <p className="terminal-description">Get started quickly with common Kilo Code commands</p> + <p className="terminal-description">Get started quickly with Kilo Code, KiloClaw, and Kilo Gateway</p> </div> <div className="terminal-container"> <div className="terminal-tabs"> @@ -313,6 +367,12 @@ export default function HomePage() { > Custom Rules </button> + <button + className={`terminal-tab ${activeTab === "kiloclaw" ? "active" : ""}`} + onClick={() => setActiveTab("kiloclaw")} + > + KiloClaw + </button> <button className={`terminal-tab ${activeTab === "gateway" ? "active" : ""}`} onClick={() => setActiveTab("gateway")} @@ -540,7 +600,7 @@ export default function HomePage() { .category-group { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(4, 1fr); gap: 3rem; } @@ -587,7 +647,7 @@ export default function HomePage() { .cards-grid { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(4, 1fr); gap: 1.5rem; } @@ -826,7 +886,7 @@ export default function HomePage() { .quick-panel { display: grid; - grid-template-columns: repeat(2, 1fr); + grid-template-columns: repeat(3, 1fr); } .quick-section { From 58844933820f3c5810de9023fcd18ce1a3046a16 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:39:55 +0000 Subject: [PATCH 92/96] docs: update BYOK providers list to reflect current support --- .../kilo-docs/pages/getting-started/byok.md | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/kilo-docs/pages/getting-started/byok.md b/packages/kilo-docs/pages/getting-started/byok.md index e9b33847366..a9d17bb86b9 100644 --- a/packages/kilo-docs/pages/getting-started/byok.md +++ b/packages/kilo-docs/pages/getting-started/byok.md @@ -10,23 +10,41 @@ Bring Your Own Key (BYOK) lets you use your own API keys when using the Kilo Gat A user or organization may want to use BYOK to: - Utilize new models quickly, Kilo Gateway supports most new models in minutes -- Use subscriptions with third-party AI providers, for example [Z.AI](https://z.ai/subscribe) or [Minimax](https://platform.minimax.io/subscribe/coding-plan) +- Use subscriptions with third-party AI providers, for example the [Z.ai Coding Plan](https://z.ai/subscribe), [Kimi Code](https://platform.moonshot.ai/), or the [BytePlus Coding Plan](https://www.byteplus.com/) - Attribute usage against existing provider commitments or agreements - Use existing credits with a provider ## Supported BYOK providers -Kilo Gateway currently supports BYOK keys for these providers: +Kilo Gateway supports BYOK keys for these providers. + +### Standard API keys + +Use your provider API key to route matching models through your account: - Anthropic - AWS Bedrock +- Fireworks - Google AI Studio - Inception - Minimax - Mistral AI +- Moonshot AI (Kimi) +- Novita - OpenAI - xAI -- Z.AI +- Z.ai + +### Subscription and direct provider plans + +These providers offer coding-focused subscriptions or dedicated endpoints. Bring the API key issued by your plan to use its included models through the Kilo Gateway: + +- BytePlus Coding Plan +- Chutes BYOK +- Kimi Code +- Mistral Codestral +- Neuralwatt +- Z.ai Coding Plan ## Add a BYOK key @@ -63,9 +81,10 @@ Your IAM user or role must have the following permissions: - When you use the **Kilo Gateway** provider, Kilo checks if there's a BYOK key for the selected model's provider. - If a matching BYOK key exists, the request is routed using your key. - If the key is invalid, the request fails. It does not fall back to using Kilo's keys. +- Subscription-based providers (such as the Z.ai Coding Plan or Kimi Code) only expose the models included in that plan. Select one of those models to route traffic through your subscription. ## Using BYOK in the Extensions and CLI - BYOK works with the Kilo Gateway provider. Users should ensure that is set as the active [provider](/docs/ai-providers). -- Select a model from a provider configured for BYOK, for example Claude Sonnet 4.5 if you configured BYOK for Anthropic. +- Select a model from a provider configured for BYOK, for example Claude Sonnet 4.5 if you configured BYOK for Anthropic, or GLM-4.7 if you configured the Z.ai Coding Plan. - (Optional) Validate with the provider that traffic is being served by that key. From 5bf9e821e963919b0a21b68177c8fe84a6eb2023 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" <kilo-maintainer[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 16:49:11 +0000 Subject: [PATCH 93/96] release: v7.2.33 --- .changeset/agent-manager-tool.md | 6 ---- .changeset/autocomplete-listener-dispose.md | 5 --- .changeset/center-sidebar-toolbar.md | 5 --- .changeset/center-wide-notifications.md | 5 --- .changeset/command-permissions-persist.md | 5 --- .changeset/commit-prompt-project-scope.md | 6 ---- .changeset/expanded-reasoning-blocks.md | 5 --- .changeset/external-directory-read-prompts.md | 5 --- .changeset/gpt55-codex-prompt.md | 5 --- .changeset/local-sessions-restart.md | 5 --- .changeset/per-agent-permissions.md | 5 --- .changeset/remote-status-badge.md | 5 --- .changeset/remove-ai-gen_ai-telemetry.md | 6 ---- .changeset/terminal-command-display.md | 5 --- bun.lock | 34 +++++++++---------- package.json | 2 +- packages/app/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++---- packages/kilo-docs/package.json | 2 +- packages/kilo-gateway/package.json | 2 +- packages/kilo-i18n/package.json | 2 +- packages/kilo-indexing/package.json | 2 +- packages/kilo-telemetry/package.json | 2 +- packages/kilo-ui/package.json | 2 +- packages/kilo-vscode/CHANGELOG.md | 26 ++++++++++++++ packages/kilo-vscode/package.json | 2 +- packages/opencode/CHANGELOG.md | 20 +++++++++++ packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/script/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/shared/package.json | 2 +- packages/storybook/package.json | 2 +- packages/ui/package.json | 2 +- script/upstream/package.json | 2 +- sdks/vscode/package.json | 2 +- 38 files changed, 89 insertions(+), 116 deletions(-) delete mode 100644 .changeset/agent-manager-tool.md delete mode 100644 .changeset/autocomplete-listener-dispose.md delete mode 100644 .changeset/center-sidebar-toolbar.md delete mode 100644 .changeset/center-wide-notifications.md delete mode 100644 .changeset/command-permissions-persist.md delete mode 100644 .changeset/commit-prompt-project-scope.md delete mode 100644 .changeset/expanded-reasoning-blocks.md delete mode 100644 .changeset/external-directory-read-prompts.md delete mode 100644 .changeset/gpt55-codex-prompt.md delete mode 100644 .changeset/local-sessions-restart.md delete mode 100644 .changeset/per-agent-permissions.md delete mode 100644 .changeset/remote-status-badge.md delete mode 100644 .changeset/remove-ai-gen_ai-telemetry.md delete mode 100644 .changeset/terminal-command-display.md diff --git a/.changeset/agent-manager-tool.md b/.changeset/agent-manager-tool.md deleted file mode 100644 index 0d332f7b8ad..00000000000 --- a/.changeset/agent-manager-tool.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"kilo-code": minor -"@kilocode/cli": minor ---- - -Support starting Agent Manager local sessions and worktree sessions from an experimental agent tool. diff --git a/.changeset/autocomplete-listener-dispose.md b/.changeset/autocomplete-listener-dispose.md deleted file mode 100644 index 2b8b830d3a0..00000000000 --- a/.changeset/autocomplete-listener-dispose.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Dispose autocomplete editor listeners when inline completions are disabled. diff --git a/.changeset/center-sidebar-toolbar.md b/.changeset/center-sidebar-toolbar.md deleted file mode 100644 index f44e3b3b055..00000000000 --- a/.changeset/center-sidebar-toolbar.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Center the sidebar prompt toolbar on wider sidebars and hide unavailable toolbar actions. diff --git a/.changeset/center-wide-notifications.md b/.changeset/center-wide-notifications.md deleted file mode 100644 index 115139e9bcc..00000000000 --- a/.changeset/center-wide-notifications.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Center welcome notifications in wide VS Code views. diff --git a/.changeset/command-permissions-persist.md b/.changeset/command-permissions-persist.md deleted file mode 100644 index 8e8e16c82e4..00000000000 --- a/.changeset/command-permissions-persist.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Avoid repeated command approval prompts when multiple sessions request the same saved command permission, without widening bash permission matching. diff --git a/.changeset/commit-prompt-project-scope.md b/.changeset/commit-prompt-project-scope.md deleted file mode 100644 index eeb0978f269..00000000000 --- a/.changeset/commit-prompt-project-scope.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Scope the custom commit message prompt to the current project. Setting it in the VS Code settings now writes to the workspace's `kilo.json` so different repositories can have different conventions, instead of silently applying globally. Also fixes the project-level config update endpoint, which previously wrote to a file that wasn't loaded. diff --git a/.changeset/expanded-reasoning-blocks.md b/.changeset/expanded-reasoning-blocks.md deleted file mode 100644 index 953662fa162..00000000000 --- a/.changeset/expanded-reasoning-blocks.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Keep reasoning blocks expanded by default and add a setting to auto-collapse them after completion. diff --git a/.changeset/external-directory-read-prompts.md b/.changeset/external-directory-read-prompts.md deleted file mode 100644 index 46c284dfc11..00000000000 --- a/.changeset/external-directory-read-prompts.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Honor allowed read-only external-directory access to Kilo config paths without repeated permission prompts. diff --git a/.changeset/gpt55-codex-prompt.md b/.changeset/gpt55-codex-prompt.md deleted file mode 100644 index ee75ff945e6..00000000000 --- a/.changeset/gpt55-codex-prompt.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Use a GPT-5.5-specific coding prompt that improves autonomous task handling while keeping older Codex generations on their existing prompt. diff --git a/.changeset/local-sessions-restart.md b/.changeset/local-sessions-restart.md deleted file mode 100644 index 3332d103ff9..00000000000 --- a/.changeset/local-sessions-restart.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Preserve Agent Manager local sessions across panel restarts when session refreshes complete out of order. diff --git a/.changeset/per-agent-permissions.md b/.changeset/per-agent-permissions.md deleted file mode 100644 index 7667cd139a5..00000000000 --- a/.changeset/per-agent-permissions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Support configuring custom agent tool permissions from the VS Code agent editor. diff --git a/.changeset/remote-status-badge.md b/.changeset/remote-status-badge.md deleted file mode 100644 index 40b2a70936a..00000000000 --- a/.changeset/remote-status-badge.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Keep Remote status visible in the TUI while remote control is connecting. diff --git a/.changeset/remove-ai-gen_ai-telemetry.md b/.changeset/remove-ai-gen_ai-telemetry.md deleted file mode 100644 index 5479a8bab1c..00000000000 --- a/.changeset/remove-ai-gen_ai-telemetry.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"kilo-code": patch -"@kilocode/cli": patch ---- - -Stop emitting `ai.*` and `gen_ai.*` OpenTelemetry spans from AI SDK calls, and remove the PostHog bridge that forwarded them. Tool/session/indexing telemetry is unchanged. diff --git a/.changeset/terminal-command-display.md b/.changeset/terminal-command-display.md deleted file mode 100644 index bb2473f51bb..00000000000 --- a/.changeset/terminal-command-display.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Add a Display setting to collapse terminal command blocks by default instead of keeping them expanded. diff --git a/bun.lock b/bun.lock index 55f0672d058..a031e862c7c 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "7.2.31", + "version": "7.2.33", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -89,7 +89,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "7.2.31", + "version": "7.2.33", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -122,7 +122,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "7.2.31", + "version": "7.2.33", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -174,7 +174,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.31", + "version": "7.2.33", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -203,7 +203,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.31", + "version": "7.2.33", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -239,7 +239,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.2.31", + "version": "7.2.33", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -249,7 +249,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.2.31", + "version": "7.2.33", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -283,7 +283,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.2.31", + "version": "7.2.33", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -297,7 +297,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.31", + "version": "7.2.33", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/shared": "workspace:*", @@ -332,7 +332,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.31", + "version": "7.2.33", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", @@ -393,7 +393,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.31", + "version": "7.2.33", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -551,7 +551,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.31", + "version": "7.2.33", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -576,7 +576,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.31", + "version": "7.2.33", "dependencies": { "semver": "^7.6.3", }, @@ -587,7 +587,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.31", + "version": "7.2.33", "dependencies": { "cross-spawn": "catalog:", }, @@ -602,7 +602,7 @@ }, "packages/shared": { "name": "@opencode-ai/shared", - "version": "7.2.31", + "version": "7.2.33", "bin": { "opencode": "./bin/opencode", }, @@ -626,7 +626,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.31", + "version": "7.2.33", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -649,7 +649,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.31", + "version": "7.2.33", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", diff --git a/package.json b/package.json index 63dee7784ea..d108390d829 100644 --- a/package.json +++ b/package.json @@ -148,6 +148,6 @@ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch" }, - "version": "7.2.31", + "version": "7.2.33", "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index ee1e6b515b4..3f0432a63c1 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "7.2.31", + "version": "7.2.33", "description": "", "type": "module", "exports": { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 435ad7e702a..419ac63ae9c 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "7.2.31", + "version": "7.2.33", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index dc6a47790a6..4710a6fe566 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "7.2.31", + "version": "7.2.33", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 4ed1c6ffa95..b12332b597e 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.2.31" +version = "7.2.33" 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.2.31/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.33/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.2.31/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.33/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.31/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.33/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.2.31/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.33/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.2.31/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.33/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 710c331b5a1..1bf6b9db6c9 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.2.31", + "version": "7.2.33", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index ada00533ded..53254a53f0b 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.2.31", + "version": "7.2.33", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index a3b881e4362..15b0679d71f 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.2.31", + "version": "7.2.33", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index b223470dc63..0c1b9ec6999 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.2.31", + "version": "7.2.33", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 5379bc74d25..63935905888 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.2.31", + "version": "7.2.33", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 5dcb8a72fcf..361878aa6d7 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.2.31", + "version": "7.2.33", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index d008b0ab0f6..9765fc11db5 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,31 @@ # kilo-code +## 7.2.33 + +### Minor Changes + +- [#9737](https://github.com/Kilo-Org/kilocode/pull/9737) [`d5fb9eb`](https://github.com/Kilo-Org/kilocode/commit/d5fb9eb2265c03127e776c99020b03bb770255a1) - Support starting Agent Manager local sessions and worktree sessions from an experimental agent tool. + +- [#9704](https://github.com/Kilo-Org/kilocode/pull/9704) [`22b5283`](https://github.com/Kilo-Org/kilocode/commit/22b5283f79e4dd8461764904b908001df969cce9) - Keep reasoning blocks expanded by default and add a setting to auto-collapse them after completion. + +- [#9708](https://github.com/Kilo-Org/kilocode/pull/9708) [`f2db4d1`](https://github.com/Kilo-Org/kilocode/commit/f2db4d165cacc8b52148e7fe77fd4af7ff71b7bc) - Add a Display setting to collapse terminal command blocks by default instead of keeping them expanded. + +### Patch Changes + +- [#9317](https://github.com/Kilo-Org/kilocode/pull/9317) [`ce4a595`](https://github.com/Kilo-Org/kilocode/commit/ce4a595cdcba5074b0ebdf74f24213a8322f2cc7) - Dispose autocomplete editor listeners when inline completions are disabled. + +- [#9651](https://github.com/Kilo-Org/kilocode/pull/9651) [`bc91af3`](https://github.com/Kilo-Org/kilocode/commit/bc91af398db6e52e4a6bbd1d26efce1b306d4697) - Center the sidebar prompt toolbar on wider sidebars and hide unavailable toolbar actions. + +- [#9753](https://github.com/Kilo-Org/kilocode/pull/9753) [`fb11c7b`](https://github.com/Kilo-Org/kilocode/commit/fb11c7b56d49cdd52f8ee96fdfc9ce30d2c883b7) - Center welcome notifications in wide VS Code views. + +- [#9460](https://github.com/Kilo-Org/kilocode/pull/9460) [`26e4c11`](https://github.com/Kilo-Org/kilocode/commit/26e4c1148f4e7a734bb8e535e02a1a9ad75be584) - Scope the custom commit message prompt to the current project. Setting it in the VS Code settings now writes to the workspace's `kilo.json` so different repositories can have different conventions, instead of silently applying globally. Also fixes the project-level config update endpoint, which previously wrote to a file that wasn't loaded. + +- [#9742](https://github.com/Kilo-Org/kilocode/pull/9742) [`a076e33`](https://github.com/Kilo-Org/kilocode/commit/a076e336b34f897a6b9463b48d2181d90f9dd997) - Preserve Agent Manager local sessions across panel restarts when session refreshes complete out of order. + +- [#9733](https://github.com/Kilo-Org/kilocode/pull/9733) [`e9f2760`](https://github.com/Kilo-Org/kilocode/commit/e9f2760d8e9a93d4b71dba6ef5aae522cb4fe263) - Support configuring custom agent tool permissions from the VS Code agent editor. + +- [#9669](https://github.com/Kilo-Org/kilocode/pull/9669) [`0bf14eb`](https://github.com/Kilo-Org/kilocode/commit/0bf14eb2ff5ef59f9dc98342218addc670a87481) - Stop emitting `ai.*` and `gen_ai.*` OpenTelemetry spans from AI SDK calls, and remove the PostHog bridge that forwarded them. Tool/session/indexing telemetry is unchanged. + ## 7.2.31 ### Patch Changes diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 2e046f4743b..a150811a94a 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.2.31", + "version": "7.2.33", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 25bf1737aae..1bc54aec8e1 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,25 @@ # @kilocode/cli +## 7.2.33 + +### Minor Changes + +- [#9737](https://github.com/Kilo-Org/kilocode/pull/9737) [`d5fb9eb`](https://github.com/Kilo-Org/kilocode/commit/d5fb9eb2265c03127e776c99020b03bb770255a1) - Support starting Agent Manager local sessions and worktree sessions from an experimental agent tool. + +### Patch Changes + +- [#9746](https://github.com/Kilo-Org/kilocode/pull/9746) [`80535d4`](https://github.com/Kilo-Org/kilocode/commit/80535d4ed6266888988a66ca28706260ee89e533) - Avoid repeated command approval prompts when multiple sessions request the same saved command permission, without widening bash permission matching. + +- [#9460](https://github.com/Kilo-Org/kilocode/pull/9460) [`26e4c11`](https://github.com/Kilo-Org/kilocode/commit/26e4c1148f4e7a734bb8e535e02a1a9ad75be584) - Scope the custom commit message prompt to the current project. Setting it in the VS Code settings now writes to the workspace's `kilo.json` so different repositories can have different conventions, instead of silently applying globally. Also fixes the project-level config update endpoint, which previously wrote to a file that wasn't loaded. + +- [#9626](https://github.com/Kilo-Org/kilocode/pull/9626) [`5dbf91c`](https://github.com/Kilo-Org/kilocode/commit/5dbf91cc167c16e04bb41e8af68108f8865a18c8) - Honor allowed read-only external-directory access to Kilo config paths without repeated permission prompts. + +- [#9745](https://github.com/Kilo-Org/kilocode/pull/9745) [`da3d79a`](https://github.com/Kilo-Org/kilocode/commit/da3d79a6886944b4ad311211e3f67c350958a6ca) - Use a GPT-5.5-specific coding prompt that improves autonomous task handling while keeping older Codex generations on their existing prompt. + +- [#9729](https://github.com/Kilo-Org/kilocode/pull/9729) [`1493d65`](https://github.com/Kilo-Org/kilocode/commit/1493d656c9afcafd41a13b45bdf734fb881536df) - Keep Remote status visible in the TUI while remote control is connecting. + +- [#9669](https://github.com/Kilo-Org/kilocode/pull/9669) [`0bf14eb`](https://github.com/Kilo-Org/kilocode/commit/0bf14eb2ff5ef59f9dc98342218addc670a87481) - Stop emitting `ai.*` and `gen_ai.*` OpenTelemetry spans from AI SDK calls, and remove the PostHog bridge that forwarded them. Tool/session/indexing telemetry is unchanged. + ## 7.2.31 ### Patch Changes diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ca6d07b6c67..1847c6a4b98 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.31", + "version": "7.2.33", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 2bf0a195d91..2d7fea2f618 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.2.31", + "version": "7.2.33", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index 8608af0b4e0..1cc80a46b78 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.2.31", + "version": "7.2.33", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 2a27ff2af9a..01d3c04b58a 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.2.31", + "version": "7.2.33", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/shared/package.json b/packages/shared/package.json index fc22a67e365..39df628fd8e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.31", + "version": "7.2.33", "name": "@opencode-ai/shared", "type": "module", "license": "MIT", diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 4d2f3428dce..6be761849e4 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.2.31", + "version": "7.2.33", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 5e22b13a264..d8b6afb80dc 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.2.31", + "version": "7.2.33", "type": "module", "license": "MIT", "exports": { diff --git a/script/upstream/package.json b/script/upstream/package.json index 375c778121a..aca6e5a68eb 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.2.31", + "version": "7.2.33", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 3d03f7330e1..9844ea16bb3 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "7.2.31", + "version": "7.2.33", "publisher": "sst-dev", "repository": { "type": "git", From 4d1402bcda5fca762edbcd60f4840b3d066296ca Mon Sep 17 00:00:00 2001 From: Mickey Lazarevic <lazarevic.miroslav@gmail.com> Date: Fri, 1 May 2026 18:52:31 +0200 Subject: [PATCH 94/96] feat(kilo-vscode): add shared package version tracking and update app name (#9740) Add version tracking for the shared package alongside the opencode package to improve cache invalidation. Also update the app name from "opencode" to "kilo" in the shared global configuration. This change ensures that the local binary build process tracks both the opencode and shared package versions, allowing for more accurate cache invalidation when either package changes. The app name update reflects the correct project branding. --- packages/kilo-vscode/script/local-bin.ts | 15 +++++++++------ packages/shared/src/global.ts | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/kilo-vscode/script/local-bin.ts b/packages/kilo-vscode/script/local-bin.ts index ee31c76a666..4da699a36cf 100644 --- a/packages/kilo-vscode/script/local-bin.ts +++ b/packages/kilo-vscode/script/local-bin.ts @@ -20,6 +20,7 @@ const forceRebuild = process.argv.includes("--force") const kiloVscodeDir = join(import.meta.dir, "..") const packagesDir = join(kiloVscodeDir, "..") const opencodeDir = join(packagesDir, "opencode") +const sharedDir = join(packagesDir, "shared") const targetBinDir = join(kiloVscodeDir, "bin") const binName = process.platform === "win32" ? "kilo.exe" : "kilo" @@ -32,8 +33,9 @@ function log(msg: string) { async function cliSourceHash(): Promise<string | null> { try { - const result = await $`git log -1 --format=%H -- .`.cwd(opencodeDir).quiet() - return result.text().trim() || null + const opencodeResult = await $`git log -1 --format=%H -- .`.cwd(opencodeDir).quiet() + const sharedResult = await $`git log -1 --format=%H -- .`.cwd(sharedDir).quiet() + return `${opencodeResult.text().trim()}-${sharedResult.text().trim()}` || null } catch { return null } @@ -41,8 +43,9 @@ async function cliSourceHash(): Promise<string | null> { async function isDirty(): Promise<boolean> { try { - const result = await $`git status --porcelain -- .`.cwd(opencodeDir).quiet() - return result.text().trim().length > 0 + const opencodeResult = await $`git status --porcelain -- .`.cwd(opencodeDir).quiet() + const sharedResult = await $`git status --porcelain -- .`.cwd(sharedDir).quiet() + return opencodeResult.text().trim().length > 0 || sharedResult.text().trim().length > 0 } catch { return false } @@ -119,8 +122,8 @@ async function ensureBuiltBinary(): Promise<string> { `No prebuilt binary found under ${relative(kiloVscodeDir, join(opencodeDir, "dist"))} - attempting build via bun.`, ) - const bunFile = Bun.file(await Bun.which("bun")) - if (!(await bunFile.exists())) { + const bunPath = Bun.which("bun") + if (!bunPath) { throw new Error( `Bun is required to build the CLI binary, but was not found on PATH. ` + `Install bun, or build the CLI separately in ${opencodeDir} and re-run.`, diff --git a/packages/shared/src/global.ts b/packages/shared/src/global.ts index 455021baa40..d476bc8b340 100644 --- a/packages/shared/src/global.ts +++ b/packages/shared/src/global.ts @@ -19,8 +19,8 @@ export namespace Global { export const layer = Layer.effect( Service, Effect.gen(function* () { - const app = "opencode" // kilocode_change start - guard against newline-contaminated HOME/XDG paths + const app = "kilo" const clean = (p: string | undefined) => p?.replace(/[\r\n]+/g, "") const home = clean(process.env.KILO_TEST_HOME ?? os.homedir())! const data = path.join(clean(xdgData)!, app) From 328d5240e70f818787fcf29afb655e7e09e5bf3a Mon Sep 17 00:00:00 2001 From: Josh Lambert <josh@kilocode.ai> Date: Sat, 2 May 2026 00:28:35 -0400 Subject: [PATCH 95/96] docs(kilo-docs): document OpenRouter passthrough for transforms and provider routing The middle-out transform and provider routing settings are not exposed as dedicated UI controls in the new VS Code extension or CLI, but can be set via the generic options passthrough in kilo.json. Add inline kilo.json examples and scope the legacy UI instructions to a separate tab that no longer promises controls the legacy UI never rendered. --- .../pages/ai-providers/openrouter.md | 92 ++++++++++++++++--- 1 file changed, 79 insertions(+), 13 deletions(-) diff --git a/packages/kilo-docs/pages/ai-providers/openrouter.md b/packages/kilo-docs/pages/ai-providers/openrouter.md index 0c08b0254c0..1750b8699b5 100644 --- a/packages/kilo-docs/pages/ai-providers/openrouter.md +++ b/packages/kilo-docs/pages/ai-providers/openrouter.md @@ -19,15 +19,6 @@ OpenRouter is an AI platform that provides access to a wide variety of language ## Configuration in Kilo Code {% tabs %} -{% tab label="VSCode (Legacy)" %} - -1. **Open Kilo Code Settings:** Click the gear icon ({% codicon name="gear" /%}) in the Kilo Code panel. -2. **Select Provider:** Choose "OpenRouter" from the "API Provider" dropdown. -3. **Enter API Key:** Paste your OpenRouter API key into the "OpenRouter API Key" field. -4. **Select Model:** Choose your desired model from the "Model" dropdown. -5. **(Optional) Custom Base URL:** If you need to use a custom base URL for the OpenRouter API, check "Use custom base URL" and enter the URL. Leave this blank for most users. - -{% /tab %} {% tab label="VSCode" %} Open **Settings** (gear icon) and go to the **Providers** tab to add OpenRouter and enter your API key. @@ -65,18 +56,90 @@ Then set your default model: } ``` +{% /tab %} +{% tab label="VSCode (Legacy)" %} + +1. **Open Kilo Code Settings:** Click the gear icon ({% codicon name="gear" /%}) in the Kilo Code panel. +2. **Select Provider:** Choose "OpenRouter" from the "API Provider" dropdown. +3. **Enter API Key:** Paste your OpenRouter API key into the "OpenRouter API Key" field. +4. **Select Model:** Choose your desired model from the "Model" dropdown. +5. **(Optional) Custom Base URL:** If you need to use a custom base URL for the OpenRouter API, check "Use custom base URL" and enter the URL. Leave this blank for most users. + {% /tab %} {% /tabs %} ## Supported Transforms -OpenRouter provides an [optional "middle-out" message transform](https://openrouter.ai/docs/features/message-transforms) to help with prompts that exceed the maximum context size of a model. You can enable it by checking the "Compress prompts and message chains to the context size" box. +OpenRouter provides an [optional "middle-out" message transform](https://openrouter.ai/docs/features/message-transforms) to help with prompts that exceed the maximum context size of a model. + +{% tabs %} +{% tab label="VSCode & CLI" %} + +The middle-out transform is not exposed as a dedicated UI control in the VS Code extension. To enable it, set `transforms` on the model's `options` in your `kilo.json` config file. Anything under `options` is forwarded verbatim to the OpenRouter AI SDK as `providerOptions.openrouter`: + +```jsonc +{ + "provider": { + "openrouter": { + "models": { + "anthropic/claude-sonnet-4-20250514": { + "options": { + "transforms": ["middle-out"], + }, + }, + }, + }, + }, +} +``` + +{% /tab %} +{% tab label="VSCode (Legacy)" %} + +The legacy extension does not expose a control for the middle-out transform, and it does not read configuration from `kilo.json`. To use this option, switch to the new VS Code extension or the CLI. + +{% /tab %} +{% /tabs %} ## Provider Routing -OpenRouter can route to many different inference providers and this can be controlled in the API Provider settings under Provider Routing. +OpenRouter can route to many different inference providers. This can be controlled directly via OpenRouter's [`provider` routing parameter](https://openrouter.ai/docs/features/provider-routing). -### Provider Sorting +{% tabs %} +{% tab label="VSCode & CLI" %} + +Provider routing is not exposed as dedicated UI controls in the VS Code extension. To configure it, set OpenRouter's `provider` routing fields under the model's `options` in your `kilo.json` config file. Everything under `options` is forwarded to the OpenRouter AI SDK as `providerOptions.openrouter`, so any field from the [OpenRouter provider routing docs](https://openrouter.ai/docs/features/provider-routing) can be used. + +```jsonc +{ + "provider": { + "openrouter": { + "models": { + "anthropic/claude-sonnet-4-20250514": { + "options": { + "provider": { + "sort": "price", // "price" | "throughput" | "latency" + "order": ["Anthropic", "Google"], // specific provider preference + "only": ["Anthropic"], // restrict to listed providers + "data_collection": "deny", // "allow" | "deny" + "zdr": true, // zero data retention + }, + }, + }, + }, + }, + }, +} +``` + +Omit any field to fall back to your OpenRouter account's default. Fields are passed through without validation — see OpenRouter's docs for the full list of supported values. + +{% /tab %} +{% tab label="VSCode (Legacy)" %} + +The legacy settings UI exposes Provider Routing as two dropdowns under **Provider Routing**: + +**Provider Sorting** - Default provider sorting: use the setting in your OpenRouter account - Prefer providers with lower price @@ -84,13 +147,16 @@ OpenRouter can route to many different inference providers and this can be contr - Prefer providers with lower latency (i.e. shorter time to first token) - A specific provider preference can also be chosen. -### Data Policy +**Data Policy** - No data policy set: use the settings in your OpenRouter account. - Allow prompt training: providers that may train on your prompts or completions are allowed. Free models generally require this option to be enabled. - Deny prompt training: providers that may train on your prompts or completions are not allowed. - Zero data retention: only providers with a strict zero data retention policy are allowed. This option is not recommended, as it will disable many popular providers, such as Anthropic and OpenAI. +{% /tab %} +{% /tabs %} + ## Tips and Notes - **Model Selection:** OpenRouter offers a wide range of models. Experiment to find the best one for your needs. From a1c08dbca48fa02a9245e07dd1fda668068f2e66 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" <kilo-maintainer[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 03:38:53 +0000 Subject: [PATCH 96/96] release: v7.2.34 --- bun.lock | 34 +++++++++++++------------- package.json | 2 +- packages/app/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++----- packages/kilo-docs/package.json | 2 +- packages/kilo-gateway/package.json | 2 +- packages/kilo-i18n/package.json | 2 +- packages/kilo-indexing/package.json | 2 +- packages/kilo-telemetry/package.json | 2 +- packages/kilo-ui/package.json | 2 +- packages/kilo-vscode/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/script/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/shared/package.json | 2 +- packages/storybook/package.json | 2 +- packages/ui/package.json | 2 +- script/upstream/package.json | 2 +- sdks/vscode/package.json | 2 +- 22 files changed, 43 insertions(+), 43 deletions(-) diff --git a/bun.lock b/bun.lock index a031e862c7c..1b50998a7eb 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "7.2.33", + "version": "7.2.34", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -89,7 +89,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "7.2.33", + "version": "7.2.34", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -122,7 +122,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "7.2.33", + "version": "7.2.34", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -174,7 +174,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.33", + "version": "7.2.34", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -203,7 +203,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.33", + "version": "7.2.34", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -239,7 +239,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.2.33", + "version": "7.2.34", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -249,7 +249,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.2.33", + "version": "7.2.34", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -283,7 +283,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.2.33", + "version": "7.2.34", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -297,7 +297,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.33", + "version": "7.2.34", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/shared": "workspace:*", @@ -332,7 +332,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.33", + "version": "7.2.34", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", @@ -393,7 +393,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.33", + "version": "7.2.34", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -551,7 +551,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.33", + "version": "7.2.34", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -576,7 +576,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.33", + "version": "7.2.34", "dependencies": { "semver": "^7.6.3", }, @@ -587,7 +587,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.33", + "version": "7.2.34", "dependencies": { "cross-spawn": "catalog:", }, @@ -602,7 +602,7 @@ }, "packages/shared": { "name": "@opencode-ai/shared", - "version": "7.2.33", + "version": "7.2.34", "bin": { "opencode": "./bin/opencode", }, @@ -626,7 +626,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.33", + "version": "7.2.34", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -649,7 +649,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.33", + "version": "7.2.34", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", diff --git a/package.json b/package.json index d108390d829..3bfe6e5c56d 100644 --- a/package.json +++ b/package.json @@ -148,6 +148,6 @@ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch" }, - "version": "7.2.33", + "version": "7.2.34", "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index 3f0432a63c1..cadb08c892a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "7.2.33", + "version": "7.2.34", "description": "", "type": "module", "exports": { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 419ac63ae9c..d5f38dd9932 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "7.2.33", + "version": "7.2.34", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 4710a6fe566..b98c61dbe34 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "7.2.33", + "version": "7.2.34", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index b12332b597e..49a7ad2f872 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.2.33" +version = "7.2.34" 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.2.33/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.34/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.2.33/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.34/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.33/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.34/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.2.33/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.34/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.2.33/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.34/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 1bf6b9db6c9..fb49ffb0366 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.2.33", + "version": "7.2.34", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 53254a53f0b..3a6bda354ac 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.2.33", + "version": "7.2.34", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 15b0679d71f..478ea684e21 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.2.33", + "version": "7.2.34", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 0c1b9ec6999..014b55f7487 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.2.33", + "version": "7.2.34", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 63935905888..10ecd4299be 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.2.33", + "version": "7.2.34", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 361878aa6d7..360f3527766 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.2.33", + "version": "7.2.34", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index a150811a94a..aa7acdcca2b 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.2.33", + "version": "7.2.34", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 1847c6a4b98..18b94376bdb 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.33", + "version": "7.2.34", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 2d7fea2f618..4fd2a98ab5b 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.2.33", + "version": "7.2.34", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index 1cc80a46b78..15aa98fdecb 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.2.33", + "version": "7.2.34", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 01d3c04b58a..c0e5afcae3b 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.2.33", + "version": "7.2.34", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/shared/package.json b/packages/shared/package.json index 39df628fd8e..806959956c2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.33", + "version": "7.2.34", "name": "@opencode-ai/shared", "type": "module", "license": "MIT", diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 6be761849e4..d5bdd225a4a 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.2.33", + "version": "7.2.34", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index d8b6afb80dc..0ad404598c8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.2.33", + "version": "7.2.34", "type": "module", "license": "MIT", "exports": { diff --git a/script/upstream/package.json b/script/upstream/package.json index aca6e5a68eb..42876cf8ee0 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.2.33", + "version": "7.2.34", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 9844ea16bb3..7acd73c13cc 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "7.2.33", + "version": "7.2.34", "publisher": "sst-dev", "repository": { "type": "git",