mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
Merge branch 'main' into feat/variant-chat-template-args
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Support browsing and resuming sessions from Agent Manager with `/sessions`.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Add `xhigh` reasoning effort option to custom provider model variants so users can access the highest tier on models that support it (gpt-5.2, gpt-5.3, gpt-5.4, gpt-5.1-codex-max, etc.).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix the sidebar "Show Changes" diff viewer: the file tree now renders correctly (previously the file rows were cramped onto a single line due to missing styles), and per-file revert buttons are available, matching the Agent Manager.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-gateway": patch
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Hide Kilo Gateway models that do not support tool calling from the model list.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Respect per-agent model selections when an agent has a `model` configured in `kilo.jsonc`. Switching the model for such an agent now sticks across agent switches and CLI restarts. To pick up a newly edited agent default, re-select the model once (or clear `~/.local/share/kilo/storage/model.json`).
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix "assistant prefill" errors when a user queues a prompt while the previous turn is still streaming. The queued message no longer lands in the middle of the prior turn's history, so the next request always ends with the user prompt.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Support mentioning folders in the prompt with @ references, including top-level folder file contents.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Add a "Contribute on GitHub" call-to-action in the Marketplace view so users can easily propose a new skill, mode, or MCP server. The CTA appears as a subtle footer below the card grid and inside the empty search-results state, linking to the `kilo-marketplace` repository.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show MCP tool call inputs alongside outputs in chat, with JSON syntax highlighting for both.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Support adaptive reasoning for Claude Opus 4.7 and expose the `xhigh` effort level for adaptive Anthropic models
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep the active chat session responsive while other sessions stream in the background. Applies to both the sidebar and the Agent Manager.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show an inline error in the Settings save bar when the configuration fails to save (for example, due to an invalid value) so the user can correct the config and retry instead of losing their unsaved changes silently.
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-i18n": "workspace:*",
|
||||
"@kilocode/kilo-ui": "workspace:*",
|
||||
@@ -86,7 +86,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -119,7 +119,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -170,7 +170,7 @@
|
||||
},
|
||||
"packages/kilo-docs": {
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@docsearch/css": "^4",
|
||||
"@docsearch/js": "^4",
|
||||
@@ -199,7 +199,7 @@
|
||||
},
|
||||
"packages/kilo-gateway": {
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -234,7 +234,7 @@
|
||||
},
|
||||
"packages/kilo-i18n": {
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -247,7 +247,7 @@
|
||||
},
|
||||
"packages/kilo-telemetry": {
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-gateway": "workspace:*",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
@@ -267,7 +267,7 @@
|
||||
},
|
||||
"packages/kilo-ui": {
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -302,7 +302,7 @@
|
||||
},
|
||||
"packages/kilo-vscode": {
|
||||
"name": "kilo-code",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"@kilocode/kilo-i18n": "workspace:*",
|
||||
@@ -355,7 +355,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "@kilocode/cli",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"bin": {
|
||||
"kilo": "./bin/kilo",
|
||||
"kilocode": "./bin/kilo",
|
||||
@@ -498,7 +498,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@kilocode/plugin",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -522,7 +522,7 @@
|
||||
},
|
||||
"packages/script": {
|
||||
"name": "@opencode-ai/script",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"semver": "^7.6.3",
|
||||
},
|
||||
@@ -533,7 +533,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@kilocode/sdk",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -548,7 +548,7 @@
|
||||
},
|
||||
"packages/storybook": {
|
||||
"name": "@opencode-ai/storybook",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solidjs/meta": "catalog:",
|
||||
@@ -571,7 +571,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -621,7 +621,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
|
||||
+1
-1
@@ -136,6 +136,6 @@
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||
},
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "kilo"
|
||||
name = "Kilo"
|
||||
description = "The open source coding agent."
|
||||
version = "7.2.11"
|
||||
version = "7.2.12"
|
||||
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.11/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/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.11/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/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.11/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/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.11/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/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.11/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack --port 3002",
|
||||
|
||||
@@ -23,11 +23,15 @@ That's it. No configuration needed.
|
||||
|
||||
You can see which underlying models are used, as well as the cost, in the expanded model picker. Model mapping information is also available on the [Gateway Model page](/docs/gateway/models-and-providers#kilo-autofrontier).
|
||||
|
||||
{% callout type="info" title="Models can change" %}
|
||||
The underlying models behind each Auto Model tier are updated server-side as better options become available or as providers change pricing and availability. The tier you select stays the same; the model it routes to may change over time.
|
||||
{% /callout %}
|
||||
|
||||
## Tiers
|
||||
|
||||
- **Frontier** — Routes to the latest and most capable paid models. Uses different models for reasoning-heavy tasks (planning, architecture, debugging) versus implementation tasks (coding, building, exploring), pairing the right capability to each type of work.
|
||||
- **Balanced** — Follows the same mode-based routing structure as Frontier but uses a more cost-effective model across all modes. A good default for most developers who want strong AI assistance without paying frontier prices.
|
||||
- **Free** — Routes to the best available free model on OpenRouter. Because free model availability shifts over time as providers change promotional periods, the mapping is updated server-side — you always get the best free option without having to track what's currently available. Quality will be lower than paid tiers, and the model may change over time.
|
||||
- **Balanced** — Uses a single cost-effective model across all modes. A good default for most developers who want strong AI assistance without paying frontier prices.
|
||||
- **Free** — Routes to the best available free models on OpenRouter, splitting traffic across them. Because free model availability shifts over time as providers change promotional periods, the mapping is updated server-side — you always get the best free option without having to track what's currently available. Quality will be lower than paid tiers, and the models may change over time.
|
||||
|
||||
## Benefits
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ For the current mode-to-model mappings, see the [Auto Model user docs](/docs/cod
|
||||
|
||||
**Who it's for**: Cost-conscious developers who want better results than free models at a fraction of frontier cost.
|
||||
|
||||
**What it does**: Follows the same mode-based routing structure as Frontier but uses GPT 5.3 Codex — a cost-effective model with strong reasoning and coding capabilities — across all modes.
|
||||
**What it does**: Uses GPT 5.3 Codex — a cost-effective model with strong reasoning and coding capabilities — for every mode. Unlike Frontier, Balanced does not vary its underlying model by mode.
|
||||
|
||||
**Pricing**: Paid, but significantly cheaper than Frontier.
|
||||
|
||||
@@ -62,17 +62,17 @@ For the current mode-to-model mappings, see the [Auto Model user docs](/docs/cod
|
||||
|
||||
**Who it's for**: Users who want to try Kilo without a credit card, students, hobbyists, and anyone exploring AI-assisted coding.
|
||||
|
||||
**What it does**: Automatically maps to the best available free model(s) for each mode. As free model availability changes due to promotional periods, the mapping updates transparently. Users always get the best free option without having to track which models are currently available.
|
||||
**What it does**: Splits requests across the best available free models, weighted by a deterministic per-session hash so a given session sticks with one model. As free model availability changes due to promotional periods, the split and the underlying models are updated transparently server-side. Users always get the best free option without having to track which models are currently available.
|
||||
|
||||
**Pricing**: Free. No credits required.
|
||||
|
||||
**Constraints**: Free models may not provide sufficient breadth to justify different models per mode. In that case, a single model may be used for all modes. Quality will be lower than Frontier or Balanced tiers — this is a tradeoff users accept by choosing free.
|
||||
**Constraints**: Free models do not vary by mode — the same model is used for every mode within a session. Quality will be lower than Frontier or Balanced tiers — this is a tradeoff users accept by choosing free.
|
||||
|
||||
### Auto: Small (internal)
|
||||
|
||||
**Who it's for**: Not user-facing. Used internally by Kilo for lightweight background tasks (session titles, commit messages, conversation summaries).
|
||||
|
||||
**What it does**: Automatically selects the right small model for lightweight tasks. When credits are available, it uses a fast paid small model.
|
||||
**What it does**: Automatically selects the right small model for lightweight tasks. When the account has a positive balance, it uses a fast paid small model; otherwise it falls back to a free small model.
|
||||
|
||||
**Why it matters**: Users never think about background tasks, and they shouldn't have to. Auto: Small ensures these tasks always work, always feel fast, and never waste credits on an expensive model when a cheap one will do.
|
||||
|
||||
@@ -115,8 +115,8 @@ The Kilo API at `api.kilo.ai` defines which underlying models each `kilo-auto/*`
|
||||
{
|
||||
"opencode": {
|
||||
"variants": {
|
||||
"architect": { "model": "anthropic/claude-opus-4-6", ... },
|
||||
"code": { "model": "anthropic/claude-sonnet-4-6", ... }
|
||||
"architect": { "model": "anthropic/claude-opus-4.7", ... },
|
||||
"code": { "model": "anthropic/claude-sonnet-4.6", ... }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,33 @@ git checkout -b docs/your-change-description
|
||||
- Reference issue numbers when applicable
|
||||
- Keep commits focused on a single change
|
||||
|
||||
### Changesets
|
||||
|
||||
User-facing changes (features, fixes, breaking changes) require a changeset file so the update shows up in the next release notes. Run the interactive tool, or create the file by hand:
|
||||
|
||||
```bash
|
||||
bunx changeset add
|
||||
```
|
||||
|
||||
Or create `.changeset/<slug>.md` manually:
|
||||
|
||||
```md
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Short description of the change for the changelog.
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Use `patch` for bug fixes, `minor` for new features, `major` for breaking changes.
|
||||
- Descriptions are read by end users in release notes — keep them concise and feature-oriented. Describe **what changed from the user's perspective**, not implementation details.
|
||||
- Write in imperative mood (e.g. "Support exporting conversations as markdown" rather than "Add a new export handler that serializes session messages to .md files").
|
||||
- Changesets are consumed at release time by the `publish.yml` workflow, which generates changelog entries for the GitHub release notes.
|
||||
|
||||
Skip the changeset only for internal refactors, CI tweaks, test-only changes, or docs that do not affect users.
|
||||
|
||||
### Testing Your Changes
|
||||
|
||||
- Run the test suite:
|
||||
|
||||
@@ -41,7 +41,7 @@ This returns model information including pricing, context window, and supported
|
||||
|
||||
| Model ID | Provider | Description |
|
||||
| ------------------------------- | --------- | ----------------------------------------------- |
|
||||
| `anthropic/claude-opus-4.6` | Anthropic | Most capable Claude model for complex reasoning |
|
||||
| `anthropic/claude-opus-4.7` | Anthropic | Most capable Claude model for complex reasoning |
|
||||
| `anthropic/claude-sonnet-4.6` | Anthropic | Balanced performance and cost |
|
||||
| `anthropic/claude-haiku-4.5` | Anthropic | Fast and cost-effective |
|
||||
| `openai/gpt-5.4` | OpenAI | Latest GPT model |
|
||||
@@ -76,42 +76,45 @@ Provided under the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia
|
||||
|
||||
Kilo Auto virtual models automatically select the best underlying model based on the task type. The selection is controlled by the `x-kilocode-mode` request header.
|
||||
|
||||
{% callout type="info" title="Underlying models can change" %}
|
||||
The mappings below reflect the current routing. The underlying models behind each `kilo-auto/*` tier are updated server-side as better options become available or as providers change pricing and availability — the tier IDs themselves remain stable.
|
||||
{% /callout %}
|
||||
|
||||
### `kilo-auto/frontier`
|
||||
|
||||
Highest performance and capability for any task.
|
||||
Highest performance and capability for any task. Frontier requests are sent with medium reasoning effort and medium verbosity.
|
||||
|
||||
| Mode | Resolved Model |
|
||||
| -------------------------------------------------------------- | ----------------------------- |
|
||||
| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `anthropic/claude-opus-4.6` |
|
||||
| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `anthropic/claude-opus-4.7` |
|
||||
| `build`, `explore`, `code` | `anthropic/claude-sonnet-4.6` |
|
||||
| Default (no mode specified) | `anthropic/claude-sonnet-4.6` |
|
||||
| Default (no / unknown mode) | `anthropic/claude-sonnet-4.6` |
|
||||
|
||||
### `kilo-auto/balanced`
|
||||
|
||||
Great balance of price and capability.
|
||||
|
||||
| Mode | Resolved Model |
|
||||
| -------------------------------------------------------------- | ---------------------- |
|
||||
| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `openai/gpt-5.3-codex` |
|
||||
| `build`, `explore`, `code` | `openai/gpt-5.3-codex` |
|
||||
| Default (no mode specified) | `openai/gpt-5.3-codex` |
|
||||
|
||||
### `kilo-auto/free`
|
||||
|
||||
Free with limited capability. No credits required.
|
||||
Great balance of price and capability. Balanced routes to the same model regardless of mode, with low reasoning effort.
|
||||
|
||||
| Mode | Resolved Model |
|
||||
| --------- | ---------------------- |
|
||||
| All modes | `minimax/minimax-m2.5` |
|
||||
| All modes | `openai/gpt-5.3-codex` |
|
||||
|
||||
### `kilo-auto/free`
|
||||
|
||||
Free with limited capability. No credits required. Requests are split across the available free models; the mapping updates server-side as free model availability shifts.
|
||||
|
||||
| Routing | Resolved Model |
|
||||
| ------- | ----------------------------- |
|
||||
| 80% | `minimax/minimax-m2.5:free` |
|
||||
| 20% | `stepfun/step-3.5-flash:free` |
|
||||
|
||||
### `kilo-auto/small`
|
||||
|
||||
Automatically routes to a small, fast model.
|
||||
Automatically routes to a small, fast model for lightweight background tasks (session titles, commit messages, summaries).
|
||||
|
||||
| Mode | Resolved Model |
|
||||
| ------------- | -------------------- |
|
||||
| Default | `openai/gpt-5-nano` |
|
||||
| Free fallback | `openai/gpt-oss-20b` |
|
||||
| Condition | Resolved Model |
|
||||
| ------------------------- | -------------------------------- |
|
||||
| Account has paid balance | `google/gemma-4-31b-it` |
|
||||
| No balance / free account | `google/gemma-4-26b-a4b-it:free` |
|
||||
|
||||
### Example usage
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Kilo-specific i18n translations and overrides",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -156,6 +156,24 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty
|
||||
}
|
||||
}
|
||||
|
||||
/* Reposition copy button tooltip to appear below (not above) to avoid clipping */
|
||||
[data-component="tool-output"] [data-slot="markdown-copy-button"]::after {
|
||||
bottom: auto;
|
||||
top: calc(100% + 4px);
|
||||
}
|
||||
|
||||
[data-slot="mcp-section-label"] {
|
||||
padding: 6px 12px 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-weak, var(--vscode-descriptionForeground));
|
||||
}
|
||||
|
||||
[data-slot="mcp-tool-divider"] {
|
||||
height: 1px;
|
||||
background: var(--border-weak-base, var(--vscode-panel-border));
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Expandable tool output content */
|
||||
[data-component="tool-output"] {
|
||||
padding: 8px 12px;
|
||||
|
||||
@@ -1026,24 +1026,84 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
|
||||
// GenericTool (upstream) does not render output; this override does.
|
||||
// When hideDetails is true, render as a row (no content), otherwise as a panel with markdown output.
|
||||
function McpTool(props: ToolProps) {
|
||||
const i18n = useI18n()
|
||||
const labelKeys = ["description", "query", "url", "filePath", "path", "pattern", "name"]
|
||||
const skipKeys = new Set(labelKeys)
|
||||
|
||||
const subtitle = () =>
|
||||
labelKeys
|
||||
.map((key) => props.input?.[key])
|
||||
.find((value): value is string => typeof value === "string" && value.length > 0)
|
||||
|
||||
const inputArgs = () => {
|
||||
if (!props.input) return []
|
||||
return Object.entries(props.input)
|
||||
.filter(([key]) => !skipKeys.has(key))
|
||||
.flatMap(([key, value]) => {
|
||||
if (typeof value === "string") return [`${key}=${value}`]
|
||||
if (typeof value === "number") return [`${key}=${value}`]
|
||||
if (typeof value === "boolean") return [`${key}=${value}`]
|
||||
return []
|
||||
})
|
||||
.slice(0, 3)
|
||||
}
|
||||
|
||||
const formatted = createMemo(() => {
|
||||
if (!props.input || Object.keys(props.input).length === 0) return ""
|
||||
return "```json\n" + JSON.stringify(props.input, null, 2) + "\n```"
|
||||
})
|
||||
|
||||
const formattedOutput = createMemo(() => {
|
||||
if (!props.output) return undefined
|
||||
try {
|
||||
const parsed = JSON.parse(props.output)
|
||||
return "```json\n" + JSON.stringify(parsed, null, 2) + "\n```"
|
||||
} catch {
|
||||
return props.output
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!props.hideDetails}
|
||||
fallback={<BasicTool hideDetails icon="mcp" status={props.status} trigger={{ title: props.tool }} />}
|
||||
fallback={
|
||||
<BasicTool
|
||||
hideDetails
|
||||
icon="mcp"
|
||||
status={props.status}
|
||||
trigger={{ title: props.tool, subtitle: subtitle(), args: inputArgs() }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<BasicTool
|
||||
icon="mcp"
|
||||
status={props.status}
|
||||
trigger={{ title: props.tool }}
|
||||
trigger={{ title: props.tool, subtitle: subtitle(), args: inputArgs() }}
|
||||
defaultOpen={props.defaultOpen}
|
||||
forceOpen={props.forceOpen}
|
||||
locked={props.locked}
|
||||
>
|
||||
<Show when={props.output}>
|
||||
{(output) => (
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
<Markdown text={output()} />
|
||||
</div>
|
||||
<Show when={formatted()}>
|
||||
{(text) => (
|
||||
<>
|
||||
<div data-slot="mcp-section-label">{i18n.t("ui.messagePart.mcp.input")}</div>
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
<Markdown text={text()} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={formattedOutput()}>
|
||||
{(text) => (
|
||||
<>
|
||||
<Show when={formatted()}>
|
||||
<div data-slot="mcp-tool-divider" />
|
||||
</Show>
|
||||
<div data-slot="mcp-section-label">{i18n.t("ui.messagePart.mcp.output")}</div>
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
<Markdown text={text()} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</BasicTool>
|
||||
@@ -1068,7 +1128,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
|
||||
return (
|
||||
<Show when={!hideQuestion()}>
|
||||
<div data-component="tool-part-wrapper" data-tool={part.tool}>
|
||||
<div data-component="tool-part-wrapper" data-part-type="tool" data-tool={part.tool}>
|
||||
<Switch>
|
||||
<Match when={part.state.status === "error" && part.state.error}>
|
||||
{(error) => {
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# kilo-code
|
||||
|
||||
## 7.2.12
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#9099](https://github.com/Kilo-Org/kilocode/pull/9099) [`49b283e`](https://github.com/Kilo-Org/kilocode/commit/49b283e72a7307a1398c38c0862214faa043c128) - Add a "Contribute on GitHub" call-to-action in the Marketplace view so users can easily propose a new skill, mode, or MCP server. The CTA appears as a subtle footer below the card grid and inside the empty search-results state, linking to the `kilo-marketplace` repository.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9066](https://github.com/Kilo-Org/kilocode/pull/9066) [`79ba643`](https://github.com/Kilo-Org/kilocode/commit/79ba643cba201971a9779e454fe66769019a7a5a) - Add `xhigh` reasoning effort option to custom provider model variants so users can access the highest tier on models that support it (gpt-5.2, gpt-5.3, gpt-5.4, gpt-5.1-codex-max, etc.).
|
||||
|
||||
- [#9068](https://github.com/Kilo-Org/kilocode/pull/9068) [`e65c2d9`](https://github.com/Kilo-Org/kilocode/commit/e65c2d99c0d234d3dc1dff2e75e58e22bea8ce7f) Thanks [@kilo-code-bot](https://github.com/apps/kilo-code-bot)! - Hide Kilo Gateway models that do not support tool calling from the model list.
|
||||
|
||||
- [#9040](https://github.com/Kilo-Org/kilocode/pull/9040) [`7d2f5a7`](https://github.com/Kilo-Org/kilocode/commit/7d2f5a7eced0b3599231f75656c0a27c68770d74) - Preserve local Agent Manager sessions after the panel reloads while still pruning stale worktree sessions.
|
||||
|
||||
- [#9057](https://github.com/Kilo-Org/kilocode/pull/9057) [`1526a4b`](https://github.com/Kilo-Org/kilocode/commit/1526a4b8a890e01c0c890b4e17b595f35bfd745b) - Keep the active chat session responsive while other sessions stream in the background. Applies to both the sidebar and the Agent Manager.
|
||||
|
||||
## 7.2.11
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -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.11",
|
||||
"version": "7.2.12",
|
||||
"icon": "assets/icons/logo-outline-black.png",
|
||||
"galleryBanner": {
|
||||
"color": "#FFFFFF",
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as vscode from "vscode"
|
||||
import type { KiloConnectionService } from "./services/cli-backend"
|
||||
import { buildWebviewHtml } from "./utils"
|
||||
import { GitOps } from "./agent-manager/GitOps"
|
||||
import { WorktreeDiffClient, type DiffTarget } from "./worktree-diff-client"
|
||||
import {
|
||||
appendOutput,
|
||||
getWorkspaceRoot,
|
||||
@@ -20,7 +21,7 @@ export class DiffViewerProvider implements vscode.Disposable {
|
||||
private panel: vscode.WebviewPanel | undefined
|
||||
private diffInterval: ReturnType<typeof setInterval> | undefined
|
||||
private lastDiffHash: string | undefined
|
||||
private cachedDiffTarget: { directory: string; baseBranch: string } | undefined
|
||||
private cachedDiffTarget: DiffTarget | undefined
|
||||
private gitOps: GitOps
|
||||
private outputChannel: vscode.OutputChannel
|
||||
private onSendComments: ((comments: unknown[], autoSend: boolean) => void) | undefined
|
||||
@@ -107,12 +108,48 @@ export class DiffViewerProvider implements vscode.Disposable {
|
||||
return
|
||||
}
|
||||
|
||||
if (type === "diffViewer.revertFile" && typeof msg.file === "string") {
|
||||
void this.revertFile(msg.file)
|
||||
return
|
||||
}
|
||||
|
||||
if (type === "openFile" && typeof msg.filePath === "string") {
|
||||
openWorkspaceRelativeFile(msg.filePath, typeof msg.line === "number" ? msg.line : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveLocalDiffTarget(): Promise<{ directory: string; baseBranch: string } | undefined> {
|
||||
private async revertFile(file: string): Promise<void> {
|
||||
const target = this.cachedDiffTarget ?? (await this.resolveLocalDiffTarget())
|
||||
if (!target) {
|
||||
this.post({
|
||||
type: "diffViewer.revertFileResult",
|
||||
file,
|
||||
status: "error",
|
||||
message: "Could not resolve diff target",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const diff = new WorktreeDiffClient(this.connectionService.getClient(), this.gitOps, (...args) =>
|
||||
this.log(...args),
|
||||
)
|
||||
const result = await diff.revertFile(target, file)
|
||||
this.post({
|
||||
type: "diffViewer.revertFileResult",
|
||||
file,
|
||||
status: result.ok ? "success" : "error",
|
||||
message: result.message,
|
||||
})
|
||||
if (result.ok) void this.pollDiff()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
this.log("Failed to revert file:", message)
|
||||
this.post({ type: "diffViewer.revertFileResult", file, status: "error", message })
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveLocalDiffTarget(): Promise<DiffTarget | undefined> {
|
||||
return await resolveLocalDiffTarget(this.gitOps, (...args) => this.log(...args), getWorkspaceRoot())
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
buildSettingPath,
|
||||
mapSSEEventToWebviewMessage,
|
||||
getErrorMessage,
|
||||
getConfigErrorDetails,
|
||||
isEventFromForeignProject,
|
||||
MessageConfirmation,
|
||||
runWithMessageConfirmation,
|
||||
@@ -31,7 +32,6 @@ import {
|
||||
flushPendingSessionRefresh as flushPendingSessionRefreshUtil,
|
||||
resolveContextDirectory,
|
||||
resolveWorkspaceDirectory,
|
||||
mergeFileSearchResults,
|
||||
SessionStreamScheduler,
|
||||
type SessionRefreshContext,
|
||||
} from "./kilo-provider-utils"
|
||||
@@ -46,6 +46,7 @@ import { retry } from "./services/cli-backend/retry"
|
||||
import { slimPart, slimParts } from "./kilo-provider/slim-metadata"
|
||||
import { handleContinueInWorktree } from "./kilo-provider/continue-worktree"
|
||||
import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files"
|
||||
import { handleFileSearch } from "./kilo-provider/file-search"
|
||||
import { getTerminalContents } from "./services/terminal/context"
|
||||
import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session"
|
||||
import { childID } from "./kilo-provider/task-session"
|
||||
@@ -87,6 +88,7 @@ import {
|
||||
handleQuestionReject,
|
||||
fetchAndSendPendingQuestions,
|
||||
} from "./kilo-provider/handlers/question"
|
||||
import { fetchAndSendPendingSuggestions, routeSuggestionWebviewMessage } from "./kilo-provider/handlers/suggestion"
|
||||
|
||||
import {
|
||||
buildActionContext,
|
||||
@@ -282,6 +284,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
// Strip edit-tool metadata.filediff.before/after (multi-MB for edit-heavy
|
||||
// sessions) to keep session switches fast. Logic in kilo-provider/slim-metadata.ts.
|
||||
private slimPart<T>(part: T): T {
|
||||
if (!this.slimEditMetadata) return part
|
||||
return slimPart(part)
|
||||
@@ -504,6 +508,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
await Promise.all([
|
||||
fetchAndSendPendingPermissions(this.permissionCtx),
|
||||
fetchAndSendPendingQuestions(this.questionCtx),
|
||||
fetchAndSendPendingSuggestions(this.questionCtx),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -557,6 +562,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
await routeSuggestionWebviewMessage(this.questionCtx, message)
|
||||
switch (message.type) {
|
||||
case "webviewReady":
|
||||
console.log("[Kilo New] KiloProvider: ✅ webviewReady received")
|
||||
@@ -809,29 +815,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
)
|
||||
break
|
||||
}
|
||||
case "requestFileSearch": {
|
||||
const sdkClient = this.client
|
||||
if (sdkClient) {
|
||||
const dir = this.getWorkspaceDirectory(this.currentSession?.id)
|
||||
const openPaths = dir ? await this.getOpenTabPaths(dir) : new Set<string>()
|
||||
void sdkClient.find
|
||||
.files({ query: message.query, directory: dir, type: "file", limit: 50 }, { throwOnError: true })
|
||||
.then(({ data: paths }) => {
|
||||
const uri = vscode.window.activeTextEditor?.document.uri
|
||||
const active =
|
||||
uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath).replaceAll("\\", "/") : undefined
|
||||
const result = mergeFileSearchResults({ query: message.query, backend: paths, open: openPaths, active })
|
||||
this.postMessage({ type: "fileSearchResult", paths: result, dir, requestId: message.requestId })
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("[Kilo New] File search failed:", error)
|
||||
this.postMessage({ type: "fileSearchResult", paths: [], dir, requestId: message.requestId })
|
||||
})
|
||||
} else {
|
||||
this.postMessage({ type: "fileSearchResult", paths: [], dir: "", requestId: message.requestId })
|
||||
}
|
||||
case "requestFileSearch":
|
||||
await handleFileSearch({
|
||||
client: this.client,
|
||||
message,
|
||||
current: this.currentSession?.id,
|
||||
context: this.contextSessionID,
|
||||
dir: (id) => this.getWorkspaceDirectory(id),
|
||||
open: (dir) => this.getOpenTabPaths(dir),
|
||||
post: (msg) => this.postMessage(msg),
|
||||
})
|
||||
break
|
||||
}
|
||||
case "requestTerminalContext":
|
||||
void this.handleTerminalContext(message.requestId)
|
||||
break
|
||||
@@ -2248,7 +2242,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
*/
|
||||
private async handleUpdateConfig(partial: Partial<Config>): Promise<void> {
|
||||
if (!this.client || this.connectionState !== "connected") {
|
||||
this.postMessage({ type: "error", message: "Not connected to CLI backend" })
|
||||
this.postMessage({ type: "configUpdateFailed", message: "Not connected to CLI backend" })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2257,42 +2251,39 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
partial.disabled_providers !== undefined ||
|
||||
partial.enabled_providers !== undefined
|
||||
|
||||
// Belt-and-suspenders guard: prevent fetchAndSendConfig from sending a
|
||||
// stale configLoaded while this write is in flight (the SSE-triggered reload
|
||||
// races with the async config.update() write on the CLI backend).
|
||||
// Guard against fetchAndSendConfig pushing stale data while the write is in flight.
|
||||
this.pending++
|
||||
|
||||
// Phase 1: write. Errors here = real save failures the user can fix + retry.
|
||||
try {
|
||||
// Reject all pending permissions and questions across every provider
|
||||
// so their CLI-side Promises resolve before disposeAll() wipes
|
||||
// Instance state. Throws on failure to abort the config save.
|
||||
await this.connectionService.drainPendingPrompts()
|
||||
|
||||
await this.client.global.config.update({ config: partial }, { throwOnError: true })
|
||||
|
||||
// Re-fetch the full merged config (global + project + all layers) so the
|
||||
// webview receives the complete resolved config, not just global-only data.
|
||||
// Config.state is reset by updateGlobal (via Instance.resetStateEntry) so
|
||||
// config.get() returns fresh data without a full dispose cycle.
|
||||
const dir = this.getWorkspaceDirectory()
|
||||
const { data: merged } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true }))
|
||||
|
||||
this.cachedConfigMessage = { type: "configLoaded", config: merged }
|
||||
this.postMessage({ type: "configUpdated", config: merged })
|
||||
|
||||
if (refreshProviders) {
|
||||
await this.fetchAndSendProviders()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to update config:", error)
|
||||
this.postMessage({
|
||||
type: "error",
|
||||
type: "configUpdateFailed",
|
||||
message: getErrorMessage(error) || "Failed to update config",
|
||||
details: getConfigErrorDetails(error),
|
||||
})
|
||||
// Send configUpdated with the last known good config so the webview
|
||||
// clears its saving flag and reverts optimistic state.
|
||||
if (this.cachedConfigMessage) {
|
||||
this.postMessage({ type: "configUpdated", config: (this.cachedConfigMessage as { config: unknown }).config })
|
||||
}
|
||||
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 }
|
||||
this.postMessage({ type: "configUpdated", config: merged })
|
||||
if (refreshProviders) await this.fetchAndSendProviders()
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Config write succeeded but post-write refresh failed:", error)
|
||||
const cached = (this.cachedConfigMessage as { config?: unknown } | null)?.config
|
||||
const optimistic =
|
||||
cached && typeof cached === "object" ? { ...(cached as Record<string, unknown>), ...partial } : partial
|
||||
this.postMessage({ type: "configUpdated", config: optimistic })
|
||||
} finally {
|
||||
this.pending--
|
||||
}
|
||||
|
||||
@@ -265,6 +265,9 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
private async onMessage(msg: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
||||
if (this.prBridge.handleMessage(msg)) return null
|
||||
if (msg.type === "requestFileSearch" && typeof msg.sessionID !== "string" && this.activeSessionId) {
|
||||
return { ...msg, sessionID: this.activeSessionId }
|
||||
}
|
||||
const m = msg as unknown as AgentManagerInMessage
|
||||
|
||||
const worktree = await this.onWorktreeMessage(m)
|
||||
|
||||
@@ -533,8 +533,13 @@ export class WorktreeStateManager {
|
||||
}
|
||||
let pruned = 0
|
||||
for (const [id, s] of Object.entries(data.sessions ?? {})) {
|
||||
// Skip orphaned sessions (null worktreeId or referencing a deleted worktree)
|
||||
if (!s.worktreeId || !this.worktrees.has(s.worktreeId)) {
|
||||
const ref = s.worktreeId
|
||||
if (ref === null) {
|
||||
this.sessions.set(id, { id, ...s })
|
||||
continue
|
||||
}
|
||||
// Skip orphaned sessions referencing a deleted worktree.
|
||||
if (!ref || !this.worktrees.has(ref)) {
|
||||
pruned++
|
||||
continue
|
||||
}
|
||||
@@ -580,9 +585,11 @@ export class WorktreeStateManager {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
// Prune orphaned sessions (worktreeId is null or references a deleted worktree)
|
||||
// Preserve local sessions; prune only sessions that reference missing worktrees.
|
||||
for (const s of [...this.sessions.values()]) {
|
||||
if (!s.worktreeId || !this.worktrees.has(s.worktreeId)) {
|
||||
const ref = s.worktreeId
|
||||
if (ref === null) continue
|
||||
if (!ref || !this.worktrees.has(ref)) {
|
||||
this.sessions.delete(s.id)
|
||||
changed = true
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@ function createHarness() {
|
||||
const manager = Object.create(AgentManagerProvider.prototype) as {
|
||||
host: Host
|
||||
panel: { sessions: { registerSession: ReturnType<typeof vi.fn> } } | undefined
|
||||
prBridge: { handleMessage: ReturnType<typeof vi.fn> }
|
||||
activeSessionId: string | undefined
|
||||
stateReady: Promise<void> | undefined
|
||||
createWorktreeOnDisk: ReturnType<typeof vi.fn>
|
||||
runSetupScriptForWorktree: ReturnType<typeof vi.fn>
|
||||
@@ -107,6 +109,7 @@ function createHarness() {
|
||||
notifyWorktreeReady: ReturnType<typeof vi.fn>
|
||||
log: ReturnType<typeof vi.fn>
|
||||
onCreateWorktree: () => Promise<null>
|
||||
onMessage: (msg: Record<string, unknown>) => Promise<Record<string, unknown> | null>
|
||||
}
|
||||
|
||||
manager.host = host
|
||||
@@ -115,6 +118,8 @@ function createHarness() {
|
||||
registerSession: vi.fn(),
|
||||
},
|
||||
}
|
||||
manager.prBridge = { handleMessage: vi.fn().mockReturnValue(false) }
|
||||
manager.activeSessionId = undefined
|
||||
manager.stateReady = Promise.resolve()
|
||||
manager.createWorktreeOnDisk = vi.fn()
|
||||
manager.runSetupScriptForWorktree = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -169,4 +174,13 @@ describe("AgentManagerProvider worktree creation", () => {
|
||||
|
||||
expect(manager.createWorktreeOnDisk).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("routes file search through the active worktree session", async () => {
|
||||
const manager = createHarness()
|
||||
manager.activeSessionId = "session-wt"
|
||||
|
||||
const result = await manager.onMessage({ type: "requestFileSearch", query: "src", requestId: "r1" })
|
||||
|
||||
expect(result).toEqual({ type: "requestFileSearch", query: "src", requestId: "r1", sessionID: "session-wt" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import { hashFileDiffs, resolveLocalDiffTarget } from "../review-utils"
|
||||
import { WorktreeDiffClient } from "../worktree-diff-client"
|
||||
import type { ApplyConflict, GitOps } from "./GitOps"
|
||||
import { shouldStopDiffPolling } from "./delete-worktree"
|
||||
import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager"
|
||||
@@ -8,7 +9,6 @@ import type { AgentManagerOutMessage } from "./types"
|
||||
const LOCAL_DIFF_ID = "local" as const
|
||||
|
||||
type Target = { sessionId: string; directory: string; baseBranch: string }
|
||||
type Status = "added" | "deleted" | "modified"
|
||||
|
||||
export interface WorktreeDiffControllerContext {
|
||||
getState: () => WorktreeStateManager | undefined
|
||||
@@ -111,12 +111,8 @@ export class WorktreeDiffController {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.ctx.git.revertFile(
|
||||
target.directory,
|
||||
target.baseBranch,
|
||||
file,
|
||||
await this.status(target, file),
|
||||
)
|
||||
const diff = new WorktreeDiffClient(this.ctx.getClient(), this.ctx.git, (...args) => this.ctx.log(...args))
|
||||
const result = await diff.revertFile(target, file)
|
||||
this.ctx.post({
|
||||
type: "agentManager.revertWorktreeFileResult",
|
||||
sessionId,
|
||||
@@ -266,18 +262,6 @@ export class WorktreeDiffController {
|
||||
return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), this.ctx.getRoot())
|
||||
}
|
||||
|
||||
private async status(target: { directory: string; baseBranch: string }, file: string): Promise<Status | undefined> {
|
||||
try {
|
||||
const { data } = await this.ctx
|
||||
.getClient()
|
||||
.worktree.diffFile({ directory: target.directory, base: target.baseBranch, file }, { throwOnError: true })
|
||||
return data?.status
|
||||
} catch (error) {
|
||||
this.ctx.log("Failed to look up file status for revert:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async ready(msg: string): Promise<void> {
|
||||
await this.ctx.getStateReady()?.catch((err) => this.ctx.log(msg, err))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Session, Agent, Event, ProviderListResponse } from "@kilocode/sdk/v2/client"
|
||||
import { prettifyError } from "zod/v4"
|
||||
import type { CloudSessionMessage } from "./services/cli-backend/types"
|
||||
import type { PartBatch, PartUpdate } from "./kilo-provider/session-stream-scheduler"
|
||||
|
||||
@@ -17,48 +18,94 @@ export type ProviderInfo = ProviderListResponse["all"][number]
|
||||
* - NotFoundError: { name: "NotFoundError", data: { message: "..." } }
|
||||
* - Plain string (raw text response)
|
||||
*/
|
||||
/** Extract a message from the first element of an array of strings or `{ message }` objects. */
|
||||
function firstMessage(arr: unknown): string | undefined {
|
||||
if (!Array.isArray(arr) || arr.length === 0) return undefined
|
||||
const first = arr[0]
|
||||
if (typeof first === "string") return first
|
||||
if (first && typeof first === "object") {
|
||||
const msg = (first as Record<string, unknown>).message
|
||||
if (typeof msg === "string") return msg
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Extract a message from SDK error `data` field shapes (NotFoundError, ConfigInvalidError, Hono validator). */
|
||||
function messageFromData(data: Record<string, unknown>): string | undefined {
|
||||
if (typeof data.message === "string") return data.message
|
||||
// ConfigInvalidError: { path, issues: [{ message, path, code }] }
|
||||
const fromIssues = firstMessage(data.issues)
|
||||
if (fromIssues) return fromIssues
|
||||
// Hono validator: { data, error: [...], success: false }
|
||||
return firstMessage(data.error)
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string | undefined {
|
||||
try {
|
||||
const json = JSON.stringify(value)
|
||||
if (json !== "{}" && json.length < 500) return json
|
||||
} catch (err) {
|
||||
console.warn("[Kilo New] getErrorMessage: JSON.stringify failed", err)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "string") return error
|
||||
if (error && typeof error === "object") {
|
||||
const obj = error as Record<string, unknown>
|
||||
// Direct .message field
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
// Direct .error field (string)
|
||||
if (typeof obj.error === "string") return obj.error
|
||||
// SDK throwOnError shape: { error: { message: "..." } } or { error: { ... } }
|
||||
if (obj.error && typeof obj.error === "object") {
|
||||
const nested = obj.error as Record<string, unknown>
|
||||
if (typeof nested.message === "string") return nested.message
|
||||
}
|
||||
// NotFoundError shape: { data: { message: "..." } }
|
||||
if (obj.data && typeof obj.data === "object") {
|
||||
const data = obj.data as Record<string, unknown>
|
||||
if (typeof data.message === "string") return data.message
|
||||
// Hono validator shape: { data: ..., error: [...], success: false }
|
||||
if (Array.isArray(data.error) && data.error.length > 0) {
|
||||
const first = data.error[0]
|
||||
if (typeof first === "string") return first
|
||||
if (first && typeof first === "object" && typeof (first as Record<string, unknown>).message === "string") {
|
||||
return (first as Record<string, unknown>).message as string
|
||||
}
|
||||
}
|
||||
}
|
||||
// BadRequestError shape: { errors: [{ message: "..." }] }
|
||||
if (Array.isArray(obj.errors) && obj.errors.length > 0) {
|
||||
const first = obj.errors[0]
|
||||
if (typeof first === "string") return first
|
||||
if (first && typeof first.message === "string") return first.message
|
||||
}
|
||||
// Last resort: try JSON.stringify for debuggability
|
||||
try {
|
||||
const json = JSON.stringify(error)
|
||||
if (json !== "{}" && json.length < 500) return json
|
||||
} catch (err) {
|
||||
console.warn("[Kilo New] getErrorMessage: JSON.stringify failed", err)
|
||||
}
|
||||
if (!error || typeof error !== "object") return String(error)
|
||||
|
||||
const obj = error as Record<string, unknown>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.error === "string") return obj.error
|
||||
|
||||
// SDK throwOnError shape: { error: { message: "..." } }
|
||||
if (obj.error && typeof obj.error === "object") {
|
||||
const nested = (obj.error as Record<string, unknown>).message
|
||||
if (typeof nested === "string") return nested
|
||||
}
|
||||
return String(error)
|
||||
|
||||
if (obj.data && typeof obj.data === "object") {
|
||||
const fromData = messageFromData(obj.data as Record<string, unknown>)
|
||||
if (fromData) return fromData
|
||||
}
|
||||
|
||||
// BadRequestError: { errors: [...] }
|
||||
const fromErrors = firstMessage(obj.errors)
|
||||
if (fromErrors) return fromErrors
|
||||
|
||||
return safeStringify(error) ?? String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a full human-readable breakdown of a config save failure, including
|
||||
* the file path and every Zod issue. Used as the expandable details next to
|
||||
* the short getErrorMessage() summary.
|
||||
*
|
||||
* Zod issues are formatted via zod's built-in `prettifyError` so the output
|
||||
* matches Zod's canonical format (array indices rendered as `foo[0].bar`, etc).
|
||||
*
|
||||
* Returns undefined when the error doesn't carry structured config data —
|
||||
* callers should omit the details section in that case.
|
||||
*/
|
||||
export function getConfigErrorDetails(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== "object") return undefined
|
||||
const data = (error as Record<string, unknown>).data
|
||||
if (!data || typeof data !== "object") return undefined
|
||||
const scoped = data as Record<string, unknown>
|
||||
const path = typeof scoped.path === "string" ? scoped.path : undefined
|
||||
const issues = Array.isArray(scoped.issues) ? scoped.issues : undefined
|
||||
if (!path && (!issues || issues.length === 0)) return undefined
|
||||
|
||||
const out: string[] = []
|
||||
if (path) out.push(`File: ${path}`)
|
||||
if (issues && issues.length > 0) {
|
||||
if (out.length > 0) out.push("")
|
||||
// prettifyError accepts any object with an `issues` array; the cast is
|
||||
// safe because it only reads the issues field.
|
||||
out.push(prettifyError({ issues } as Parameters<typeof prettifyError>[0]))
|
||||
}
|
||||
return out.join("\n")
|
||||
}
|
||||
|
||||
export class MessageConfirmation {
|
||||
@@ -305,8 +352,24 @@ export type WebviewMessage =
|
||||
}
|
||||
}
|
||||
| { type: "todoUpdated"; sessionID: string; items: unknown[] }
|
||||
| { type: "questionRequest"; question: { id: string; sessionID: string; questions: unknown[]; tool?: unknown } }
|
||||
| {
|
||||
type: "questionRequest"
|
||||
question: { id: string; sessionID: string; questions: unknown[]; blocking?: boolean; tool?: unknown }
|
||||
}
|
||||
| { type: "questionResolved"; requestID: string }
|
||||
| {
|
||||
type: "suggestionRequest"
|
||||
suggestion: {
|
||||
id: string
|
||||
sessionID: string
|
||||
text: string
|
||||
actions: unknown[]
|
||||
blocking?: boolean
|
||||
tool?: unknown
|
||||
}
|
||||
}
|
||||
| { type: "suggestionResolved"; requestID: string }
|
||||
| { type: "suggestionError"; requestID: string }
|
||||
| { type: "permissionResolved"; permissionID: string }
|
||||
| { type: "permissionError"; permissionID: string }
|
||||
| { type: "sessionCreated"; session: ReturnType<typeof sessionToWebview>; draftID?: string }
|
||||
@@ -410,6 +473,7 @@ export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | un
|
||||
id: event.properties.id,
|
||||
sessionID: event.properties.sessionID,
|
||||
questions: event.properties.questions,
|
||||
blocking: event.properties.blocking,
|
||||
tool: event.properties.tool,
|
||||
},
|
||||
}
|
||||
@@ -419,6 +483,24 @@ export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | un
|
||||
type: "questionResolved",
|
||||
requestID: event.properties.requestID,
|
||||
}
|
||||
case "suggestion.shown":
|
||||
return {
|
||||
type: "suggestionRequest",
|
||||
suggestion: {
|
||||
id: event.properties.id,
|
||||
sessionID: event.properties.sessionID,
|
||||
text: event.properties.text,
|
||||
actions: event.properties.actions,
|
||||
blocking: event.properties.blocking,
|
||||
tool: event.properties.tool,
|
||||
},
|
||||
}
|
||||
case "suggestion.accepted":
|
||||
case "suggestion.dismissed":
|
||||
return {
|
||||
type: "suggestionResolved",
|
||||
requestID: event.properties.requestID,
|
||||
}
|
||||
case "session.error": {
|
||||
return {
|
||||
type: "sessionError",
|
||||
@@ -482,12 +564,16 @@ export function mergeFileSearchResults(input: {
|
||||
open: Set<string>
|
||||
active?: string
|
||||
}): string[] {
|
||||
const query = input.query.trim().toLowerCase()
|
||||
const norm = (p: string) => p.replaceAll("\\", "/")
|
||||
const query = norm(input.query).trim().toLowerCase()
|
||||
const open = new Set([...input.open].map(norm))
|
||||
const active = input.active ? norm(input.active) : undefined
|
||||
const backend = input.backend.map(norm)
|
||||
const ok = (p: string) => !query || p.toLowerCase().includes(query)
|
||||
const tabs =
|
||||
input.active && input.open.has(input.active) && ok(input.active)
|
||||
? [input.active, ...[...input.open].filter((p) => p !== input.active && ok(p))]
|
||||
: [...input.open].filter(ok)
|
||||
active && open.has(active) && ok(active)
|
||||
? [active, ...[...open].filter((p) => p !== active && ok(p))]
|
||||
: [...open].filter(ok)
|
||||
const seen = new Set(tabs)
|
||||
return [...tabs, ...input.backend.filter((p) => !seen.has(p))]
|
||||
return [...tabs, ...backend.filter((p) => !seen.has(p))]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export type FileSearchItem = { path: string; type: "file" | "folder" }
|
||||
|
||||
const normalize = (p: string) => p.replaceAll("\\", "/")
|
||||
const trim = (p: string) => normalize(p).replace(/\/+$/, "")
|
||||
|
||||
function base(p: string): string {
|
||||
const clean = trim(p)
|
||||
return clean.split("/").pop() ?? clean
|
||||
}
|
||||
|
||||
function rank(query: string, p: string): number {
|
||||
const clean = trim(p).toLowerCase()
|
||||
const name = base(p).toLowerCase()
|
||||
if (clean === query || name === query) return 0
|
||||
if (name.startsWith(query) || (query.includes("/") && clean.startsWith(query))) return 1
|
||||
if (name.includes(query)) return 2
|
||||
if (clean.includes(query)) return 3
|
||||
return 4
|
||||
}
|
||||
|
||||
export function mergeFileSearchItems(input: { query: string; files: string[]; folders: string[] }): FileSearchItem[] {
|
||||
const query = normalize(input.query).trim().toLowerCase()
|
||||
const files = input.files.map((p) => ({ path: normalize(p), type: "file" as const }))
|
||||
// Dedup folders against themselves; a file and a folder that share a stem are distinct entries.
|
||||
const seen = new Set<string>()
|
||||
const folders = input.folders
|
||||
.filter((p) => {
|
||||
const key = trim(p)
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
.map((p, index) => ({
|
||||
item: { path: normalize(p), type: "folder" as const },
|
||||
index,
|
||||
rank: query ? rank(query, p) : 4,
|
||||
}))
|
||||
|
||||
if (!query) return [...files, ...folders.map((x) => x.item)]
|
||||
|
||||
const sorted = [...folders].sort((a, b) => a.rank - b.rank || a.index - b.index)
|
||||
const boosted = sorted.filter((x) => x.rank <= 1).map((x) => x.item)
|
||||
const rest = sorted.filter((x) => x.rank > 1).map((x) => x.item)
|
||||
return [...boosted, ...files, ...rest]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import { mergeFileSearchResults } from "../kilo-provider-utils"
|
||||
import { mergeFileSearchItems } from "./file-search-items"
|
||||
|
||||
type Message = {
|
||||
query: string
|
||||
requestId: string
|
||||
sessionID?: string
|
||||
}
|
||||
|
||||
type Input = {
|
||||
client: KiloClient | null
|
||||
message: Message
|
||||
current?: string
|
||||
context?: string
|
||||
dir: (id?: string) => string
|
||||
open: (dir: string) => Promise<Set<string>>
|
||||
post: (message: unknown) => void
|
||||
}
|
||||
|
||||
export async function handleFileSearch(input: Input): Promise<void> {
|
||||
const client = input.client
|
||||
if (!client) {
|
||||
input.post({ type: "fileSearchResult", paths: [], items: [], dir: "", requestId: input.message.requestId })
|
||||
return
|
||||
}
|
||||
|
||||
const id = input.message.sessionID ?? input.current ?? input.context
|
||||
const dir = input.dir(id)
|
||||
const open = dir ? await input.open(dir) : new Set<string>()
|
||||
|
||||
const query = input.message.query
|
||||
void Promise.allSettled([
|
||||
client.find.files({ query, directory: dir, type: "file", limit: 50 }, { throwOnError: true }),
|
||||
client.find.files({ query, directory: dir, type: "directory", limit: 50 }, { throwOnError: true }),
|
||||
]).then(([fileRes, folderRes]) => {
|
||||
const files = settled(fileRes, "file")
|
||||
const folders = settled(folderRes, "folder")
|
||||
const uri = vscode.window.activeTextEditor?.document.uri
|
||||
const rel = uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath) : undefined
|
||||
const active = rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.replaceAll("\\", "/") : undefined
|
||||
const result = mergeFileSearchResults({ query, backend: files, open, active })
|
||||
const items = mergeFileSearchItems({ query, files: result, folders })
|
||||
input.post({ type: "fileSearchResult", paths: result, items, dir, requestId: input.message.requestId })
|
||||
})
|
||||
}
|
||||
|
||||
function settled(result: PromiseSettledResult<{ data: string[] }>, kind: "file" | "folder"): string[] {
|
||||
if (result.status === "fulfilled") return result.value.data
|
||||
console.error(`[Kilo New] File search (${kind}) failed:`, result.reason)
|
||||
return []
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export async function fetchAndSendPendingQuestions(ctx: QuestionContext): Promis
|
||||
id: q.id,
|
||||
sessionID: q.sessionID,
|
||||
questions: q.questions,
|
||||
blocking: q.blocking,
|
||||
tool: q.tool,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Suggestion handlers — extracted from KiloProvider.
|
||||
*
|
||||
* Manages suggestion accept and dismiss flows plus recovery after SSE reconnects.
|
||||
* No vscode dependency.
|
||||
*/
|
||||
|
||||
import type { KiloClient, SuggestionRequest } from "@kilocode/sdk/v2/client"
|
||||
import { recoveryDirs } from "./permission-handler"
|
||||
|
||||
export type RecoverableSuggestion = SuggestionRequest
|
||||
|
||||
export interface SuggestionContext {
|
||||
readonly client: KiloClient | null
|
||||
readonly currentSessionId: string | undefined
|
||||
readonly trackedSessionIds: Set<string>
|
||||
readonly sessionDirectories: ReadonlyMap<string, string>
|
||||
postMessage(msg: unknown): void
|
||||
getWorkspaceDirectory(sessionId?: string): string
|
||||
}
|
||||
|
||||
export function recoverableSuggestions(items: RecoverableSuggestion[], tracked: Set<string>, seen: Set<string>) {
|
||||
return items.filter((item) => {
|
||||
if (seen.has(item.id)) return false
|
||||
seen.add(item.id)
|
||||
return tracked.has(item.sessionID)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Route suggestion-related webview messages.
|
||||
* Extracted from the main message handler to stay within the complexity limit.
|
||||
*/
|
||||
export async function routeSuggestionWebviewMessage(
|
||||
ctx: SuggestionContext,
|
||||
message: { type: string; requestID?: string; sessionID?: string; index?: number },
|
||||
): Promise<void> {
|
||||
switch (message.type) {
|
||||
case "suggestionAccept":
|
||||
await handleSuggestionAccept(ctx, message.requestID!, message.index!, message.sessionID)
|
||||
break
|
||||
case "suggestionDismiss":
|
||||
await handleSuggestionDismiss(ctx, message.requestID!, message.sessionID)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSuggestionAccept(
|
||||
ctx: SuggestionContext,
|
||||
requestID: string,
|
||||
index: number,
|
||||
sessionID?: string,
|
||||
): Promise<void> {
|
||||
if (!ctx.client) {
|
||||
ctx.postMessage({ type: "suggestionError", requestID })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.client.suggestion.accept(
|
||||
{ requestID, index, directory: ctx.getWorkspaceDirectory(sessionID ?? ctx.currentSessionId) },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to accept suggestion:", error)
|
||||
ctx.postMessage({ type: "suggestionError", requestID })
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSuggestionDismiss(
|
||||
ctx: SuggestionContext,
|
||||
requestID: string,
|
||||
sessionID?: string,
|
||||
): Promise<void> {
|
||||
if (!ctx.client) {
|
||||
ctx.postMessage({ type: "suggestionError", requestID })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.client.suggestion.dismiss(
|
||||
{ requestID, directory: ctx.getWorkspaceDirectory(sessionID ?? ctx.currentSessionId) },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to dismiss suggestion:", error)
|
||||
ctx.postMessage({ type: "suggestionError", requestID })
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAndSendPendingSuggestions(ctx: SuggestionContext): Promise<void> {
|
||||
if (!ctx.client) return
|
||||
try {
|
||||
const dirs = recoveryDirs(ctx.getWorkspaceDirectory(), ctx.sessionDirectories)
|
||||
|
||||
const seen = new Set<string>()
|
||||
for (const dir of dirs) {
|
||||
const { data } = await ctx.client.suggestion.list({ directory: dir })
|
||||
if (!data) continue
|
||||
for (const suggestion of recoverableSuggestions(data, ctx.trackedSessionIds, seen)) {
|
||||
ctx.postMessage({
|
||||
type: "suggestionRequest",
|
||||
suggestion,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to fetch pending suggestions:", error)
|
||||
}
|
||||
}
|
||||
@@ -405,6 +405,7 @@ export class KiloConnectionService {
|
||||
if (error) throw new Error(`Failed to reject question ${q.id}: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
await drainSuggestions(this.client, dir)
|
||||
await drainNetworkWaits(this.client, dir)
|
||||
}
|
||||
for (const listener of this.clearPendingPromptsListeners) {
|
||||
@@ -633,3 +634,14 @@ export class KiloConnectionService {
|
||||
this.startHealthPoll(config.baseUrl, config.password)
|
||||
}
|
||||
}
|
||||
|
||||
async function drainSuggestions(client: KiloClient, directory: string): Promise<void> {
|
||||
const { data, error: err } = await client.suggestion.list({ directory })
|
||||
if (err) throw new Error(`Failed to list suggestions for ${directory}: ${String(err)}`)
|
||||
if (data) {
|
||||
for (const s of data) {
|
||||
const { error } = await client.suggestion.dismiss({ requestID: s.id, directory })
|
||||
if (error) throw new Error(`Failed to dismiss suggestion ${s.id}: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,17 @@ export function resolveEventSessionId(
|
||||
case "question.replied":
|
||||
case "question.rejected":
|
||||
return event.properties.sessionID
|
||||
default:
|
||||
return resolveSuggestionSessionId(event)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSuggestionSessionId(event: Event): string | undefined {
|
||||
switch (event.type) {
|
||||
case "suggestion.shown":
|
||||
case "suggestion.accepted":
|
||||
case "suggestion.dismissed":
|
||||
return event.properties.sessionID
|
||||
default:
|
||||
// session.network.* events are not yet in the SDK Event type union
|
||||
// (pending SDK regeneration). Handle them via string comparison.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import type { GitOps } from "./agent-manager/GitOps"
|
||||
|
||||
/**
|
||||
* A worktree diff target: the working directory and the base branch we diff
|
||||
* against (usually the tracking branch).
|
||||
*/
|
||||
export type DiffTarget = { directory: string; baseBranch: string }
|
||||
|
||||
type Status = "added" | "deleted" | "modified"
|
||||
|
||||
/**
|
||||
* Thin coordinator that wraps (KiloClient, GitOps, DiffTarget) and exposes the
|
||||
* small set of operations used by both the sidebar DiffViewerProvider and the
|
||||
* agent manager's WorktreeDiffController.
|
||||
*
|
||||
* Keeping the helper off review-utils.ts: this deals in HTTP + git orchestration,
|
||||
* not the small path/vscode helpers that file is scoped to.
|
||||
*/
|
||||
export class WorktreeDiffClient {
|
||||
constructor(
|
||||
private readonly client: KiloClient,
|
||||
private readonly git: GitOps,
|
||||
private readonly log: (...args: unknown[]) => void,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Look up the diff status for a single file. Used by revert flows to pick
|
||||
* the right git strategy (added → delete, modified/deleted → checkout).
|
||||
* Returns `undefined` on error so callers can still attempt a best-effort
|
||||
* revert — `GitOps.revertFile` defaults to a modified-file strategy.
|
||||
*/
|
||||
async fileStatus(target: DiffTarget, file: string): Promise<Status | undefined> {
|
||||
try {
|
||||
const { data } = await this.client.worktree.diffFile(
|
||||
{ directory: target.directory, base: target.baseBranch, file },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
return data?.status
|
||||
} catch (err) {
|
||||
this.log("Failed to look up file status for revert:", err)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert a single file in the worktree. Composes `fileStatus` + `GitOps.revertFile`.
|
||||
* Returns a normalized result; callers handle UI/messaging.
|
||||
*/
|
||||
async revertFile(target: DiffTarget, file: string): Promise<{ ok: boolean; message: string }> {
|
||||
const status = await this.fileStatus(target, file)
|
||||
return this.git.revertFile(target.directory, target.baseBranch, file, status)
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,30 @@ describe("resolveEventSessionId", () => {
|
||||
expect(resolveEventSessionId(e, noLookup)).toBe("s11")
|
||||
})
|
||||
|
||||
it("returns sessionID from suggestion.shown", () => {
|
||||
const e = event({
|
||||
type: "suggestion.shown",
|
||||
properties: { id: "sug_1", sessionID: "s12", text: "Review?", actions: [] },
|
||||
})
|
||||
expect(resolveEventSessionId(e, noLookup)).toBe("s12")
|
||||
})
|
||||
|
||||
it("returns sessionID from suggestion.accepted", () => {
|
||||
const e = event({
|
||||
type: "suggestion.accepted",
|
||||
properties: { sessionID: "s13", requestID: "sug_1", index: 0, action: { label: "Start", prompt: "x" } },
|
||||
})
|
||||
expect(resolveEventSessionId(e, noLookup)).toBe("s13")
|
||||
})
|
||||
|
||||
it("returns sessionID from suggestion.dismissed", () => {
|
||||
const e = event({
|
||||
type: "suggestion.dismissed",
|
||||
properties: { sessionID: "s14", requestID: "sug_2" },
|
||||
})
|
||||
expect(resolveEventSessionId(e, noLookup)).toBe("s14")
|
||||
})
|
||||
|
||||
it("returns undefined for unknown event types (global events)", () => {
|
||||
const e = event({ type: "server.connected", properties: {} })
|
||||
expect(resolveEventSessionId(e, noLookup)).toBeUndefined()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Architecture test: FullScreenDiffView CSS co-location.
|
||||
*
|
||||
* `FullScreenDiffView` and its children (`FileTree`, etc.) rely on classes
|
||||
* defined in BOTH `agent-manager.css` and `agent-manager-review.css`. The
|
||||
* component is shared by multiple webview bundles (sidebar diff viewer,
|
||||
* agent manager, storybook). Historically, each bundle was responsible for
|
||||
* importing its own CSS, which led to regressions when someone forgot to
|
||||
* wire the review stylesheet into a new entry point (see PR #7455 fallout).
|
||||
*
|
||||
* Current invariant: `FullScreenDiffView.tsx` imports both stylesheets at the
|
||||
* top of the file, so any bundle pulling in the component transitively gets
|
||||
* the styles via esbuild's CSS bundling.
|
||||
*
|
||||
* If this test fails, do NOT move the CSS imports elsewhere — fix the
|
||||
* component file to import the missing stylesheet, or add a new stylesheet
|
||||
* to the REQUIRED list if you intentionally split the styles.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "../..")
|
||||
const FULL_SCREEN_DIFF_VIEW = path.join(ROOT, "webview-ui/agent-manager/FullScreenDiffView.tsx")
|
||||
const REQUIRED = ["./agent-manager.css", "./agent-manager-review.css"] as const
|
||||
|
||||
describe("FullScreenDiffView — CSS co-location", () => {
|
||||
it("imports every stylesheet required to render correctly", () => {
|
||||
const src = fs.readFileSync(FULL_SCREEN_DIFF_VIEW, "utf-8")
|
||||
const missing = REQUIRED.filter((css) => !src.includes(`import "${css}"`))
|
||||
|
||||
expect(
|
||||
missing,
|
||||
`FullScreenDiffView is missing required CSS imports:\n` +
|
||||
missing.map((m) => ` - import "${m}"`).join("\n") +
|
||||
`\n\nAdd them at the top of FullScreenDiffView.tsx. The component is\n` +
|
||||
`shared by multiple webview bundles (sidebar diff viewer, agent manager,\n` +
|
||||
`storybook) and every bundle relies on these imports for complete styling.\n`,
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -50,6 +50,11 @@ describe("buildMentionResults", () => {
|
||||
const result = buildMentionResults("src", ["src/index.ts"])
|
||||
expect(result.map((item) => item.type)).toEqual(["file"])
|
||||
})
|
||||
|
||||
it("includes folder results", () => {
|
||||
const result = buildMentionResults("src", [{ path: "src", type: "folder" }])
|
||||
expect(result).toEqual([{ type: "folder", value: "src" }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("syncMentionedPaths", () => {
|
||||
|
||||
@@ -9,8 +9,11 @@ import {
|
||||
mapCloudSessionMessageToWebviewMessage,
|
||||
MessageConfirmation,
|
||||
mergeFileSearchResults,
|
||||
getErrorMessage,
|
||||
getConfigErrorDetails,
|
||||
type ProviderInfo,
|
||||
} from "../../src/kilo-provider-utils"
|
||||
import { mergeFileSearchItems } from "../../src/kilo-provider/file-search-items"
|
||||
import type { CloudSessionMessage } from "../../src/services/cli-backend/types"
|
||||
import type {
|
||||
Session,
|
||||
@@ -26,6 +29,9 @@ import type {
|
||||
EventQuestionAsked,
|
||||
EventQuestionReplied,
|
||||
EventQuestionRejected,
|
||||
EventSuggestionShown,
|
||||
EventSuggestionAccepted,
|
||||
EventSuggestionDismissed,
|
||||
EventSessionCreated,
|
||||
EventSessionUpdated,
|
||||
EventServerConnected,
|
||||
@@ -444,6 +450,43 @@ describe("mapSSEEventToWebviewMessage", () => {
|
||||
}
|
||||
})
|
||||
|
||||
it("maps suggestion.shown to suggestionRequest", () => {
|
||||
const event: EventSuggestionShown = {
|
||||
type: "suggestion.shown",
|
||||
properties: {
|
||||
id: "sug-1",
|
||||
sessionID: "sess-1",
|
||||
text: "Review changes?",
|
||||
actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
|
||||
},
|
||||
}
|
||||
const msg = mapSSEEventToWebviewMessage(event, "sess-1")
|
||||
expect(msg?.type).toBe("suggestionRequest")
|
||||
})
|
||||
|
||||
it("maps suggestion.accepted to suggestionResolved", () => {
|
||||
const event: EventSuggestionAccepted = {
|
||||
type: "suggestion.accepted",
|
||||
properties: {
|
||||
sessionID: "sess-1",
|
||||
requestID: "sug-1",
|
||||
index: 0,
|
||||
action: { label: "Start", prompt: "/local-review-uncommitted" },
|
||||
},
|
||||
}
|
||||
const msg = mapSSEEventToWebviewMessage(event, "sess-1")
|
||||
expect(msg?.type).toBe("suggestionResolved")
|
||||
})
|
||||
|
||||
it("maps suggestion.dismissed to suggestionResolved", () => {
|
||||
const event: EventSuggestionDismissed = {
|
||||
type: "suggestion.dismissed",
|
||||
properties: { sessionID: "sess-1", requestID: "sug-2" },
|
||||
}
|
||||
const msg = mapSSEEventToWebviewMessage(event, "sess-1")
|
||||
expect(msg?.type).toBe("suggestionResolved")
|
||||
})
|
||||
|
||||
it("maps session.created to sessionCreated with ISO dates", () => {
|
||||
const event: EventSessionCreated = {
|
||||
type: "session.created",
|
||||
@@ -564,6 +607,47 @@ describe("mapCloudSessionMessage", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("mergeFileSearchItems", () => {
|
||||
it("puts exact folder matches before file matches", () => {
|
||||
const result = mergeFileSearchItems({
|
||||
query: "script",
|
||||
files: ["script/hooks", "script/release", "script/beta.ts"],
|
||||
folders: ["script/", "script/run-script/"],
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ path: "script/", type: "folder" },
|
||||
{ path: "script/hooks", type: "file" },
|
||||
{ path: "script/release", type: "file" },
|
||||
{ path: "script/beta.ts", type: "file" },
|
||||
{ path: "script/run-script/", type: "folder" },
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps file ordering before non-prefix folder matches", () => {
|
||||
const result = mergeFileSearchItems({
|
||||
query: "test",
|
||||
files: ["src/test.ts"],
|
||||
folders: ["src/latest/"],
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ path: "src/test.ts", type: "file" },
|
||||
{ path: "src/latest/", type: "folder" },
|
||||
])
|
||||
})
|
||||
|
||||
it("normalizes Windows separators for matching and output", () => {
|
||||
const result = mergeFileSearchItems({
|
||||
query: "kilo-vscode",
|
||||
files: ["packages\\kilo-vscode\\src\\KiloProvider.ts"],
|
||||
folders: ["packages\\kilo-vscode\\"],
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ path: "packages/kilo-vscode/", type: "folder" },
|
||||
{ path: "packages/kilo-vscode/src/KiloProvider.ts", type: "file" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("mergeFileSearchResults", () => {
|
||||
it("returns backend results when no open files", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
@@ -657,4 +741,152 @@ describe("mergeFileSearchResults", () => {
|
||||
})
|
||||
expect(result).toEqual(["src/utils/path.ts", "src/index.ts"])
|
||||
})
|
||||
|
||||
it("normalizes backslash paths before filtering and deduping", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "utils/path",
|
||||
backend: ["src\\utils\\path.ts"],
|
||||
open: new Set(["src/utils/path.ts"]),
|
||||
active: "src\\utils\\path.ts",
|
||||
})
|
||||
expect(result).toEqual(["src/utils/path.ts"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getErrorMessage", () => {
|
||||
it("extracts message from an Error instance", () => {
|
||||
expect(getErrorMessage(new Error("boom"))).toBe("boom")
|
||||
})
|
||||
|
||||
it("returns the string as-is", () => {
|
||||
expect(getErrorMessage("plain text failure")).toBe("plain text failure")
|
||||
})
|
||||
|
||||
it("reads a direct .message field", () => {
|
||||
expect(getErrorMessage({ message: "bad input" })).toBe("bad input")
|
||||
})
|
||||
|
||||
it("reads a direct .error string field", () => {
|
||||
expect(getErrorMessage({ error: "nope" })).toBe("nope")
|
||||
})
|
||||
|
||||
it("reads SDK throwOnError shape { error: { message } }", () => {
|
||||
expect(getErrorMessage({ error: { message: "sdk said no" } })).toBe("sdk said no")
|
||||
})
|
||||
|
||||
it("reads NotFoundError shape { data: { message } }", () => {
|
||||
expect(getErrorMessage({ name: "NotFoundError", data: { message: "not found" } })).toBe("not found")
|
||||
})
|
||||
|
||||
it("reads the first Zod issue message from ConfigInvalidError", () => {
|
||||
const err = {
|
||||
name: "ConfigInvalidError",
|
||||
data: {
|
||||
path: "/Users/me/.config/kilo/kilo.json",
|
||||
issues: [
|
||||
{ code: "unrecognized_keys", keys: ["indexing"], path: [], message: 'Unrecognized key: "indexing"' },
|
||||
{ code: "invalid_type", path: ["timeout"], message: "Expected number" },
|
||||
],
|
||||
},
|
||||
}
|
||||
expect(getErrorMessage(err)).toBe('Unrecognized key: "indexing"')
|
||||
})
|
||||
|
||||
it("reads Hono validator shape { data: { error: [{ message }] } }", () => {
|
||||
const err = { data: { error: [{ message: "required" }] }, success: false }
|
||||
expect(getErrorMessage(err)).toBe("required")
|
||||
})
|
||||
|
||||
it("reads Hono validator shape with string errors", () => {
|
||||
expect(getErrorMessage({ data: { error: ["bad field"] } })).toBe("bad field")
|
||||
})
|
||||
|
||||
it("reads BadRequestError shape { errors: [{ message }] }", () => {
|
||||
expect(getErrorMessage({ errors: [{ message: "first error" }, { message: "second" }] })).toBe("first error")
|
||||
})
|
||||
|
||||
it("reads BadRequestError shape with string errors", () => {
|
||||
expect(getErrorMessage({ errors: ["boom"] })).toBe("boom")
|
||||
})
|
||||
|
||||
it("falls back to JSON for unknown object shapes", () => {
|
||||
expect(getErrorMessage({ weird: true, code: 42 })).toBe('{"weird":true,"code":42}')
|
||||
})
|
||||
|
||||
it("falls back to String() for non-serializable values", () => {
|
||||
expect(getErrorMessage(undefined)).toBe("undefined")
|
||||
expect(getErrorMessage(null)).toBe("null")
|
||||
expect(getErrorMessage(42)).toBe("42")
|
||||
})
|
||||
|
||||
it("skips JSON fallback for empty objects", () => {
|
||||
expect(getErrorMessage({})).toBe("[object Object]")
|
||||
})
|
||||
|
||||
it("prefers .message over nested shapes", () => {
|
||||
const err = { message: "outer", data: { issues: [{ message: "inner" }] } }
|
||||
expect(getErrorMessage(err)).toBe("outer")
|
||||
})
|
||||
|
||||
it("falls through when the first issue has no message", () => {
|
||||
// firstMessage only inspects index 0, so an invalid first entry causes the
|
||||
// branch to skip and the JSON fallback kicks in.
|
||||
const err = { data: { issues: [{ code: "bad" }] } }
|
||||
expect(getErrorMessage(err)).toBe('{"data":{"issues":[{"code":"bad"}]}}')
|
||||
})
|
||||
})
|
||||
|
||||
describe("getConfigErrorDetails", () => {
|
||||
it("returns undefined for non-object errors", () => {
|
||||
expect(getConfigErrorDetails("oops")).toBeUndefined()
|
||||
expect(getConfigErrorDetails(undefined)).toBeUndefined()
|
||||
expect(getConfigErrorDetails(null)).toBeUndefined()
|
||||
expect(getConfigErrorDetails(42)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns undefined when .data is missing", () => {
|
||||
expect(getConfigErrorDetails({ message: "hi" })).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns undefined when .data has no path or issues", () => {
|
||||
expect(getConfigErrorDetails({ data: { unrelated: true } })).toBeUndefined()
|
||||
})
|
||||
|
||||
it("formats a single-issue ConfigInvalidError", () => {
|
||||
const err = {
|
||||
data: {
|
||||
path: "/home/me/.config/kilo/kilo.json",
|
||||
issues: [{ code: "unrecognized_keys", keys: ["indexing"], path: [], message: 'Unrecognized key: "indexing"' }],
|
||||
},
|
||||
}
|
||||
expect(getConfigErrorDetails(err)).toBe('File: /home/me/.config/kilo/kilo.json\n\n✖ Unrecognized key: "indexing"')
|
||||
})
|
||||
|
||||
it("formats a multi-issue ConfigInvalidError with paths (including array indices)", () => {
|
||||
const err = {
|
||||
data: {
|
||||
path: "/cfg.json",
|
||||
issues: [
|
||||
{ path: ["timeout"], message: "Expected number" },
|
||||
{ path: ["agents", 0, "name"], message: "Required" },
|
||||
],
|
||||
},
|
||||
}
|
||||
expect(getConfigErrorDetails(err)).toBe(
|
||||
"File: /cfg.json\n\n✖ Expected number\n → at timeout\n✖ Required\n → at agents[0].name",
|
||||
)
|
||||
})
|
||||
|
||||
it("omits the path line when only issues are present", () => {
|
||||
const err = { data: { issues: [{ path: [], message: "something" }] } }
|
||||
expect(getConfigErrorDetails(err)).toBe("✖ something")
|
||||
})
|
||||
|
||||
it("omits the issues section when only the path is present", () => {
|
||||
expect(getConfigErrorDetails({ data: { path: "/cfg.json" } })).toBe("File: /cfg.json")
|
||||
})
|
||||
|
||||
it("returns undefined when issues array is empty and no path", () => {
|
||||
expect(getConfigErrorDetails({ data: { issues: [] } })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
dirName,
|
||||
buildHighlightSegments,
|
||||
atEnd,
|
||||
isPromptBlocked,
|
||||
isPromptBusy,
|
||||
isSuggesting,
|
||||
isQuestioning,
|
||||
} from "../../webview-ui/src/components/chat/prompt-input-utils"
|
||||
|
||||
describe("fileName", () => {
|
||||
@@ -145,3 +149,79 @@ describe("atEnd", () => {
|
||||
expect(atEnd(0, 0, 10)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isPromptBlocked", () => {
|
||||
it("returns false when zero permissions", () => {
|
||||
expect(isPromptBlocked(0)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true when permissions exist", () => {
|
||||
expect(isPromptBlocked(1)).toBe(true)
|
||||
expect(isPromptBlocked(3)).toBe(true)
|
||||
})
|
||||
|
||||
it("accepts exactly one argument (locks the API against regression)", () => {
|
||||
// Prevents a future change from reintroducing the question/blocking coupling.
|
||||
// See prompt-send-contract.test.ts for the source-level complement.
|
||||
expect(isPromptBlocked.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isPromptBusy", () => {
|
||||
it("returns true when busy and neither suggesting nor questioning", () => {
|
||||
expect(isPromptBusy("busy", false, false)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when idle regardless of suggesting/questioning", () => {
|
||||
expect(isPromptBusy("idle", false, false)).toBe(false)
|
||||
expect(isPromptBusy("idle", true, false)).toBe(false)
|
||||
expect(isPromptBusy("idle", false, true)).toBe(false)
|
||||
expect(isPromptBusy("idle", true, true)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false when busy but suggesting is true (suggestion decoupling)", () => {
|
||||
expect(isPromptBusy("busy", true, false)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false when busy but questioning is true (question decoupling)", () => {
|
||||
expect(isPromptBusy("busy", false, true)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false when busy and both suggesting and questioning", () => {
|
||||
expect(isPromptBusy("busy", true, true)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true for non-idle non-busy status when not suggesting/questioning", () => {
|
||||
expect(isPromptBusy("retry", false, false)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isSuggesting", () => {
|
||||
it("returns true when not blocked and suggestions > 0", () => {
|
||||
expect(isSuggesting(false, 1)).toBe(true)
|
||||
expect(isSuggesting(false, 3)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when blocked even with suggestions", () => {
|
||||
expect(isSuggesting(true, 2)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false when not blocked but no suggestions", () => {
|
||||
expect(isSuggesting(false, 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isQuestioning", () => {
|
||||
it("returns true when not blocked and questions > 0", () => {
|
||||
expect(isQuestioning(false, 1)).toBe(true)
|
||||
expect(isQuestioning(false, 5)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when blocked even with questions", () => {
|
||||
expect(isQuestioning(true, 2)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false when not blocked but no questions", () => {
|
||||
expect(isQuestioning(false, 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Source contract tests for prompt send paths.
|
||||
*
|
||||
* Static analysis — reads session.tsx source and verifies that sendMessage and
|
||||
* sendCommand still dismiss suggestions and reject questions before dispatching.
|
||||
* Also reads ChatView.tsx and asserts the prompt-block predicate is fed only
|
||||
* permission counts, never question counts — guarantees that a pending question
|
||||
* cannot re-block the prompt input.
|
||||
*
|
||||
* Protects against accidental removal during Kilo development.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "../..")
|
||||
const SESSION_FILE = path.join(ROOT, "webview-ui/src/context/session.tsx")
|
||||
const CHATVIEW_FILE = path.join(ROOT, "webview-ui/src/components/chat/ChatView.tsx")
|
||||
const PROMPT_UTILS_FILE = path.join(ROOT, "webview-ui/src/components/chat/prompt-input-utils.ts")
|
||||
|
||||
function readFile(filePath: string): string {
|
||||
return fs.readFileSync(filePath, "utf-8")
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the body of a named function from the source.
|
||||
* Finds `function <name>(` and returns everything from there to the next
|
||||
* `function ` declaration at the same or lower indentation, or to end of file.
|
||||
*/
|
||||
function extractFunctionBody(source: string, name: string): string {
|
||||
const marker = `function ${name}(`
|
||||
const start = source.indexOf(marker)
|
||||
if (start === -1) return ""
|
||||
|
||||
// Find the next `function ` declaration after the opening one.
|
||||
// We search for a newline followed by ` function ` (2-space indent, matching
|
||||
// the indentation level of sendMessage/sendCommand inside SessionProvider).
|
||||
const rest = source.slice(start + marker.length)
|
||||
const next = rest.search(/\n function /)
|
||||
return next === -1 ? rest : rest.slice(0, next)
|
||||
}
|
||||
|
||||
describe("sendMessage dismisses pending tool requests", () => {
|
||||
const source = readFile(SESSION_FILE)
|
||||
const body = extractFunctionBody(source, "sendMessage")
|
||||
|
||||
it("function sendMessage exists in session.tsx", () => {
|
||||
expect(body.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("dismisses suggestions before sending", () => {
|
||||
expect(body).toContain("dismissSuggestion")
|
||||
})
|
||||
|
||||
it("rejects questions before sending", () => {
|
||||
expect(body).toContain("rejectQuestion")
|
||||
})
|
||||
})
|
||||
|
||||
describe("sendCommand dismisses pending tool requests", () => {
|
||||
const source = readFile(SESSION_FILE)
|
||||
const body = extractFunctionBody(source, "sendCommand")
|
||||
|
||||
it("function sendCommand exists in session.tsx", () => {
|
||||
expect(body.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("dismisses suggestions before sending", () => {
|
||||
expect(body).toContain("dismissSuggestion")
|
||||
})
|
||||
|
||||
it("rejects questions before sending", () => {
|
||||
expect(body).toContain("rejectQuestion")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ChatView prompt-block contract", () => {
|
||||
const source = readFile(CHATVIEW_FILE)
|
||||
|
||||
it("calls isPromptBlocked with exactly one argument (familyPermissions length)", () => {
|
||||
// Exact call shape — prettier formatting is deterministic here, so a strict
|
||||
// match catches both "someone added a second arg" and "someone wrapped it in
|
||||
// a different expression".
|
||||
expect(source).toMatch(/blocked\s*=\s*\(\)\s*=>\s*isPromptBlocked\(familyPermissions\(\)\.length\)/)
|
||||
})
|
||||
|
||||
it("does not pass any second argument to isPromptBlocked", () => {
|
||||
expect(source).not.toMatch(/isPromptBlocked\s*\([^,)]*,[^)]*\)/)
|
||||
})
|
||||
|
||||
it("does not define a blockingQuestions memo", () => {
|
||||
expect(source).not.toContain("blockingQuestions")
|
||||
})
|
||||
|
||||
it("does not reference q.blocking when building the blocked state", () => {
|
||||
expect(source).not.toMatch(/q\.blocking/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isPromptBlocked signature contract", () => {
|
||||
const source = readFile(PROMPT_UTILS_FILE)
|
||||
|
||||
it("declares exactly one parameter (source-level guard)", () => {
|
||||
// Complements the runtime `isPromptBlocked.length === 1` check in
|
||||
// prompt-input-utils.test.ts. `Function.prototype.length` counts parameters
|
||||
// before the first default — this regex catches a future regression that
|
||||
// sneaks in a second param with a default value (which would otherwise keep
|
||||
// `.length === 1` and slip past the runtime check).
|
||||
const match = source.match(/export function isPromptBlocked\(([^)]*)\)/)
|
||||
expect(match).not.toBeNull()
|
||||
const params = match![1]
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0)
|
||||
expect(params).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
fetchAndSendPendingSuggestions,
|
||||
recoverableSuggestions,
|
||||
type RecoverableSuggestion,
|
||||
type SuggestionContext,
|
||||
} from "../../src/kilo-provider/handlers/suggestion"
|
||||
|
||||
function pending(id: string, sessionID: string): RecoverableSuggestion {
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
text: "Review changes?",
|
||||
actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
|
||||
}
|
||||
}
|
||||
|
||||
type Items = Record<string, RecoverableSuggestion[]>
|
||||
|
||||
function suggestionClient(itemsPerDir: Items, queries: string[]) {
|
||||
return {
|
||||
suggestion: {
|
||||
list: async (args?: { directory?: string }) => {
|
||||
const dir = args?.directory ?? ""
|
||||
queries.push(dir)
|
||||
return { data: itemsPerDir[dir] ?? [] }
|
||||
},
|
||||
accept: async () => ({ data: true }),
|
||||
dismiss: async () => ({ data: true }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function ctx(opts: { tracked: string[]; dirs?: Map<string, string>; itemsPerDir?: Items }) {
|
||||
const messages: unknown[] = []
|
||||
const queries: string[] = []
|
||||
const sdk = suggestionClient(opts.itemsPerDir ?? {}, queries) as unknown as SuggestionContext["client"]
|
||||
|
||||
const fake: SuggestionContext = {
|
||||
client: sdk,
|
||||
currentSessionId: undefined,
|
||||
trackedSessionIds: new Set(opts.tracked),
|
||||
sessionDirectories: opts.dirs ?? new Map(),
|
||||
postMessage: (msg) => messages.push(msg),
|
||||
getWorkspaceDirectory: () => "/workspace",
|
||||
}
|
||||
|
||||
return { fake, messages, queries }
|
||||
}
|
||||
|
||||
describe("recoverableSuggestions", () => {
|
||||
it("filters out untracked suggestions and deduplicates by id", () => {
|
||||
const seen = new Set<string>()
|
||||
const list = [pending("s1", "tracked"), pending("s1", "tracked"), pending("s2", "other")]
|
||||
expect(recoverableSuggestions(list, new Set(["tracked"]), seen)).toEqual([pending("s1", "tracked")])
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchAndSendPendingSuggestions", () => {
|
||||
it("forwards suggestions from tracked sessions", async () => {
|
||||
const dirs = new Map([["s1", "/wt"]])
|
||||
const { fake, messages, queries } = ctx({
|
||||
tracked: ["s1"],
|
||||
dirs,
|
||||
itemsPerDir: { "/wt": [pending("sug-1", "s1")] },
|
||||
})
|
||||
|
||||
await fetchAndSendPendingSuggestions(fake)
|
||||
|
||||
expect(queries).toContain("/workspace")
|
||||
expect(queries).toContain("/wt")
|
||||
expect(messages).toEqual([{ type: "suggestionRequest", suggestion: pending("sug-1", "s1") }])
|
||||
})
|
||||
|
||||
it("does nothing when client is null", async () => {
|
||||
const messages: unknown[] = []
|
||||
const fake: SuggestionContext = {
|
||||
client: null,
|
||||
currentSessionId: undefined,
|
||||
trackedSessionIds: new Set(["s1"]),
|
||||
sessionDirectories: new Map(),
|
||||
postMessage: (msg) => messages.push(msg),
|
||||
getWorkspaceDirectory: () => "/workspace",
|
||||
}
|
||||
|
||||
await fetchAndSendPendingSuggestions(fake)
|
||||
expect(messages).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -150,10 +150,11 @@ describe("WorktreeStateManager", () => {
|
||||
})
|
||||
|
||||
describe("persistence", () => {
|
||||
it("saves and loads state, pruning orphaned sessions", async () => {
|
||||
it("saves and loads state, preserving local sessions and pruning orphaned sessions", async () => {
|
||||
const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
|
||||
manager.addSession("s1", wt.id)
|
||||
manager.addSession("s2", null)
|
||||
manager.addSession("s3", "missing")
|
||||
// Flush fire-and-forget saves from mutations, then do a final save
|
||||
await manager.flush()
|
||||
await manager.save()
|
||||
@@ -163,10 +164,10 @@ describe("WorktreeStateManager", () => {
|
||||
|
||||
expect(loaded.getWorktrees()).toHaveLength(1)
|
||||
expect(loaded.getWorktrees()[0].branch).toBe("fix")
|
||||
// s2 had null worktreeId so it gets pruned on load
|
||||
expect(loaded.getSessions()).toHaveLength(1)
|
||||
expect(loaded.getSessions()).toHaveLength(2)
|
||||
expect(loaded.getSession("s1")?.worktreeId).toBe(wt.id)
|
||||
expect(loaded.getSession("s2")).toBeUndefined()
|
||||
expect(loaded.getSession("s2")?.worktreeId).toBeNull()
|
||||
expect(loaded.getSession("s3")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("load is a no-op when file does not exist", async () => {
|
||||
@@ -312,19 +313,20 @@ describe("WorktreeStateManager", () => {
|
||||
expect(manager.getSession("s1")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("prunes orphaned sessions with null worktreeId on validate", async () => {
|
||||
it("preserves local sessions and prunes missing worktree references on validate", async () => {
|
||||
const existing = path.join(root, "wt-exists")
|
||||
fs.mkdirSync(existing, { recursive: true })
|
||||
|
||||
const wt = manager.addWorktree({ branch: "exists", path: existing, parentBranch: "main" })
|
||||
manager.addSession("s1", wt.id)
|
||||
manager.addSession("s2", null)
|
||||
manager.addSession("s3", "missing")
|
||||
|
||||
await manager.validate(root)
|
||||
|
||||
// s1 stays (its worktree exists), s2 is pruned (null worktreeId)
|
||||
expect(manager.getSession("s1")).toBeTruthy()
|
||||
expect(manager.getSession("s2")).toBeUndefined()
|
||||
expect(manager.getSession("s2")?.worktreeId).toBeNull()
|
||||
expect(manager.getSession("s3")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("resolves relative paths against root", async () => {
|
||||
@@ -446,7 +448,7 @@ describe("WorktreeStateManager", () => {
|
||||
expect(manager.getSessions()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("handles partial data with missing worktrees key and prunes orphaned sessions", async () => {
|
||||
it("handles partial data with missing worktrees key and local sessions", async () => {
|
||||
const file = path.join(root, ".kilo", "agent-manager.json")
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
@@ -457,8 +459,7 @@ describe("WorktreeStateManager", () => {
|
||||
await manager.load()
|
||||
|
||||
expect(manager.getWorktrees()).toHaveLength(0)
|
||||
// Orphaned session with null worktreeId is pruned on load
|
||||
expect(manager.getSessions()).toHaveLength(0)
|
||||
expect(manager.getSession("s-1")?.worktreeId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8476ecb49183dbfed9be2924a8aa4622e8b95eb098c50609b986f9b8eb6cfe2d
|
||||
size 14390
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:123f32340a93af21146d08bb40738710bdd84099a27d0310316bb937360ca9f9
|
||||
size 14605
|
||||
oid sha256:07b3bf094c0a82e64fc7cb4e22aa37ada3dd5904a19a313879c6016c2ab55a00
|
||||
size 14586
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:eee5f5d61cf2ac9ad0b987a8b220d76c503be0d11447766e6669da4684698c82
|
||||
size 6130
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9dbdd4b71bea71529e8835def452bbb627837d5bb6dbe460d882b20c4add6e98
|
||||
size 17650
|
||||
oid sha256:74a42ec77a8ac8d5a1c555b46e3d550c9acf3198f45cc87da6582c0ff924d5f9
|
||||
size 17677
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4f50e8ca1c012b64797cf57d5293ac59afefa79c8f1d703b66ac796e5b85fbaf
|
||||
size 6402
|
||||
oid sha256:c583144b11ac9608ec755e9a683dced8ae6ffae9a6f3833e1c0eec6b664f358e
|
||||
size 7720
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3fca4a95e3754b2d1316635fd756fa3fe8895213de7332d130a2f44075f06485
|
||||
size 19072
|
||||
oid sha256:8348db9191e1e616c93bf54ae33702a57e25c092a8f80a24d1da04811e523500
|
||||
size 26519
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f539f72e2c53c6ce84d26397715bb9c4e80c2c107e4584023f2bdbeba0086e62
|
||||
size 6958
|
||||
oid sha256:15116299b4700cceb501114e8fdca8b3ddfa5fb9d793f7cbab44b2647bbe64a4
|
||||
size 8070
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c4ea685888b585c50e8902a3df4a0fc85104a217b41bf6763d8e7e2f113c32ab
|
||||
size 4356
|
||||
oid sha256:ed66186a7aacdd4d31ff250a9c31fb1b244834cbb23e36abc51af706545f4518
|
||||
size 5059
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8e0fecc40d49bdd601a7d1ed6c8d4bb6c3854843c3e67eca408d3cd0a42b9f4f
|
||||
size 8193
|
||||
oid sha256:1d21dc25072b1c52cacaf0a91369f45e89f43ccc181d643e6b65030b829e5b1e
|
||||
size 8591
|
||||
|
||||
@@ -78,6 +78,7 @@ import { NotificationsProvider } from "../src/context/notifications"
|
||||
import { SessionProvider, useSession } from "../src/context/session"
|
||||
import { WorktreeModeProvider } from "../src/context/worktree-mode"
|
||||
import { ChatView } from "../src/components/chat"
|
||||
import HistoryView from "../src/components/history/HistoryView"
|
||||
import { NewWorktreeDialog } from "./NewWorktreeDialog"
|
||||
import { LanguageBridge, DataBridge } from "../src/App"
|
||||
import { useLanguage } from "../src/context/language"
|
||||
@@ -348,6 +349,7 @@ const AgentManagerContent: Component = () => {
|
||||
let diffRaf: number | undefined
|
||||
let pendingDiffWidth: number | undefined
|
||||
|
||||
const [history, setHistory] = createSignal(false)
|
||||
const [sidePanel, setSidePanel] = createSignal<SidePanel>(null)
|
||||
const diffOpen = () => sidePanel() === "diff"
|
||||
const [diffDatas, setDiffDatas] = createSignal<Record<string, WorktreeFileDiff[]>>({})
|
||||
@@ -697,28 +699,23 @@ const AgentManagerContent: Component = () => {
|
||||
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 })
|
||||
}
|
||||
for (const id of removed) vscode.postMessage({ type: "agentManager.forgetSession", sessionId: id })
|
||||
setLocalSessionIDs(valid)
|
||||
}
|
||||
})
|
||||
// Drop in-memory review state for worktrees that no longer exist.
|
||||
createEffect(() => {
|
||||
const ids = new Set(worktrees().map((wt) => wt.id))
|
||||
|
||||
setReviewOpenByContext((prev) => {
|
||||
const next = Object.fromEntries(Object.entries(prev).filter(([id]) => id === LOCAL || ids.has(id)))
|
||||
if (Object.keys(next).length === Object.keys(prev).length) return prev
|
||||
return next
|
||||
})
|
||||
|
||||
setReviewCommentsByContext((prev) => {
|
||||
const next = Object.fromEntries(Object.entries(prev).filter(([id]) => id === LOCAL || ids.has(id)))
|
||||
if (Object.keys(next).length === Object.keys(prev).length) return prev
|
||||
return next
|
||||
})
|
||||
|
||||
setApplyStates((prev) => {
|
||||
const next = Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id)))
|
||||
if (Object.keys(next).length === Object.keys(prev).length) return prev
|
||||
@@ -1000,12 +997,8 @@ const AgentManagerContent: Component = () => {
|
||||
if (fallback && !isPending(fallback.id)) {
|
||||
setActivePendingId(undefined)
|
||||
session.selectSession(fallback.id)
|
||||
} else if (fallback && isPending(fallback.id)) {
|
||||
setActivePendingId(fallback.id)
|
||||
session.clearCurrentSession()
|
||||
vscode.postMessage({ type: "agentManager.showExistingLocalTerminal" })
|
||||
} else {
|
||||
setActivePendingId(undefined)
|
||||
setActivePendingId(fallback && isPending(fallback.id) ? fallback.id : undefined)
|
||||
session.clearCurrentSession()
|
||||
vscode.postMessage({ type: "agentManager.showExistingLocalTerminal" })
|
||||
}
|
||||
@@ -1021,11 +1014,8 @@ const AgentManagerContent: Component = () => {
|
||||
const remembered = tabMemory()[worktreeId]
|
||||
const target = remembered ? sessions.find((s) => s.id === remembered) : undefined
|
||||
const fallback = target ?? sessions[0]
|
||||
if (fallback) {
|
||||
session.selectSession(fallback.id)
|
||||
} else {
|
||||
session.setCurrentSessionID(undefined)
|
||||
}
|
||||
if (fallback) session.selectSession(fallback.id)
|
||||
else session.setCurrentSessionID(undefined)
|
||||
setReviewActive(remembered === REVIEW_TAB_ID && reviewOpenByContext()[worktreeId] === true)
|
||||
}
|
||||
|
||||
@@ -1048,7 +1038,8 @@ const AgentManagerContent: Component = () => {
|
||||
|
||||
onMount(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
const msg = event.data as ExtensionMessage
|
||||
const msg = event.data
|
||||
if (msg?.type === "navigate" && msg.view === "history") return setHistory(true)
|
||||
if (msg?.type !== "action") return
|
||||
if (msg.action === "sessionPrevious") navigate("up")
|
||||
else if (msg.action === "sessionNext") navigate("down")
|
||||
@@ -1062,9 +1053,7 @@ const AgentManagerContent: Component = () => {
|
||||
if (reviewActive()) {
|
||||
closeReviewTab()
|
||||
setSidePanel("diff")
|
||||
} else {
|
||||
setSidePanel((prev) => (prev === "diff" ? null : "diff"))
|
||||
}
|
||||
} else setSidePanel((prev) => (prev === "diff" ? null : "diff"))
|
||||
} else if (msg.action === "newTab") handleNewTabForCurrentSelection()
|
||||
else if (msg.action === "closeTab") closeActiveTab()
|
||||
else if (msg.action === "newWorktree") handleNewWorktreeOrPromote()
|
||||
@@ -1886,9 +1875,7 @@ const AgentManagerContent: Component = () => {
|
||||
if (pending) {
|
||||
setLocalSessionIDs((prev) => prev.map((id) => (id === pending ? sid : id)))
|
||||
setActivePendingId(undefined)
|
||||
} else {
|
||||
setLocalSessionIDs((prev) => [...prev, sid])
|
||||
}
|
||||
} else setLocalSessionIDs((prev) => [...prev, sid])
|
||||
setSelection(LOCAL)
|
||||
setReviewActive(false)
|
||||
session.selectSession(sid)
|
||||
@@ -1897,20 +1884,14 @@ const AgentManagerContent: Component = () => {
|
||||
|
||||
const handleAddSession = () => {
|
||||
const sel = selection()
|
||||
if (sel === LOCAL) {
|
||||
addPendingTab()
|
||||
} else if (sel) {
|
||||
vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel })
|
||||
}
|
||||
if (sel === LOCAL) addPendingTab()
|
||||
else if (sel) vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel })
|
||||
}
|
||||
|
||||
const handleForkSession = (sessionId: string) => {
|
||||
const sel = selection()
|
||||
if (sel === LOCAL) {
|
||||
vscode.postMessage({ type: "agentManager.forkSession", sessionId })
|
||||
} else if (sel) {
|
||||
vscode.postMessage({ type: "agentManager.forkSession", sessionId, worktreeId: sel })
|
||||
}
|
||||
if (sel === LOCAL) vscode.postMessage({ type: "agentManager.forkSession", sessionId })
|
||||
else if (sel) vscode.postMessage({ type: "agentManager.forkSession", sessionId, worktreeId: sel })
|
||||
}
|
||||
|
||||
const handleCloseTab = (sessionId: string) => {
|
||||
@@ -1920,14 +1901,12 @@ const AgentManagerContent: Component = () => {
|
||||
const tabs = activeTabs()
|
||||
const idx = tabs.findIndex((s) => s.id === sessionId)
|
||||
const next = tabs[idx + 1] ?? tabs[idx - 1]
|
||||
if (next) {
|
||||
if (isPending(next.id)) {
|
||||
setActivePendingId(next.id)
|
||||
session.clearCurrentSession()
|
||||
} else {
|
||||
setActivePendingId(undefined)
|
||||
session.selectSession(next.id)
|
||||
}
|
||||
if (next && isPending(next.id)) {
|
||||
setActivePendingId(next.id)
|
||||
session.clearCurrentSession()
|
||||
} else if (next) {
|
||||
setActivePendingId(undefined)
|
||||
session.selectSession(next.id)
|
||||
} else {
|
||||
setActivePendingId(undefined)
|
||||
session.clearCurrentSession()
|
||||
@@ -1935,9 +1914,7 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
if (pending || localSet().has(sessionId)) {
|
||||
setLocalSessionIDs((prev) => prev.filter((id) => id !== sessionId))
|
||||
if (!pending) {
|
||||
vscode.postMessage({ type: "agentManager.forgetSession", sessionId })
|
||||
}
|
||||
if (!pending) vscode.postMessage({ type: "agentManager.forgetSession", sessionId })
|
||||
} else {
|
||||
vscode.postMessage({ type: "agentManager.closeSession", sessionId })
|
||||
}
|
||||
@@ -2921,7 +2898,29 @@ const AgentManagerContent: Component = () => {
|
||||
</Show>
|
||||
)
|
||||
})()}
|
||||
<Show when={!contextEmpty()}>
|
||||
<Show when={history()}>
|
||||
<HistoryView
|
||||
onSelectSession={(id) => {
|
||||
setHistory(false)
|
||||
if (localSessionIDs().includes(id)) {
|
||||
saveTabMemory()
|
||||
session.selectSession(id)
|
||||
setSelection(LOCAL)
|
||||
return
|
||||
}
|
||||
const ms = worktreeSessionIds().has(id) ? managedSessions().find((s) => s.id === id) : undefined
|
||||
if (ms?.worktreeId) {
|
||||
selectWorktree(ms.worktreeId)
|
||||
session.selectSession(id)
|
||||
setReviewActive(false)
|
||||
return
|
||||
}
|
||||
openLocally(id)
|
||||
}}
|
||||
onBack={() => setHistory(false)}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!contextEmpty() && !history()}>
|
||||
{/* Chat + side diff panel (hidden when review tab is active) */}
|
||||
<div
|
||||
class={`am-detail-content ${sidePanel() !== null ? "am-detail-split" : ""}`}
|
||||
@@ -2947,6 +2946,7 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
openLocally(id)
|
||||
}}
|
||||
onShowHistory={() => setHistory(true)}
|
||||
readonly={readOnly()}
|
||||
continueInWorktree={selection() === LOCAL}
|
||||
promptBoxId={`agent-manager:${selection() ?? "unassigned"}`}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { type Component, createSignal, createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||
// Styles are co-located with the component so every consumer (sidebar diff viewer,
|
||||
// agent manager, storybook) picks them up automatically. Do not move these out —
|
||||
// see tests/unit/diff-viewer-css-arch.test.ts for the invariant.
|
||||
import "./agent-manager.css"
|
||||
import "./agent-manager-review.css"
|
||||
import { Diff } from "@kilocode/kilo-ui/diff"
|
||||
import { Accordion } from "@kilocode/kilo-ui/accordion"
|
||||
import { StickyAccordionHeader } from "@kilocode/kilo-ui/sticky-accordion-header"
|
||||
|
||||
@@ -26,6 +26,16 @@ const DiffViewerContent: Component = () => {
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [comments, setComments] = createSignal<ReviewComment[]>([])
|
||||
const [diffStyle, setDiffStyle] = createSignal<DiffStyle>("unified")
|
||||
const [reverting, setReverting] = createSignal<Set<string>>(new Set())
|
||||
|
||||
const markReverting = (file: string, active: boolean) => {
|
||||
setReverting((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (active) next.add(file)
|
||||
else next.delete(file)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const unsubscribe = vscode.onMessage((msg) => {
|
||||
if (msg.type === "diffViewer.diffs") {
|
||||
@@ -37,6 +47,11 @@ const DiffViewerContent: Component = () => {
|
||||
setLoading(msg.loading)
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === "diffViewer.revertFileResult") {
|
||||
markReverting(msg.file, false)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
const handler = (event: MessageEvent) => {
|
||||
@@ -67,6 +82,11 @@ const DiffViewerContent: Component = () => {
|
||||
onOpenFile={(relativePath) => {
|
||||
post({ type: "openFile", filePath: relativePath })
|
||||
}}
|
||||
onRevertFile={(file) => {
|
||||
markReverting(file, true)
|
||||
post({ type: "diffViewer.revertFile", file })
|
||||
}}
|
||||
revertingFiles={reverting()}
|
||||
onClose={() => {
|
||||
post({ type: "diffViewer.close" })
|
||||
}}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
import { useData } from "@kilocode/kilo-ui/context/data"
|
||||
import { useSession } from "../../context/session"
|
||||
import { QuestionDock } from "./QuestionDock"
|
||||
import { SuggestBar } from "./SuggestBar"
|
||||
|
||||
// Tools that the upstream message-part renderer suppresses (returns null for).
|
||||
// We render these ourselves via ToolRegistry when they complete,
|
||||
@@ -41,6 +42,21 @@ function isRenderable(part: SDKPart): boolean {
|
||||
return !!PART_MAPPING[part.type]
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a tool part to an active request (question or suggestion) by tool name
|
||||
* and callID/messageID. Returns the matched request or undefined.
|
||||
*/
|
||||
function matchToolRequest<T extends { tool?: { callID: string; messageID: string } }>(
|
||||
part: SDKPart,
|
||||
name: string,
|
||||
requests: T[],
|
||||
): T | undefined {
|
||||
if (part.type !== "tool") return undefined
|
||||
const tp = part as unknown as ToolPart
|
||||
if (tp.tool !== name) return undefined
|
||||
return requests.find((r) => r.tool?.callID === tp.callID && r.tool?.messageID === tp.messageID)
|
||||
}
|
||||
|
||||
interface AssistantMessageProps {
|
||||
message: SDKAssistantMessage
|
||||
showAssistantCopyPartID?: string | null
|
||||
@@ -87,35 +103,40 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
|
||||
part.type === "tool" && UPSTREAM_SUPPRESSED_TOOLS.has((part as SDKPart & { tool: string }).tool)
|
||||
|
||||
// Active question tool parts render the interactive QuestionDock inline
|
||||
const activeQuestion = createMemo(() => {
|
||||
if (part.type !== "tool") return undefined
|
||||
const tp = part as unknown as ToolPart
|
||||
if (tp.tool !== "question") return undefined
|
||||
return session.questions().find((q) => q.tool?.callID === tp.callID && q.tool?.messageID === tp.messageID)
|
||||
})
|
||||
const activeQuestion = createMemo(() => matchToolRequest(part, "question", session.questions()))
|
||||
|
||||
// Active suggestion tool parts render the interactive SuggestBar inline
|
||||
const activeSuggestion = createMemo(() => matchToolRequest(part, "suggest", session.suggestions()))
|
||||
|
||||
return (
|
||||
<Show when={isUpstreamSuppressed || activeQuestion() || PART_MAPPING[part.type]}>
|
||||
<Show when={isUpstreamSuppressed || activeQuestion() || activeSuggestion() || PART_MAPPING[part.type]}>
|
||||
<div data-component="tool-part-wrapper" data-part-type={part.type}>
|
||||
<Show
|
||||
when={activeQuestion()}
|
||||
fallback={
|
||||
<Show
|
||||
when={isUpstreamSuppressed}
|
||||
when={activeSuggestion()}
|
||||
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} />
|
||||
{(req) => <SuggestBar request={req()} />}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useVSCode } from "../../context/vscode"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useWorktreeMode } from "../../context/worktree-mode"
|
||||
import { useServer } from "../../context/server"
|
||||
import { isPromptBlocked, isSuggesting, isQuestioning } from "./prompt-input-utils"
|
||||
|
||||
interface ChatViewProps {
|
||||
onSelectSession?: (id: string) => void
|
||||
@@ -55,13 +56,20 @@ export const ChatView: Component<ChatViewProps> = (props) => {
|
||||
// not once per accessor call (questionRequest, permissionRequest, blocked all read these).
|
||||
const familyPermissions = createMemo(() => session.scopedPermissions(id()))
|
||||
const familyQuestions = createMemo(() => session.scopedQuestions(id()))
|
||||
|
||||
const familySuggestions = createMemo(() => session.scopedSuggestions(id()))
|
||||
// Non-tool questions (standalone, not from the question tool) render inline in
|
||||
// the message list since they don't have an associated tool part in the conversation.
|
||||
// Tool-linked questions render inline at their tool part position via AssistantMessage.
|
||||
const standaloneQuestions = createMemo(() => familyQuestions().filter((q) => !q.tool))
|
||||
const standaloneSuggestions = createMemo(() => familySuggestions().filter((s) => !s.tool))
|
||||
const permissionRequest = () => familyPermissions().find((p) => p.sessionID === id()) ?? familyPermissions()[0]
|
||||
const blocked = () => familyPermissions().length > 0 || familyQuestions().length > 0
|
||||
// Prompt input is decoupled from questions/suggestions — only permissions block.
|
||||
// Pending questions and suggestions are auto-dismissed in sendMessage/sendCommand.
|
||||
const blocked = () => isPromptBlocked(familyPermissions().length)
|
||||
// Session is busy only because a suggestion tool call is pending — prompt should behave as idle
|
||||
const suggesting = () => isSuggesting(blocked(), familySuggestions().length)
|
||||
// Session is busy only because a question tool call is pending — prompt should behave as idle
|
||||
const questioning = () => isQuestioning(blocked(), familyQuestions().length)
|
||||
const dock = () => !props.readonly || !!permissionRequest()
|
||||
|
||||
// When a bottom-dock permission disappears while the session is busy,
|
||||
@@ -129,6 +137,7 @@ export const ChatView: Component<ChatViewProps> = (props) => {
|
||||
onSelectSession={props.onSelectSession}
|
||||
onShowHistory={props.onShowHistory}
|
||||
questions={standaloneQuestions}
|
||||
suggestions={standaloneSuggestions}
|
||||
readonly={props.readonly}
|
||||
/>
|
||||
</div>
|
||||
@@ -214,7 +223,13 @@ export const ChatView: Component<ChatViewProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!props.readonly}>
|
||||
<PromptInput blocked={blocked} boxId={props.promptBoxId} pendingSessionID={props.pendingSessionID} />
|
||||
<PromptInput
|
||||
blocked={blocked}
|
||||
suggesting={suggesting}
|
||||
questioning={questioning}
|
||||
boxId={props.promptBoxId}
|
||||
pendingSessionID={props.pendingSessionID}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -23,8 +23,9 @@ import { AccountSwitcher } from "../shared/AccountSwitcher"
|
||||
import { KiloNotifications } from "./KiloNotifications"
|
||||
import { WorkingIndicator } from "../shared/WorkingIndicator"
|
||||
import { QuestionDock } from "./QuestionDock"
|
||||
import { SuggestBar } from "./SuggestBar"
|
||||
import { activeUserMessageID as getActiveUserMessageID } from "../../context/session-queue"
|
||||
import type { QuestionRequest } from "../../types/messages"
|
||||
import type { QuestionRequest, SuggestionRequest } from "../../types/messages"
|
||||
|
||||
const KiloLogo = (): JSX.Element => {
|
||||
const iconsBaseUri = (window as { ICONS_BASE_URI?: string }).ICONS_BASE_URI || ""
|
||||
@@ -44,6 +45,8 @@ interface MessageListProps {
|
||||
onShowHistory?: () => void
|
||||
/** Non-tool question requests to render inline at the bottom of the message list */
|
||||
questions?: () => QuestionRequest[]
|
||||
/** Non-tool suggestion requests to render inline at the bottom of the message list */
|
||||
suggestions?: () => SuggestionRequest[]
|
||||
/** When true (subagent viewer), replace the welcome screen with an initializing indicator */
|
||||
readonly?: boolean
|
||||
}
|
||||
@@ -173,6 +176,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
</Show>
|
||||
<WorkingIndicator />
|
||||
<For each={props.questions?.()}>{(req) => <QuestionDock request={req} />}</For>
|
||||
<For each={props.suggestions?.()}>{(req) => <SuggestBar request={req} />}</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useImageAttachments, type ImageAttachment } from "../../hooks/useImageA
|
||||
import { convertToMentionPath } from "../../utils/path-mentions"
|
||||
import { usePromptHistory } from "../../hooks/usePromptHistory"
|
||||
import { WandSparkles } from "@kilocode/kilo-ui/lucide"
|
||||
import { fileName, dirName, buildHighlightSegments, atEnd } from "./prompt-input-utils"
|
||||
import { fileName, dirName, buildHighlightSegments, atEnd, isPromptBusy } from "./prompt-input-utils"
|
||||
import type { ReviewComment, TextPart } from "../../types/messages"
|
||||
import { formatReviewCommentsMarkdown } from "../../utils/review-comment-markdown"
|
||||
import { pendingDraftKey, scopeDraftKey, sessionDraftKey } from "../../utils/prompt-drafts"
|
||||
@@ -50,6 +50,10 @@ function mergeReviewComments(current: ReviewComment[], incoming: ReviewComment[]
|
||||
|
||||
interface PromptInputProps {
|
||||
blocked?: () => boolean
|
||||
/** When true, session is busy only because a suggestion is pending — treat as idle for input */
|
||||
suggesting?: () => boolean
|
||||
/** When true, session is busy only because a question is pending — treat as idle for input */
|
||||
questioning?: () => boolean
|
||||
boxId?: string
|
||||
pendingSessionID?: string
|
||||
}
|
||||
@@ -61,10 +65,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const vscode = useVSCode()
|
||||
const worktree = useWorktreeMode()
|
||||
const dialog = useDialog()
|
||||
const mention = useFileMention(vscode)
|
||||
const mention = useFileMention(
|
||||
vscode,
|
||||
() => session.currentSessionID() ?? props.pendingSessionID ?? session.draftSessionID(),
|
||||
)
|
||||
const terminal = useTerminalContext(vscode)
|
||||
const excluded = worktree ? new Set(["sessions"]) : undefined
|
||||
const slash = useSlashCommand(vscode, excluded)
|
||||
const slash = useSlashCommand(vscode)
|
||||
const imageAttach = useImageAttachments()
|
||||
imageAttach.setFilePathDropHandler((paths) => {
|
||||
const cwd = server.workspaceDirectory()
|
||||
@@ -268,7 +274,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
window.addEventListener("compactSession", onCompact)
|
||||
onCleanup(() => window.removeEventListener("compactSession", onCompact))
|
||||
|
||||
const isBusy = () => session.status() !== "idle"
|
||||
const isBusy = () => isPromptBusy(session.status(), !!props.suggesting?.(), !!props.questioning?.())
|
||||
const isDisabled = () => !server.isConnected()
|
||||
const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0
|
||||
const canSend = () => hasInput() && !isDisabled() && !terminal.pending() && !props.blocked?.()
|
||||
@@ -711,7 +717,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div class="file-mention-dropdown" ref={dropdownRef}>
|
||||
<Show
|
||||
when={mention.mentionResults().length > 0}
|
||||
fallback={<div class="file-mention-empty">No files found</div>}
|
||||
fallback={<div class="file-mention-empty">No files or folders found</div>}
|
||||
>
|
||||
<For each={mention.mentionResults()}>
|
||||
{(item, index) => (
|
||||
@@ -732,8 +738,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileIcon node={{ path: item.value, type: "file" }} class="file-mention-icon" />
|
||||
<span class="file-mention-name">{fileName(item.value)}</span>
|
||||
<FileIcon
|
||||
node={{ path: item.value, type: item.type === "folder" ? "directory" : "file" }}
|
||||
class="file-mention-icon"
|
||||
/>
|
||||
<span class="file-mention-name">
|
||||
{item.type === "folder" ? `${fileName(item.value)}/` : fileName(item.value)}
|
||||
</span>
|
||||
<span class="file-mention-dir">{dirName(item.value)}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import type { Component } from "solid-js"
|
||||
import { For, Show } from "solid-js"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useSession } from "../../context/session"
|
||||
import type { SuggestionRequest } from "../../types/messages"
|
||||
|
||||
export const SuggestBar: Component<{ request: SuggestionRequest }> = (props) => {
|
||||
const session = useSession()
|
||||
const language = useLanguage()
|
||||
|
||||
const error = () => session.suggestionErrors().has(props.request.id)
|
||||
const responding = () => session.respondingSuggestions().has(props.request.id)
|
||||
|
||||
const accept = (index: number) => {
|
||||
if (responding()) return
|
||||
session.acceptSuggestion(props.request.id, index)
|
||||
}
|
||||
|
||||
const dismiss = () => {
|
||||
if (responding()) return
|
||||
session.dismissSuggestion(props.request.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="suggest-bar">
|
||||
<div data-slot="suggest-bar-copy">
|
||||
<span data-slot="suggest-bar-icon">
|
||||
<Icon name="brain" size="small" />
|
||||
</span>
|
||||
<span data-slot="suggest-bar-text">{props.request.text}</span>
|
||||
</div>
|
||||
<Show when={error()}>
|
||||
<div data-slot="suggest-bar-error">
|
||||
<Icon name="close" size="small" />
|
||||
<span>Failed — try again</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="suggest-bar-actions">
|
||||
<For each={props.request.actions}>
|
||||
{(action, index) => (
|
||||
<Button
|
||||
variant={index() === 0 ? "secondary" : "ghost"}
|
||||
size="small"
|
||||
disabled={responding()}
|
||||
onClick={() => accept(index())}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
<IconButton
|
||||
icon="close"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
disabled={responding()}
|
||||
label={language.t("common.dismiss")}
|
||||
onClick={dismiss}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -49,3 +49,47 @@ export function buildHighlightSegments(val: string, paths: Set<string>): { text:
|
||||
export function atEnd(start: number, end: number, len: number): boolean {
|
||||
return start === end && end === len
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the input prompt should be blocked.
|
||||
*
|
||||
* Only permission requests block the prompt in the VS Code webview. Questions
|
||||
* and suggestions never block — they are dismissed automatically when a new
|
||||
* message is sent (see session.tsx sendMessage/sendCommand).
|
||||
*
|
||||
* The single-parameter signature is intentional: taking question-count would
|
||||
* structurally allow a future regression to re-couple the prompt to pending
|
||||
* questions. Keep this function at one argument.
|
||||
*/
|
||||
export function isPromptBlocked(permissions: number): boolean {
|
||||
return permissions > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session is busy from the prompt's perspective.
|
||||
* Returns false (idle-like) when the session is busy only because
|
||||
* a suggestion or question tool call is pending.
|
||||
*/
|
||||
export function isPromptBusy(status: string, suggesting: boolean, questioning: boolean): boolean {
|
||||
return status !== "idle" && !suggesting && !questioning
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session is busy only because a suggestion is pending.
|
||||
* True when no permission request is blocking the prompt and at least one
|
||||
* suggestion is active. The `!blocked` gate keeps the Stop button available
|
||||
* when permissions block input — it does NOT mean suggestions block.
|
||||
*/
|
||||
export function isSuggesting(blocked: boolean, suggestions: number): boolean {
|
||||
return !blocked && suggestions > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session is busy only because a question is pending.
|
||||
* True when no permission request is blocking the prompt and at least one
|
||||
* question is active. The `!blocked` gate keeps the Stop button available
|
||||
* when permissions block input — it does NOT mean questions block.
|
||||
*/
|
||||
export function isQuestioning(blocked: boolean, questions: number): boolean {
|
||||
return !blocked && questions > 0
|
||||
}
|
||||
|
||||
@@ -34,9 +34,10 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const vscode = useVSCode()
|
||||
const { isDirty, saveConfig, discardConfig } = useConfig()
|
||||
const { isDirty, saving, saveError, saveConfig, discardConfig } = useConfig()
|
||||
const session = useSession()
|
||||
const [active, setActive] = createSignal(props.tab ?? "models")
|
||||
const [errorExpanded, setErrorExpanded] = createSignal(false)
|
||||
|
||||
const busyCount = () => Object.values(session.allStatusMap()).filter((s) => s.type === "busy").length
|
||||
|
||||
@@ -222,19 +223,46 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||
</Tabs>
|
||||
|
||||
{/* Save bar — slides in when there are unsaved config changes */}
|
||||
<div
|
||||
class={`settings-save-bar${isDirty() ? " settings-save-bar--visible" : ""}`}
|
||||
inert={!isDirty() || undefined}
|
||||
aria-hidden={!isDirty()}
|
||||
>
|
||||
<span class="settings-save-bar-label">{language.t("settings.saveBar.unsavedChanges")}</span>
|
||||
<Button variant="ghost" size="small" onClick={discardConfig}>
|
||||
{language.t("settings.saveBar.discard")}
|
||||
</Button>
|
||||
<Button variant="primary" size="small" onClick={handleSave}>
|
||||
{language.t("settings.saveBar.save")}
|
||||
</Button>
|
||||
</div>
|
||||
<Show when={isDirty()}>
|
||||
<div class="settings-save-bar-wrap">
|
||||
<Show when={saveError()}>
|
||||
{(err) => (
|
||||
<div class="settings-save-bar-error">
|
||||
<div
|
||||
class="settings-save-bar-error-header"
|
||||
onClick={() => setErrorExpanded((v) => !v)}
|
||||
role="button"
|
||||
aria-expanded={errorExpanded()}
|
||||
>
|
||||
<span
|
||||
class={`settings-save-bar-error-chevron${
|
||||
errorExpanded() ? " settings-save-bar-error-chevron-expanded" : ""
|
||||
}`}
|
||||
>
|
||||
<Icon name="chevron-right" size="small" />
|
||||
</span>
|
||||
<span class="settings-save-bar-error-title">
|
||||
{language.t("settings.saveBar.saveFailed")}:{" "}
|
||||
<span class="settings-save-bar-error-firstline">{err().message}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Show when={errorExpanded()}>
|
||||
<pre class="settings-save-bar-error-details">{err().details ?? err().message}</pre>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="settings-save-bar">
|
||||
<span class="settings-save-bar-label">{language.t("settings.saveBar.unsavedChanges")}</span>
|
||||
<Button variant="ghost" size="small" onClick={discardConfig} disabled={saving()}>
|
||||
{language.t("settings.saveBar.discard")}
|
||||
</Button>
|
||||
<Button variant="primary" size="small" onClick={handleSave} disabled={saving()}>
|
||||
{saving() ? language.t("settings.saveBar.saving") : language.t("settings.saveBar.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,7 +83,8 @@ export const WorkingIndicator: Component = () => {
|
||||
.permissions()
|
||||
.filter((p) => p.sessionID === id && !(p.tool && ["todowrite", "todoread"].includes(p.toolName)))
|
||||
const questions = session.questions().filter((q) => q.sessionID === id)
|
||||
return perms.length > 0 || questions.length > 0
|
||||
const suggestions = session.suggestions().filter((s) => s.sessionID === id)
|
||||
return perms.length > 0 || questions.length > 0 || suggestions.length > 0
|
||||
}
|
||||
|
||||
const isRetrying = () => session.statusInfo().type === "retry"
|
||||
|
||||
@@ -14,10 +14,17 @@ import { useVSCode } from "./vscode"
|
||||
import type { Config, ExtensionMessage } from "../types/messages"
|
||||
import { deepMerge, stripNulls, resolveConfig } from "../utils/config-utils"
|
||||
|
||||
export interface SaveError {
|
||||
message: string
|
||||
details?: string
|
||||
}
|
||||
|
||||
interface ConfigContextValue {
|
||||
config: Accessor<Config>
|
||||
loading: Accessor<boolean>
|
||||
isDirty: Accessor<boolean>
|
||||
saving: Accessor<boolean>
|
||||
saveError: Accessor<SaveError | null>
|
||||
updateConfig: (partial: Partial<Config>) => void
|
||||
saveConfig: () => void
|
||||
discardConfig: () => void
|
||||
@@ -36,7 +43,10 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
const [saved, setSaved] = createSignal<Config>({})
|
||||
// True while a saveConfig() write is in-flight — used to clear draft on success
|
||||
// and to guard against stale configLoaded messages overwriting optimistic state.
|
||||
let saving = false
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
// Error from the most recent saveConfig() attempt, or null if no error.
|
||||
// Cleared when the user edits the draft again or starts a new save.
|
||||
const [saveError, setSaveError] = createSignal<SaveError | null>(null)
|
||||
|
||||
// Register handler immediately (not in onMount) so we never miss
|
||||
// a configLoaded message that arrives before the DOM mount.
|
||||
@@ -44,7 +54,7 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
if (message.type === "configLoaded") {
|
||||
// Skip if a save is in-flight — a stale configLoaded must not overwrite
|
||||
// the optimistically-updated state while the write is being confirmed.
|
||||
if (saving) return
|
||||
if (saving()) return
|
||||
// Re-apply the draft on top so pending changes (e.g. a toggled switch the
|
||||
// user hasn't saved yet) stay visible instead of snapping back.
|
||||
setConfig(resolveConfig(message.config, draft(), isDirty()))
|
||||
@@ -53,12 +63,13 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
return
|
||||
}
|
||||
if (message.type === "configUpdated") {
|
||||
if (saving) {
|
||||
if (saving()) {
|
||||
// This configUpdated is the confirmation of our saveConfig() write.
|
||||
// Clear the draft now that the server has confirmed the write.
|
||||
saving = false
|
||||
setSaving(false)
|
||||
setDraft({})
|
||||
setIsDirty(false)
|
||||
setSaveError(null)
|
||||
setConfig(message.config)
|
||||
} else {
|
||||
// configUpdated from a different source (e.g. PermissionDock save).
|
||||
@@ -68,6 +79,13 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
setSaved(message.config)
|
||||
return
|
||||
}
|
||||
if (message.type === "configUpdateFailed") {
|
||||
// The write was rejected (e.g. schema validation) — surface the error
|
||||
// and keep the draft + isDirty so the user can correct and retry.
|
||||
setSaving(false)
|
||||
setSaveError({ message: message.message, details: message.details })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(unsubscribe)
|
||||
@@ -102,6 +120,9 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
// Accumulate in draft — will be sent on saveConfig()
|
||||
setDraft((prev) => deepMerge(prev as Config, partial))
|
||||
setIsDirty(true)
|
||||
// Clear any stale error from a previous failed save — the user is editing
|
||||
// again, so the old error message no longer reflects the current draft.
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
function saveConfig() {
|
||||
@@ -109,7 +130,8 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
if (Object.keys(changes).length === 0) return
|
||||
// Don't clear draft/isDirty yet — wait for configUpdated confirmation.
|
||||
// If the write fails, the save bar stays visible so the user can retry.
|
||||
saving = true
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
vscode.postMessage({ type: "updateConfig", config: changes })
|
||||
}
|
||||
|
||||
@@ -117,12 +139,15 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
setConfig(saved())
|
||||
setDraft({})
|
||||
setIsDirty(false)
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
const value: ConfigContextValue = {
|
||||
config,
|
||||
loading,
|
||||
isDirty,
|
||||
saving,
|
||||
saveError,
|
||||
updateConfig,
|
||||
saveConfig,
|
||||
discardConfig,
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
SessionStatusInfo,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SuggestionRequest,
|
||||
TodoItem,
|
||||
ModelSelection,
|
||||
ContextUsage,
|
||||
@@ -114,10 +115,14 @@ interface SessionContextValue {
|
||||
// Pending question requests (unscoped — all tracked sessions)
|
||||
questions: Accessor<QuestionRequest[]>
|
||||
questionErrors: Accessor<Set<string>>
|
||||
suggestions: Accessor<SuggestionRequest[]>
|
||||
suggestionErrors: Accessor<Set<string>>
|
||||
respondingSuggestions: Accessor<Set<string>>
|
||||
|
||||
// Scoped permissions/questions — filtered to a session's family (self + subagents)
|
||||
scopedPermissions: (sessionID: string | undefined) => PermissionRequest[]
|
||||
scopedQuestions: (sessionID: string | undefined) => QuestionRequest[]
|
||||
scopedSuggestions: (sessionID: string | undefined) => SuggestionRequest[]
|
||||
|
||||
// Model selection (global, extension-lifetime)
|
||||
selected: Accessor<ModelSelection | null>
|
||||
@@ -192,6 +197,8 @@ interface SessionContextValue {
|
||||
) => void
|
||||
replyToQuestion: (requestID: string, answers: string[][]) => void
|
||||
rejectQuestion: (requestID: string) => void
|
||||
acceptSuggestion: (requestID: string, index: number) => void
|
||||
dismissSuggestion: (requestID: string) => void
|
||||
createSession: () => void
|
||||
clearCurrentSession: () => void
|
||||
loadSessions: () => void
|
||||
@@ -251,6 +258,9 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
|
||||
// Tracks question IDs that failed so the UI can reset sending state
|
||||
const [questionErrors, setQuestionErrors] = createSignal<Set<string>>(new Set())
|
||||
const [suggestions, setSuggestions] = createSignal<SuggestionRequest[]>([])
|
||||
const [suggestionErrors, setSuggestionErrors] = createSignal<Set<string>>(new Set())
|
||||
const [respondingSuggestions, setRespondingSuggestions] = createSignal<Set<string>>(new Set())
|
||||
|
||||
// Tracks whether the user has explicitly set a model override per agent (to
|
||||
// prevent the default-sync effect from overwriting it).
|
||||
@@ -660,6 +670,8 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
}
|
||||
|
||||
function handleExtensionMessage(message: ExtensionMessage): void {
|
||||
// Route suggestion messages (extracted to stay within complexity limit)
|
||||
routeSuggestionMessage(message)
|
||||
if (handleStreamMessage(message)) return
|
||||
switch (message.type) {
|
||||
case "sessionCreated":
|
||||
@@ -697,7 +709,10 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
case "clearPendingPrompts":
|
||||
setPermissions([])
|
||||
setQuestions([])
|
||||
setSuggestions([])
|
||||
setRespondingPermissions(new Set<string>())
|
||||
setSuggestionErrors(new Set<string>())
|
||||
setRespondingSuggestions(new Set<string>())
|
||||
break
|
||||
|
||||
case "sessionsLoaded":
|
||||
@@ -1017,6 +1032,60 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
setQuestionErrors((prev) => new Set(prev).add(requestID))
|
||||
}
|
||||
|
||||
function handleSuggestionRequest(suggestion: SuggestionRequest) {
|
||||
setSuggestions((prev) => {
|
||||
const idx = prev.findIndex((item) => item.id === suggestion.id)
|
||||
if (idx === -1) return [...prev, suggestion]
|
||||
const next = prev.slice()
|
||||
next[idx] = suggestion
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleSuggestionResolved(requestID: string) {
|
||||
setSuggestions((prev) => prev.filter((item) => item.id !== requestID))
|
||||
setRespondingSuggestions((prev) => {
|
||||
if (!prev.has(requestID)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(requestID)
|
||||
return next
|
||||
})
|
||||
setSuggestionErrors((prev) => {
|
||||
if (!prev.has(requestID)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(requestID)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleSuggestionError(requestID: string) {
|
||||
setRespondingSuggestions((prev) => {
|
||||
if (!prev.has(requestID)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(requestID)
|
||||
return next
|
||||
})
|
||||
setSuggestionErrors((prev) => new Set(prev).add(requestID))
|
||||
}
|
||||
|
||||
/**
|
||||
* Route suggestion-related extension messages.
|
||||
* Extracted from the main message handler to stay within the complexity limit.
|
||||
*/
|
||||
function routeSuggestionMessage(message: ExtensionMessage) {
|
||||
switch (message.type) {
|
||||
case "suggestionRequest":
|
||||
handleSuggestionRequest(message.suggestion)
|
||||
break
|
||||
case "suggestionResolved":
|
||||
handleSuggestionResolved(message.requestID)
|
||||
break
|
||||
case "suggestionError":
|
||||
handleSuggestionError(message.requestID)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a failed send: remove the optimistic message from the store
|
||||
* and show a toast. The PromptInput restores the draft text separately
|
||||
@@ -1133,6 +1202,12 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
return questions().filter((q) => family.has(q.sessionID))
|
||||
}
|
||||
|
||||
function scopedSuggestions(sessionID: string | undefined): SuggestionRequest[] {
|
||||
if (!sessionID) return []
|
||||
const family = sessionFamily(sessionID)
|
||||
return suggestions().filter((item) => family.has(item.sessionID))
|
||||
}
|
||||
|
||||
function handleTodoUpdated(sessionID: string, items: TodoItem[]) {
|
||||
setStore("todos", sessionID, items)
|
||||
}
|
||||
@@ -1213,6 +1288,24 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
return next
|
||||
})
|
||||
}
|
||||
const gone = suggestions()
|
||||
.filter((item) => item.sessionID === sessionID)
|
||||
.map((item) => item.id)
|
||||
if (gone.length > 0) {
|
||||
setSuggestions((prev) => prev.filter((item) => item.sessionID !== sessionID))
|
||||
setSuggestionErrors((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const id of gone) next.delete(id)
|
||||
if (next.size === prev.size) return prev
|
||||
return next
|
||||
})
|
||||
setRespondingSuggestions((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const id of gone) next.delete(id)
|
||||
if (next.size === prev.size) return prev
|
||||
return next
|
||||
})
|
||||
}
|
||||
setPermissions((prev) => removeSessionPermissions(prev, sessionID))
|
||||
setStatusMap(
|
||||
produce((map) => {
|
||||
@@ -1425,6 +1518,11 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
}
|
||||
|
||||
const sid = currentSessionID()
|
||||
const suggestion = scopedSuggestions(sid)[0]
|
||||
if (suggestion) dismissSuggestion(suggestion.id)
|
||||
for (const q of scopedQuestions(sid)) {
|
||||
rejectQuestion(q.id)
|
||||
}
|
||||
if (sid) addOptimistic(sid, messageID, text, files)
|
||||
|
||||
const agent = selectedAgentName() !== defaultAgent() ? selectedAgentName() : undefined
|
||||
@@ -1478,6 +1576,11 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
|
||||
const messageID = Identifier.ascending("message")
|
||||
const sid = currentSessionID()
|
||||
const suggestion = scopedSuggestions(sid)[0]
|
||||
if (suggestion) dismissSuggestion(suggestion.id)
|
||||
for (const q of scopedQuestions(sid)) {
|
||||
rejectQuestion(q.id)
|
||||
}
|
||||
|
||||
if (sid) addOptimistic(sid, messageID, `/${command} ${args}`.trim(), files)
|
||||
|
||||
@@ -1568,6 +1671,15 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
})
|
||||
}
|
||||
|
||||
function clearSuggestionError(requestID: string) {
|
||||
setSuggestionErrors((prev) => {
|
||||
if (!prev.has(requestID)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(requestID)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function replyToQuestion(requestID: string, answers: string[][]) {
|
||||
clearQuestionError(requestID)
|
||||
const question = questions().find((item) => item.id === requestID)
|
||||
@@ -1591,6 +1703,29 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
})
|
||||
}
|
||||
|
||||
function acceptSuggestion(requestID: string, index: number) {
|
||||
clearSuggestionError(requestID)
|
||||
setRespondingSuggestions((prev) => new Set(prev).add(requestID))
|
||||
const sid = suggestions().find((s) => s.id === requestID)?.sessionID ?? currentSessionID() ?? ""
|
||||
vscode.postMessage({
|
||||
type: "suggestionAccept",
|
||||
requestID,
|
||||
sessionID: sid,
|
||||
index,
|
||||
})
|
||||
}
|
||||
|
||||
function dismissSuggestion(requestID: string) {
|
||||
clearSuggestionError(requestID)
|
||||
setRespondingSuggestions((prev) => new Set(prev).add(requestID))
|
||||
const sid = suggestions().find((s) => s.id === requestID)?.sessionID ?? currentSessionID() ?? ""
|
||||
vscode.postMessage({
|
||||
type: "suggestionDismiss",
|
||||
requestID,
|
||||
sessionID: sid,
|
||||
})
|
||||
}
|
||||
|
||||
function createSession() {
|
||||
if (!server.isConnected()) {
|
||||
console.warn("[Kilo New] Cannot create session: not connected")
|
||||
@@ -1835,8 +1970,12 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
respondingPermissions,
|
||||
questions,
|
||||
questionErrors,
|
||||
suggestions,
|
||||
suggestionErrors,
|
||||
respondingSuggestions,
|
||||
scopedPermissions,
|
||||
scopedQuestions,
|
||||
scopedSuggestions,
|
||||
selected,
|
||||
selectModel,
|
||||
hasModelOverride,
|
||||
@@ -1898,6 +2037,8 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
respondToPermission,
|
||||
replyToQuestion,
|
||||
rejectQuestion,
|
||||
acceptSuggestion,
|
||||
dismissSuggestion,
|
||||
createSession,
|
||||
clearCurrentSession,
|
||||
loadSessions,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileAttachment } from "../types/messages"
|
||||
import type { FileAttachment, FileSearchItem } from "../types/messages"
|
||||
import { TERMINAL_MENTION } from "./terminal-context-utils"
|
||||
|
||||
export const AT_PATTERN = /(?:^|\s)@(\S*)$/
|
||||
@@ -6,6 +6,7 @@ export const AT_PATTERN = /(?:^|\s)@(\S*)$/
|
||||
export type MentionResult =
|
||||
| { type: "terminal"; value: typeof TERMINAL_MENTION; label: string; description: string }
|
||||
| { type: "file"; value: string }
|
||||
| { type: "folder"; value: string }
|
||||
|
||||
export const TERMINAL_RESULT: MentionResult = {
|
||||
type: "terminal",
|
||||
@@ -27,8 +28,13 @@ export function getTerminalMentionResult(query: string): MentionResult[] {
|
||||
return [TERMINAL_RESULT]
|
||||
}
|
||||
|
||||
export function buildMentionResults(query: string, paths: string[]): MentionResult[] {
|
||||
return [...getTerminalMentionResult(query), ...paths.map((path) => ({ type: "file" as const, value: path }))]
|
||||
export function buildMentionResults(query: string, items: Array<FileSearchItem | string>): MentionResult[] {
|
||||
const results: MentionResult[] = items.map((item) => {
|
||||
if (typeof item === "string") return { type: "file", value: item }
|
||||
if (item.type === "folder") return { type: "folder", value: item.path }
|
||||
return { type: "file", value: item.path }
|
||||
})
|
||||
return [...getTerminalMentionResult(query), ...results]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +56,7 @@ export function syncMentionedPaths(prev: Set<string>, text: string): Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the @mention pattern before the cursor with the selected file path.
|
||||
* Replace the @mention pattern before the cursor with the selected path.
|
||||
* Returns the new text string.
|
||||
*/
|
||||
export function buildTextAfterMentionSelect(before: string, after: string, path: string): string {
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface FileMention {
|
||||
addPaths: (paths: string[], cwd: string) => void
|
||||
}
|
||||
|
||||
export function useFileMention(vscode: VSCodeContext): FileMention {
|
||||
export function useFileMention(vscode: VSCodeContext, sessionID?: Accessor<string | undefined>): FileMention {
|
||||
const [mentionedPaths, setMentionedPaths] = createSignal<Set<string>>(new Set())
|
||||
const [mentionQuery, setMentionQuery] = createSignal<string | null>(null)
|
||||
const [mentionResults, setMentionResults] = createSignal<MentionResult[]>([])
|
||||
@@ -60,10 +60,10 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
|
||||
|
||||
const unsubscribe = vscode.onMessage((message) => {
|
||||
if (message.type !== "fileSearchResult") return
|
||||
const result = message as { type: "fileSearchResult"; paths: string[]; dir: string; requestId: string }
|
||||
if (result.requestId === `file-search-${fileSearchCounter}`) {
|
||||
workspaceDir = result.dir
|
||||
setMentionResults(buildMentionResults(mentionQuery() ?? "", result.paths))
|
||||
if (message.requestId === `file-search-${fileSearchCounter}`) {
|
||||
const items = message.items ?? message.paths.map((path) => ({ path, type: "file" as const }))
|
||||
workspaceDir = message.dir
|
||||
setMentionResults(buildMentionResults(mentionQuery() ?? "", items))
|
||||
setMentionIndex(0)
|
||||
}
|
||||
})
|
||||
@@ -77,7 +77,13 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
|
||||
if (fileSearchTimer) clearTimeout(fileSearchTimer)
|
||||
fileSearchTimer = setTimeout(() => {
|
||||
fileSearchCounter++
|
||||
vscode.postMessage({ type: "requestFileSearch", query, requestId: `file-search-${fileSearchCounter}` })
|
||||
const id = sessionID?.()
|
||||
vscode.postMessage({
|
||||
type: "requestFileSearch",
|
||||
query,
|
||||
requestId: `file-search-${fileSearchCounter}`,
|
||||
...(id ? { sessionID: id } : {}),
|
||||
})
|
||||
}, FILE_SEARCH_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
@@ -110,7 +116,8 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
|
||||
textarea.setSelectionRange(pos, pos)
|
||||
textarea.focus()
|
||||
|
||||
if (result.type === "file") setMentionedPaths((prev) => new Set([...prev, result.value]))
|
||||
if (result.type === "file" || result.type === "folder")
|
||||
setMentionedPaths((prev) => new Set([...prev, result.value]))
|
||||
closeMention()
|
||||
onSelect?.()
|
||||
}
|
||||
|
||||
+2
@@ -1391,6 +1391,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "عدة جلسات تعمل وستتوقف",
|
||||
"settings.saveBar.saveAnyway": "حفظ على أي حال",
|
||||
"settings.saveBar.cancel": "إلغاء",
|
||||
"settings.saveBar.saving": "جارٍ الحفظ…",
|
||||
"settings.saveBar.saveFailed": "تعذر حفظ الإعدادات",
|
||||
"notifications.action.next": "التالي",
|
||||
"notifications.action.close": "إغلاق",
|
||||
"notifications.action.tryModel": "جرّب {{model}}",
|
||||
|
||||
+2
@@ -1421,6 +1421,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "Várias sessões estão em execução e serão interrompidas",
|
||||
"settings.saveBar.saveAnyway": "Salvar mesmo assim",
|
||||
"settings.saveBar.cancel": "Cancelar",
|
||||
"settings.saveBar.saving": "Salvando…",
|
||||
"settings.saveBar.saveFailed": "Não foi possível salvar as configurações",
|
||||
"notifications.action.next": "Próximo",
|
||||
"notifications.action.close": "Fechar",
|
||||
"notifications.action.tryModel": "Experimentar {{model}}",
|
||||
|
||||
+2
@@ -1417,6 +1417,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "Nekoliko sesija je pokrenuto i bit će prekinuto",
|
||||
"settings.saveBar.saveAnyway": "Spremi svejedno",
|
||||
"settings.saveBar.cancel": "Otkaži",
|
||||
"settings.saveBar.saving": "Spremanje…",
|
||||
"settings.saveBar.saveFailed": "Postavke nije moguće spremiti",
|
||||
"notifications.action.next": "Sljedeći",
|
||||
"notifications.action.close": "Zatvori",
|
||||
"notifications.action.tryModel": "Probaj {{model}}",
|
||||
|
||||
+2
@@ -1408,6 +1408,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "Flere sessioner kører og vil blive afbrudt",
|
||||
"settings.saveBar.saveAnyway": "Gem alligevel",
|
||||
"settings.saveBar.cancel": "Annuller",
|
||||
"settings.saveBar.saving": "Gemmer…",
|
||||
"settings.saveBar.saveFailed": "Kunne ikke gemme indstillinger",
|
||||
"notifications.action.next": "Næste",
|
||||
"notifications.action.close": "Luk",
|
||||
"notifications.action.tryModel": "Prøv {{model}}",
|
||||
|
||||
+2
@@ -1435,6 +1435,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "Mehrere Sitzungen laufen und werden unterbrochen",
|
||||
"settings.saveBar.saveAnyway": "Trotzdem speichern",
|
||||
"settings.saveBar.cancel": "Abbrechen",
|
||||
"settings.saveBar.saving": "Speichern…",
|
||||
"settings.saveBar.saveFailed": "Einstellungen konnten nicht gespeichert werden",
|
||||
"notifications.action.next": "Weiter",
|
||||
"notifications.action.close": "Schließen",
|
||||
"notifications.action.tryModel": "{{model}} ausprobieren",
|
||||
|
||||
@@ -1413,10 +1413,12 @@ export const dict = {
|
||||
"settings.saveBar.unsavedChanges": "Unsaved changes",
|
||||
"settings.saveBar.discard": "Discard",
|
||||
"settings.saveBar.save": "Save",
|
||||
"settings.saveBar.saving": "Saving…",
|
||||
"settings.saveBar.warning.one": "One session is running and will be interrupted",
|
||||
"settings.saveBar.warning.many": "Several sessions are running and will be interrupted",
|
||||
"settings.saveBar.saveAnyway": "Save anyway",
|
||||
"settings.saveBar.cancel": "Cancel",
|
||||
"settings.saveBar.saveFailed": "Couldn't save settings",
|
||||
|
||||
"notifications.action.next": "Next",
|
||||
"notifications.action.close": "Close",
|
||||
|
||||
+2
@@ -1424,6 +1424,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "Varias sesiones están en ejecución y se interrumpirán",
|
||||
"settings.saveBar.saveAnyway": "Guardar de todas formas",
|
||||
"settings.saveBar.cancel": "Cancelar",
|
||||
"settings.saveBar.saving": "Guardando…",
|
||||
"settings.saveBar.saveFailed": "No se pudieron guardar los ajustes",
|
||||
"notifications.action.next": "Siguiente",
|
||||
"notifications.action.close": "Cerrar",
|
||||
"notifications.action.tryModel": "Probar {{model}}",
|
||||
|
||||
+2
@@ -1439,6 +1439,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.one": "Une session est en cours et sera interrompue",
|
||||
"settings.saveBar.warning.many": "Plusieurs sessions sont en cours et seront interrompues",
|
||||
"settings.saveBar.saveAnyway": "Enregistrer quand même",
|
||||
"settings.saveBar.saving": "Enregistrement…",
|
||||
"settings.saveBar.saveFailed": "Impossible d'enregistrer les paramètres",
|
||||
"settings.saveBar.cancel": "Annuler",
|
||||
"notifications.action.next": "Suivant",
|
||||
"notifications.action.close": "Fermer",
|
||||
|
||||
+2
@@ -1406,6 +1406,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "複数のセッションが実行中で中断されます",
|
||||
"settings.saveBar.saveAnyway": "それでも保存",
|
||||
"settings.saveBar.cancel": "キャンセル",
|
||||
"settings.saveBar.saving": "保存中…",
|
||||
"settings.saveBar.saveFailed": "設定を保存できませんでした",
|
||||
"notifications.action.next": "次へ",
|
||||
"notifications.action.close": "閉じる",
|
||||
"notifications.action.tryModel": "{{model}}を試す",
|
||||
|
||||
+2
@@ -1394,6 +1394,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "여러 세션이 실행 중이며 중단됩니다",
|
||||
"settings.saveBar.saveAnyway": "그래도 저장",
|
||||
"settings.saveBar.cancel": "취소",
|
||||
"settings.saveBar.saving": "저장 중…",
|
||||
"settings.saveBar.saveFailed": "설정을 저장할 수 없습니다",
|
||||
"notifications.action.next": "다음",
|
||||
"notifications.action.close": "닫기",
|
||||
"notifications.action.tryModel": "{{model}} 시도",
|
||||
|
||||
+2
@@ -1402,6 +1402,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "Meerdere sessies zijn actief en worden onderbroken",
|
||||
"settings.saveBar.saveAnyway": "Toch opslaan",
|
||||
"settings.saveBar.cancel": "Annuleren",
|
||||
"settings.saveBar.saving": "Bezig met opslaan…",
|
||||
"settings.saveBar.saveFailed": "Instellingen konden niet worden opgeslagen",
|
||||
"notifications.action.next": "Volgende",
|
||||
"notifications.action.close": "Sluiten",
|
||||
"notifications.action.tryModel": "Probeer {{model}}",
|
||||
|
||||
+2
@@ -1406,6 +1406,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "Flere økter kjører og vil bli avbrutt",
|
||||
"settings.saveBar.saveAnyway": "Lagre uansett",
|
||||
"settings.saveBar.cancel": "Avbryt",
|
||||
"settings.saveBar.saving": "Lagrer…",
|
||||
"settings.saveBar.saveFailed": "Kunne ikke lagre innstillinger",
|
||||
"notifications.action.next": "Neste",
|
||||
"notifications.action.close": "Lukk",
|
||||
"notifications.action.tryModel": "Prøv {{model}}",
|
||||
|
||||
+2
@@ -1416,6 +1416,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "Kilka sesji jest uruchomionych i zostanie przerwanych",
|
||||
"settings.saveBar.saveAnyway": "Zapisz mimo to",
|
||||
"settings.saveBar.cancel": "Anuluj",
|
||||
"settings.saveBar.saving": "Zapisywanie…",
|
||||
"settings.saveBar.saveFailed": "Nie można zapisać ustawień",
|
||||
"notifications.action.next": "Następny",
|
||||
"notifications.action.close": "Zamknij",
|
||||
"notifications.action.tryModel": "Wypróbuj {{model}}",
|
||||
|
||||
+2
@@ -1415,6 +1415,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "Несколько сеансов выполняются и будут прерваны",
|
||||
"settings.saveBar.saveAnyway": "Сохранить в любом случае",
|
||||
"settings.saveBar.cancel": "Отмена",
|
||||
"settings.saveBar.saving": "Сохранение…",
|
||||
"settings.saveBar.saveFailed": "Не удалось сохранить настройки",
|
||||
"notifications.action.next": "Далее",
|
||||
"notifications.action.close": "Закрыть",
|
||||
"notifications.action.tryModel": "Попробовать {{model}}",
|
||||
|
||||
+2
@@ -1391,6 +1391,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "มีหลายเซสชันกำลังทำงานและจะถูกขัดจังหวะ",
|
||||
"settings.saveBar.saveAnyway": "บันทึกต่อไป",
|
||||
"settings.saveBar.cancel": "ยกเลิก",
|
||||
"settings.saveBar.saving": "กำลังบันทึก…",
|
||||
"settings.saveBar.saveFailed": "ไม่สามารถบันทึกการตั้งค่าได้",
|
||||
"notifications.action.next": "ถัดไป",
|
||||
"notifications.action.close": "ปิด",
|
||||
"notifications.action.tryModel": "ลองใช้ {{model}}",
|
||||
|
||||
+2
@@ -1394,6 +1394,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "Birden fazla oturum çalışıyor ve kesintiye uğrayacak",
|
||||
"settings.saveBar.saveAnyway": "Yine de kaydet",
|
||||
"settings.saveBar.cancel": "İptal",
|
||||
"settings.saveBar.saving": "Kaydediliyor…",
|
||||
"settings.saveBar.saveFailed": "Ayarlar kaydedilemedi",
|
||||
"notifications.action.next": "Sonraki",
|
||||
"notifications.action.close": "Kapat",
|
||||
"notifications.action.tryModel": "Dene {{model}}",
|
||||
|
||||
+2
@@ -1395,6 +1395,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "Кілька сесій виконуються і будуть перервані",
|
||||
"settings.saveBar.saveAnyway": "Зберегти все одно",
|
||||
"settings.saveBar.cancel": "Скасувати",
|
||||
"settings.saveBar.saving": "Збереження…",
|
||||
"settings.saveBar.saveFailed": "Не вдалося зберегти налаштування",
|
||||
"notifications.action.next": "Далі",
|
||||
"notifications.action.close": "Закрити",
|
||||
"notifications.action.tryModel": "Спробувати {{model}}",
|
||||
|
||||
+2
@@ -1362,6 +1362,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.one": "一个会话正在运行,将被中断",
|
||||
"settings.saveBar.warning.many": "多个会话正在运行,将被中断",
|
||||
"settings.saveBar.saveAnyway": "仍然保存",
|
||||
"settings.saveBar.saving": "保存中…",
|
||||
"settings.saveBar.saveFailed": "无法保存设置",
|
||||
"settings.saveBar.cancel": "取消",
|
||||
"notifications.action.next": "下一个",
|
||||
"notifications.action.close": "关闭",
|
||||
|
||||
+2
@@ -1367,6 +1367,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "多個工作階段正在執行,將被中斷",
|
||||
"settings.saveBar.saveAnyway": "仍然儲存",
|
||||
"settings.saveBar.cancel": "取消",
|
||||
"settings.saveBar.saving": "儲存中…",
|
||||
"settings.saveBar.saveFailed": "無法儲存設定",
|
||||
"notifications.action.next": "下一個",
|
||||
"notifications.action.close": "關閉",
|
||||
"notifications.action.tryModel": "嘗試 {{model}}",
|
||||
|
||||
@@ -33,7 +33,13 @@ import { dict as appEn } from "../i18n/en"
|
||||
import { dict as amEn } from "../../agent-manager/i18n/en"
|
||||
import { dict as kiloEn } from "@kilocode/kilo-i18n/en"
|
||||
import { resolveTemplate } from "../context/language-utils"
|
||||
import type { Config, KilocodeNotification, PermissionRequest, QuestionRequest } from "../types/messages"
|
||||
import type {
|
||||
Config,
|
||||
KilocodeNotification,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SuggestionRequest,
|
||||
} from "../types/messages"
|
||||
|
||||
// Merged English dictionary (same merge order as the real LanguageProvider)
|
||||
const dict: Record<string, string> = { ...appEn, ...amEn, ...uiEn, ...kiloEn }
|
||||
@@ -120,11 +126,13 @@ export function mockSessionValue(overrides?: {
|
||||
id?: string
|
||||
permissions?: PermissionRequest[]
|
||||
questions?: QuestionRequest[]
|
||||
suggestions?: SuggestionRequest[]
|
||||
status?: string
|
||||
}) {
|
||||
const id = overrides?.id ?? "story-session-001"
|
||||
const permissions = overrides?.permissions ?? []
|
||||
const qs = overrides?.questions ?? []
|
||||
const suggestions = overrides?.suggestions ?? []
|
||||
const status = (overrides?.status ?? "idle") as "idle" | "busy"
|
||||
|
||||
return {
|
||||
@@ -154,8 +162,12 @@ export function mockSessionValue(overrides?: {
|
||||
respondingPermissions: () => new Set<string>(),
|
||||
questions: () => qs,
|
||||
questionErrors: () => new Set<string>(),
|
||||
suggestions: () => suggestions,
|
||||
suggestionErrors: () => new Set<string>(),
|
||||
respondingSuggestions: () => new Set<string>(),
|
||||
scopedPermissions: (sid?: string) => (sid ? permissions.filter((p) => p.sessionID === sid) : permissions),
|
||||
scopedQuestions: (sid?: string) => (sid ? qs.filter((q) => q.sessionID === sid) : qs),
|
||||
scopedSuggestions: (sid?: string) => (sid ? suggestions.filter((item) => item.sessionID === sid) : suggestions),
|
||||
selected: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }),
|
||||
selectModel: noop,
|
||||
hasModelOverride: () => false,
|
||||
@@ -186,11 +198,14 @@ export function mockSessionValue(overrides?: {
|
||||
currentVariant: () => undefined,
|
||||
selectVariant: noop,
|
||||
sendMessage: noop,
|
||||
sendCommand: noop,
|
||||
abort: noop,
|
||||
compact: noop,
|
||||
respondToPermission: noop,
|
||||
replyToQuestion: noop,
|
||||
rejectQuestion: noop,
|
||||
acceptSuggestion: noop,
|
||||
dismissSuggestion: noop,
|
||||
createSession: noop,
|
||||
clearCurrentSession: noop,
|
||||
loadSessions: noop,
|
||||
@@ -211,6 +226,7 @@ interface StoryProvidersProps {
|
||||
data?: any
|
||||
permissions?: PermissionRequest[]
|
||||
questions?: QuestionRequest[]
|
||||
suggestions?: SuggestionRequest[]
|
||||
notifications?: KilocodeNotification[]
|
||||
status?: string
|
||||
sessionID?: string
|
||||
@@ -227,6 +243,8 @@ const ConfigWrapper: ParentComponent<{ config?: Config }> = (props) => {
|
||||
config: () => props.config!,
|
||||
loading: () => false,
|
||||
isDirty: () => false,
|
||||
saving: () => false,
|
||||
saveError: () => null,
|
||||
updateConfig: noop,
|
||||
saveConfig: noop,
|
||||
discardConfig: noop,
|
||||
@@ -242,6 +260,7 @@ export const StoryProviders: ParentComponent<StoryProvidersProps> = (props) => {
|
||||
id: props.sessionID,
|
||||
permissions: props.permissions,
|
||||
questions: props.questions,
|
||||
suggestions: props.suggestions,
|
||||
status: props.status,
|
||||
})
|
||||
const notifications = mockNotificationsValue(props.notifications)
|
||||
|
||||
@@ -12,10 +12,11 @@ import { StoryProviders, defaultMockData, mockSessionValue } from "./StoryProvid
|
||||
import { ChatView } from "../components/chat/ChatView"
|
||||
import { TaskHeader } from "../components/chat/TaskHeader"
|
||||
import { QuestionDock } from "../components/chat/QuestionDock"
|
||||
import { SuggestBar } from "../components/chat/SuggestBar"
|
||||
import { MessageList } from "../components/chat/MessageList"
|
||||
import { SessionContext } from "../context/session"
|
||||
import { ServerContext } from "../context/server"
|
||||
import type { QuestionRequest, TodoItem } from "../types/messages"
|
||||
import type { QuestionRequest, SuggestionRequest, TodoItem } from "../types/messages"
|
||||
|
||||
const SESSION_ID = "story-session-chat-001"
|
||||
|
||||
@@ -67,6 +68,14 @@ const multiQuestion: QuestionRequest = {
|
||||
tool: { messageID: "asst-msg-001", callID: "call-question-002" },
|
||||
}
|
||||
|
||||
const reviewSuggestion: SuggestionRequest = {
|
||||
id: "s-review-001",
|
||||
sessionID: SESSION_ID,
|
||||
text: "Start a code review of uncommitted changes?",
|
||||
actions: [{ label: "Start review", description: "Run a local review now", prompt: "/local-review-uncommitted" }],
|
||||
tool: { messageID: "asst-msg-002", callID: "call-suggest-001" },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meta
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -115,6 +124,44 @@ export const ChatViewWithMessages: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatView with a pending question tool call and an empty input.
|
||||
*
|
||||
* Locks in the fix for the regression where the question tool's pending request
|
||||
* caused the Send button to render as a Stop square. The snapshot captures the
|
||||
* prompt bar footer — the submit control must be the paper-plane arrow icon,
|
||||
* not the filled square Stop icon.
|
||||
*
|
||||
* If someone re-couples the prompt input to the question tool, this story's
|
||||
* baseline PNG will diverge and the visual-regression CI job will fail.
|
||||
*/
|
||||
const pendingToolQuestion: QuestionRequest = {
|
||||
id: "q-toolcall-001",
|
||||
sessionID: SESSION_ID,
|
||||
questions: [
|
||||
{
|
||||
question: "What would you like to do next?",
|
||||
header: "Next step",
|
||||
options: [
|
||||
{ label: "Continue", description: "Keep going with the current plan" },
|
||||
{ label: "Revise", description: "Adjust the approach before continuing" },
|
||||
],
|
||||
},
|
||||
],
|
||||
tool: { messageID: "asst-q-001", callID: "call-q-001" },
|
||||
}
|
||||
|
||||
export const ChatViewWithPendingQuestionEmptyInput: Story = {
|
||||
name: "ChatView — pending question, empty input (submit must be arrow, not square)",
|
||||
render: () => (
|
||||
<StoryProviders sessionID={SESSION_ID} status="busy" questions={[pendingToolQuestion]}>
|
||||
<div style={{ "max-height": "400px", display: "flex", "flex-direction": "column" }}>
|
||||
<ChatView />
|
||||
</div>
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QuestionDock stories
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -173,6 +220,17 @@ export const QuestionDockManyOptions: Story = {
|
||||
),
|
||||
}
|
||||
|
||||
export const SuggestBarReview: Story = {
|
||||
name: "SuggestBar — review suggestion",
|
||||
render: () => (
|
||||
<StoryProviders sessionID={SESSION_ID} suggestions={[reviewSuggestion]}>
|
||||
<div style={{ width: "100%" }}>
|
||||
<SuggestBar request={reviewSuggestion} />
|
||||
</div>
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
const toolUserID = "user-msg-spacing-001"
|
||||
const toolAssistantID = "asst-msg-spacing-001"
|
||||
const queuedUserID = "user-msg-spacing-002"
|
||||
|
||||
@@ -19,5 +19,6 @@
|
||||
@import "./notifications.css";
|
||||
@import "./tool-overrides.css";
|
||||
@import "./question-dock.css";
|
||||
@import "./suggest-bar.css";
|
||||
@import "./settings.css";
|
||||
@import "./high-contrast.css";
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
/* Settings save bar */
|
||||
.settings-save-bar-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
.settings-save-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
border-top: 1px solid var(--border-weak-base);
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transition:
|
||||
max-height 0.2s ease,
|
||||
opacity 0.2s ease,
|
||||
padding 0.2s ease;
|
||||
}
|
||||
|
||||
.settings-save-bar--visible {
|
||||
max-height: 52px;
|
||||
opacity: 1;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
@@ -27,6 +19,62 @@
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* Save error — mirrors the startup-error-banner style */
|
||||
.settings-save-bar-error {
|
||||
border: 1px solid var(--vscode-inputValidation-errorBorder, #f14c4c);
|
||||
border-radius: 4px;
|
||||
background: var(--vscode-inputValidation-errorBackground, rgba(241, 76, 76, 0.1));
|
||||
margin: 8px 16px 0 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-save-bar-error-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-save-bar-error-title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-save-bar-error-firstline {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.settings-save-bar-error-chevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
transition: transform 150ms;
|
||||
}
|
||||
|
||||
.settings-save-bar-error-chevron-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.settings-save-bar-error-details {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--vscode-inputValidation-errorBorder, #f14c4c);
|
||||
background: var(--vscode-editor-background);
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
[data-slot="settings-row"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user