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
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-docs": patch
|
||||
---
|
||||
|
||||
Update KiloClaw pricing post-beta
|
||||
@@ -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/**"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 832 KiB |
|
After Width: | Height: | Size: 807 KiB |
|
After Width: | Height: | Size: 837 KiB |
|
After Width: | Height: | Size: 591 KiB |
|
After Width: | Height: | Size: 528 KiB |
|
After Width: | Height: | Size: 661 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 53 KiB |
@@ -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>
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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": "الخطة جاهزة:",
|
||||
}
|
||||
|
||||
@@ -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:",
|
||||
}
|
||||
|
||||
@@ -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:",
|
||||
}
|
||||
|
||||
@@ -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:",
|
||||
}
|
||||
|
||||
@@ -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>>
|
||||
|
||||