Merge remote-tracking branch 'origin/main' into stylish-bloom

# Conflicts:
#	packages/kilo-vscode/webview-ui/agent-manager/MarkdownAnnotationLayer.tsx
#	packages/kilo-vscode/webview-ui/agent-manager/markdown-annotation-mutation.ts
This commit is contained in:
marius-kilocode
2026-05-19 15:46:46 +02:00
179 changed files with 4555 additions and 2124 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Resume interrupted CLI turns automatically after network recovery while giving users 10 seconds to cancel.
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Make sections in the model picker collapsible. Click any section header (Favorites, Recommended, or a provider like Kilo Gateway) to hide its models. Collapse state resets each time the picker opens.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show JetBrains question prompts one question at a time with aligned native option rows.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Render active question and permission prompts inside the scrollable JetBrains chat transcript.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-docs": patch
---
Update KiloClaw pricing post-beta
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"@kilocode/kilo-docs": patch
---
Harden Mermaid diagram rendering with upstream security fixes.
@@ -0,0 +1,21 @@
# kilocode_change - new file
name: Check forbidden strings
on:
pull_request:
workflow_dispatch:
jobs:
check:
name: Check forbidden strings
if: github.repository == 'Kilo-Org/kilocode'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6 # kilocode_change
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: oven-sh/setup-bun@v2
- name: Run check
run: bun run script/check-forbidden-strings.ts
@@ -5,7 +5,6 @@ on:
paths:
- ".github/**"
- "github/**"
- "sdks/vscode/**"
- "packages/extensions/**"
- "packages/opencode/**"
- "packages/script/**"
+8
View File
@@ -59,6 +59,14 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun
# kilocode_change start
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
# kilocode_change end
- name: Configure git identity
run: |
git config --global user.email "kilo-maintainer[bot]@users.noreply.github.com"
+9 -1
View File
@@ -249,7 +249,15 @@ be broken. Check every auto-merged file for:
files changed. Note that this tool compares against the merge base via `HEAD`
and will be silent until the merge commit lands
- other CI guards that touched files imply (knip for `kilo-vscode/`,
`check-kilocode-change`, source-links, visual regression)
`check-kilocode-change`, source-links, visual regression,
`script/check-forbidden-strings.ts`)
- if you encounter a hardcoded upstream URL, repo path, or attribution string
during conflict resolution that obviously shouldn't ship in Kilo (e.g. another
`https://opencode.ai/...` link, an `anomalyco/opencode` reference, an
attribution header naming "opencode"), suggest adding a literal pattern for
it to `script/check-forbidden-strings.ts` in the merge summary so future
merges catch it automatically. Don't add it silently mid-merge — flag it for
the user.
### 9. Commit with the standard message
+3 -1
View File
@@ -102,7 +102,9 @@ kilo run --auto "run tests and fix any failures"
We welcome contributions from developers, writers, and enthusiasts!
To get started, please read our [Contributing Guide](/CONTRIBUTING.md). It includes details on setting up your environment, coding standards, types of contribution and how to submit pull requests.
See [RELEASING.md](RELEASING.md) for the release process.
See [RELEASING.md](RELEASING.md) for the VS Code extension and CLI release process.
See [packages/kilo-jetbrains/RELEASING.md](packages/kilo-jetbrains/RELEASING.md) for the JetBrains plugin release process.
## Code of Conduct
+90
View File
@@ -0,0 +1,90 @@
# REVIEW.md
Guidance for the automated reviewer (kilo-code-bot) on PRs in this repo.
The goal of the review is to catch things CI **cannot** catch: bugs, design issues, and judgment calls about style and fork hygiene. Be helpful, not pedantic — frame everything as a suggestion the human can accept or reject.
## Don't duplicate CI
CI already runs and will report failures directly. Do **not** comment on:
- Lint, formatting, or typecheck errors (root `lint`, `turbo typecheck`)
- Test failures (CLI tests, vscode tests)
- `knip` unused exports
- `kilocode_change` marker rules — both directions:
- Missing markers on shared opencode files (`script/check-opencode-annotations.ts`)
- Markers present in kilo-only paths like `packages/kilo-vscode/`, `packages/kilo-ui/`, `packages/opencode/src/kilocode/` (`bun run check-kilocode-change`)
- Workflow allowlist drift (`script/check-workflows.ts`)
- Stale `packages/kilo-docs/source-links.md` (`script/extract-source-links.ts`)
- Markdown table padding (`script/check-md-table-padding.ts`)
- Visual regression snapshots (CI generates baselines on Linux)
- SDK regeneration drift (`generate.yml`)
- Generated artifact freshness (`check-kilo-generated-artifacts.yml`)
- Docs link checks, nix evals, container builds
If the only issue you'd raise is one of the above, just say `lgtm`.
## What to focus on
### 1. Bugs and correctness
Read enough of the surrounding file to actually understand the change — diffs alone hide context. Look for:
- Logic errors, off-by-one, wrong conditions, swapped arguments
- Unhandled error paths, swallowed promises, missing `await`
- Race conditions, especially around session/process lifecycle in the CLI and Agent Manager
- Resource leaks (unclosed file handles, child processes, subscriptions)
- Inputs that aren't validated where they cross trust boundaries (server routes, IPC, config loading)
### 2. Style guide judgment calls
The full guide is in `AGENTS.md`. Don't be a zealot — only flag actual violations, and recognize when the existing code already complies through a different mechanism.
- **No `let`**: prefer `const` with ternary or IIFE (`packages/opencode/src/util/iife.ts`). But `let` is fine when it's genuinely the simplest option; don't demand IIFE rewrites for trivial cases.
- **No `else`**: prefer early returns. Don't complain about `else` if the code already uses early returns elsewhere. You **may** flag excessive nesting regardless.
- **No empty `catch`**: always flag — empty catches hide bugs.
- **Avoid `try`/`catch` where possible**: if a try/catch is added, consider whether it's needed at all.
- **Avoid `any`**: flag new `any` usage unless there's a clear reason.
- **Single-word names**: prefer `cfg`, `pid`, `dir`, `opts`, `err` over `inputPID`, `connectTimeout`. Only flag newly introduced multi-word names where a clear single-word alternative exists.
- **Avoid unnecessary destructuring**: prefer `obj.a` over `const { a } = obj` to preserve context.
- **Bun APIs**: prefer `Bun.file()` etc. over node equivalents in CLI code.
- **Type inference**: avoid explicit annotations unless needed for exports/clarity.
When suggesting fixes, ensure the suggestion is valid TypeScript (matched braces, correct syntax). Prefer prose comments over `suggestion` blocks unless the fix is trivially mechanical.
### 3. Fork merge hygiene
Kilo CLI is a fork of opencode. Minimizing diff against upstream is a top priority.
- If a change modifies a shared opencode file (anything under `packages/opencode/` not in a path containing `kilocode`), ask whether the logic could live in a Kilo-only directory instead (`packages/opencode/src/kilocode/`, `packages/kilo-gateway/`, etc.) or be reduced to a smaller hook.
- Refactors or reorganizations of upstream code are a red flag — flag them unless clearly justified.
- See `.kilo/skills/kilocode-merge-minimizer/SKILL.md` for the decision rules.
### 4. Cloud config schema mirror
When `Config.Info` in `packages/opencode/src/config/config.ts` gains a new `kilocode_change` field, the matching JSON Schema entry must also be added in the cloud repo (`apps/web/src/app/config.json/extras.ts`). CI does **not** check this — flag it as a reminder if you see a new config field added.
### 5. Test quality
- Tests should exercise real implementation, not duplicate logic into the test.
- Mocks should be avoided where reasonable; flag mock-heavy tests that look like they're testing the mock rather than the code.
- New behavior in `packages/opencode/` should generally come with a test under `packages/opencode/test/`.
### 6. User-facing changes
- Features, bug fixes, and breaking changes should include a changeset (`.changeset/*.md`). If a PR clearly changes user-visible behavior and has no changeset, mention it.
- Changeset descriptions are read by end users — if one is present but written as implementation notes ("Add a new export handler that serializes…"), suggest a user-facing rewrite ("Support exporting conversations as markdown").
- PR descriptions should explain **why**, not enumerate files. Skip file-by-file inventories.
### 7. UI changes
For changes under `packages/kilo-vscode/webview-ui/`:
- Significant visual or layout changes should have a Storybook story added under `webview-ui/src/stories/`. Minor tweaks and i18n-only changes don't need one.
- Don't ask for locally generated baseline PNGs — those must come from Linux CI.
## How to comment
- Leave comments on the exact line via `gh api .../pulls/{n}/comments`.
- Make it clear suggestions are suggestions; the human decides.
- If the PR is clean against the above, comment `lgtm` and nothing else.
+8 -40
View File
@@ -77,7 +77,7 @@
"@vscode/codicons": "^0.0.44",
"@xyflow/react": "12.10.2",
"js-yaml": "^4.1.0",
"mermaid": "11.12.3",
"mermaid": "11.15.0",
"next": "^16.1.5",
"posthog-js": "^1.335.3",
"prismjs": "^1.30.0",
@@ -546,7 +546,7 @@
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"mermaid": "11.14.0",
"mermaid": "11.15.0",
"morphdom": "2.7.8",
"motion": "12.34.5",
"motion-dom": "12.34.3",
@@ -968,15 +968,7 @@
"@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="],
"@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@12.0.0", "", { "dependencies": { "@chevrotain/gast": "12.0.0", "@chevrotain/types": "12.0.0" } }, "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg=="],
"@chevrotain/gast": ["@chevrotain/gast@12.0.0", "", { "dependencies": { "@chevrotain/types": "12.0.0" } }, "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ=="],
"@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@12.0.0", "", {}, "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA=="],
"@chevrotain/types": ["@chevrotain/types@12.0.0", "", {}, "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA=="],
"@chevrotain/utils": ["@chevrotain/utils@12.0.0", "", {}, "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA=="],
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
@@ -1348,7 +1340,7 @@
"@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="],
"@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="],
"@mermaid-js/parser": ["@mermaid-js/parser@1.1.1", "", { "dependencies": { "@chevrotain/types": "~11.1.1" } }, "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw=="],
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
@@ -2554,10 +2546,6 @@
"cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
"chevrotain": ["chevrotain@12.0.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", "@chevrotain/regexp-to-ast": "12.0.0", "@chevrotain/types": "12.0.0", "@chevrotain/utils": "12.0.0" } }, "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ=="],
"chevrotain-allstar": ["chevrotain-allstar@0.4.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^12.0.0" } }, "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
@@ -2742,7 +2730,7 @@
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
"dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="],
"dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
@@ -2876,6 +2864,8 @@
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="],
"esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
"esbuild-plugin-solid": ["esbuild-plugin-solid@0.6.0", "", { "dependencies": { "@babel/core": "^7.20.12", "@babel/preset-typescript": "^7.18.6", "babel-preset-solid": "^1.6.9" }, "peerDependencies": { "esbuild": ">=0.20", "solid-js": ">= 1.0" } }, "sha512-V1FvDALwLDX6K0XNYM9CMRAnMzA0+Ecu55qBUT9q/eAJh1KIDsTMFoOzMSgyHqbOfvrVfO3Mws3z7TW2GVnIZA=="],
@@ -3360,8 +3350,6 @@
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
"langium": ["langium@4.2.2", "", { "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", "chevrotain": "~12.0.0", "chevrotain-allstar": "~0.4.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ=="],
"layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="],
"lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="],
@@ -3492,7 +3480,7 @@
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"mermaid": ["mermaid@11.12.3", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^1.0.0", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ=="],
"mermaid": ["mermaid@11.15.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "es-toolkit": "^1.45.1", "katex": "^0.16.25", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw=="],
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
@@ -4420,16 +4408,8 @@
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="],
"vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="],
"vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="],
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
"vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
@@ -4736,8 +4716,6 @@
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/ui/mermaid": ["mermaid@11.14.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g=="],
"@opencode-ai/ui/tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
"@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="],
@@ -5014,8 +4992,6 @@
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"mermaid/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -5184,8 +5160,6 @@
"vitest/why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
"whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -5310,12 +5284,6 @@
"@opencode-ai/ui/@solid-primitives/resize-observer/@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="],
"@opencode-ai/ui/mermaid/dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="],
"@opencode-ai/ui/mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"@opencode-ai/ui/mermaid/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="],
"@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw=="],
+1
View File
@@ -1,6 +1,7 @@
[install]
exact = true
minimumReleaseAge = 410520 # seconds (~4.75 days / ~114 hours)
minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser"]
[test]
root = "./do-not-run-tests-from-root"
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-n8qDAnmhHLO3PICGFAL6UzPi6k+Dwtl9Wg87PKqa/vY=",
"aarch64-linux": "sha256-XBCHg6BYL1J3M5JCUZda9e8ZKi8EEpI2hez6w3ICOYs=",
"aarch64-darwin": "sha256-3K35v/UghrM8CSpWvbvJVLeoF94MALWVA7F5B++Po00=",
"x86_64-darwin": "sha256-aTjNOPvGygJ34A1Q5+qziyyuSFa0w6WMUjUcsXSLtBU="
"x86_64-linux": "sha256-NZqcbv0MX1WV/BkAT1poERcDEFiA4s1Gsf7W4p5wjlk=",
"aarch64-linux": "sha256-WSl5114O35I9Fae7+BbYXWznKSApPXN/Ripjqr6NO5U=",
"aarch64-darwin": "sha256-VemzuMnNbZo7vgHS5UiHVFGPVy9VL3gVwm+cw6ij1cg=",
"x86_64-darwin": "sha256-tg2Zt8qE5EZWCVNLxKzXtaygvzAkO0IFysMfFhIfZBs="
}
}
+4 -1
View File
@@ -12,7 +12,10 @@ import fs from "fs/promises"
*/
export async function ensureRealDir(p: string) {
await fs.mkdir(p, { recursive: true })
const ok = await fs.stat(p).then(() => true).catch(() => false)
const ok = await fs
.stat(p)
.then(() => true)
.catch(() => false)
if (!ok) {
await fs.rm(p, { force: true })
await fs.mkdir(p, { recursive: true })
@@ -134,7 +134,6 @@ const edges: Edge[] = [
type: "smoothstep",
style: { strokeWidth: 2, stroke: "#888", strokeDasharray: "5 3" },
},
]
export const wantedLifecycle: DiagramDefinition = {
@@ -9,11 +9,7 @@ import { diagrams } from "./diagrams"
* re-renders of FlowDiagram (otherwise React would unmount/remount it
* on every parent render and tear down the ResizeObserver each time).
*/
function FitOnResize({
useReactFlow,
}: {
useReactFlow: typeof import("@xyflow/react").useReactFlow
}) {
function FitOnResize({ useReactFlow }: { useReactFlow: typeof import("@xyflow/react").useReactFlow }) {
const { fitView } = useReactFlow()
const containerRef = useRef<HTMLDivElement | null>(null)
@@ -14,6 +14,7 @@
| `kilo uninstall` | uninstall kilo and remove all related files |
| `kilo serve` | starts a headless kilo server |
| `kilo models [provider]` | list all available models |
| `kilo roll-call <filter>` | batch-test text models matching a filter for connectivity and latency |
| `kilo stats` | show token usage and cost statistics |
| `kilo export [sessionID]` | export session data as JSON |
| `kilo import <file>` | import session data from JSON file or URL |
+1 -1
View File
@@ -16,7 +16,7 @@
"@vscode/codicons": "^0.0.44",
"@xyflow/react": "12.10.2",
"js-yaml": "^4.1.0",
"mermaid": "11.12.3",
"mermaid": "11.15.0",
"next": "^16.1.5",
"posthog-js": "^1.335.3",
"prismjs": "^1.30.0",
@@ -165,25 +165,25 @@ Positionals:
message message to send [string] [default: []]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--command the command to run, use message for args [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session before continuing (requires --continue or --session) [boolean]
--share share the session [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--format format: default (formatted) or json (raw JSON events) [string] [choices: "default", "json"] [default: "default"]
-f, --file file(s) to attach to message [array]
--title title for the session (uses truncated prompt if no value provided) [string]
--attach attach to a running opencode server (e.g., http://localhost:4096) [string]
-p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string]
--dir directory to run in, path on remote server if attaching [string]
--port port for the local server (defaults to random port if no value provided) [number]
--variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) [string]
--thinking show thinking blocks [boolean] [default: false]
--auto auto-approve all permissions (for autonomous/pipeline usage) [boolean] [default: false]
--help Show help [boolean]
--version Show version number [boolean]
--command the command to run, use message for args [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session before continuing (requires --continue or --session) [boolean]
--share share the session [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--format format: default (formatted) or json (raw JSON events) [string] [choices: "default", "json"] [default: "default"]
-f, --file file(s) to attach to message [array]
--title title for the session (uses truncated prompt if no value provided) [string]
--attach attach to a running opencode server (e.g., http://localhost:4096) [string]
-p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string]
--dir directory to run in, path on remote server if attaching [string]
--port port for the local server (defaults to random port if no value provided) [number]
--variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) [string]
--thinking show thinking blocks [boolean] [default: false]
--auto auto-approve all permissions (for autonomous/pipeline usage) [boolean] [default: false]
```
## kilo debug
@@ -669,6 +669,25 @@ Options:
--refresh refresh the models cache from models.dev [boolean]
```
## kilo roll-call
```
batch-test text models matching a filter for connectivity and latency
Positionals:
filter regex to filter models by provider/modelID (required) [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--prompt Prompt to send to each model [string] [default: "Hello"]
--timeout Timeout for each model call in milliseconds [number] [default: 25000]
--parallel Number of parallel model calls [number] [default: 5]
--verbose Show verbose output [boolean] [default: false]
--quiet Suppress progress and decoration [boolean] [default: false]
--output Output format (table, json, or md) [string] [choices: "table", "json", "md"] [default: "table"]
```
## kilo stats
```
@@ -25,6 +25,8 @@ When you're reviewing a pull request and want a second opinion on a piece of cod
The bot reads the review comment, the surrounding diff, and the relevant code in the repository to give you an informed answer.
{% image src="/docs/img/connect/github/github-review.png" alt="Asking @kilocode-bot a question on a GitHub pull request review comment" width="800" /%}
### Fix issues directly from GitHub
Tag the bot on any issue and ask it to handle the fix:
@@ -40,6 +42,8 @@ The bot will:
- Create a branch with the implementation
- Open a pull request
{% image src="/docs/img/connect/github/github-issue.png" alt="Asking @kilocode-bot to fix a GitHub issue" width="800" /%}
### Diagnose bug reports
When a bug report comes in and you want to understand what's going on before diving in:
@@ -50,6 +54,8 @@ When a bug report comes in and you want to understand what's going on before div
The bot examines the bug report, searches the codebase for related code paths, and shares its analysis directly in the issue thread.
{% image src="/docs/img/connect/github/github-bug.png" alt="Asking @kilocode-bot to diagnose a bug report on a GitHub issue" width="800" /%}
---
## How It Works
@@ -26,6 +26,8 @@ The bot will:
- Show a thinking/processing animation in Linear while it works
- Link the resulting pull request back to the issue
{% image src="/docs/img/connect/linear/linear-fix-issue.png" alt="Asking @kilo to fix an issue in Linear" width="800" /%}
### Apply changes across multiple repositories
If a fix or upgrade needs to land in several repos at once:
@@ -36,6 +38,8 @@ If a fix or upgrade needs to land in several repos at once:
The bot handles each repository independently, creating separate branches and pull requests for each.
{% image src="/docs/img/connect/linear/linear-multi-repo.png" alt="Asking @kilo to apply changes across multiple repositories from Linear" width="800" /%}
### Get help understanding an issue
Before jumping into a fix, ask the bot to analyze the problem:
@@ -46,6 +50,8 @@ Before jumping into a fix, ask the bot to analyze the problem:
The bot examines the issue context and searches the connected codebase to surface likely causes.
{% image src="/docs/img/connect/linear/linear-understand-issue.png" alt="Asking @kilo to analyze the cause of a Linear issue" width="800" /%}
---
## How It Works
@@ -28,6 +28,8 @@ When you mention `@Kilo` in a thread, the bot:
@Kilo how is error handling implemented in the payment processing module?
```
{% image src="/docs/img/connect/slack/slackbot-ask-questions.webp" alt="Asking Kilo a question about the codebase in Slack" width="800" /%}
### Implement fixes and features from Slack discussions
When your team identifies a bug or improvement in a thread, ask the bot to handle it:
@@ -43,6 +45,8 @@ The bot will:
- Create a branch with the implementation
- Push a pull request to your repository
{% image src="/docs/img/connect/slack/slackbot-turn-discussions-into-PRs.webp" alt="Kilo turning a Slack thread discussion into a pull request" width="800" /%}
### Implement changes across multiple repositories
If the same change needs to land in several repos, just tell the bot:
@@ -51,6 +55,8 @@ If the same change needs to land in several repos, just tell the bot:
@Kilo please fix this in the cloud, landing, and handbook repos
```
{% image src="/docs/img/connect/slack/slackbot-coding.webp" alt="Kilo implementing changes across multiple repositories from Slack" width="800" /%}
### Debug issues
Paste an error message or stack trace and ask for help:
@@ -61,6 +67,8 @@ Paste an error message or stack trace and ask for help:
Can you help me understand what's causing it?
```
{% image src="/docs/img/connect/slack/slackbot-bugs.webp" alt="Kilo helping debug a production error in Slack" width="800" /%}
---
## How to Interact
@@ -189,10 +189,6 @@ Each instance runs on a dedicated machine — there is no shared infrastructure
Your storage is region-pinned — once your instance is created in a region (e.g., DFW), it always runs there. OpenClaw config lives at `/root/.openclaw` and the workspace at `/root/clawd`.
{% callout type="info" %}
These are the beta specifications for machines and subject to change without notice.
{% /callout %}
## Related
- [KiloClaw Overview](/docs/kiloclaw/overview)
@@ -9,10 +9,7 @@ KiloClaw uses Kilo Gateway credits by default — if you route requests through
## Instance Hosting
KiloClaw hosting is **free during the beta period**. Each user gets a dedicated machine (2 shared vCPUs, 3 GB RAM, 10 GB SSD) at no cost.
> ️ **Info**
> Beta pricing is subject to change. Paid hosting tiers may be introduced after the beta period ends. Any changes will be announced in advance.
Each user gets a dedicated machine (2 shared vCPUs, 3 GB RAM, 10 GB SSD). Visit [kilo.ai/pricing](https://kilo.ai/pricing) for current pricing and plans.
## Model Inference
@@ -7,7 +7,7 @@ description: "One-click deployment of your personal AI agent with OpenClaw"
KiloClaw is Kilo's hosted [OpenClaw](https://openclaw.ai) service — a one-click deployment that gives you a personal AI agent without the complexity of self-hosting. OpenClaw is a 24/7, open source AI agent that connects to chat platforms like Telegram, Discord, and Slack so it can take real actions automatically, not just chat.
KiloClaw is powered by KiloCode. The API key is platform-managed, so you never need to bring your own. KiloClaw is currently in **Beta**.
KiloClaw is powered by KiloCode. The API key is platform-managed, so you never need to bring your own.
## Why KiloClaw?
Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+5 -3
View File
@@ -30,6 +30,8 @@
<!-- packages/opencode/src/provider/error.ts -->
- <https://cli.github.com/>
<!-- packages/kilo-vscode/src/agent-manager/WorktreeManager.ts -->
- <https://cloudflare.com/cdn-cgi/trace>
<!-- packages/opencode/src/session/network.ts -->
- <https://cookbook.openai.com/examples/using_logprobs>
<!-- packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts -->
- <https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services>
@@ -42,8 +44,6 @@
<!-- packages/opencode/src/provider/transform.ts -->
- <https://git-scm.com>
<!-- packages/kilo-vscode/src/agent-manager/WorktreeManager.ts -->
- <https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml>
<!-- packages/opencode/src/cli/cmd/tui/component/error-component.tsx -->
- <https://github.com/apps/kiloconnect>
<!-- packages/opencode/src/cli/cmd/github.ts -->
- <https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts>
@@ -68,6 +68,7 @@
<!-- packages/opencode/src/kilocode/encoding.ts -->
- <https://github.com/Kilo-Org/kilocode/issues/new?template=bug-report.yml>
<!-- packages/opencode/src/cli/cmd/tui/app.tsx -->
<!-- packages/opencode/src/cli/cmd/tui/component/error-component.tsx -->
- <https://github.com/Kilo-Org/kilocode/issues/new/choose>
<!-- packages/kilo-vscode/webview-ui/src/components/chat/FeedbackDialog.tsx -->
- <https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip>
@@ -83,8 +84,10 @@
- <https://kilo.ai>
<!-- packages/opencode/src/cli/cmd/github.ts -->
<!-- packages/opencode/src/mcp/oauth-provider.ts -->
<!-- packages/opencode/src/session/network.ts -->
- <https://kilo.ai/>
<!-- packages/opencode/src/cli/cmd/generate.ts -->
<!-- packages/opencode/src/provider/provider.ts -->
- <https://kilo.ai/discord>
<!-- packages/kilo-vscode/webview-ui/src/components/chat/FeedbackDialog.tsx -->
<!-- packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx -->
@@ -113,7 +116,6 @@
<!-- packages/opencode/src/config/model-id.ts -->
- <https://opencode.ai/>
<!-- packages/opencode/src/cli/cmd/generate.ts -->
<!-- packages/opencode/src/provider/provider.ts -->
- <https://opencode.ai/auth>
<!-- packages/opencode/src/cli/cmd/providers.ts -->
- <https://opencode.ai/docs/agents>
+12 -10
View File
@@ -91,11 +91,12 @@ test("returns error with kind=http on non-auth HTTP error (e.g. 500)", async ()
test("returns models without error on success", async () => {
const orig = globalThis.fetch
stubFetch(async () =>
new Response(VALID_RESPONSE, {
status: 200,
headers: { "content-type": "application/json" },
}),
stubFetch(
async () =>
new Response(VALID_RESPONSE, {
status: 200,
headers: { "content-type": "application/json" },
}),
)
const result = await fetchKiloModels({})
@@ -108,11 +109,12 @@ test("returns models without error on success", async () => {
test("returns error with kind=schema when response body is invalid JSON", async () => {
const orig = globalThis.fetch
stubFetch(async () =>
new Response("not valid json{{{{", {
status: 200,
headers: { "content-type": "application/json" },
}),
stubFetch(
async () =>
new Response("not valid json{{{{", {
status: 200,
headers: { "content-type": "application/json" },
}),
)
const result = await fetchKiloModels({})
+3 -2
View File
@@ -67,8 +67,9 @@
## Build
- **Full build**: `bun run build` from `packages/kilo-jetbrains/` (builds CLI + Gradle plugin).
- **Gradle only**: `./gradlew buildPlugin` from `packages/kilo-jetbrains/` (requires CLI binaries already present).
- **Typecheck**: `bun run typecheck` or `./gradlew typecheck` from `packages/kilo-jetbrains/` — compiles all Kotlin sources including the generated API client. Does NOT require CLI binaries.
- **Full build**: `bun run build` from `packages/kilo-jetbrains/` (prepares CLI binaries + runs Gradle `buildPlugin`).
- **Gradle only**: `./gradlew buildPlugin` from `packages/kilo-jetbrains/` (requires CLI binaries already present in `backend/build/generated/cli/`; run `bun run build --prepare-cli` first).
- **Via Turbo**: `bun turbo build --filter=@kilocode/kilo-jetbrains` from repo root.
- **Run in sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does NOT build CLI binaries.
+2 -19
View File
@@ -52,26 +52,9 @@ The built plugin archive is at `build/distributions/kilo.jetbrains-<version>.zip
---
## Publish release candidates
## Releasing
JetBrains release-candidate builds publish from tags matching `jetbrains/vx.y.z-rc.n`, for example `jetbrains/v7.0.1-rc.1`. The git tag is the source of truth for the Marketplace plugin version; the `v` prefix is stripped before the version is set in the plugin metadata so the Marketplace version remains a plain semver (`x.y.z-rc.n`). Production Gradle builds fail if `HEAD` is not tagged with `jetbrains/v<version>`. Local dev builds use the placeholder `0.0.0-dev` version.
The `publish-jetbrains` workflow publishes RC builds to the JetBrains Marketplace `eap` channel and uploads the same plugin ZIP to a GitHub prerelease for the tag. Stable tags like `jetbrains/vx.y.z` are recognized by the workflow but intentionally rejected until default-channel publishing is enabled.
Testers can install or update EAP builds from this custom plugin repository:
```
https://plugins.jetbrains.com/plugins/eap/list
```
Required GitHub Actions secrets:
- `JETBRAINS_MARKETPLACE_TOKEN`
- `JETBRAINS_CERTIFICATE_CHAIN`
- `JETBRAINS_PRIVATE_KEY`
- `JETBRAINS_PRIVATE_KEY_PASSWORD`
Before the first publish, complete `RELEASE_TODO.md`.
See [RELEASING.md](RELEASING.md) for the full release process, including how to tag and push an RC, where to watch workflow progress, and how to install RC builds via the custom plugin repository.
---
+62
View File
@@ -0,0 +1,62 @@
# Releasing the JetBrains Plugin
## RC releases (currently the only supported flow)
Stable release tags (`jetbrains/vx.y.z`) are recognized by the workflow but intentionally rejected. Only RC tags are accepted right now.
### 1. Create and push a tag
Tag format: `jetbrains/v<major>.<minor>.<patch>-rc.<n>`
```
git tag jetbrains/v7.0.1-rc.1
git push origin jetbrains/v7.0.1-rc.1
```
### 2. Watch the workflow
The `publish-jetbrains` workflow starts automatically on tag push. Follow progress at:
[https://github.com/Kilo-Org/kilocode/actions/workflows/publish-jetbrains.yml](https://github.com/Kilo-Org/kilocode/actions/workflows/publish-jetbrains.yml)
The workflow:
1. Validates the tag format and required secrets.
2. Downloads CLI binaries for all 6 platforms from the matching GitHub Release.
3. Verifies and signs the plugin with `./gradlew verifyPlugin publishPlugin -Pproduction=true`.
4. Publishes the signed ZIP to the JetBrains Marketplace `eap` channel.
5. Uploads the signed ZIP to a GitHub prerelease for the tag.
### 3. Verify on the Marketplace
Once the workflow succeeds, the new version should appear in the plugin's version list:
[https://plugins.jetbrains.com/plugin/28350-kilo-code/edit/versions](https://plugins.jetbrains.com/plugin/28350-kilo-code/edit/versions)
---
## Installing RC builds via the custom plugin repository
RC builds are published to the `eap` channel, not the default channel. To get them in IntelliJ IDEA:
1. Open **Settings > Plugins**.
2. Click the gear icon and choose **Manage Plugin Repositories**.
3. Add the following URL:
```
https://plugins.jetbrains.com/plugins/list?channel=eap&pluginId=28350
```
4. Search for **Kilo Code** in the Marketplace tab — the latest RC version will appear and update automatically.
---
## Required GitHub Actions secrets
| Secret | Purpose |
|---|---|
| `JETBRAINS_MARKETPLACE_TOKEN` | Marketplace API token for publishing |
| `JETBRAINS_CERTIFICATE_CHAIN` | PEM certificate chain for plugin signing |
| `JETBRAINS_PRIVATE_KEY` | PEM private key for plugin signing |
| `JETBRAINS_PRIVATE_KEY_PASSWORD` | Password for the private key |
Before the first publish, complete `RELEASE_TODO.md` to set up these secrets and the Marketplace plugin entry.
@@ -11,6 +11,7 @@ kotlin {
}
val generatedApi = layout.buildDirectory.dir("generated/openapi/src/main/kotlin")
val generatedSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.json")
sourceSets {
main {
@@ -19,10 +20,17 @@ sourceSets {
}
}
val generateOpenApiSpec by tasks.registering(GenerateOpenApiSpecTask::class) {
description = "Generate CLI OpenAPI spec into the build directory"
opencodeDir.set(rootProject.layout.projectDirectory.dir("../opencode"))
serverSrcDir.set(rootProject.layout.projectDirectory.dir("../opencode/src/server"))
spec.set(generatedSpec)
}
openApiGenerate {
generatorName.set("kotlin")
library.set("jvm-okhttp4")
inputSpec.set("${rootDir}/../sdk/openapi.json")
inputSpec.set(generatedSpec.map { it.asFile.absolutePath })
outputDir.set(layout.buildDirectory.dir("generated/openapi").get().asFile.absolutePath)
packageName.set("ai.kilocode.jetbrains.api")
apiPackage.set("ai.kilocode.jetbrains.api.client")
@@ -55,6 +63,10 @@ openApiGenerate {
generateModelDocumentation.set(false)
}
tasks.named("openApiGenerate") {
dependsOn(generateOpenApiSpec)
}
val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
dependsOn("openApiGenerate")
generated.set(generatedApi)
@@ -76,42 +88,17 @@ val requiredPlatforms = listOf(
"windows-arm64",
)
val localCli by tasks.registering(PrepareLocalCliTask::class) {
description = "Prepare local CLI binary for JetBrains dev"
val os = providers.systemProperty("os.name").map {
val name = it.lowercase()
if (name.contains("mac")) return@map "darwin"
if (name.contains("win")) return@map "windows"
if (name.contains("linux")) return@map "linux"
throw GradleException("Unsupported host OS: $it")
}
val arch = providers.systemProperty("os.arch").map {
val name = it.lowercase()
if (name == "aarch64" || name == "arm64") return@map "arm64"
if (name == "x86_64" || name == "amd64") return@map "x64"
throw GradleException("Unsupported host arch: $it")
}
script.set(rootProject.layout.projectDirectory.file("script/build.ts"))
root.set(rootProject.layout.projectDirectory)
out.set(cliDir)
platform.set(os.zip(arch) { a, b -> "$a-$b" })
exe.set(platform.map { if (it.startsWith("windows")) "kilo.exe" else "kilo" })
}
val prod = production
val checkCli by tasks.registering(CheckCliTask::class) {
description = "Verify CLI binaries exist before building"
description = "Verify CLI binaries exist before packaging"
dir.set(cliDir)
this.production.set(prod)
platforms.set(requiredPlatforms)
if (!prod.get()) {
dependsOn(localCli)
}
}
tasks.processResources {
dependsOn(checkCli)
}
// CLI binaries are verified only at packaging time (buildPlugin), not at
// processResources time, so that Kotlin compile and tests work without binaries.
// Wire checkCli to buildPlugin in the root build.gradle.kts instead.
dependencies {
intellijPlatform {
@@ -90,6 +90,7 @@ class KiloBackendWorkspace(
coroutineScope {
launch {
val result = fetchWithRetry("providers") { fetchProviders() }
ensureActive()
if (result.value != null) {
prov = result.value
progress.updateAndGet { it.copy(providers = true) }
@@ -102,6 +103,7 @@ class KiloBackendWorkspace(
}
launch {
val result = fetchWithRetry("agents") { fetchAgents() }
ensureActive()
if (result.value != null) {
ag = result.value
progress.updateAndGet { it.copy(agents = true) }
@@ -114,6 +116,7 @@ class KiloBackendWorkspace(
}
launch {
val result = fetchWithRetry("commands") { fetchCommands() }
ensureActive()
if (result.value != null) {
cmd = result.value
progress.updateAndGet { it.copy(commands = true) }
@@ -126,6 +129,7 @@ class KiloBackendWorkspace(
}
launch {
val result = fetchWithRetry("skills") { fetchSkills() }
ensureActive()
if (result.value != null) {
sk = result.value
progress.updateAndGet { it.copy(skills = true) }
@@ -138,6 +142,7 @@ class KiloBackendWorkspace(
}
}
ensureActive()
_state.value = KiloWorkspaceState.Ready(
providers = prov!!,
agents = ag!!,
@@ -145,7 +150,6 @@ class KiloBackendWorkspace(
skills = sk!!,
)
log.info("Workspace data loaded for $directory")
ensureActive()
startWatchingGlobalSseEvents()
} catch (e: CancellationException) {
throw e
@@ -12,9 +12,9 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.util.concurrent.CountDownLatch
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -366,25 +366,26 @@ class KiloBackendAppServiceTest {
@Test
fun `loading tracks progress through Loading state`() = runBlocking {
val gate = CountDownLatch(1)
mock.responseGate = gate
val svc = create()
val states = mutableListOf<KiloAppState>()
val collector = scope.launch {
svc.appState.collect { states.add(it) }
try {
svc.connect()
val loading = withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Loading }
}
assertIs<KiloAppState.Loading>(loading)
gate.countDown()
val ready = withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
assertIs<KiloAppState.Ready>(ready)
} finally {
gate.countDown()
}
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
collector.cancel()
// Should have passed through Loading at least once
assertTrue(states.any { it is KiloAppState.Loading })
// Should have reached Ready
assertTrue(states.any { it is KiloAppState.Ready })
}
@Test
@@ -120,7 +120,8 @@ class KiloBackendModelStateManagerTest {
}
private fun start(): Int {
mock.path = """{"home":"$dir","state":"$dir","config":"$dir","worktree":"$dir","directory":"$dir"}"""
val path = dir.toString().replace("\\", "/")
mock.path = """{"home":"$path","state":"$path","config":"$path","worktree":"$path","directory":"$path"}"""
return mock.start()
}
}
@@ -81,6 +81,9 @@ class MockCliServer : AutoCloseable {
/** Configurable delay for all endpoint responses (ms). 0 = no delay. */
@Volatile var responseDelay: Long = 0
/** Optional gate for REST responses; SSE stays unblocked so the app can enter Loading. */
@Volatile var responseGate: CountDownLatch? = null
/** Request counts by bare path (e.g. "/session" or "/global/config"). Thread-safe. */
private val counts = ConcurrentHashMap<String, AtomicInteger>()
@@ -209,6 +212,7 @@ class MockCliServer : AutoCloseable {
// Optional delay for race condition testing
val delay = responseDelay
if (delay > 0) Thread.sleep(delay)
if (bare != "/global/event") responseGate?.await()
when {
path == "/global/health" -> respond(output, 200, health)
@@ -6,29 +6,31 @@ import ai.kilocode.log.KiloLog
* Test logger that captures messages for assertions and prints to stdout.
*/
class TestLog : KiloLog {
val messages = mutableListOf<String>()
private val items = mutableListOf<String>()
val messages: List<String>
get() = synchronized(items) { items.toList() }
override var isDebugEnabled: Boolean = true
override fun debug(block: () -> String) {
if (!isDebugEnabled) return
val msg = block()
synchronized(messages) { messages.add("DEBUG: $msg") }
synchronized(items) { items.add("DEBUG: $msg") }
println("[test] DEBUG: $msg")
}
override fun info(msg: String) {
synchronized(messages) { messages.add("INFO: $msg") }
synchronized(items) { items.add("INFO: $msg") }
println("[test] INFO: $msg")
}
override fun warn(msg: String, t: Throwable?) {
synchronized(messages) { messages.add("WARN: $msg") }
synchronized(items) { items.add("WARN: $msg") }
println("[test] WARN: $msg")
t?.printStackTrace()
}
override fun error(msg: String, t: Throwable?) {
synchronized(messages) { messages.add("ERROR: $msg") }
synchronized(items) { items.add("ERROR: $msg") }
System.err.println("[test] ERROR: $msg")
t?.printStackTrace()
}
@@ -5,6 +5,7 @@ import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
@@ -14,9 +15,14 @@ import java.io.File
* Verify that CLI binaries exist before packaging the plugin.
* In production mode, all platform binaries must be present.
* In dev mode, only the current platform binary is required.
*
* CLI binaries must be prepared separately before packaging:
* Local: bun run build (from packages/kilo-jetbrains/)
* Production: bun run build:production
*/
abstract class CheckCliTask : DefaultTask() {
@get:InputDirectory
@get:Optional
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val dir: DirectoryProperty
@@ -0,0 +1,104 @@
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import javax.inject.Inject
import org.gradle.process.ExecOperations
import java.io.ByteArrayOutputStream
/**
* Generates the CLI OpenAPI spec into the build directory so the JetBrains
* Gradle build is self-contained and does not mutate the tracked
* packages/sdk/openapi.json.
*
* Runs `bun dev generate` from the opencode package directory and captures
* stdout to [spec]. stderr is captured separately and included in the error
* message on failure.
*
* Gradle up-to-date tracking is scoped to [serverSrcDir] (the opencode server
* source) to avoid busting the cache on unrelated changes to dist/, node_modules/,
* etc.
*/
abstract class GenerateOpenApiSpecTask : DefaultTask() {
/**
* The server source directory inside the opencode package — the only files
* that affect the OpenAPI output. Scoped to `src/server/` to avoid busting
* the Gradle up-to-date check on unrelated file changes (dist/, node_modules/).
*/
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val serverSrcDir: DirectoryProperty
/**
* Root of the `packages/opencode/` package — the working directory for bun.
* Marked @Internal because it is not itself a Gradle input; only [serverSrcDir]
* (a subdirectory) participates in up-to-date checking.
*/
@get:Internal
abstract val opencodeDir: DirectoryProperty
/** Destination file for the generated openapi.json. */
@get:OutputFile
abstract val spec: RegularFileProperty
@get:Inject
abstract val exec: ExecOperations
@TaskAction
fun run() {
val out = ByteArrayOutputStream()
val err = ByteArrayOutputStream()
val result = exec.exec {
workingDir = opencodeDir.get().asFile
commandLine(findBun(), "run", "--conditions=browser", "./src/index.ts", "generate")
standardOutput = out
errorOutput = err
isIgnoreExitValue = true
}
if (result.exitValue != 0) {
throw GradleException(
"bun dev generate failed with exit code ${result.exitValue}.\n" +
err.toString(Charsets.UTF_8).take(2000)
)
}
val json = out.toString(Charsets.UTF_8)
if (!json.trimStart().startsWith("{")) {
throw GradleException(
"bun dev generate did not produce JSON.\n" +
"stdout: ${json.take(200)}\n" +
"stderr: ${err.toString(Charsets.UTF_8).take(500)}"
)
}
spec.get().asFile.also { it.parentFile.mkdirs() }.writeText(json)
}
private fun findBun(): String {
val which = runCatching {
ProcessBuilder("which", "bun")
.redirectErrorStream(true)
.start()
.inputStream.bufferedReader().readLine()?.trim()
}.getOrNull()
if (which != null && java.io.File(which).isFile) return which
val home = System.getProperty("user.home")
val candidates = listOf(
"$home/.bun/bin/bun",
"/opt/homebrew/bin/bun",
"/usr/local/bin/bun",
"$home/.nvm/current/bin/bun",
)
for (path in candidates) {
val f = java.io.File(path)
if (f.isFile && f.canExecute()) return f.absolutePath
}
return "bun"
}
}
@@ -1,74 +0,0 @@
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import org.gradle.process.ExecOperations
import java.io.File
import javax.inject.Inject
abstract class PrepareLocalCliTask : DefaultTask() {
@get:InputFile
abstract val script: RegularFileProperty
@get:Internal
abstract val root: DirectoryProperty
@get:OutputDirectory
abstract val out: DirectoryProperty
@get:Input
abstract val platform: Property<String>
@get:Input
abstract val exe: Property<String>
@get:Inject
abstract val exec: ExecOperations
@TaskAction
fun run() {
val bin = out.file("${platform.get()}/${exe.get()}").get().asFile
if (bin.exists()) return
exec.exec {
workingDir = root.get().asFile
commandLine(findBun(), "script/build.ts", "--prepare-cli")
}
}
/**
* Resolve the absolute path to `bun`. The Gradle daemon's PATH is often
* stripped down and doesn't include Homebrew or user-local bin dirs.
* Probe common install locations so the build works without manual PATH setup.
*/
private fun findBun(): String {
// 1. Already on PATH?
val which = runCatching {
ProcessBuilder("which", "bun")
.redirectErrorStream(true)
.start()
.inputStream.bufferedReader().readLine()?.trim()
}.getOrNull()
if (which != null && File(which).isFile) return which
// 2. Common install locations
val home = System.getProperty("user.home")
val candidates = listOf(
"$home/.bun/bin/bun",
"/opt/homebrew/bin/bun",
"/usr/local/bin/bun",
"$home/.nvm/current/bin/bun",
)
for (path in candidates) {
val f = File(path)
if (f.isFile && f.canExecute()) return f.absolutePath
}
// 3. Fall back — let the OS resolve it (will fail with a clear message)
return "bun"
}
}
+18
View File
@@ -127,6 +127,24 @@ tasks {
}
}
// Compile-only typecheck: verifies Kotlin compiles (including generated API client)
// without running processResources, CLI binary prep, or buildPlugin.
tasks.register("typecheck") {
dependsOn(
":shared:compileKotlin",
":frontend:compileKotlin",
":backend:compileKotlin",
":frontend:compileTestKotlin",
":backend:compileTestKotlin",
)
}
// CLI binaries must be present before packaging. Wire the check here (not in
// :backend:processResources) so compile/test tasks work without CLI binaries.
tasks.named("buildPlugin") {
dependsOn(":backend:checkCli")
}
tasks.named<JavaExec>("runIde") {
dependsOn(":backend:processResources")
jvmArgumentProviders += CommandLineArgumentProvider {
@@ -12,9 +12,7 @@ import ai.kilocode.client.session.ui.LoadingPanel
import ai.kilocode.client.session.ui.ReasoningPicker
import ai.kilocode.client.session.ui.mode.ModePicker
import ai.kilocode.client.session.ui.model.ModelPicker
import ai.kilocode.client.session.ui.PermissionPanel
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.ui.QuestionPanel
import ai.kilocode.client.session.ui.SessionRootPanel
import ai.kilocode.client.session.ui.SessionMessageListPanel
import ai.kilocode.client.session.ui.header.SessionHeaderPanel
@@ -23,6 +21,8 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.controller.EVENT_FLUSH_MS
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.session.controller.SessionControllerEvent
import ai.kilocode.client.session.views.PermissionView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.log.KiloLog
import com.intellij.ide.ui.LafManagerListener
@@ -35,7 +35,6 @@ import com.intellij.openapi.util.registry.Registry
import kotlinx.coroutines.CoroutineScope
import java.awt.BorderLayout
import javax.swing.BoxLayout
import javax.swing.BoxLayout.Y_AXIS
import javax.swing.JComponent
import javax.swing.JPanel
@@ -97,8 +96,8 @@ class SessionUi(
internal lateinit var scroll: SessionScroll
private lateinit var question: QuestionPanel
private lateinit var permission: PermissionPanel
private lateinit var question: QuestionView
private lateinit var permission: PermissionView
private lateinit var connection: ConnectionPanel
private lateinit var prompt: PromptPanel
@@ -146,12 +145,18 @@ class SessionUi(
load = LoadingPanel()
progressBody = load
messageBody = SessionMessageListPanel(controller.model, this)
question = QuestionView(
reply = { id, dto -> controller.replyQuestion(id, dto) },
reject = { id -> controller.rejectQuestion(id) },
scroll = { scroll.followBottom(true) },
)
permission = PermissionView(
reply = { id, dto -> controller.replyPermission(id, dto) },
)
messageBody = SessionMessageListPanel(controller.model, this, question, permission)
header = SessionHeaderPanel(controller, this)
scroll = SessionScroll(root, sessionContent, messageBody, blankBody)
question = QuestionPanel(controller)
permission = PermissionPanel(controller)
connection = ConnectionPanel(this, controller)
prompt = PromptPanel(
@@ -163,12 +168,8 @@ class SessionUi(
sessionContent.add(header, BorderLayout.NORTH)
sessionContent.add(scroll.component, BorderLayout.CENTER)
root.content.add(sessionContent, BorderLayout.CENTER)
// Dock panels stay in normal flow so each visible state takes layout space
// above the prompt.
root.content.add(JPanel().apply {
this.layout = BoxLayout(this, Y_AXIS)
add(question)
add(permission)
layout = BoxLayout(this, BoxLayout.Y_AXIS)
add(connection)
add(prompt)
}, BorderLayout.SOUTH)
@@ -321,22 +322,6 @@ class SessionUi(
private fun onStateChanged(state: SessionState) {
prompt.setBusy(state.isBusy())
when (state) {
is SessionState.AwaitingQuestion -> {
permission.hidePanel()
question.show(state.question)
}
is SessionState.AwaitingPermission -> {
question.hidePanel()
permission.show(state.permission)
}
else -> {
question.hidePanel()
permission.hidePanel()
}
}
refresh()
}
@@ -37,6 +37,7 @@ class HistoryController(
@Volatile
private var resolved = false
private val lock = Mutex()
private val deletes = Mutex()
/** Whether to filter cloud history by the current repository. */
var repoOnly: Boolean = false
@@ -94,7 +95,9 @@ class HistoryController(
val dir = item.directory ?: workspace.directory
cs.launch {
try {
sessions.deleteSession(item.id, dir)
deletes.withLock {
sessions.deleteSession(item.id, dir)
}
edt {
deleting.remove(item.id)
local.remove(item.id)
@@ -68,6 +68,7 @@ class Reasoning(id: String) : Content(id) {
/** Tool invocation with lifecycle state. */
class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) {
var state: ToolExecState = ToolExecState.PENDING
var callId: String? = null
var title: String? = null
var input: Map<String, String> = emptyMap()
var metadata: Map<String, String> = emptyMap()
@@ -352,6 +352,7 @@ class SessionModel {
is Tool -> {
existing.kind = toolKind(dto.tool)
existing.state = parseToolState(dto.state)
existing.callId = dto.callID
existing.title = dto.title
existing.input = dto.input
existing.metadata = dto.metadata
@@ -383,6 +384,7 @@ class SessionModel {
}
"tool" -> Tool(dto.id, dto.tool ?: "unknown", toolKind(dto.tool)).apply {
state = parseToolState(dto.state)
callId = dto.callID
title = dto.title
input = dto.input
metadata = dto.metadata
@@ -1,83 +0,0 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.ui.style.Dock
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.rpc.dto.PermissionReplyDto
import com.intellij.icons.AllIcons
import com.intellij.ui.dsl.builder.RightGap
import com.intellij.ui.dsl.builder.RowLayout
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
/**
* Docked permission panel — shown above the prompt when the session is in
* [ai.kilocode.client.session.model.SessionState.AwaitingPermission].
*
* The inner layout is built via Kotlin UI DSL inside [show] so it reflects
* the current permission's tool, patterns, and optional message.
*
* Layout (mirrors VS Code's PermissionDock):
* ```
* ┌─────────────────────────────────────────┐
* │ ⚠ Permission request │
* │ Tool: edit • Patterns: *.kt │
* │ <optional message> │
* │ [Allow] [Deny] │
* └─────────────────────────────────────────┘
* ```
*/
class PermissionPanel(
private val controller: SessionController,
) : BorderLayoutPanel() {
private lateinit var requestId: String
init {
border = Dock.warning()
isVisible = false
}
/** Populate the panel for [permission] and make it visible. */
fun show(permission: Permission) {
requestId = permission.id
val patterns = permission.patterns.joinToString(", ").ifEmpty { "*" }
removeAll()
add(panel {
row {
icon(AllIcons.General.Warning).gap(RightGap.SMALL)
label(KiloBundle.message("session.permission.title")).bold()
}
row {
label(KiloBundle.message("session.permission.meta", permission.name, patterns))
}
val msg = permission.message
if (!msg.isNullOrBlank()) {
row {
comment(msg)
}
}
row {
button(KiloBundle.message("session.permission.allow")) { decide("once") }.gap(RightGap.SMALL)
button(KiloBundle.message("session.permission.deny")) { decide("reject") }
}.layout(RowLayout.INDEPENDENT)
}, BorderLayout.CENTER)
isVisible = true
revalidate()
repaint()
}
/** Hide this panel. */
fun hidePanel() {
isVisible = false
}
private fun decide(reply: String) {
controller.replyPermission(requestId, PermissionReplyDto(reply = reply))
hidePanel()
}
}
@@ -1,92 +0,0 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Question
import ai.kilocode.client.session.ui.style.Dock
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.rpc.dto.QuestionReplyDto
import com.intellij.icons.AllIcons
import com.intellij.ui.dsl.builder.RightGap
import com.intellij.ui.dsl.builder.RowLayout
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
/**
* Docked question panel — shown above the prompt when the session is in
* [ai.kilocode.client.session.model.SessionState.AwaitingQuestion].
*
* The inner layout is rebuilt via Kotlin UI DSL each time [show] is called,
* so the option buttons always match the current question.
*
* Layout (mirrors VS Code's QuestionDock):
* ```
* ┌─────────────────────────────────────────┐
* │ ❔ <header> │
* │ <prompt text> │
* │ [Option A] [Option B] [Dismiss] │
* └─────────────────────────────────────────┘
* ```
*/
class QuestionPanel(
private val controller: SessionController,
) : BorderLayoutPanel() {
private var requestId: String? = null
init {
border = Dock.neutral()
isVisible = false
}
/** Populate the panel for the first item in [question] and make it visible. */
fun show(question: Question) {
val item = question.items.firstOrNull() ?: run {
hidePanel()
return
}
requestId = question.id
removeAll()
add(panel {
row {
icon(AllIcons.General.QuestionDialog).gap(RightGap.SMALL)
label(item.header).bold()
}
row {
label(item.question)
}
row {
for (opt in item.options) {
button(opt.label) { reply(listOf(listOf(opt.label))) }
.gap(RightGap.SMALL)
.applyToComponent { toolTipText = opt.description }
}
button(KiloBundle.message("session.question.dismiss")) { reject() }
}.layout(RowLayout.INDEPENDENT)
}, BorderLayout.CENTER)
isVisible = true
revalidate()
repaint()
}
/** Hide this panel. */
fun hidePanel() {
requestId = null
removeAll()
isVisible = false
}
private fun reply(answers: List<List<String>>) {
val id = requestId ?: return
controller.replyQuestion(id, QuestionReplyDto(answers))
hidePanel()
}
private fun reject() {
val id = requestId ?: return
controller.rejectQuestion(id)
hidePanel()
}
}
@@ -2,10 +2,14 @@ package ai.kilocode.client.session.ui
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.model.ToolCallRef
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.MessageView
import ai.kilocode.client.session.views.PermissionView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.session.views.TurnView
import com.intellij.openapi.Disposable
import com.intellij.util.ui.JBUI
@@ -29,11 +33,17 @@ import com.intellij.util.ui.JBUI
* bottom of the transcript inside the scroll pane and shows a spinner while
* the session is busy.
*
* Optional [question] and [permission] views are kept immediately before
* [progress] in component order and shown/hidden in response to
* [SessionModelEvent.StateChanged].
*
* All method calls must happen on the EDT.
*/
class SessionMessageListPanel(
private val model: SessionModel,
parent: Disposable,
private val question: QuestionView? = null,
private val permission: PermissionView? = null,
) : SessionLayoutPanel(
JBUI.scale(SessionUiStyle.SessionLayout.GAP),
JBUI.insets(
@@ -48,6 +58,7 @@ class SessionMessageListPanel(
private val msgToTurn = HashMap<String, TurnView>()
private val msgToView = HashMap<String, MessageView>()
private var style = SessionEditorStyle.current()
private var hiddenTool: ToolCallRef? = null
/** Progress footer — always the last child inside the scroll. */
val progress = ProgressPanel(model, parent)
@@ -90,12 +101,16 @@ class SessionMessageListPanel(
is SessionModelEvent.HistoryLoaded -> rebuild()
is SessionModelEvent.Cleared -> clear()
is SessionModelEvent.StateChanged -> {
syncActive(event.state)
anchorFooter()
refresh()
}
// Message events: structural changes are handled via turn events above.
// State/diff/todos changes are handled by other panels in SessionUi.
is SessionModelEvent.MessageAdded,
is SessionModelEvent.MessageUpdated,
is SessionModelEvent.MessageRemoved,
is SessionModelEvent.StateChanged,
is SessionModelEvent.DiffUpdated,
is SessionModelEvent.TodosUpdated,
is SessionModelEvent.SessionUpdated,
@@ -217,6 +232,7 @@ class SessionMessageListPanel(
add(tv)
}
syncActive(model.state)
anchorFooter()
refresh()
}
@@ -226,19 +242,64 @@ class SessionMessageListPanel(
msgToTurn.clear()
msgToView.clear()
removeAll()
syncActive(model.state)
anchorFooter()
refresh()
}
/** Re-insert [progress] as the last child so it always renders after all turn views. */
/**
* Show or hide active question/permission views based on [state].
* Both views are always kept as children of this panel (added in [anchorFooter]),
* but visibility is controlled here.
*/
private fun syncActive(state: SessionState = model.state) {
when (state) {
is SessionState.AwaitingQuestion -> {
setHiddenQuestionTool(state.question.tool)
permission?.hideView()
question?.show(state.question)
}
is SessionState.AwaitingPermission -> {
setHiddenQuestionTool(null)
question?.hideView()
permission?.show(state.permission)
}
else -> {
setHiddenQuestionTool(null)
question?.hideView()
permission?.hideView()
}
}
}
/** Fan out the hidden question tool ref to all registered [MessageView]s. */
private fun setHiddenQuestionTool(ref: ToolCallRef?) {
if (hiddenTool == ref) return
hiddenTool = ref
for (mv in msgToView.values) mv.setHiddenQuestionTool(ref)
}
/**
* Re-insert [question], [permission], and [progress] as the last children
* so active views always render after all turn views, and progress is last.
*
* Both active views are added even when invisible — [SessionLayout] skips
* invisible children, so no extra space is consumed, and the component tree
* remains stable for tests.
*/
private fun anchorFooter() {
if (question != null) remove(question)
if (permission != null) remove(permission)
remove(progress)
if (question != null) add(question)
if (permission != null) add(permission)
add(progress)
}
private fun register(msgId: String, tv: TurnView, mv: MessageView) {
msgToTurn[msgId] = tv
msgToView[msgId] = mv
mv.setHiddenQuestionTool(hiddenTool)
}
private fun unregister(msgId: String) {
@@ -254,6 +315,8 @@ class SessionMessageListPanel(
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
for (view in turnViews.values) view.applyStyle(style)
question?.applyStyle(style)
permission?.applyStyle(style)
progress.applyStyle(style)
refresh()
}
@@ -105,20 +105,10 @@ object SessionUiStyle {
}
}
/** Border presets for question, permission, and connection dock panels. */
/** Border presets for connection dock panel. */
object Dock {
fun banner(): Border = JBUI.Borders.compound(
JBUI.Borders.customLineTop(SessionUiStyle.View.line()),
JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.lg(), 0, UiStyle.Gap.lg()),
)!!
fun neutral(): Border = JBUI.Borders.compound(
JBUI.Borders.customLine(SessionUiStyle.View.line(), 1),
JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad()),
)!!
fun warning(): Border = JBUI.Borders.compound(
customLine(UiStyle.Colors.warningLabelForeground(), 1),
JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad()),
)!!
}
@@ -3,6 +3,9 @@ package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Message
import ai.kilocode.client.session.model.StepFinish
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolCallRef
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.ui.SessionView
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
@@ -36,6 +39,7 @@ class MessageView(
get() = if (role == SessionUiStyle.View.Message.USER_ROLE) SessionView.Kind.UserPrompt else SessionView.Kind.Default
private val parts = LinkedHashMap<String, PartView>()
private var hidden: ToolCallRef? = null
init {
isOpaque = false
@@ -48,6 +52,7 @@ class MessageView(
// Populate content that already exists (e.g. after loadHistory)
for ((_, content) in msg.parts) {
if (content is StepFinish) continue
if (isHidden(content)) continue
val view = ViewFactory.create(content)
view.applyStyle(style)
parts[content.id] = view
@@ -55,11 +60,35 @@ class MessageView(
}
}
/**
* Suppress the running/pending question tool part that matches [ref] while
* the linked question request is active. Pass null to stop suppressing.
*/
fun setHiddenQuestionTool(ref: ToolCallRef?) {
if (hidden == ref) return
hidden = ref
rebuildParts()
}
/** Add or update the renderer for [content]. */
fun upsertPart(content: Content) {
if (content is StepFinish) return
if (isHidden(content)) {
// Remove any stale view for this content so it disappears when suppressed
val stale = parts.remove(content.id)
if (stale != null) {
remove(stale)
syncBorder()
refresh()
}
return
}
val existing = parts[content.id]
if (existing != null) {
if (ViewFactory.shouldReplace(existing, content)) {
replacePart(content, existing)
return
}
existing.update(content)
refresh()
return
@@ -72,6 +101,18 @@ class MessageView(
refresh()
}
private fun replacePart(content: Content, existing: PartView) {
val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount
parts.remove(content.id)
remove(existing)
val view = ViewFactory.create(content)
view.applyStyle(style)
parts[content.id] = view
add(view, at)
syncBorder()
refresh()
}
/** Remove the renderer for [contentId] if present. */
fun removePart(contentId: String) {
val view = parts.remove(contentId) ?: return
@@ -80,6 +121,37 @@ class MessageView(
refresh()
}
/**
* Returns true when [content] should be suppressed because it is the
* pending/running question tool part linked to the active question.
*/
private fun isHidden(content: Content): Boolean {
val ref = hidden ?: return false
if (content !is Tool) return false
if (content.name != "question") return false
if (content.state != ToolExecState.PENDING && content.state != ToolExecState.RUNNING) return false
return msg.info.id == ref.messageId && content.callId == ref.callId
}
/**
* Clear and rebuild all part views from [msg.parts].
* Called only when the hidden ref changes to avoid unnecessary rebuilds.
*/
private fun rebuildParts() {
parts.values.forEach { remove(it) }
parts.clear()
for ((_, content) in msg.parts) {
if (content is StepFinish) continue
if (isHidden(content)) continue
val view = ViewFactory.create(content)
view.applyStyle(style)
parts[content.id] = view
add(view)
}
syncBorder()
refresh()
}
private fun syncBorder() {
if (msg.info.role != SessionUiStyle.View.Message.ASSISTANT_ROLE) return
border = assistantBorder()
@@ -0,0 +1,100 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.ui.SessionView
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.rpc.dto.PermissionReplyDto
import com.intellij.icons.AllIcons
import com.intellij.ui.dsl.builder.RightGap
import com.intellij.ui.dsl.builder.RowLayout
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
/**
* Transcript-style permission view — rendered inside [ai.kilocode.client.session.ui.SessionMessageListPanel]
* at the end of the transcript when the session is in
* [ai.kilocode.client.session.model.SessionState.AwaitingPermission].
*
* Unlike the old docked [ai.kilocode.client.session.ui.PermissionPanel], this view lives inside
* the scrollable transcript so the user can scroll through prior messages while a permission is pending.
*/
class PermissionView(
private val reply: (String, PermissionReplyDto) -> Unit,
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
override val sessionViewKind = SessionView.Kind.Default
private var requestId: String? = null
private var style = SessionEditorStyle.current()
init {
isOpaque = false
isVisible = false
}
/** Populate the view for [permission] and make it visible. */
fun show(permission: Permission) {
requestId = permission.id
val patterns = permission.patterns.joinToString(", ").ifEmpty { "*" }
removeAll()
val card = BorderLayoutPanel()
card.isOpaque = true
card.background = SessionUiStyle.View.surface()
card.border = SessionUiStyle.View.card()
card.add(panel {
row {
icon(AllIcons.General.Warning).gap(RightGap.SMALL)
label(KiloBundle.message("session.permission.title")).bold()
}
row {
label(KiloBundle.message("session.permission.meta", permission.name, patterns))
}
val msg = permission.message
if (!msg.isNullOrBlank()) {
row {
comment(msg)
}
}
row {
button(KiloBundle.message("session.permission.allow")) { decide("once") }.gap(RightGap.SMALL)
button(KiloBundle.message("session.permission.deny")) { decide("reject") }
}.layout(RowLayout.INDEPENDENT)
}.also { it.isOpaque = false }, BorderLayout.CENTER)
add(card, BorderLayout.CENTER)
isVisible = true
refresh()
}
/** Hide this view and clear the active request id. */
fun hideView() {
requestId = null
removeAll()
isVisible = false
refresh()
}
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
}
private fun decide(value: String) {
val id = requestId ?: return
reply(id, PermissionReplyDto(reply = value))
hideView()
}
private fun refresh() {
revalidate()
repaint()
parent?.revalidate()
parent?.repaint()
}
}
@@ -1,5 +1,6 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.views.question.QuestionResultView
import ai.kilocode.client.session.model.Compaction
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Generic
@@ -20,9 +21,21 @@ object ViewFactory {
fun create(content: Content): PartView = when (content) {
is Text -> TextView(content)
is Reasoning -> ReasoningView(content)
is Tool -> ToolView(content)
is Tool -> if (QuestionResultView.canRender(content)) QuestionResultView(content) else ToolView(content)
is Compaction -> CompactionView(content)
is StepFinish -> error("step-finish is timeline-only")
is Generic -> GenericView(content)
}
/**
* Returns true when [view] must be replaced by a new renderer for [content].
* This happens when a running question tool (rendered as [ToolView]) completes
* with structured data and should become a [QuestionResultView].
*/
fun shouldReplace(view: PartView, content: Content): Boolean {
if (content !is Tool) return false
if (view is QuestionResultView) return !QuestionResultView.canRender(content)
if (view is ToolView) return QuestionResultView.canRender(content)
return false
}
}
@@ -0,0 +1,44 @@
package ai.kilocode.client.session.views.question
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
internal data class QuestionResult(
val questions: List<String>,
val answers: List<List<String>>,
)
internal object QuestionResultParser {
private val json = Json { ignoreUnknownKeys = true }
fun parse(tool: Tool): QuestionResult? {
if (tool.name != "question" || tool.state != ToolExecState.COMPLETED) return null
val questions = parseQuestions(tool.input["questions"] ?: return null) ?: return null
return QuestionResult(questions, parseAnswers(tool.metadata["answers"]))
}
private fun parseQuestions(raw: String): List<String>? {
val arr = runCatching { json.parseToJsonElement(raw).jsonArray }.getOrNull() ?: return null
val list = arr.mapNotNull { elem ->
elem.jsonObject["question"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
}
return list.takeIf { it.isNotEmpty() }
}
private fun parseAnswers(raw: String?): List<List<String>> {
val arr = raw
?.takeIf { it.isNotBlank() }
?.let { runCatching { json.parseToJsonElement(it).jsonArray }.getOrNull() }
return arr?.map { elem ->
runCatching {
elem.jsonArray.mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) }
}.getOrDefault(emptyList())
} ?: emptyList()
}
}
@@ -0,0 +1,281 @@
package ai.kilocode.client.session.views.question
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.PartView
import ai.kilocode.client.session.views.ToolView
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTextArea
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Component
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Font
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.BoxLayout
import javax.swing.JPanel
import javax.swing.SwingUtilities
class QuestionResultView(tool: Tool) : PartView() {
override val contentId: String = tool.id
private var result = QuestionResultParser.parse(tool) ?: QuestionResult(emptyList(), emptyList())
private var style = SessionEditorStyle.current()
private val texts = mutableListOf<Pair<JBTextArea, Boolean>>()
private val root = object : JPanel(BorderLayout()) {
override fun updateUI() {
super.updateUI()
isOpaque = true
background = SessionUiStyle.View.surface()
border = SessionUiStyle.View.card()
}
}
private val header = object : JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)) {
override fun updateUI() {
super.updateUI()
isOpaque = true
background = SessionUiStyle.View.header()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
}
private val glyph = JBLabel(AllIcons.General.Balloon)
private val title = JBLabel()
private val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() }
private val arrow = JBLabel()
private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = false
}
private var pane: JPanel? = null
private val click = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) { toggle() }
}
private val mouse = object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) { setHover(true) }
override fun mouseExited(e: MouseEvent) {
if (inside(e)) return
setHover(false)
}
}
init {
layout = BorderLayout()
isOpaque = false
center.add(title, BorderLayout.WEST)
center.add(sub, BorderLayout.CENTER)
header.add(glyph, BorderLayout.WEST)
header.add(center, BorderLayout.CENTER)
header.add(arrow, BorderLayout.EAST)
root.add(header, BorderLayout.NORTH)
listOf(header, glyph, title, sub, arrow, center).forEach {
it.addMouseListener(click)
it.addMouseListener(mouse)
it.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
}
header.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
applyStyle(SessionEditorStyle.current())
add(root, BorderLayout.CENTER)
syncLabels()
syncArrow()
}
override fun update(content: Content) {
if (content !is Tool) return
val next = QuestionResultParser.parse(content) ?: QuestionResult(emptyList(), emptyList())
if (next == result) return
result = next
syncLabels()
syncBody()
refresh()
}
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
val label = setFont(title, style.boldEditorFont) || setFont(sub, style.smallEditorFont)
val body = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc }
if (!label && !body) return
refresh()
}
fun toggle() {
if (isExpanded()) {
pane?.let { root.remove(it) }
} else {
root.add(body(), BorderLayout.CENTER)
}
syncArrow()
refresh()
}
fun isExpanded(): Boolean = pane?.parent === root
fun labelText(): String = listOf(title.text, sub.text).filter { it.isNotBlank() }.joinToString(" ")
fun bodyText(): String = result.questions.mapIndexed { i, q ->
val joined = result.answers.getOrNull(i)?.joinToString(", ").orEmpty()
listOf(q, joined.ifBlank { KiloBundle.message("session.question.review.notAnswered") }).joinToString("\n")
}.joinToString("\n")
fun bodyCreated(): Boolean = pane != null
fun bodyFonts(): List<Font> = texts.map { it.first.font }
override fun dumpLabel(): String = "QuestionResultView#$contentId(${labelText()})"
companion object {
fun canRender(tool: Tool): Boolean = QuestionResultParser.parse(tool) != null
}
private fun body(): JPanel {
pane?.let { return it }
val panel = object : JPanel() {
override fun updateUI() {
super.updateUI()
isOpaque = true
background = SessionUiStyle.View.surface()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
}.apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
}
pane = panel
syncBody()
return panel
}
private fun syncLabels() {
title.text = KiloBundle.message("session.question.result.title")
val count = result.answers.count { it.isNotEmpty() }
sub.text = KiloBundle.message("session.question.result.answered", count)
sub.foreground = UiStyle.Colors.weak()
}
private fun syncBody() {
val panel = pane ?: return
panel.removeAll()
texts.clear()
for ((i, q) in result.questions.withIndex()) {
val row = JPanel().apply {
isOpaque = false
layout = BoxLayout(this, BoxLayout.Y_AXIS)
alignmentX = Component.LEFT_ALIGNMENT
}
if (i > 0) row.border = JBUI.Borders.emptyTop(UiStyle.Gap.lg())
val qText = makeText(q, UiStyle.Colors.weak(), false)
qText.alignmentX = Component.LEFT_ALIGNMENT
row.add(qText)
val joined = result.answers.getOrNull(i)?.joinToString(", ").orEmpty()
val aText = makeText(
joined.ifBlank { KiloBundle.message("session.question.review.notAnswered") },
UiStyle.Colors.fg(),
true,
)
aText.alignmentX = Component.LEFT_ALIGNMENT
row.add(aText)
panel.add(row)
}
}
private fun makeText(value: String, color: Color, bold: Boolean): JBTextArea {
val area = object : JBTextArea(value) {
override fun getPreferredSize() = withWidth(super.getPreferredSize().height)
override fun getMaximumSize(): Dimension {
val size = preferredSize
return Dimension(Int.MAX_VALUE, size.height)
}
private fun withWidth(fallback: Int): Dimension {
val width = space()
if (width <= 0) return Dimension(super.getPreferredSize().width, fallback)
val old = size
setSize(width, Int.MAX_VALUE)
val size = super.getPreferredSize()
setSize(old)
return Dimension(width, size.height)
}
private fun space(): Int {
var node = parent
while (node != null) {
if (node.width > 0) {
val ins = node.insets
return (node.width - ins.left - ins.right).coerceAtLeast(0)
}
node = node.parent
}
return width
}
}.apply {
isEditable = false
isOpaque = false
isFocusable = false
caret.isVisible = false
caret.isSelectionVisible = false
lineWrap = true
wrapStyleWord = true
foreground = color
border = JBUI.Borders.empty()
}
texts.add(area to bold)
setFont(area, bold)
return area
}
private fun syncArrow() {
arrow.icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight
}
private fun setHover(value: Boolean) {
val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header()
if (header.background?.rgb == color.rgb) return
header.background = color
header.repaint()
}
private fun inside(e: MouseEvent): Boolean {
val point = SwingUtilities.convertPoint(e.component, e.point, header)
return header.contains(point)
}
private fun setFont(label: JBLabel, font: Font): Boolean {
if (label.font == font) return false
label.font = font
return true
}
private fun setFont(area: JBTextArea, bold: Boolean): Boolean {
val font = if (bold) style.boldEditorFont else style.transcriptFont
if (area.font == font) return false
area.font = font
return true
}
private fun refresh() {
revalidate()
repaint()
}
}
@@ -0,0 +1,471 @@
package ai.kilocode.client.session.views.question
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Question
import ai.kilocode.client.session.model.QuestionItem
import ai.kilocode.client.session.model.QuestionOption
import ai.kilocode.client.session.ui.SessionView
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.HoverIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.QuestionReplyDto
import com.intellij.icons.AllIcons
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBRadioButton
import com.intellij.ui.components.JBTextArea
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Component
import java.awt.Dimension
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.AbstractButton
import javax.swing.Box
import javax.swing.BoxLayout
import javax.swing.ButtonGroup
import javax.swing.JButton
import javax.swing.JPanel
/** Question tool form rendered inside the session transcript. */
class QuestionView(
private val reply: (String, QuestionReplyDto) -> Unit,
private val reject: (String) -> Unit,
private val scroll: () -> Unit = {},
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
override val sessionViewKind = SessionView.Kind.Default
private var request: String? = null
private var question: Question? = null
private var idx = 0
private var selections = emptyList<MutableSet<String>>()
private var style = SessionEditorStyle.current()
private val texts = mutableListOf<Pair<JBTextArea, Boolean>>()
private val card = object : BorderLayoutPanel() {
override fun updateUI() {
super.updateUI()
isOpaque = true
background = SessionUiStyle.View.surface()
border = SessionUiStyle.View.card()
}
}
private val root = JPanel().apply {
isOpaque = false
layout = BoxLayout(this, BoxLayout.Y_AXIS)
border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad())
}
private val header = JPanel(BorderLayout()).apply {
isOpaque = false
border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
alignmentX = Component.LEFT_ALIGNMENT
}
private val summary = JBLabel()
private val nav = JPanel().apply {
isOpaque = false
layout = BoxLayout(this, BoxLayout.X_AXIS)
}
private val back = HoverIcon().apply {
val ico = AllIcons.Actions.Back
icon = ico
disabledIcon = IconLoader.getDisabledIcon(ico)
toolTipText = KiloBundle.message("session.question.back")
addActionListener { goBack() }
}
private val fwd = HoverIcon().apply {
val ico = AllIcons.Actions.Forward
icon = ico
disabledIcon = IconLoader.getDisabledIcon(ico)
toolTipText = KiloBundle.message("session.question.next")
addActionListener { goForward() }
}
private val body = JPanel().apply {
isOpaque = false
layout = BoxLayout(this, BoxLayout.Y_AXIS)
alignmentX = Component.LEFT_ALIGNMENT
}
private val footer = JPanel(BorderLayout()).apply {
isOpaque = false
border = JBUI.Borders.emptyTop(UiStyle.Gap.lg())
alignmentX = Component.LEFT_ALIGNMENT
}
private val dismiss = JButton(KiloBundle.message("session.question.dismiss")).apply {
addActionListener { doReject() }
}
private val right = JPanel().apply {
isOpaque = false
layout = BoxLayout(this, BoxLayout.X_AXIS)
}
init {
isOpaque = false
isVisible = false
nav.add(back)
nav.add(fwd)
header.add(summary, BorderLayout.WEST)
header.add(nav, BorderLayout.EAST)
footer.add(dismiss, BorderLayout.WEST)
footer.add(right, BorderLayout.EAST)
root.add(header)
root.add(body)
root.add(footer)
card.add(root, BorderLayout.CENTER)
add(card, BorderLayout.CENTER)
}
fun show(q: Question) {
if (q.items.isEmpty()) {
hideView()
return
}
request = q.id
question = q
idx = 0
selections = List(q.items.size) { mutableSetOf() }
isVisible = true
syncPage()
}
fun hideView() {
request = null
question = null
idx = 0
selections = emptyList()
texts.clear()
body.removeAll()
right.removeAll()
isVisible = false
refresh()
}
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
val changed = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc }
if (!changed) return
refresh()
}
private fun syncPage() {
val q = question ?: return
texts.clear()
body.removeAll()
if (review(q)) addReview(q) else addContent(q.items[idx], selections[idx])
syncHeader(q)
syncFooter(q)
syncControls(q)
refresh()
}
private fun syncHeader(q: Question) {
val total = q.items.size
val shown = minOf(idx + 1, total)
summary.text = KiloBundle.message("session.question.summary", shown, total)
summary.foreground = UiStyle.Colors.weak()
nav.isVisible = total > 1
}
private fun syncFooter(q: Question) {
right.removeAll()
if (review(q)) {
val back = JButton(KiloBundle.message("session.question.back")).apply {
addActionListener { goBack() }
}
val submit = JButton(KiloBundle.message("session.question.submit")).apply {
putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true)
addActionListener { doReply() }
}
right.add(back)
right.add(Box.createHorizontalStrut(JBUI.scale(UiStyle.Gap.sm())))
right.add(submit)
return
}
val label = when {
direct(q) -> KiloBundle.message("session.question.submit")
lastItem(q) -> KiloBundle.message("session.question.review")
else -> KiloBundle.message("session.question.next")
}
val button = JButton(label).apply {
putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, direct(q) || lastItem(q))
addActionListener {
when {
direct(q) -> doReply()
lastItem(q) -> goReview()
else -> goForward()
}
}
}
right.add(button)
}
private fun syncControls(q: Question) {
val ready = selections.getOrNull(idx)?.isNotEmpty() == true
back.isEnabled = idx > 0
fwd.isEnabled = idx < q.items.size && ready
for (node in right.components) {
if (node is JButton && node.text != KiloBundle.message("session.question.back")) {
node.isEnabled = review(q) || ready
}
}
}
private fun addContent(item: QuestionItem, set: MutableSet<String>) {
val title = text(item.question, UiStyle.Colors.fg(), true)
title.border = JBUI.Borders.emptyBottom(UiStyle.Gap.xs())
title.alignmentX = Component.LEFT_ALIGNMENT
body.add(title)
val hint = text(
KiloBundle.message(if (item.multiple) "session.question.hint.multi" else "session.question.hint.single"),
UiStyle.Colors.weak(),
)
hint.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
hint.alignmentX = Component.LEFT_ALIGNMENT
body.add(hint)
val opts = optionList(item, set)
opts.alignmentX = Component.LEFT_ALIGNMENT
body.add(opts)
}
private fun addReview(q: Question) {
val title = text(KiloBundle.message("session.question.review.title"), UiStyle.Colors.fg(), true)
title.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
title.alignmentX = Component.LEFT_ALIGNMENT
body.add(title)
for ((i, item) in q.items.withIndex()) {
val row = reviewRow(item, i)
row.alignmentX = Component.LEFT_ALIGNMENT
body.add(row)
}
}
private fun reviewRow(item: QuestionItem, i: Int): JPanel {
val row = JPanel().apply {
isOpaque = false
layout = BoxLayout(this, BoxLayout.Y_AXIS)
border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
}
val question = text(item.question, UiStyle.Colors.weak())
question.alignmentX = Component.LEFT_ALIGNMENT
row.add(question)
val joined = selections.getOrNull(i)?.joinToString(", ").orEmpty()
val answer = text(
joined.ifBlank { KiloBundle.message("session.question.review.notAnswered") },
UiStyle.Colors.fg(),
true,
)
answer.alignmentX = Component.LEFT_ALIGNMENT
row.add(answer)
return row
}
private fun optionList(item: QuestionItem, set: MutableSet<String>): JPanel {
val panel = JPanel().apply {
isOpaque = false
layout = BoxLayout(this, BoxLayout.Y_AXIS)
}
if (item.multiple) {
for (opt in item.options) panel.add(checkboxRow(opt, set))
return panel
}
val group = ButtonGroup()
for (opt in item.options) panel.add(radioRow(opt, set, group))
return panel
}
private fun radioRow(opt: QuestionOption, set: MutableSet<String>, group: ButtonGroup): JPanel {
val radio = JBRadioButton().apply {
actionCommand = opt.label
isSelected = opt.label in set
isOpaque = false
}
group.add(radio)
radio.addActionListener {
set.clear()
set.add(opt.label)
refreshSelection()
}
return optionRow(radio, opt)
}
private fun checkboxRow(opt: QuestionOption, set: MutableSet<String>): JPanel {
val box = JBCheckBox().apply {
actionCommand = opt.label
isSelected = opt.label in set
isOpaque = false
}
box.addActionListener {
if (box.isSelected) set.add(opt.label) else set.remove(opt.label)
refreshSelection()
}
return optionRow(box, opt)
}
private fun optionRow(toggle: AbstractButton, opt: QuestionOption): JPanel {
val row = JPanel(BorderLayout()).apply {
isOpaque = false
border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
toolTipText = opt.description.ifBlank { null }
alignmentX = Component.LEFT_ALIGNMENT
}
val press = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (toggle.isEnabled) toggle.doClick()
}
}
val icon = JPanel(BorderLayout()).apply {
isOpaque = false
border = JBUI.Borders.emptyRight(UiStyle.Gap.sm())
add(toggle, BorderLayout.NORTH)
addMouseListener(press)
}
val col = JPanel().apply {
isOpaque = false
layout = BoxLayout(this, BoxLayout.Y_AXIS)
addMouseListener(press)
}
val label = text(opt.label, UiStyle.Colors.fg(), true)
label.alignmentX = Component.LEFT_ALIGNMENT
label.addMouseListener(press)
col.add(label)
if (opt.description.isNotBlank()) {
val desc = text(opt.description, UiStyle.Colors.weak())
desc.alignmentX = Component.LEFT_ALIGNMENT
desc.addMouseListener(press)
col.add(desc)
}
row.addMouseListener(press)
row.add(icon, BorderLayout.WEST)
row.add(col, BorderLayout.CENTER)
return row
}
private fun text(value: String, color: Color, bold: Boolean = false): JBTextArea {
val area = object : JBTextArea(value) {
override fun getPreferredSize() = withWidth(super.getPreferredSize().height)
override fun getMaximumSize(): Dimension {
val size = preferredSize
return Dimension(Int.MAX_VALUE, size.height)
}
private fun withWidth(fallback: Int): Dimension {
val width = space()
if (width <= 0) return Dimension(super.getPreferredSize().width, fallback)
val old = size
setSize(width, Int.MAX_VALUE)
val size = super.getPreferredSize()
setSize(old)
return Dimension(width, size.height)
}
private fun space(): Int {
var node = parent
while (node != null) {
if (node.width > 0) {
val ins = node.insets
return (node.width - ins.left - ins.right).coerceAtLeast(0)
}
node = node.parent
}
return width
}
}.apply {
isEditable = false
isOpaque = false
isFocusable = false
caret.isVisible = false
caret.isSelectionVisible = false
lineWrap = true
wrapStyleWord = true
foreground = color
border = JBUI.Borders.empty()
}
texts.add(area to bold)
setFont(area, bold)
return area
}
private fun single(q: Question): Boolean = q.items.size == 1 && !q.items[0].multiple
private fun review(q: Question): Boolean = !single(q) && idx == q.items.size
private fun lastItem(q: Question): Boolean = idx == q.items.size - 1
private fun direct(q: Question): Boolean = single(q)
private fun goBack() {
if (idx <= 0) return
idx--
syncPage()
scroll()
}
private fun goForward() {
val q = question ?: return
if (idx >= q.items.size || selections.getOrNull(idx)?.isEmpty() != false) return
val review = idx == q.items.size - 1 && !direct(q)
if (review) {
goReview()
}
if (!review) {
idx++
syncPage()
scroll()
}
}
private fun goReview() {
val q = question ?: return
if (idx == q.items.size - 1 && selections[idx].isNotEmpty()) {
idx = q.items.size
syncPage()
scroll()
}
}
private fun refreshSelection() {
question?.let(::syncControls)
refresh()
scroll()
}
private fun doReply() {
val id = request ?: return
if (selections.any { it.isEmpty() }) return
reply(id, QuestionReplyDto(selections.map { it.toList() }))
hideView()
}
private fun doReject() {
val id = request ?: return
reject(id)
hideView()
}
private fun setFont(area: JBTextArea, bold: Boolean): Boolean {
val font = if (bold) style.boldEditorFont else style.transcriptFont
if (area.font == font) return false
area.font = font
return true
}
private fun refresh() {
revalidate()
repaint()
parent?.revalidate()
parent?.repaint()
}
}
@@ -18,6 +18,17 @@ session.permission.meta=Tool: {0} • Patterns: {1}
session.permission.allow=Allow
session.permission.deny=Deny
session.question.dismiss=Dismiss
session.question.submit=Submit
session.question.next=Next
session.question.back=Back
session.question.summary={0} of {1} questions
session.question.hint.single=Select one answer
session.question.hint.multi=Select one or more answers
session.question.review=Review
session.question.review.title=Review your answers
session.question.review.notAnswered=(not answered)
session.question.result.title=Questions
session.question.result.answered={0} answered
session.status.considering=Considering next steps…
session.status.thinking=Thinking…
@@ -43,6 +43,9 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
private lateinit var workspace: Workspace
private lateinit var controller: HistoryController
private lateinit var manager: FakeManager
/** Counts fully-completed deletes (incremented on EDT after local.remove). */
@Volatile
private var deleteCount = 0
override fun setUp() {
super.setUp()
@@ -53,7 +56,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY)
})
workspace = workspaces.workspace("/test")
controller = HistoryController(sessions, workspace, scope)
controller = HistoryController(sessions, workspace, scope, deleted = { deleteCount++ })
manager = FakeManager()
}
@@ -210,9 +213,8 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
val event = event(action, manager, selection(HistorySource.LOCAL, items), controller)
action.actionPerformed(event)
flush()
assertEquals(listOf("ses_1", "ses_2"), rpc.deletes.map { it.first })
awaitDeletes(2)
assertEquals(listOf("ses_1", "ses_2"), rpc.deletes.map { it.first }.sorted())
assertTrue(controller.local.items.isEmpty())
}
@@ -235,7 +237,8 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
assertTrue(rpc.deletes.isEmpty())
rpc.deleteGate?.complete(Unit)
waitFor { rpc.deletes.size == 1 }
awaitDeletes(1)
assertEquals(listOf("ses_1"), rpc.deletes.map { it.first })
}
@@ -464,6 +467,11 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
)
)
/** Waits until [n] deletes have fully completed (deleted callback fired on EDT after local.remove). */
private fun awaitDeletes(n: Int) {
waitFor { deleteCount >= n }
}
private fun flush() = runBlocking {
repeat(10) {
delay(100)
@@ -10,13 +10,13 @@ import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.ui.ConnectionPanel
import ai.kilocode.client.session.ui.EmptySessionPanel
import ai.kilocode.client.session.ui.LoadingPanel
import ai.kilocode.client.session.ui.PermissionPanel
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.ui.QuestionPanel
import ai.kilocode.client.session.ui.SessionMessageListPanel
import ai.kilocode.client.session.ui.SessionRootPanel
import ai.kilocode.client.session.ui.header.SessionHeaderPanel
import ai.kilocode.client.session.controller.SessionControllerEvent
import ai.kilocode.client.session.views.PermissionView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.rpc.dto.MessageWithPartsDto
import com.intellij.ui.components.JBScrollPane
import javax.swing.JLayeredPane
@@ -34,10 +34,8 @@ class SessionUiLayoutTest : SessionUiTestBase() {
assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay))
}
fun `test connection panel is docked between permission and prompt`() {
fun `test bottom stack contains connection and prompt only`() {
val root = find<SessionRootPanel>(ui)
val question = find<QuestionPanel>(ui)
val permission = find<PermissionPanel>(ui)
val connection = find<ConnectionPanel>(ui)
val prompt = find<PromptPanel>(ui)
val stack = prompt.parent
@@ -45,7 +43,19 @@ class SessionUiLayoutTest : SessionUiTestBase() {
assertSame(root.content, stack.parent)
assertSame(stack, connection.parent)
assertEquals(1, root.overlay.componentCount)
assertEquals(listOf(question, permission, connection, prompt), stack.components.toList())
assertEquals(listOf(connection, prompt), stack.components.toList())
}
fun `test active views are children of message list panel`() {
ui = newUi(id = "ses_test")
settle()
val messages = find<SessionMessageListPanel>(ui)
val qv = find<QuestionView>(ui)
val pv = find<PermissionView>(ui)
assertSame(messages, qv.parent)
assertSame(messages, pv.parent)
}
fun `test header is docked above shared scroll pane and hidden while empty`() {
@@ -80,42 +90,68 @@ class SessionUiLayoutTest : SessionUiTestBase() {
assertTrue(connection.y + connection.height <= prompt.y)
}
fun `test connection panel moves after visible question panel`() {
val connection = find<ConnectionPanel>(ui)
val question = find<QuestionPanel>(ui)
val prompt = find<PromptPanel>(ui)
fun `test connection panel is unaffected by active question view`() {
ui = newUi(id = "ses_test")
settle()
showConnection()
layout()
assertFalse(question.isVisible)
val connection = find<ConnectionPanel>(ui)
val prompt = find<PromptPanel>(ui)
val top = connection.y
controller().model.setState(questionStateChanged())
layout()
assertTrue(question.isVisible)
assertTrue(question.y < connection.y)
assertTrue(top < connection.y)
assertTrue(find<QuestionView>(ui).isVisible)
assertSame(find<SessionMessageListPanel>(ui), find<QuestionView>(ui).parent)
assertEquals(top, connection.y)
assertTrue(connection.y + connection.height <= prompt.y)
assertSame(find<SessionMessageListPanel>(ui), scrollView())
}
fun `test connection panel moves after visible permission panel`() {
val connection = find<ConnectionPanel>(ui)
val permission = find<PermissionPanel>(ui)
val prompt = find<PromptPanel>(ui)
fun `test connection panel is unaffected by active permission view`() {
ui = newUi(id = "ses_test")
settle()
showConnection()
layout()
assertFalse(permission.isVisible)
val connection = find<ConnectionPanel>(ui)
val prompt = find<PromptPanel>(ui)
val top = connection.y
controller().model.setState(permissionStateChanged())
layout()
assertTrue(permission.isVisible)
assertTrue(permission.y < connection.y)
assertTrue(top < connection.y)
assertTrue(find<PermissionView>(ui).isVisible)
assertSame(find<SessionMessageListPanel>(ui), find<PermissionView>(ui).parent)
assertEquals(top, connection.y)
assertTrue(connection.y + connection.height <= prompt.y)
assertSame(find<SessionMessageListPanel>(ui), scrollView())
}
fun `test active question view renders inside message scroll view`() {
ui = newUi(id = "ses_test")
settle()
controller().model.setState(questionStateChanged())
layout()
assertSame(find<SessionMessageListPanel>(ui), scrollView())
assertTrue(find<QuestionView>(ui).isVisible)
assertSame(find<SessionMessageListPanel>(ui), find<QuestionView>(ui).parent)
assertTrue(find<QuestionView>(ui).parent !== find<PromptPanel>(ui).parent)
}
fun `test active permission view renders inside message scroll view`() {
ui = newUi(id = "ses_test")
settle()
controller().model.setState(permissionStateChanged())
layout()
assertSame(find<SessionMessageListPanel>(ui), scrollView())
assertTrue(find<PermissionView>(ui).isVisible)
assertSame(find<SessionMessageListPanel>(ui), find<PermissionView>(ui).parent)
assertTrue(find<PermissionView>(ui).parent !== find<PromptPanel>(ui).parent)
}
fun `test empty and message bodies share the same scroll pane`() {
@@ -0,0 +1,124 @@
package ai.kilocode.client.session.controller
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.QuestionInfoDto
import ai.kilocode.rpc.dto.QuestionOptionDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
/**
* End-to-end-ish controller test that drives a realistic CLI-shaped event
* sequence through [SessionController], verifies model/state, sends a
* synthetic question reply, and verifies the reply payload forwarded to
* [ai.kilocode.client.testing.FakeSessionRpcApi].
*
* KiloCliDataParser lives in the backend module and is not available in
* the frontend test classpath. The fallback plan from the implementation
* plan is used here: DTOs are constructed directly, which still validates
* the full controller/model path.
*/
class JsonSessionStreamTest : SessionControllerTestBase() {
fun `test cli stream with assistant text then two-question prompt and reply`() {
val (m, _, modelEvents) = prompted()
// --- session.turn.open ---
emit(ChatEventDto.TurnOpen("ses_test"))
// --- message.updated for assistant message ---
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg_assistant", "ses_test", "assistant")))
// --- message.part.updated for text part ---
emit(
ChatEventDto.PartUpdated(
"ses_test",
part("part_text", "ses_test", "msg_assistant", "text", text = "I'll help you with that."),
)
)
// --- question.asked with two question items ---
val request = QuestionRequestDto(
id = "q_strategy",
sessionID = "ses_test",
questions = listOf(
QuestionInfoDto(
question = "Which implementation approach?",
header = "Approach",
options = listOf(
QuestionOptionDto("Minimal", "Keep changes minimal"),
QuestionOptionDto("Refactor", "Full refactor"),
),
multiple = false,
custom = false,
),
QuestionInfoDto(
question = "Which test level?",
header = "Test Level",
options = listOf(
QuestionOptionDto("Unit", "Unit tests only"),
QuestionOptionDto("Integration", "Integration tests"),
),
multiple = false,
custom = false,
),
),
)
emit(ChatEventDto.QuestionAsked("ses_test", request))
// Assert state is AwaitingQuestion
assertTrue("Expected AwaitingQuestion state", m.model.state is SessionState.AwaitingQuestion)
val questionState = m.model.state as SessionState.AwaitingQuestion
assertEquals("q_strategy", questionState.question.id)
assertEquals(2, questionState.question.items.size)
// Assert session model includes assistant text and both question items
assertSession(
"""
assistant#msg_assistant
text#part_text:
I'll help you with that.
---
question#q_strategy
tool: <none>
header: Approach
prompt: Which implementation approach?
option: Minimal - Keep changes minimal
option: Refactor - Full refactor
multiple: false
custom: false
header: Test Level
prompt: Which test level?
option: Unit - Unit tests only
option: Integration - Integration tests
multiple: false
custom: false
[code] [kilo/gpt-5] [awaiting-question]
""",
m,
)
modelEvents.clear()
// --- Synthetic reply ---
edt {
m.replyQuestion(
"q_strategy",
QuestionReplyDto(listOf(listOf("Minimal"), listOf("Unit"))),
)
}
flush()
// Assert the reply was forwarded through RPC
assertQuestionReply("q_strategy /test [[Minimal],[Unit]]", rpc.questionReplies)
// --- question.replied — controller moves to Busy ---
emit(ChatEventDto.QuestionReplied("ses_test", "q_strategy"))
assertTrue(
"Expected Busy state after QuestionReplied",
m.model.state is SessionState.Busy,
)
}
}
@@ -295,6 +295,16 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
assertEquals(expected.trimIndent().trim(), act)
}
protected fun assertQuestionReply(expected: String, replies: List<Triple<String, String, ai.kilocode.rpc.dto.QuestionReplyDto>>) {
val act = replies.joinToString("\n") { (id, dir, reply) ->
val answers = reply.answers.joinToString(",", "[", "]") { inner ->
inner.joinToString(",", "[", "]")
}
"$id $dir $answers"
}
assertEquals(expected.trimIndent().trim(), act)
}
protected fun snapshot(c: SessionController) = Snapshot(
body = c.model.toString().trim(),
turns = c.model.toTurnsString().trim(),
@@ -110,7 +110,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
fun `test busy status is seeded from statuses map`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy"))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
@@ -119,7 +119,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
"""
[code] [kilo/gpt-5] [busy] [considering next steps]
""",
m, show = false,
m, show = true,
)
}
@@ -131,7 +131,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
next = 5000L,
))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
@@ -140,7 +140,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
"""
[code] [kilo/gpt-5] [retry] [Rate limited]
""",
m, show = false,
m, show = true,
)
val state = m.model.state as SessionState.Retry
assertEquals(3, state.attempt)
@@ -154,7 +154,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
requestID = "req_xyz",
))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
@@ -163,7 +163,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
"""
[code] [kilo/gpt-5] [offline] [No network]
""",
m, show = false,
m, show = true,
)
assertEquals("req_xyz", (m.model.state as SessionState.Offline).requestId)
}
@@ -171,7 +171,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
fun `test idle status in map leaves controller in Idle`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("idle"))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
@@ -180,14 +180,14 @@ class SessionRecoveryTest : SessionControllerTestBase() {
"""
[code] [kilo/gpt-5] [idle]
""",
m, show = false,
m, show = true,
)
}
fun `test missing status entry leaves controller in Idle`() {
rpc.statuses.value = emptyMap()
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
@@ -196,7 +196,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
"""
[code] [kilo/gpt-5] [idle]
""",
m, show = false,
m, show = true,
)
}
@@ -211,7 +211,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
)
)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
@@ -229,7 +229,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
[code] [kilo/gpt-5] [awaiting-permission]
""",
m, show = false,
m, show = true,
)
}
@@ -243,7 +243,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
)
)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
@@ -259,7 +259,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
[code] [kilo/gpt-5] [awaiting-question]
""",
m, show = false,
m, show = true,
)
}
}
@@ -26,6 +26,8 @@ import kotlinx.coroutines.runBlocking
import java.awt.Cursor
import java.awt.event.KeyEvent
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import java.util.concurrent.atomic.AtomicInteger
import javax.swing.JComponent
@@ -282,7 +284,7 @@ class HistoryControllerTest : BasePlatformTestCase() {
}
fun `test cloud history uses relative time and sections`() {
val now = Instant.now()
val now = LocalDate.of(2026, 5, 18).atTime(12, 0).atZone(ZoneId.systemDefault()).toInstant()
val today = CloudHistoryItem(cloud("cloud_today", "Today", now.minus(5, ChronoUnit.HOURS)))
val yesterday = CloudHistoryItem(cloud("cloud_yesterday", "Yesterday", now.minus(1, ChronoUnit.DAYS)))
val offset = CloudHistoryItem(
@@ -1,115 +0,0 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.awt.Container
import javax.swing.AbstractButton
@Suppress("UnstableApiUsage")
class PermissionPanelTest : BasePlatformTestCase() {
private lateinit var parent: Disposable
private lateinit var scope: CoroutineScope
private lateinit var rpc: FakeSessionRpcApi
private lateinit var app: KiloAppService
private lateinit var workspaces: KiloWorkspaceService
private lateinit var workspace: Workspace
private lateinit var controller: SessionController
private lateinit var panel: PermissionPanel
override fun setUp() {
super.setUp()
parent = Disposer.newDisposable("permission-panel")
scope = CoroutineScope(SupervisorJob())
rpc = FakeSessionRpcApi()
val sessions = KiloSessionService(project, scope, rpc)
val api = FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) }
val work = FakeWorkspaceRpcApi().also {
it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY)
}
app = KiloAppService(scope, api)
workspaces = KiloWorkspaceService(scope, work)
workspace = workspaces.workspace("/test")
controller = SessionController(parent, SessionRef.Local("ses_test"), sessions, workspace, app, scope, BorderLayoutPanel())
panel = PermissionPanel(controller)
}
override fun tearDown() {
try {
Disposer.dispose(parent)
scope.cancel()
} finally {
super.tearDown()
}
}
fun `test allow button uses bundle text and replies once`() {
panel.show(permission())
buttons(panel).first { it.text == "Allow" }.doClick()
flush()
assertFalse(panel.isVisible)
assertEquals("perm1", rpc.permissionReplies.single().first)
assertEquals("once", rpc.permissionReplies.single().third.reply)
}
fun `test deny button uses bundle text and rejects`() {
panel.show(permission())
buttons(panel).first { it.text == "Deny" }.doClick()
flush()
assertFalse(panel.isVisible)
assertEquals("perm1", rpc.permissionReplies.single().first)
assertEquals("reject", rpc.permissionReplies.single().third.reply)
}
private fun permission() = Permission(
id = "perm1",
sessionId = "ses_test",
name = "edit",
patterns = listOf("*.kt"),
always = emptyList(),
meta = PermissionMeta(),
message = "Review file changes",
)
private fun buttons(root: Container): List<AbstractButton> = root.components.flatMap { comp ->
val item = if (comp is AbstractButton) listOf(comp) else emptyList()
if (comp is Container) item + buttons(comp) else item
}
private fun flush() = runBlocking {
repeat(5) {
delay(100)
ApplicationManager.getApplication().invokeAndWait {
UIUtil.dispatchAllInvocationEvents()
}
}
}
}
@@ -1,134 +0,0 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.session.model.Question
import ai.kilocode.client.session.model.QuestionItem
import ai.kilocode.client.session.model.QuestionOption
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.awt.Container
import javax.swing.JButton
@Suppress("UnstableApiUsage")
class QuestionPanelTest : BasePlatformTestCase() {
private lateinit var parent: Disposable
private lateinit var scope: CoroutineScope
private lateinit var rpc: FakeSessionRpcApi
private lateinit var app: KiloAppService
private lateinit var workspaces: KiloWorkspaceService
private lateinit var workspace: Workspace
private lateinit var controller: SessionController
private lateinit var panel: QuestionPanel
override fun setUp() {
super.setUp()
parent = Disposer.newDisposable("question-panel")
scope = CoroutineScope(SupervisorJob())
rpc = FakeSessionRpcApi()
val sessions = KiloSessionService(project, scope, rpc)
val appRpc = FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) }
val workspaceRpc = FakeWorkspaceRpcApi().also {
it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY)
}
app = KiloAppService(scope, appRpc)
workspaces = KiloWorkspaceService(scope, workspaceRpc)
workspace = workspaces.workspace("/test")
val root = BorderLayoutPanel()
controller = SessionController(parent, SessionRef.Local("ses_test"), sessions, workspace, app, scope, root)
panel = QuestionPanel(controller)
}
override fun tearDown() {
try {
Disposer.dispose(parent)
scope.cancel()
} finally {
super.tearDown()
}
}
fun `test empty question hides panel and clears stale request id`() {
panel.show(
Question(
id = "req_old",
items = listOf(
QuestionItem(
question = "Pick one",
header = "Header",
options = listOf(QuestionOption("Yes", "desc")),
multiple = false,
custom = true,
)
),
)
)
assertTrue(panel.isVisible)
panel.show(Question(id = "req_new", items = emptyList()))
assertFalse(panel.isVisible)
assertEquals(0, panel.componentCount)
assertTrue(rpc.questionReplies.isEmpty())
assertTrue(rpc.questionRejects.isEmpty())
}
fun `test dismiss button uses bundle text and rejects question`() {
panel.show(
Question(
id = "req_1",
items = listOf(
QuestionItem(
question = "Pick one",
header = "Header",
options = listOf(QuestionOption("Yes", "desc")),
multiple = false,
custom = true,
)
),
)
)
val button = buttons(panel).first { it.text == "Dismiss" }
button.doClick()
flush()
assertFalse(panel.isVisible)
assertEquals("req_1", rpc.questionRejects.single().first)
}
private fun buttons(root: Container): List<JButton> = root.components.flatMap { comp ->
val item = if (comp is JButton) listOf(comp) else emptyList()
if (comp is Container) item + buttons(comp) else item
}
private fun flush() = runBlocking {
repeat(5) {
delay(100)
ApplicationManager.getApplication().invokeAndWait {
UIUtil.dispatchAllInvocationEvents()
}
}
}
}
@@ -1,8 +1,19 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.client.session.model.Question
import ai.kilocode.client.session.model.QuestionItem
import ai.kilocode.client.session.model.QuestionOption
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.model.ToolCallRef
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.views.PermissionView
import ai.kilocode.client.session.views.question.QuestionResultView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.session.views.TextView
import ai.kilocode.client.session.views.ToolView
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
@@ -10,6 +21,7 @@ import ai.kilocode.rpc.dto.PartDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.awt.Container
/**
* Tests for [SessionMessageListPanel] structural and index integrity.
@@ -257,8 +269,189 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
assertTrue(text.md.overrideSheet().contains("25pt"))
}
// ------ active view tests ------
fun `test active question is anchored before progress footer`() {
val item = panelWithPrompts()
model.upsertMessage(msg("u1", "user"))
model.setState(SessionState.AwaitingQuestion(question()))
val qv = find<QuestionView>(item)!!
val pv = find<PermissionView>(item)!!
val comps = item.components.toList()
assertTrue(qv.isVisible)
assertFalse(pv.isVisible)
assertSame(item.progress, comps.last())
assertTrue(comps.indexOf(qv) < comps.indexOf(item.progress))
}
fun `test active permission replaces active question`() {
val item = panelWithPrompts()
model.setState(SessionState.AwaitingQuestion(question()))
model.setState(SessionState.AwaitingPermission(permission()))
val qv = find<QuestionView>(item)!!
val pv = find<PermissionView>(item)!!
val comps = item.components.toList()
assertFalse(qv.isVisible)
assertTrue(pv.isVisible)
assertSame(item.progress, comps.last())
}
fun `test idle hides active prompt and keeps progress footer last`() {
val item = panelWithPrompts()
model.setState(SessionState.AwaitingQuestion(question()))
model.setState(SessionState.Idle)
val qv = find<QuestionView>(item)!!
val pv = find<PermissionView>(item)!!
assertFalse(qv.isVisible)
assertFalse(pv.isVisible)
assertSame(item.progress, item.components.last())
}
fun `test cleared hides active prompt`() {
val item = panelWithPrompts()
model.setState(SessionState.AwaitingPermission(permission()))
model.clear()
val pv = find<PermissionView>(item)!!
assertFalse(pv.isVisible)
assertSame(item.progress, item.components.last())
}
// ------ question tool suppression ------
fun `test active linked question hides matching running question tool`() {
val item = panelWithPrompts()
model.upsertMessage(msg("a1", "assistant"))
model.updateContent("a1", toolPart("tp1", "a1", "question", "call1", state = "running"))
val mv = item.findMessage("a1")!!
assertEquals(listOf("tp1"), mv.partIds())
model.setState(SessionState.AwaitingQuestion(question(tool = ToolCallRef("a1", "call1"))))
assertTrue(mv.partIds().isEmpty())
}
fun `test clearing active question restores hidden question tool`() {
val item = panelWithPrompts()
model.upsertMessage(msg("a1", "assistant"))
model.updateContent("a1", toolPart("tp1", "a1", "question", "call1", state = "running"))
model.setState(SessionState.AwaitingQuestion(question(tool = ToolCallRef("a1", "call1"))))
val mv = item.findMessage("a1")!!
assertTrue(mv.partIds().isEmpty())
model.setState(SessionState.Idle)
assertEquals(listOf("tp1"), mv.partIds())
}
fun `test active question does not hide unrelated question tool`() {
val item = panelWithPrompts()
model.upsertMessage(msg("a1", "assistant"))
// tool part with a different callId
model.updateContent("a1", toolPart("tp1", "a1", "question", "other-call", state = "running"))
model.setState(SessionState.AwaitingQuestion(question(tool = ToolCallRef("a1", "call1"))))
val mv = item.findMessage("a1")!!
assertEquals(listOf("tp1"), mv.partIds())
}
fun `test completed question tool remains visible while question active`() {
val item = panelWithPrompts()
model.upsertMessage(msg("a1", "assistant"))
// completed state — must NOT be suppressed even when callId matches
// No structured input/metadata so it renders as ToolView
model.updateContent("a1", toolPart("tp1", "a1", "question", "call1", state = "completed"))
model.setState(SessionState.AwaitingQuestion(question(tool = ToolCallRef("a1", "call1"))))
val mv = item.findMessage("a1")!!
assertEquals(listOf("tp1"), mv.partIds())
assertTrue(mv.part("tp1") is ToolView)
}
fun `test completed question update replaces generic tool view with question result view`() {
val item = panelWithPrompts()
model.upsertMessage(msg("a1", "assistant"))
// Running question tool — no structured data yet, renders as ToolView
model.updateContent("a1", toolPart("tp1", "a1", "question", "call1", state = "running"))
val mv = item.findMessage("a1")!!
assertTrue("Running question tool should be ToolView", mv.part("tp1") is ToolView)
// Complete with structured data — should replace ToolView with QuestionResultView
model.updateContent(
"a1",
toolPart(
"tp1", "a1", "question", "call1", state = "completed",
input = mapOf("questions" to """[{"question":"Which strategy?"},{"question":"Which checks?"}]"""),
metadata = mapOf("answers" to """[["Comprehensive"],["Build"]]"""),
),
)
assertTrue("Completed question with data should be QuestionResultView", mv.part("tp1") is QuestionResultView)
assertEquals(listOf("tp1"), mv.partIds())
}
// ------ helpers ------
private fun panelWithPrompts(): SessionMessageListPanel {
val q = QuestionView(
reply = { _, _ -> },
reject = { _ -> },
)
val p = PermissionView(
reply = { _, _ -> },
)
return SessionMessageListPanel(model, parent, q, p)
}
private inline fun <reified T> find(root: Container): T? = findCls(root, T::class.java)
private fun <T> findCls(root: Container, cls: Class<T>): T? {
if (cls.isInstance(root)) return cls.cast(root)
for (child in root.components) {
if (cls.isInstance(child)) return cls.cast(child)
if (child is Container) {
val item = findCls(child, cls)
if (item != null) return item
}
}
return null
}
private fun question(id: String = "q1", tool: ToolCallRef? = null) = Question(
id = id,
tool = tool,
items = listOf(
QuestionItem(
question = "Proceed?",
header = "Confirm",
options = listOf(QuestionOption("Yes", "Continue")),
multiple = false,
custom = true,
),
),
)
private fun permission(id: String = "p1") = Permission(
id = id,
sessionId = "ses",
name = "edit",
patterns = listOf("*.kt"),
always = emptyList(),
meta = PermissionMeta(),
)
private fun msg(id: String, role: String) = MessageDto(
id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0),
)
@@ -266,4 +459,17 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
private fun part(id: String, mid: String, type: String, text: String? = null) = PartDto(
id = id, sessionID = "ses", messageID = mid, type = type, text = text,
)
private fun toolPart(
id: String,
mid: String,
tool: String,
callId: String,
state: String = "running",
input: Map<String, String> = emptyMap(),
metadata: Map<String, String> = emptyMap(),
) = PartDto(
id = id, sessionID = "ses", messageID = mid, type = "tool", tool = tool, callID = callId, state = state,
input = input, metadata = metadata,
)
}
@@ -0,0 +1,74 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.rpc.dto.PermissionReplyDto
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.awt.Container
import javax.swing.AbstractButton
@Suppress("UnstableApiUsage")
class PermissionViewTest : BasePlatformTestCase() {
private val replies = mutableListOf<Pair<String, PermissionReplyDto>>()
private lateinit var view: PermissionView
override fun setUp() {
super.setUp()
view = PermissionView(
reply = { id, dto -> replies.add(id to dto) },
)
}
fun `test allow button uses bundle text and replies once`() {
view.show(permission())
buttons(view).first { it.text == "Allow" }.doClick()
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals("perm1", replies.single().first)
assertEquals("once", replies.single().second.reply)
}
fun `test deny button uses bundle text and rejects`() {
view.show(permission())
buttons(view).first { it.text == "Deny" }.doClick()
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals("perm1", replies.single().first)
assertEquals("reject", replies.single().second.reply)
}
fun `test blank patterns display star`() {
view.show(
Permission(
id = "perm2",
sessionId = "ses",
name = "edit",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(),
)
)
assertTrue(view.isVisible)
}
private fun permission() = Permission(
id = "perm1",
sessionId = "ses_test",
name = "edit",
patterns = listOf("*.kt"),
always = emptyList(),
meta = PermissionMeta(),
message = "Review file changes",
)
private fun buttons(root: Container): List<AbstractButton> = root.components.flatMap { comp ->
val item = if (comp is AbstractButton) listOf(comp) else emptyList()
if (comp is Container) item + buttons(comp) else item
}
}
@@ -0,0 +1,223 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.toolKind
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.views.question.QuestionResultView
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@Suppress("UnstableApiUsage")
class QuestionResultViewTest : BasePlatformTestCase() {
// ------ canRender (integration with parser) ------
fun `test completed question tool with valid data is renderable`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Which strategy?"},{"question":"Which checks?"}]"""),
metadata = mapOf("answers" to """[["Comprehensive"],["Build"]]"""),
)
assertTrue(QuestionResultView.canRender(tool))
}
fun `test completed question tool without questions is not renderable`() {
assertFalse(QuestionResultView.canRender(completedTool(input = emptyMap(), metadata = emptyMap())))
}
fun `test running question tool is not renderable`() {
assertFalse(QuestionResultView.canRender(runningTool("question")))
}
// ------ label text ------
fun `test completed question tool renders answer summary`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Which implementation strategy should we use?"},{"question":"Which validation checks should be run?"}]"""),
metadata = mapOf("answers" to """[["Comprehensive"],["Build"]]"""),
output = "User has answered your questions: raw output should not be rendered",
)
val view = QuestionResultView(tool)
assertTrue(view.labelText().contains("Questions"))
assertTrue(view.labelText().contains("2 answered"))
assertTrue(view.bodyText().contains("Which implementation strategy should we use?"))
assertTrue(view.bodyText().contains("Which validation checks should be run?"))
assertTrue(view.bodyText().contains("Comprehensive"))
assertTrue(view.bodyText().contains("Build"))
assertFalse(view.bodyText().contains("raw output should not be rendered"))
}
fun `test collapsed view does not create body components`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = mapOf("answers" to """[["A1"]]"""),
)
val view = QuestionResultView(tool)
assertFalse("Default collapsed state should not eagerly create body", view.bodyCreated())
assertTrue(view.bodyText().contains("Q1"))
assertFalse("Reading assertion text should not create Swing body", view.bodyCreated())
}
fun `test missing answer renders not answered`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Q1"},{"question":"Q2"}]"""),
metadata = mapOf("answers" to """[["Answer1"]]"""),
)
val view = QuestionResultView(tool)
assertTrue(view.bodyText().contains("Q1"))
assertTrue(view.bodyText().contains("Answer1"))
assertTrue(view.bodyText().contains("Q2"))
assertTrue(view.bodyText().contains("(not answered)"))
}
fun `test no answers metadata renders all not answered`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = emptyMap(),
)
val view = QuestionResultView(tool)
assertTrue(view.bodyText().contains("Q1"))
assertTrue(view.bodyText().contains("(not answered)"))
}
fun `test multi answer row joins with comma`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Select features"}]"""),
metadata = mapOf("answers" to """[["Manual verification","Unit tests"]]"""),
)
val view = QuestionResultView(tool)
assertTrue(view.bodyText().contains("Manual verification, Unit tests"))
}
fun `test label shows count of non-empty answers`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Q1"},{"question":"Q2"}]"""),
metadata = mapOf("answers" to """[["A1"],[]]"""),
)
val view = QuestionResultView(tool)
// Only 1 non-empty answer
assertTrue(view.labelText().contains("1 answered"))
}
// ------ toggle expand/collapse ------
fun `test toggle collapses and expands body`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = mapOf("answers" to """[["A1"]]"""),
)
val view = QuestionResultView(tool)
assertFalse("Default state should be collapsed", view.isExpanded())
view.toggle()
assertTrue("Should be expanded after toggle", view.isExpanded())
view.toggle()
assertFalse("Should be collapsed after second toggle", view.isExpanded())
}
// ------ view factory routing ------
fun `test view factory uses question result view for completed parsable question tool`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = mapOf("answers" to """[["A1"]]"""),
)
val view = ViewFactory.create(tool)
assertTrue(view is QuestionResultView)
}
fun `test view factory falls back to tool view for invalid question result`() {
val tool = completedTool(
input = emptyMap(),
metadata = emptyMap(),
)
val view = ViewFactory.create(tool)
assertTrue(view is ToolView)
}
fun `test view factory falls back to tool view for running question`() {
val tool = runningTool("question")
val view = ViewFactory.create(tool)
assertTrue(view is ToolView)
}
// ------ dumpLabel ------
fun `test dumpLabel format`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = mapOf("answers" to """[["A1"]]"""),
)
val view = QuestionResultView(tool)
assertTrue(view.dumpLabel().startsWith("QuestionResultView#"))
assertTrue(view.dumpLabel().contains("Questions"))
}
// ------ applyStyle ------
fun `test applyStyle updates fonts`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = mapOf("answers" to """[["A1"]]"""),
)
val view = QuestionResultView(tool)
val style = SessionEditorStyle.create(family = "Courier New", size = 22)
view.applyStyle(style)
view.toggle()
assertTrue(view.bodyFonts().contains(style.transcriptFont))
assertTrue(view.bodyFonts().contains(style.boldEditorFont))
}
// ------ update ------
fun `test update with completed structured tool refreshes content`() {
val initial = completedTool(
input = mapOf("questions" to """[{"question":"Initial Q"}]"""),
metadata = mapOf("answers" to """[["Initial A"]]"""),
)
val view = QuestionResultView(initial)
val updated = completedTool(
id = initial.id,
input = mapOf("questions" to """[{"question":"Updated Q"}]"""),
metadata = mapOf("answers" to """[["Updated A"]]"""),
)
view.update(updated)
assertFalse("Collapsed update should not create body components", view.bodyCreated())
assertTrue(view.bodyText().contains("Updated Q"))
assertTrue(view.bodyText().contains("Updated A"))
assertFalse(view.bodyText().contains("Initial Q"))
}
// ------ helpers ------
private fun completedTool(
id: String = "tp1",
name: String = "question",
input: Map<String, String> = emptyMap(),
metadata: Map<String, String> = emptyMap(),
output: String? = null,
): Tool = Tool(id, name, toolKind(name)).apply {
state = ToolExecState.COMPLETED
this.input = input
this.metadata = metadata
this.output = output
}
private fun runningTool(name: String, id: String = "tp1"): Tool =
Tool(id, name, toolKind(name)).apply { state = ToolExecState.RUNNING }
}
@@ -0,0 +1,521 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Question
import ai.kilocode.client.session.model.QuestionItem
import ai.kilocode.client.session.model.QuestionOption
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.ui.HoverIcon
import ai.kilocode.rpc.dto.QuestionReplyDto
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBRadioButton
import com.intellij.ui.components.JBTextArea
import java.awt.Container
import javax.swing.AbstractButton
import javax.swing.JButton
@Suppress("UnstableApiUsage")
class QuestionViewTest : BasePlatformTestCase() {
private val replies = mutableListOf<Pair<String, QuestionReplyDto>>()
private val rejects = mutableListOf<String>()
private var scrolls = 0
private lateinit var view: QuestionView
override fun setUp() {
super.setUp()
view = QuestionView(
reply = { id, dto -> replies.add(id to dto) },
reject = { id -> rejects.add(id) },
scroll = { scrolls++ },
)
}
// ------ empty question ------
fun `test empty question hides view and clears stale request id`() {
view.show(
Question(
id = "req_old",
items = listOf(
QuestionItem(
question = "Pick one",
header = "Header",
options = listOf(QuestionOption("Yes", "desc")),
multiple = false,
custom = true,
)
),
)
)
assertTrue(view.isVisible)
view.show(Question(id = "req_new", items = emptyList()))
assertFalse(view.isVisible)
assertTrue(replies.isEmpty())
assertTrue(rejects.isEmpty())
}
// ------ dismiss ------
fun `test dismiss button uses bundle text and rejects question`() {
view.show(
Question(
id = "req_1",
items = listOf(
QuestionItem(
question = "Pick one",
header = "Header",
options = listOf(QuestionOption("Yes", "desc")),
multiple = false,
custom = true,
)
),
)
)
button(view, "Dismiss").doClick()
assertFalse(view.isVisible)
assertEquals("req_1", rejects.single())
assertTrue(replies.isEmpty())
}
// ------ radio options ------
fun `test single question renders radio options`() {
view.show(singleSelectQuestion("req_r"))
val radios = findAll<JBRadioButton>(view)
assertEquals(2, radios.size)
assertEquals("Minimal", radios[0].actionCommand)
assertEquals("Balanced", radios[1].actionCommand)
assertTrue(findAll<JBCheckBox>(view).isEmpty())
}
fun `test single question submit sends selected answer`() {
view.show(singleSelectQuestion("req_2"))
// Select via radio button
option<JBRadioButton>(view, "Minimal").doClick()
button(view, "Submit").doClick()
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals("req_2", replies.single().first)
assertEquals(listOf(listOf("Minimal")), replies.single().second.answers)
}
fun `test submit is disabled until question is answered`() {
view.show(singleSelectQuestion("req_required"))
val submit = button(view, "Submit")
assertFalse("Submit should be disabled before selection", submit.isEnabled)
submit.doClick()
assertTrue("Disabled submit should not send a reply", replies.isEmpty())
option<JBRadioButton>(view, "Minimal").doClick()
assertTrue("Submit should be enabled after selection", submit.isEnabled)
}
// ------ option label and description ------
fun `test option row aligns label and description beside button`() {
view.show(
Question(
id = "desc_test",
items = listOf(
QuestionItem(
question = "How to proceed?",
header = "Approach",
options = listOf(
QuestionOption("Minimal", "Smallest safe change"),
),
multiple = false,
custom = false,
)
),
)
)
val radio = option<JBRadioButton>(view, "Minimal")
assertTrue("radio should keep text in aligned renderer", radio.text.isNullOrEmpty())
val label = findAll<JBTextArea>(view).firstOrNull { it.text == "Minimal" }
assertNotNull("option label should be present", label)
assertTrue("option label should be bold", label!!.font.isBold)
assertTrue("option label should wrap", label.lineWrap)
val desc = findAll<JBTextArea>(view).firstOrNull { it.text == "Smallest safe change" }
assertNotNull("description should be present", desc)
assertTrue("description should wrap", desc!!.lineWrap)
assertEquals("description should align in the text renderer", label.parent, desc.parent)
val style = SessionEditorStyle.current()
assertEquals("option label should use bold editor font", style.boldEditorFont, label.font)
assertEquals("description should use transcript font", style.transcriptFont, desc.font)
}
fun `test question title and hint use editor fonts`() {
view.show(singleSelectQuestion("q_fonts"))
val style = SessionEditorStyle.current()
val title = text(view, "Choose approach")
val hint = text(view, "Select one answer")
assertEquals(style.boldEditorFont, title.font)
assertEquals(style.transcriptFont, hint.font)
}
// ------ multi-question navigation ------
fun `test multi question shows one question at a time and navigates`() {
view.show(twoItemQuestion("q_nav"))
// First question shown, second not
assertLabelsContain(view, "Choose approach")
assertLabelsDoNotContain(view, "Choose test level")
assertLabelsContain(view, "1 of 2 questions")
// Select an answer on first question, then click Next
option<JBRadioButton>(view, "Minimal").doClick()
button(view, "Next").doClick()
// Second question shown, first not
assertLabelsContain(view, "Choose test level")
assertLabelsDoNotContain(view, "Choose approach")
assertLabelsContain(view, "2 of 2 questions")
// Select answer on second question, click Review
option<JBRadioButton>(view, "Unit").doClick()
button(view, "Review").doClick()
// Review page shown
assertLabelsContain(view, "Review your answers")
assertLabelsContain(view, "Choose approach")
assertLabelsContain(view, "Minimal")
assertLabelsContain(view, "Choose test level")
assertLabelsContain(view, "Unit")
// Submit from review page
button(view, "Submit").doClick()
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals("q_nav", replies.single().first)
assertEquals(listOf(listOf("Minimal"), listOf("Unit")), replies.single().second.answers)
}
fun `test multi question uses review before submit`() {
view.show(twoItemQuestion("q_review"))
option<JBRadioButton>(view, "Minimal").doClick()
button(view, "Next").doClick()
option<JBRadioButton>(view, "Unit").doClick()
// Clicking Review should NOT submit
button(view, "Review").doClick()
assertTrue("Should still be visible after Review", view.isVisible)
assertTrue("No replies should be sent after Review", replies.isEmpty())
// Now submit from review page
button(view, "Submit").doClick()
assertFalse(view.isVisible)
assertEquals(1, replies.size)
}
fun `test back preserves previous selection`() {
view.show(twoItemQuestion("q_back"))
// Answer first question
option<JBRadioButton>(view, "Minimal").doClick()
button(view, "Next").doClick()
// Go back via header nav icon
navButton(view, "Back").doClick()
// First question visible again, selection preserved
assertLabelsContain(view, "Choose approach")
assertLabelsContain(view, "1 of 2 questions")
val radios = findAll<JBRadioButton>(view)
assertTrue("Minimal should still be selected", radios.first { it.actionCommand == "Minimal" }.isSelected)
// Change selection to Balanced, go forward, then to review
option<JBRadioButton>(view, "Balanced").doClick()
button(view, "Next").doClick()
option<JBRadioButton>(view, "Unit").doClick()
button(view, "Review").doClick()
button(view, "Submit").doClick()
assertEquals(listOf(listOf("Balanced"), listOf("Unit")), replies.single().second.answers)
}
fun `test review back preserves answers`() {
view.show(twoItemQuestion("q_review_back"))
option<JBRadioButton>(view, "Minimal").doClick()
button(view, "Next").doClick()
option<JBRadioButton>(view, "Unit").doClick()
button(view, "Review").doClick()
// On review page - go back to last question
button(view, "Back").doClick()
// Back on second question — selection should be preserved
assertLabelsContain(view, "Choose test level")
assertLabelsContain(view, "2 of 2 questions")
val radios = findAll<JBRadioButton>(view)
assertTrue("Unit should still be selected", radios.first { it.actionCommand == "Unit" }.isSelected)
}
fun `test review displays multi select answers joined`() {
val q = Question(
id = "q_multi",
items = listOf(
QuestionItem(
question = "Choose features",
header = "Features",
options = listOf(
QuestionOption("A", "Feature A"),
QuestionOption("B", "Feature B"),
),
multiple = true,
custom = false,
),
),
)
view.show(q)
option<JBCheckBox>(view, "A").doClick()
option<JBCheckBox>(view, "B").doClick()
button(view, "Review").doClick()
assertLabelsContain(view, "A, B")
}
fun `test single select question still submits directly`() {
view.show(singleSelectQuestion("q_direct"))
option<JBRadioButton>(view, "Minimal").doClick()
button(view, "Submit").doClick()
// Should submit directly without a review step
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals(listOf(listOf("Minimal")), replies.single().second.answers)
}
fun `test next is disabled until current question is answered`() {
view.show(twoItemQuestion("q_disabled"))
val next = button(view, "Next")
assertFalse("Next should be disabled before selection", next.isEnabled)
option<JBRadioButton>(view, "Minimal").doClick()
val nextAfter = button(view, "Next")
assertTrue("Next should be enabled after selection", nextAfter.isEnabled)
}
fun `test selection updates existing footer controls`() {
view.show(twoItemQuestion("q_retained"))
val next = button(view, "Next")
option<JBRadioButton>(view, "Minimal").doClick()
assertSame("selection should not rebuild the footer button", next, button(view, "Next"))
assertTrue("existing Next button should be enabled", next.isEnabled)
}
fun `test header nav disables unavailable directions`() {
view.show(twoItemQuestion("q_nav_disabled"))
assertFalse("Back should be disabled on first question", navButton(view, "Back").isEnabled)
assertNotNull("Back should have disabled icon", navButton(view, "Back").disabledIcon)
assertFalse("Forward should be disabled before selection", navButton(view, "Next").isEnabled)
assertNotNull("Forward should have disabled icon", navButton(view, "Next").disabledIcon)
option<JBRadioButton>(view, "Minimal").doClick()
assertTrue("Forward should be enabled after selection on first question", navButton(view, "Next").isEnabled)
button(view, "Next").doClick()
assertTrue("Back should be enabled on second question", navButton(view, "Back").isEnabled)
// Forward on last question is enabled only after selection (can go to review)
assertFalse("Forward should be disabled on last question before selection", navButton(view, "Next").isEnabled)
option<JBRadioButton>(view, "Unit").doClick()
assertTrue("Forward should be enabled on last question after selection (goes to review)", navButton(view, "Next").isEnabled)
}
fun `test submit button uses default style`() {
view.show(singleSelectQuestion("q_default"))
val submit = button(view, "Submit")
assertEquals(true, submit.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY))
}
fun `test single question hides header nav`() {
view.show(singleSelectQuestion("q_single"))
assertTrue(findAll<HoverIcon>(view).all { !it.parent.isVisible })
}
fun `test selection requests scroll to bottom`() {
view.show(singleSelectQuestion("q_scroll"))
option<JBRadioButton>(view, "Minimal").doClick()
assertEquals(1, scrolls)
}
fun `test question navigation requests scroll to bottom`() {
view.show(twoItemQuestion("q_nav_scroll"))
option<JBRadioButton>(view, "Minimal").doClick()
button(view, "Next").doClick()
navButton(view, "Back").doClick()
assertEquals(3, scrolls)
}
// ------ multi-select checkboxes ------
fun `test multiple selection item uses checkboxes and toggles options`() {
view.show(
Question(
id = "req_3",
items = listOf(
QuestionItem(
question = "Select features",
header = "Features",
options = listOf(
QuestionOption("A", "Feature A"),
QuestionOption("B", "Feature B"),
QuestionOption("C", "Feature C"),
),
multiple = true,
custom = false,
)
),
)
)
val boxes = findAll<JBCheckBox>(view)
assertEquals(3, boxes.size)
assertTrue(findAll<JBRadioButton>(view).isEmpty())
boxes.first { it.actionCommand == "A" }.doClick()
boxes.first { it.actionCommand == "B" }.doClick()
option<JBCheckBox>(view, "B").doClick()
// Single multi-select item gets a Review step (VS Code parity: single() is false when multiple=true)
button(view, "Review").doClick()
button(view, "Submit").doClick()
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals("req_3", replies.single().first)
assertEquals(listOf(listOf("A")), replies.single().second.answers)
}
// ------ helpers ------
/**
* Find a [JButton] by button text covers footer buttons (Dismiss, Next, Submit, Review, Back).
* For icon-only nav buttons (Back/Forward) that use tooltip, use [navButton].
*/
private fun button(root: Container, text: String): JButton =
findAll<JButton>(root).first { it.text == text }
/** Find a [HoverIcon] nav button by tooltip text (Back / Next nav arrows). */
private fun navButton(root: Container, tooltip: String): HoverIcon =
findAll<HoverIcon>(root).first { it.toolTipText == tooltip }
private inline fun <reified T : AbstractButton> option(root: Container, label: String): T =
findAll<T>(root).first { it.actionCommand == label }
private fun text(root: Container, value: String): JBTextArea =
findAll<JBTextArea>(root).first { it.text == value }
private fun singleSelectQuestion(id: String) = Question(
id = id,
items = listOf(
QuestionItem(
question = "Choose approach",
header = "Approach",
options = listOf(
QuestionOption("Minimal", "Smallest safe change"),
QuestionOption("Balanced", "Focused implementation with tests"),
),
multiple = false,
custom = false,
)
),
)
private fun twoItemQuestion(id: String) = Question(
id = id,
items = listOf(
QuestionItem(
question = "Choose approach",
header = "Approach",
options = listOf(
QuestionOption("Minimal", "Smallest safe change"),
QuestionOption("Balanced", "Focused implementation"),
),
multiple = false,
custom = false,
),
QuestionItem(
question = "Choose test level",
header = "Test Level",
options = listOf(
QuestionOption("Unit", "Unit tests"),
QuestionOption("Integration", "Integration tests"),
),
multiple = false,
custom = false,
),
),
)
private fun assertLabelsContain(root: Container, text: String) {
val found = findAll<JBLabel>(root).any { it.text == text } || findAll<JBTextArea>(root).any { it.text == text }
assertTrue("Expected label '$text' to be present", found)
}
private fun assertLabelsDoNotContain(root: Container, text: String) {
val found = findAll<JBLabel>(root).any { it.text == text } || findAll<JBTextArea>(root).any { it.text == text }
assertFalse("Expected label '$text' to be absent, but it was found", found)
}
private inline fun <reified T> findAll(root: Container): List<T> = findAllCls(root, T::class.java)
/**
* Recursively find all components of type [cls], but do NOT recurse into
* [AbstractButton] subtypes buttons may have internal sub-components
* (e.g. IntelliJ UI delegate children) that would produce spurious matches.
*/
private fun <T> findAllCls(root: Container, cls: Class<T>): List<T> {
val result = mutableListOf<T>()
if (cls.isInstance(root)) result.add(cls.cast(root))
for (child in root.components) {
if (cls.isInstance(child)) result.add(cls.cast(child))
// Do not recurse into button internals to avoid double-counting
if (child is Container && child !is AbstractButton) {
result.addAll(findAllCls(child, cls))
}
}
return result
}
}
@@ -0,0 +1,155 @@
package ai.kilocode.client.session.views.question
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.toolKind
import junit.framework.TestCase
class QuestionResultParserTest : TestCase() {
// ------ parse returns null for ineligible tools ------
fun `test non-question tool returns null`() {
val tool = tool("bash", ToolExecState.COMPLETED, input = mapOf("questions" to """[{"question":"Q1"}]"""))
assertNull(QuestionResultParser.parse(tool))
}
fun `test running question tool returns null`() {
val tool = tool("question", ToolExecState.RUNNING, input = mapOf("questions" to """[{"question":"Q1"}]"""))
assertNull(QuestionResultParser.parse(tool))
}
fun `test pending question tool returns null`() {
val tool = tool("question", ToolExecState.PENDING, input = mapOf("questions" to """[{"question":"Q1"}]"""))
assertNull(QuestionResultParser.parse(tool))
}
fun `test error state question tool returns null`() {
val tool = tool("question", ToolExecState.ERROR, input = mapOf("questions" to """[{"question":"Q1"}]"""))
assertNull(QuestionResultParser.parse(tool))
}
// ------ missing or invalid questions input ------
fun `test missing questions key returns null`() {
val tool = tool("question", ToolExecState.COMPLETED, input = emptyMap())
assertNull(QuestionResultParser.parse(tool))
}
fun `test invalid questions JSON returns null`() {
val tool = tool("question", ToolExecState.COMPLETED, input = mapOf("questions" to "not json"))
assertNull(QuestionResultParser.parse(tool))
}
fun `test empty questions array returns null`() {
val tool = tool("question", ToolExecState.COMPLETED, input = mapOf("questions" to "[]"))
assertNull(QuestionResultParser.parse(tool))
}
fun `test blank question strings are ignored and empty result returns null`() {
val tool = tool("question", ToolExecState.COMPLETED, input = mapOf("questions" to """[{"question":" "}]"""))
assertNull(QuestionResultParser.parse(tool))
}
fun `test question object missing question key is ignored`() {
val tool = tool("question", ToolExecState.COMPLETED, input = mapOf("questions" to """[{"header":"no question field"}]"""))
assertNull(QuestionResultParser.parse(tool))
}
// ------ valid questions parsing ------
fun `test single valid question is parsed`() {
val tool = tool("question", ToolExecState.COMPLETED, input = mapOf("questions" to """[{"question":"Which strategy?"}]"""))
val result = QuestionResultParser.parse(tool)
assertNotNull(result)
assertEquals(listOf("Which strategy?"), result!!.questions)
}
fun `test multiple valid questions are parsed`() {
val tool = tool("question", ToolExecState.COMPLETED, input = mapOf("questions" to """[{"question":"Q1"},{"question":"Q2"}]"""))
val result = QuestionResultParser.parse(tool)
assertNotNull(result)
assertEquals(listOf("Q1", "Q2"), result!!.questions)
}
fun `test blank questions are filtered out leaving valid ones`() {
val tool = tool("question", ToolExecState.COMPLETED, input = mapOf("questions" to """[{"question":" "},{"question":"Real question"}]"""))
val result = QuestionResultParser.parse(tool)
assertNotNull(result)
assertEquals(listOf("Real question"), result!!.questions)
}
// ------ answers parsing ------
fun `test missing answers metadata produces empty answer lists`() {
val tool = tool("question", ToolExecState.COMPLETED, input = mapOf("questions" to """[{"question":"Q1"}]"""), metadata = emptyMap())
val result = QuestionResultParser.parse(tool)!!
assertEquals(emptyList<List<String>>(), result.answers)
}
fun `test blank answers metadata produces empty answer lists`() {
val tool = tool("question", ToolExecState.COMPLETED, input = mapOf("questions" to """[{"question":"Q1"}]"""), metadata = mapOf("answers" to " "))
val result = QuestionResultParser.parse(tool)!!
assertEquals(emptyList<List<String>>(), result.answers)
}
fun `test invalid answers JSON produces empty answer lists`() {
val tool = tool("question", ToolExecState.COMPLETED, input = mapOf("questions" to """[{"question":"Q1"}]"""), metadata = mapOf("answers" to "not json"))
val result = QuestionResultParser.parse(tool)!!
assertEquals(emptyList<List<String>>(), result.answers)
}
fun `test valid answers are parsed`() {
val tool = tool(
"question", ToolExecState.COMPLETED,
input = mapOf("questions" to """[{"question":"Q1"},{"question":"Q2"}]"""),
metadata = mapOf("answers" to """[["Answer1"],["Answer2"]]"""),
)
val result = QuestionResultParser.parse(tool)!!
assertEquals(listOf(listOf("Answer1"), listOf("Answer2")), result.answers)
}
fun `test multi-answer row is parsed as list`() {
val tool = tool(
"question", ToolExecState.COMPLETED,
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = mapOf("answers" to """[["A","B","C"]]"""),
)
val result = QuestionResultParser.parse(tool)!!
assertEquals(listOf(listOf("A", "B", "C")), result.answers)
}
fun `test blank strings in answer rows are filtered out`() {
val tool = tool(
"question", ToolExecState.COMPLETED,
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = mapOf("answers" to """[[" ","Valid"," "]]"""),
)
val result = QuestionResultParser.parse(tool)!!
assertEquals(listOf(listOf("Valid")), result.answers)
}
fun `test answers count can differ from questions count`() {
val tool = tool(
"question", ToolExecState.COMPLETED,
input = mapOf("questions" to """[{"question":"Q1"},{"question":"Q2"}]"""),
metadata = mapOf("answers" to """[["A1"]]"""),
)
val result = QuestionResultParser.parse(tool)!!
assertEquals(2, result.questions.size)
assertEquals(1, result.answers.size)
}
// ------ helpers ------
private fun tool(
name: String,
state: ToolExecState,
input: Map<String, String> = emptyMap(),
metadata: Map<String, String> = emptyMap(),
): Tool = Tool("tp1", name, toolKind(name)).apply {
this.state = state
this.input = input
this.metadata = metadata
}
}
@@ -52,8 +52,8 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
var recentFailures = 0
var recentGate: CompletableDeferred<Unit>? = null
/** Local sessions returned by [list]. */
val listed = mutableListOf<SessionDto>()
/** Local sessions returned by [list]. Accessed from concurrent coroutines in delete tests. */
val listed = java.util.concurrent.CopyOnWriteArrayList<SessionDto>()
/** Cloud sessions returned by [cloudSessions]. */
val cloud = mutableListOf<CloudSessionDto>()
@@ -85,7 +85,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
val permissionRulesSaved = mutableListOf<Triple<String, String, PermissionAlwaysRulesDto>>()
val questionReplies = mutableListOf<Triple<String, String, QuestionReplyDto>>()
val questionRejects = mutableListOf<Pair<String, String>>()
val deletes = mutableListOf<Pair<String, String>>()
val deletes = java.util.concurrent.CopyOnWriteArrayList<Pair<String, String>>()
var deleteGate: CompletableDeferred<Unit>? = null
val renames = mutableListOf<Triple<String, String, String>>()
var renameThrows: Exception? = null
@@ -2,3 +2,4 @@ kotlin.stdlib.default.dependency=false
org.gradle.configuration-cache=true
org.gradle.caching=true
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m
org.jetbrains.intellij.platform.useCacheRedirector=false
@@ -1,6 +1,6 @@
[versions]
intellij-platform = "2026.1"
intellij-gradle-plugin = "2.14.0"
intellij-gradle-plugin = "2.16.0"
intellij-rpc-plugin = "2.3.20-RC2-0.1"
kotlin-jvm-plugin = "2.3.20"
kotlin-serialization-plugin = "2.3.20"
+4 -1
View File
@@ -3,6 +3,9 @@
"private": true,
"scripts": {
"build": "bun script/build.ts",
"build:production": "bun script/build.ts --production"
"build:production": "bun script/build.ts --production",
"typecheck": "./gradlew typecheck",
"test": "./gradlew test",
"test:ci": "bun script/test-ci.ts"
}
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bun
/**
* CI test runner for the JetBrains plugin.
*
* Runs ./gradlew test --continue so all modules run even when some fail,
* then collects per-module JUnit XML results into .artifacts/unit/junit.xml
* so mikepenz/action-junit-report can find them at the standard path.
*
* Always exits 0 test failures are surfaced as JUnit report annotations,
* not as CI job failures. The suite runs on both Linux and Windows but
* IntelliJ Swing/coroutine tests are inherently flaky on Windows, so failing
* the job on test failures would be noisy.
*/
import { $ } from "bun"
import { join } from "node:path"
import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"
const root = join(import.meta.dir, "..")
const gradlew = process.platform === "win32" ? "./gradlew.bat" : "./gradlew"
const result = await $`${gradlew} test --continue`.cwd(root).nothrow()
const modules = [".", "shared", "frontend", "backend"]
const suites: string[] = []
for (const mod of modules) {
const dir = join(root, mod === "." ? "" : mod, "build", "test-results", "test")
if (!existsSync(dir)) continue
for (const f of readdirSync(dir)) {
if (!f.endsWith(".xml")) continue
// Strip leading XML declaration so it does not appear as a nested
// declaration inside the <testsuites> wrapper, which would produce
// malformed XML and fail the JUnit report uploader.
const xml = readFileSync(join(dir, f), "utf8").replace(/^\s*<\?xml[^>]*\?>\s*/u, "")
suites.push(xml)
}
}
const out = join(root, ".artifacts", "unit", "junit.xml")
mkdirSync(join(root, ".artifacts", "unit"), { recursive: true })
writeFileSync(out, `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites>\n${suites.join("\n")}\n</testsuites>\n`)
console.log(`[jetbrains-test] collected ${suites.length} suite(s) -> ${out}`)
if (result.exitCode !== 0) {
console.log(`[jetbrains-test] Gradle exited ${result.exitCode} — failures visible in JUnit report`)
}
@@ -29,6 +29,11 @@ type Audio = {
language?: string
}
type Args = {
pipe?: string[]
input: string[]
}
let active: Recording | undefined
let starting: string | undefined
@@ -119,13 +124,15 @@ async function waitForStart(state: Recording): Promise<void> {
})
}
async function startWithArgs(bin: string, file: string, input: Input, args: string[][]): Promise<Recording> {
async function startWithArgs(bin: string, file: string, input: Input, args: Args[]): Promise<Recording> {
const [first, ...rest] = args
if (!first) throw new Error(`Unsupported platform for speech input: ${process.platform}`)
const proc = spawn(bin, ["-y", ...first, "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", "-f", "wav", file], {
stdio: ["pipe", "ignore", "pipe"],
})
const proc = first.pipe
? pipeProcess(first.pipe, bin, file)
: spawn(bin, ["-y", ...first.input, "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", "-f", "wav", file], {
stdio: ["pipe", "ignore", "pipe"],
})
const state: Recording = { ...input, file, proc, stderr: [], stopped: false }
active = state
@@ -151,6 +158,48 @@ async function startWithArgs(bin: string, file: string, input: Input, args: stri
}
}
function pipeProcess(pipe: string[], bin: string, file: string): ChildProcess {
const source = spawn("pw-record", pipe, { stdio: ["ignore", "pipe", "pipe"] })
const proc = spawn(
bin,
[
"-y",
"-f",
"s16le",
"-ar",
"16000",
"-ac",
"1",
"-i",
"pipe:0",
"-acodec",
"pcm_s16le",
"-ar",
"16000",
"-ac",
"1",
"-f",
"wav",
file,
],
{
stdio: ["pipe", "ignore", "pipe"],
},
)
if (source.stdout && proc.stdin) source.stdout.pipe(proc.stdin)
source.on("error", (err) => proc.emit("error", err))
source.stderr?.on("data", (data: Buffer) => proc.stderr?.emit("data", data))
source.once("exit", () => {
if (proc.stdin?.writable) proc.stdin.end()
})
proc.once("exit", () => {
if (!source.killed) source.kill("SIGTERM")
})
return proc
}
async function stopProcess(state: Recording): Promise<void> {
if (state.proc.exitCode !== null || state.proc.signalCode) return
@@ -226,24 +275,33 @@ function platformPaths(): string[] {
return ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/snap/bin/ffmpeg", "/home/linuxbrew/.linuxbrew/bin/ffmpeg"]
}
async function inputArgSets(bin: string): Promise<string[][]> {
if (process.platform === "darwin") return [["-f", "avfoundation", "-i", ":default"]]
if (process.platform === "linux")
async function inputArgSets(bin: string): Promise<Args[]> {
if (process.platform === "darwin") return [{ input: ["-f", "avfoundation", "-i", ":default"] }]
if (process.platform === "linux") {
const device = process.env.KILO_FFMPEG_AUDIO_DEVICE
if (device)
return [
{ pipe: ["--target", device, "--format", "s16", "--rate", "16000", "--channels", "1", "-"], input: [] },
{ input: ["-f", "pulse", "-i", device] },
{ input: ["-f", "alsa", "-i", device] },
]
return [
["-f", "pulse", "-i", "default"],
["-f", "alsa", "-i", "default"],
{ pipe: ["--format", "s16", "--rate", "16000", "--channels", "1", "-"], input: [] },
{ input: ["-f", "pulse", "-i", "default"] },
{ input: ["-f", "alsa", "-i", "default"] },
]
}
if (process.platform === "win32") return await windowsInputArgSets(bin)
return []
}
async function windowsInputArgSets(bin: string): Promise<string[][]> {
async function windowsInputArgSets(bin: string): Promise<Args[]> {
const configured = process.env.KILO_FFMPEG_AUDIO_DEVICE
if (configured) return [["-f", "dshow", "-i", `audio=${configured}`]]
if (configured) return [{ input: ["-f", "dshow", "-i", `audio=${configured}`] }]
const devices = await listDshowAudioDevices(bin)
if (devices.length === 0) throw new Error("No Windows audio input devices found for speech input")
return devices.map((device) => ["-f", "dshow", "-i", `audio=${device}`])
return devices.map((device) => ({ input: ["-f", "dshow", "-i", `audio=${device}`] }))
}
async function listDshowAudioDevices(bin: string): Promise<string[]> {
@@ -266,11 +324,20 @@ function processOutput(err: unknown): string {
}
function summary(state: Recording, fallback: string): string {
const stderr = state.stderr.join("\n").trim()
const stderr = cleanOutput(state.stderr.join("\n"))
if (!stderr) return fallback
return `${fallback}: ${stderr.slice(-800)}`
}
export function cleanOutput(raw: string): string {
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.filter((line) => !/^(ffmpeg version|built with|configuration:|lib[a-z]+\s+\d)/i.test(line))
return lines.join("\n").trim()
}
async function removeFile(file: string): Promise<void> {
await unlink(file).catch((err: unknown) => {
console.warn("[Kilo New] Failed to remove speech recording", err)
@@ -44,12 +44,12 @@ describe("mergeFileSearchItems", () => {
it("keeps active and open file results before prefix folder matches", () => {
const result = mergeFileSearchItems({
query: "e",
files: ["sdks/vscode/src/extension.ts"],
files: ["packages/kilo-vscode/src/extension.ts"],
folders: ["packages/extensions/", "packages/example/", "packages/core/src/effect/"],
open: new Set(["sdks/vscode/src/extension.ts"]),
open: new Set(["packages/kilo-vscode/src/extension.ts"]),
})
expect(result).toEqual([
{ path: "sdks/vscode/src/extension.ts", type: "opened-file" },
{ path: "packages/kilo-vscode/src/extension.ts", type: "opened-file" },
{ path: "packages/extensions/", type: "folder" },
{ path: "packages/example/", type: "folder" },
{ path: "packages/core/src/effect/", type: "folder" },
@@ -0,0 +1,105 @@
/**
* Tests for plan_exit webview helpers:
* - planDisplayPath: relative/absolute path display logic
* - plan_exit renderer uses openFile, not openDiff
*/
import { describe, expect, it } from "bun:test"
import { planDisplayPath } from "../../webview-ui/src/utils/plan-path"
import fs from "node:fs"
import path from "node:path"
describe("planDisplayPath", () => {
it("returns a relative path unchanged", () => {
expect(planDisplayPath(".kilo/plans/my-plan.md", "/repo")).toBe(".kilo/plans/my-plan.md")
})
it("returns absolute path inside repo as repo-relative", () => {
expect(planDisplayPath("/repo/.kilo/plans/my-plan.md", "/repo")).toBe(".kilo/plans/my-plan.md")
})
it("returns absolute path inside repo with trailing slash on root as repo-relative", () => {
expect(planDisplayPath("/repo/.kilo/plans/my-plan.md", "/repo/")).toBe(".kilo/plans/my-plan.md")
})
it("returns absolute path outside repo unchanged", () => {
expect(planDisplayPath("/other/path/plan.md", "/repo")).toBe("/other/path/plan.md")
})
it("handles Windows absolute paths inside root", () => {
expect(planDisplayPath("C:\\repo\\.kilo\\plans\\plan.md", "C:\\repo")).toBe(".kilo\\plans\\plan.md")
})
it("handles Windows absolute paths outside root", () => {
expect(planDisplayPath("D:\\other\\plan.md", "C:\\repo")).toBe("D:\\other\\plan.md")
})
it("returns empty string unchanged", () => {
expect(planDisplayPath("", "/repo")).toBe("")
})
it("path equal to root returns original", () => {
// Edge: plan path IS the root directory itself — fall back to original
expect(planDisplayPath("/repo", "/repo")).toBe("/repo")
})
})
describe("plan_exit renderer uses openFile not openDiff (source)", () => {
const ROOT = path.resolve(import.meta.dir, "../..")
const FILE = path.join(ROOT, "webview-ui/src/components/chat/AssistantMessage.tsx")
const TURN_FILE = path.join(ROOT, "webview-ui/src/components/chat/VscodeSessionTurn.tsx")
const src = fs.readFileSync(FILE, "utf-8")
const turnSrc = fs.readFileSync(TURN_FILE, "utf-8")
it("PlanExitCard calls data.openFile", () => {
expect(src).toContain("data.openFile")
})
it("uses a safe inert anchor href", () => {
expect(src).toContain('href="#"')
expect(src).not.toContain("href={display()}")
})
it("always uses the generic ready label", () => {
expect(src).toContain('language.t("plan.exit.ready")')
expect(src).not.toContain('language.t("plan.exit.readyUpdated")')
expect(src).not.toContain('language.t("plan.exit.readyNew")')
})
it("does not infer status from tool history", () => {
expect(src).not.toContain("function inferPlanStatus")
expect(src).not.toContain("function patchUpdatedPlan")
expect(src).not.toContain("function toolTouchesPlan")
expect(src).not.toContain("toolDeletions")
expect(src).not.toContain("readyUpdated")
expect(src).not.toContain("readyNew")
expect(src).not.toContain("Object.values(data.store.part ?? {}).flat()")
expect(src).not.toContain("[...props.parts, ...all()]")
expect(src).not.toContain("turnParts")
expect(turnSrc).not.toContain("assistantMessages().flatMap")
expect(turnSrc).not.toContain("turnParts={assistantParts()}")
})
it("does not depend on opencode-provided plan status metadata", () => {
expect(src).not.toContain('meta.status === "updated" || meta.status === "new"')
expect(src).not.toContain("meta.status")
})
it("PlanExitCard does not call openDiffVirtual", () => {
// Extract just the PlanExitCard function body to scope the assertion
const start = src.indexOf("function PlanExitCard")
const end = src.indexOf("\nfunction ", start + 1)
const block = end === -1 ? src.slice(start) : src.slice(start, end)
expect(block).not.toContain("openDiffVirtual")
expect(block).not.toContain("openDiff")
})
it("plan_exit tool is handled before generic Part renderer", () => {
const planExitIdx = src.indexOf("planExit()")
// <Part may be followed by newline or space
const partIdx = src.search(/<Part[\s\n]/)
expect(planExitIdx).toBeGreaterThan(0)
expect(partIdx).toBeGreaterThan(0)
expect(planExitIdx).toBeLessThan(partIdx)
})
})
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"
import { parseDshowAudioDevices } from "../../src/speech-to-text/capture"
import { cleanOutput, parseDshowAudioDevices } from "../../src/speech-to-text/capture"
describe("parseDshowAudioDevices", () => {
it("extracts Windows dshow audio device names", () => {
@@ -20,3 +20,20 @@ describe("parseDshowAudioDevices", () => {
expect(parseDshowAudioDevices(raw)).toEqual(["Microphone"])
})
})
describe("cleanOutput", () => {
it("removes ffmpeg build noise from capture errors", () => {
const raw = `
ffmpeg version 4.2.7 Copyright (c) 2000-2022 the FFmpeg developers
built with gcc 9 (Ubuntu 9.4.0-1ubuntu1~20.04.2)
configuration: --enable-libopus --enable-libx264
libavutil 56. 31.100 / 56. 31.100
ALSA lib ../../../src/pcm/pcm.c:2477:(snd_pcm_open_conf) Unknown field libs
default: Input/output error
`
expect(cleanOutput(raw)).toBe(
"ALSA lib ../../../src/pcm/pcm.c:2477:(snd_pcm_open_conf) Unknown field libs\ndefault: Input/output error",
)
})
})
@@ -35,16 +35,16 @@ describe("useFileMention", () => {
type: "fileSearchResult",
requestId: "file-search-1",
dir: "/repo",
paths: ["sdks/vscode/src/extension.ts"],
items: [{ path: "sdks/vscode/src/extension.ts", type: "opened-file" }],
paths: ["packages/kilo-vscode/src/extension.ts"],
items: [{ path: "packages/kilo-vscode/src/extension.ts", type: "opened-file" }],
})
}
expect(mention.mentionResults()).toEqual([{ type: "opened-file", value: "sdks/vscode/src/extension.ts" }])
expect(mention.mentionResults()).toEqual([{ type: "opened-file", value: "packages/kilo-vscode/src/extension.ts" }])
mention.onInput("@ex", 3)
expect(mention.mentionResults()).toEqual([{ type: "opened-file", value: "sdks/vscode/src/extension.ts" }])
expect(mention.mentionResults()).toEqual([{ type: "opened-file", value: "packages/kilo-vscode/src/extension.ts" }])
dispose.fn?.()
})
@@ -1,8 +1,8 @@
import { type Component, createEffect, createMemo, onCleanup } from "solid-js"
import type { AnnotationSide, DiffLineAnnotation, SelectedLineRange } from "@pierre/diffs"
import { markdownCommentBlocks, type MarkdownRange } from "./markdown-comment-ranges"
import { isAnnotationMutation, selector } from "./markdown-annotation-mutation"
import type { AnnotationMeta } from "./review-annotations"
import { annotationSelector, isAnnotationMutation } from "./markdown-annotation-mutation"
type Insert = "after" | "list" | "table"
@@ -89,6 +89,8 @@ function matches(annotation: DiffLineAnnotation<AnnotationMeta>, anchor: Anchor,
return true
}
const selector = annotationSelector()
function removeInserted(root: HTMLElement, layer: HTMLElement): void {
layer.replaceChildren()
root.querySelectorAll(selector).forEach((node) => node.remove())
@@ -1,4 +1,4 @@
export const selector = ".am-markdown-inline-annotations, .am-markdown-list-annotation, .am-markdown-table-annotation"
const selector = ".am-markdown-inline-annotations, .am-markdown-list-annotation, .am-markdown-table-annotation"
// Keep host insertion observable, only nested annotation UI updates are safe to ignore.
export function isAnnotationMutation(mutation: Pick<MutationRecord, "target">): boolean {
@@ -7,3 +7,7 @@ export function isAnnotationMutation(mutation: Pick<MutationRecord, "target">):
if (typeof target.closest !== "function") return false
return target.closest(selector) !== null
}
export function annotationSelector(): string {
return selector
}
@@ -15,6 +15,8 @@ import { FullScreenDiffView } from "../agent-manager/FullScreenDiffView"
import { mergeWorktreeDiffs } from "../agent-manager/diff-state"
import { LanguageProvider, useLanguage } from "../src/context/language"
import { ServerProvider, useServer } from "../src/context/server"
import { ConfigProvider } from "../src/context/config"
import { ProviderProvider } from "../src/context/provider"
import { getVSCodeAPI, VSCodeProvider, useVSCode } from "../src/context/vscode"
import type { BranchInfo, ReviewComment, WebviewMessage, WorktreeFileDiff } from "../src/types/messages"
import type { DiffSourceCapabilities, DiffSourceDescriptor } from "../../src/diff/sources/types"
@@ -301,7 +303,11 @@ export const DiffViewerApp: Component = () => {
<DialogProvider>
<VSCodeProvider>
<ServerProvider>
<DiffViewerShell />
<ProviderProvider>
<ConfigProvider>
<DiffViewerShell />
</ConfigProvider>
</ProviderProvider>
</ServerProvider>
</VSCodeProvider>
</DialogProvider>
@@ -21,7 +21,10 @@ import { useData } from "@kilocode/kilo-ui/context/data"
import { useSession } from "../../context/session"
import { useDisplay } from "../../context/display"
import { useConfig } from "../../context/config"
import { useLanguage } from "../../context/language"
import { useServer } from "../../context/server"
import { snapshotProgress } from "../../context/session-utils"
import { planDisplayPath } from "../../utils/plan-path"
import { QuestionDock } from "./QuestionDock"
import { SuggestBar } from "./SuggestBar"
@@ -30,6 +33,50 @@ import { SuggestBar } from "./SuggestBar"
// so the user can see what the AI set up.
export const UPSTREAM_SUPPRESSED_TOOLS = new Set(["todowrite", "todoread"])
/** Extract plan path from a completed plan_exit tool part. */
function planExitInfo(part: SDKPart): { plan: string } | undefined {
if (part.type !== "tool") return undefined
const tp = part as unknown as ToolPart
if (tp.tool !== "plan_exit") return undefined
if (tp.state?.status !== "completed") return undefined
const meta = (tp.state as { metadata?: Record<string, unknown> }).metadata ?? {}
const plan = typeof meta.plan === "string" ? meta.plan : undefined
if (!plan) return undefined
return { plan }
}
function PlanExitCard(props: { part: ToolPart }) {
const language = useLanguage()
const server = useServer()
const data = useData()
const info = createMemo(() => planExitInfo(props.part as unknown as SDKPart))
const display = createMemo(() => {
const i = info()
if (!i) return ""
return planDisplayPath(i.plan, server.workspaceDirectory())
})
const label = createMemo(() => {
if (!info()) return ""
return language.t("plan.exit.ready")
})
const open = (e: MouseEvent) => {
e.preventDefault()
const i = info()
if (!i || !data.openFile) return
data.openFile(i.plan)
}
return (
<Show when={info()}>
<div data-component="plan-exit-card">
<span data-slot="plan-exit-label">{label()}</span>{" "}
<a data-slot="plan-exit-link" href="#" onClick={open}>
{display()}
</a>
</div>
</Show>
)
}
function isRenderable(part: SDKPart): boolean {
if (part.type === "tool") {
const tool = (part as SDKPart & { tool: string }).tool
@@ -156,10 +203,24 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
if (tool.state?.status === "error") return
return part
})
const planExit = createMemo(() => {
if (part.type !== "tool") return
const tp = part as unknown as ToolPart
if (tp.tool !== "plan_exit") return
if (tp.state?.status !== "completed") return
return tp
})
return (
<Show
when={isUpstreamSuppressed || activeQuestion() || activeSuggestion() || bash() || PART_MAPPING[part.type]}
when={
isUpstreamSuppressed ||
activeQuestion() ||
activeSuggestion() ||
bash() ||
planExit() ||
PART_MAPPING[part.type]
}
>
<div data-component="tool-part-wrapper" data-part-type={part.type}>
<Show
@@ -169,30 +230,37 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
when={activeSuggestion()}
fallback={
<Show
when={bash()}
when={planExit()}
fallback={
<Show
when={isUpstreamSuppressed}
when={bash()}
fallback={
<Part
part={part}
message={props.message as SDKMessage}
showAssistantCopyPartID={props.showAssistantCopyPartID}
reasoningAutoCollapse={display.reasoningAutoCollapse()}
feedback={props.feedback}
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}
reasoningAutoCollapse={display.reasoningAutoCollapse()}
feedback={props.feedback}
animate={
part.type === "tool" &&
((part as unknown as ToolPart).state?.status === "pending" ||
(part as unknown as ToolPart).state?.status === "running")
}
/>
}
/>
>
<TodoToolCard part={part as unknown as ToolPart} />
</Show>
}
>
<TodoToolCard part={part as unknown as ToolPart} />
{(tool) => <BashToolCard part={tool() as unknown as ToolPart} defaultOpen={open()} />}
</Show>
}
>
{(tool) => <BashToolCard part={tool() as unknown as ToolPart} defaultOpen={open()} />}
{(tp) => <PlanExitCard part={tp()} />}
</Show>
}
>
@@ -120,6 +120,9 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const [preActiveKey, setPreActiveKey] = createSignal<string | null>(null)
const [previewKey, setPreviewKey] = createSignal<string | null>(null)
const [previewHeight, setPreviewHeight] = createSignal(500)
// Per-group collapse state. Not persisted — resets every time the
// selector mounts so groups are always expanded on reopen.
const [collapsed, setCollapsed] = createSignal<Set<string>>(new Set())
// Snapshot of the active model key captured when the popover opens.
// Used to reorder favorites so the current model appears first — but only
// based on the state at open-time, not reactively, to avoid list jumps
@@ -281,8 +284,22 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
return [...result, ...rest]
})
// Collapse state is honored even during search so users can skip past
// large providers (e.g. Kilo Gateway) without scrolling through every match.
const isGroupOpen = (key: string) => !collapsed().has(key)
function toggleGroup(key: string) {
setCollapsed((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
const rows = createMemo<ModelRow[]>(() => {
const list = groups().flatMap((g) => g.rows)
const c = collapsed()
const list = groups().flatMap((g) => (c.has(g.key) ? [] : g.rows))
if (!props.allowClear) return list
return [{ key: CLEAR_KEY, kind: "clear" }, ...list]
})
@@ -633,97 +650,126 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
</Show>
<For each={groups()}>
{(group) => (
<>
<div class="model-selector-group-label">{group.label}</div>
<For each={group.rows}>
{(row) => {
if (!row.model) return null
const model = row.model
const hovered = () => isSelected(row.key)
const preActive = () => isPreActive(row.key)
const showSelectBtn = () => expanded() && preActive() && !isActive(model)
const starred = () => favoriteKeys().has(modelKey(model.providerID, model.id))
const showProvider = () => row.kind === "favorite"
return (
<div
ref={(el) => {
refs.set(row.key, el)
onCleanup(() => refs.delete(row.key))
}}
class={`model-selector-item${(hovered() && !pointer()) || preActive() ? " keyboard-focused" : ""}${hovered() || preActive() ? " selected" : ""}${isActive(model) && row.kind === "model" ? " active" : ""}`}
role="option"
aria-selected={isActive(model) && row.kind === "model"}
onClick={() => {
setRow(row.key)
setPreviewKey(row.key)
if (!expanded()) selectRow(row)
searchRef?.focus()
}}
onDblClick={() => {
if (expanded()) selectRow(row)
}}
onMouseMove={() => {
setPointer(true)
}}
onMouseEnter={() => {
if (pointer()) setSelectedKey(row.key)
}}
>
<div class="model-selector-item-left">
<span class="model-selector-item-name">
{(() => {
const full = sanitizeName(model.name)
const sep = full.indexOf(": ")
if (sep < 0) return <span class="model-selector-item-name-main">{full}</span>
return (
<>
<span class="model-selector-item-name-provider">{full.slice(0, sep)}</span>
<span class="model-selector-item-name-main">{full.slice(sep + 2)}</span>
</>
)
})()}
</span>
<Show when={isFree(model)}>
<Tag data-variant="member">{language.t("model.tag.free")}</Tag>
</Show>
<Show when={showProvider()}>
<span class="model-selector-item-provider-tag">{model.providerName}</span>
</Show>
</div>
<Show when={session && props.favorites !== false}>
<button
type="button"
class={`model-selector-star${starred() ? " model-selector-star--active" : ""}`}
aria-label={
starred() ? language.t("model.favorite.remove") : language.t("model.favorite.add")
}
aria-pressed={starred()}
onClick={(e) => {
e.stopPropagation()
toggleFavorite(model, row)
{(group) => {
const shown = () => isGroupOpen(group.key)
return (
<>
<button
type="button"
class="model-selector-group-label"
aria-expanded={shown()}
aria-label={language.t(shown() ? "model.group.collapse" : "model.group.expand", {
group: group.label,
})}
onMouseDown={(e) => e.preventDefault()}
onClick={() => toggleGroup(group.key)}
>
<svg
class={`model-selector-group-chevron${shown() ? "" : " model-selector-group-chevron--collapsed"}`}
width="10"
height="10"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<path d="M4 6l4 5 4-5H4z" />
</svg>
<span>{group.label}</span>
<Show when={!shown() && !!debouncedSearch()}>
<span class="model-selector-group-match-dot" aria-hidden="true" />
</Show>
</button>
<Show when={shown()}>
<For each={group.rows}>
{(row) => {
if (!row.model) return null
const model = row.model
const hovered = () => isSelected(row.key)
const preActive = () => isPreActive(row.key)
const showSelectBtn = () => expanded() && preActive() && !isActive(model)
const starred = () => favoriteKeys().has(modelKey(model.providerID, model.id))
const showProvider = () => row.kind === "favorite"
return (
<div
ref={(el) => {
refs.set(row.key, el)
onCleanup(() => refs.delete(row.key))
}}
class={`model-selector-item${(hovered() && !pointer()) || preActive() ? " keyboard-focused" : ""}${hovered() || preActive() ? " selected" : ""}${isActive(model) && row.kind === "model" ? " active" : ""}`}
role="option"
aria-selected={isActive(model) && row.kind === "model"}
onClick={() => {
setRow(row.key)
setPreviewKey(row.key)
if (!expanded()) selectRow(row)
searchRef?.focus()
}}
onDblClick={() => {
if (expanded()) selectRow(row)
}}
onMouseMove={() => {
setPointer(true)
}}
onMouseEnter={() => {
if (pointer()) setSelectedKey(row.key)
}}
>
<Icon name={starred() ? "star-filled" : "star"} size="small" />
</button>
</Show>
<Show when={expanded()}>
<button
class={`model-selector-item-select-btn${showSelectBtn() ? "" : " model-selector-item-select-btn--hidden"}`}
onClick={(e) => {
e.stopPropagation()
selectRow(row)
}}
>
{language.t("dialog.model.select")}
</button>
</Show>
</div>
)
}}
</For>
</>
)}
<div class="model-selector-item-left">
<span class="model-selector-item-name">
{(() => {
const full = sanitizeName(model.name)
const sep = full.indexOf(": ")
if (sep < 0) return <span class="model-selector-item-name-main">{full}</span>
return (
<>
<span class="model-selector-item-name-provider">{full.slice(0, sep)}</span>
<span class="model-selector-item-name-main">{full.slice(sep + 2)}</span>
</>
)
})()}
</span>
<Show when={isFree(model)}>
<Tag data-variant="member">{language.t("model.tag.free")}</Tag>
</Show>
<Show when={showProvider()}>
<span class="model-selector-item-provider-tag">{model.providerName}</span>
</Show>
</div>
<Show when={session && props.favorites !== false}>
<button
type="button"
class={`model-selector-star${starred() ? " model-selector-star--active" : ""}`}
aria-label={
starred() ? language.t("model.favorite.remove") : language.t("model.favorite.add")
}
aria-pressed={starred()}
onClick={(e) => {
e.stopPropagation()
toggleFavorite(model, row)
}}
>
<Icon name={starred() ? "star-filled" : "star"} size="small" />
</button>
</Show>
<Show when={expanded()}>
<button
class={`model-selector-item-select-btn${showSelectBtn() ? "" : " model-selector-item-select-btn--hidden"}`}
onClick={(e) => {
e.stopPropagation()
selectRow(row)
}}
>
{language.t("dialog.model.select")}
</button>
</Show>
</div>
)
}}
</For>
</Show>
</>
)
}}
</For>
</div>
+3
View File
@@ -176,6 +176,8 @@ export const dict = {
"model.tag.latest": "الأحدث",
"model.group.recommended": "موصى به",
"model.group.favorites": "المفضلة",
"model.group.collapse": "طي {{group}}",
"model.group.expand": "توسيع {{group}}",
"model.favorite.add": "إضافة إلى المفضلة",
"model.favorite.remove": "إزالة من المفضلة",
"model.provider.anthropic": "Anthropic",
@@ -1586,4 +1588,5 @@ export const dict = {
"diffViewer.baseBranch.empty": "لا توجد فروع مطابقة",
"diffViewer.baseBranch.loading": "جارٍ تحميل الفروع…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "الخطة جاهزة:",
}
+3
View File
@@ -177,6 +177,8 @@ export const dict = {
"model.tag.latest": "Mais recente",
"model.group.recommended": "Recomendado",
"model.group.favorites": "Favoritos",
"model.group.collapse": "Recolher {{group}}",
"model.group.expand": "Expandir {{group}}",
"model.favorite.add": "Adicionar aos favoritos",
"model.favorite.remove": "Remover dos favoritos",
"model.provider.anthropic": "Anthropic",
@@ -1629,4 +1631,5 @@ export const dict = {
"diffViewer.baseBranch.empty": "Nenhum branch correspondente",
"diffViewer.baseBranch.loading": "Carregando branches…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "Plano pronto:",
}
+3
View File
@@ -178,6 +178,8 @@ export const dict = {
"model.tag.latest": "Najnovije",
"model.group.recommended": "Preporučeno",
"model.group.favorites": "Favoriti",
"model.group.collapse": "Sakrij {{group}}",
"model.group.expand": "Prikaži {{group}}",
"model.favorite.add": "Dodaj u favorite",
"model.favorite.remove": "Ukloni iz favorita",
"model.provider.anthropic": "Anthropic",
@@ -1625,4 +1627,5 @@ export const dict = {
"diffViewer.baseBranch.empty": "No matching branches",
"diffViewer.baseBranch.loading": "Loading branches…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "Plan je spreman:",
}
+3
View File
@@ -177,6 +177,8 @@ export const dict = {
"model.tag.latest": "Nyeste",
"model.group.recommended": "Anbefalet",
"model.group.favorites": "Favoritter",
"model.group.collapse": "Skjul {{group}}",
"model.group.expand": "Vis {{group}}",
"model.favorite.add": "Føj til favoritter",
"model.favorite.remove": "Fjern fra favoritter",
@@ -1614,4 +1616,5 @@ export const dict = {
"diffViewer.baseBranch.empty": "No matching branches",
"diffViewer.baseBranch.loading": "Loading branches…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "Planen er klar:",
}
+3
View File
@@ -181,6 +181,8 @@ export const dict = {
"model.tag.latest": "Neueste",
"model.group.recommended": "Empfohlen",
"model.group.favorites": "Favoriten",
"model.group.collapse": "{{group}} einklappen",
"model.group.expand": "{{group}} ausklappen",
"model.favorite.add": "Zu Favoriten hinzufügen",
"model.favorite.remove": "Aus Favoriten entfernen",
@@ -1645,4 +1647,5 @@ export const dict = {
"diffViewer.baseBranch.empty": "Keine passenden Branches",
"diffViewer.baseBranch.loading": "Branches werden geladen…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "Plan ist bereit:",
} satisfies Partial<Record<Keys, string>>

Some files were not shown because too many files have changed in this diff Show More