mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge branch 'main' into docs-improve-onboarding-flow
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix the reasoning-variant and mode dropdowns being clipped inside the New Worktree dialog. The dialog's scroll-container overflow escape now covers all inline selector popovers, not just the model picker, so dropdowns render fully above the prompt input.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix multiline bidirectional prompt input rendering and file mention arrow navigation.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Render images inline in the chat view when the agent reads an image file. Images appear below the read tool card and can be clicked to open a full-size preview.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Improve JetBrains session and code block padding alignment.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Reduce JetBrains plugin size by downloading the Kilo Core release on first connect.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Cap JetBrains reasoning and shell hover previews to a compact popup size.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Match the JetBrains progress text color to transcript text.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Hide the JetBrains prompt separator while the prompt is focused.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Balance JetBrains prompt input text padding.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Disable spellchecking in the JetBrains prompt input.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Remove old JetBrains CLI binaries so they no longer accumulate in the IDE cache. Only the active version is kept, the downloaded archive is deleted after extraction, and reinstalling re-downloads a fresh binary.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Auto-collapse JetBrains reasoning blocks when they finish streaming, keep manual expand/collapse choices, and preview collapsed reasoning on hover.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Use the session background across the JetBrains chat view from initial render.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Fix the JetBrains plugin finding shell-installed tools like bun and gh when launched from Finder or Dock.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Integrate project memory into the VS Code extension: memory status and controls in the Context settings tab, task-header and assistant-message affordances, the `/memory` prompt command, and Show/Toggle Project Memory command-palette entries.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Allow Shift+Arrow selections in the prompt input to shrink back across file mentions.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Add a reload action that reboots the per-directory instance, picking up config, skills, agents, commands, and MCP prompts changed on disk. Sessions and history are preserved. Surfaces: `/reload` in the CLI palette and editor chat, a reload button in the task header and settings panel, the `Kilo Code: Reload Config and Skills` command, and a `POST /instance/reload` HTTP endpoint. The endpoint returns 409 while a session is actively running.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Widen the Additional Writable Paths input in the Sandboxing settings so longer filesystem paths are easier to read while typing.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"@kilocode/kilo-gateway": patch
|
||||
---
|
||||
|
||||
Use cloud account preferences to select the active Kilo organization and hide unavailable personal accounts.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Preserve the selected mode when sending the first message in a new VS Code task so the chosen model is paired with the correct agent instructions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Wait for microphone capture to start before showing voice input as recording.
|
||||
@@ -125,19 +125,11 @@ jobs:
|
||||
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
|
||||
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Prepare CLI resources
|
||||
working-directory: packages/kilo-jetbrains
|
||||
run: bun script/build.ts --production --prepare-cli
|
||||
env:
|
||||
KILO_VERSION: ${{ steps.release.outputs.version }}
|
||||
KILO_CHANNEL: ${{ steps.release.outputs.cli_channel }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
|
||||
- name: Verify plugin
|
||||
working-directory: packages/kilo-jetbrains
|
||||
run: ./gradlew verifyPlugin -Pproduction=true -Pkilo.channel="$CHANNEL"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
CHANNEL: ${{ steps.release.outputs.marketplace_channel }}
|
||||
|
||||
|
||||
@@ -34,7 +34,40 @@ Accepted specs:
|
||||
| `x.y.z-rc.n` | Explicit RC release. |
|
||||
| `x.y.z` | Explicit stable release. |
|
||||
|
||||
Show the resolved `version`, `kind`, and default `fromTagDefault` to the user and ask for confirmation before continuing.
|
||||
Show the resolved `version`, `kind`, and default `fromTagDefault` to the user.
|
||||
|
||||
## CLI Pin Verification
|
||||
|
||||
Before dispatching prepare, verify the JetBrains plugin is pinned to the intended Kilo Core release. The plugin downloads the CLI version from `packages/kilo-jetbrains/package.json`, not from the JetBrains plugin version.
|
||||
|
||||
Read the pinned CLI version:
|
||||
|
||||
```bash
|
||||
bun -e 'const p=require("./packages/kilo-jetbrains/package.json"); console.log(p.version)'
|
||||
```
|
||||
|
||||
Verify the matching GitHub Release exists and includes every runtime asset the backend may download:
|
||||
|
||||
```bash
|
||||
cli_version="7.4.1"
|
||||
gh release view "v${cli_version}" --repo Kilo-Org/kilocode --json assets \
|
||||
--jq '.assets[].name' | sort
|
||||
```
|
||||
|
||||
Expected assets:
|
||||
|
||||
```text
|
||||
kilo-darwin-arm64.zip
|
||||
kilo-darwin-x64.zip
|
||||
kilo-linux-arm64.tar.gz
|
||||
kilo-linux-x64.tar.gz
|
||||
kilo-windows-arm64.zip
|
||||
kilo-windows-x64.zip
|
||||
```
|
||||
|
||||
If the pin is stale or the release assets are missing, stop and ask the user to update `packages/kilo-jetbrains/package.json` on `main` before dispatching prepare. The prepare workflow tags `origin/main`, so the pin must already be reviewed and merged before the release tag is created.
|
||||
|
||||
Show the resolved JetBrains plugin version, release kind, default `fromTagDefault`, pinned CLI version, and CLI release asset status to the user, then ask for confirmation before continuing.
|
||||
|
||||
## Prepare Workflow
|
||||
|
||||
|
||||
+2
-2
@@ -126,10 +126,10 @@ Requires Java 21 (see [Prerequisites](#prerequisites)). From `packages/kilo-jetb
|
||||
```bash
|
||||
./gradlew typecheck # Compile-check all Kotlin sources
|
||||
./gradlew test # Run all tests (backend + frontend)
|
||||
./gradlew --no-configuration-cache runIdeSplitMode # Launch local split-mode sandbox and prepare the CLI
|
||||
./gradlew --no-configuration-cache runIdeSplitMode # Launch local split-mode sandbox; backend downloads the pinned CLI
|
||||
```
|
||||
|
||||
Use `./gradlew runIde` only for a monolithic sandbox; prepare the CLI first with `bun run build --prepare-cli`.
|
||||
Use `./gradlew runIde` only for a monolithic sandbox. JetBrains dev runs do not build or bundle CLI binaries; the backend downloads the pinned release at connect time.
|
||||
|
||||
Or via the root turbo filter to run only JetBrains checks from the repo root:
|
||||
|
||||
|
||||
@@ -338,6 +338,7 @@
|
||||
"@kilocode/kilo-gateway": "workspace:*",
|
||||
"@kilocode/kilo-i18n": "workspace:*",
|
||||
"@kilocode/kilo-indexing": "workspace:*",
|
||||
"@kilocode/kilo-memory": "workspace:*",
|
||||
"@kilocode/kilo-ui": "workspace:*",
|
||||
"@kilocode/plugin": "workspace:*",
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-jTNTUFR9PuOqU3HVmA9tWR6E07O+LgQyubZOvQOxZ5I=",
|
||||
"aarch64-linux": "sha256-afSECQ+kAsWirSpUAPe8sVcsal4Cu37MBWG/klc5I4Q=",
|
||||
"aarch64-darwin": "sha256-z2LNeKVnKu95c6W2vl1x0qHTmZK9nwJlneurDibLjYw=",
|
||||
"x86_64-darwin": "sha256-9dRFffouzJNfewnMm9Hm0FgIbYeUMBgv9mJUEwlSZS0="
|
||||
"x86_64-linux": "sha256-5/NgXEBhaNEEqIz9GVCXxQaLJwkqWXAAZOPoa6GyHTI=",
|
||||
"aarch64-linux": "sha256-qt7PKn0SAhDCcYtXRY9+1TBp9x3Far7kK24jhUzoMf8=",
|
||||
"aarch64-darwin": "sha256-3FN/i3chx01S52lAv0tjxVtRpUlPkIfInphpq+69uWA=",
|
||||
"x86_64-darwin": "sha256-+rOacJLx+qOCRGW4F+AjvFcc9XUE2biB0BRSy3dTWGI="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ Use the Markdoc codicon tag format:
|
||||
|
||||
## Documentation Guidelines
|
||||
|
||||
### Style Guide
|
||||
|
||||
Before writing documentation, review `packages/kilo-docs/STYLE_GUIDE.md` for voice, tone, and formatting conventions.
|
||||
|
||||
### Adding New Pages
|
||||
|
||||
1. Create your page in the appropriate directory under `pages/`
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
---
|
||||
title: "Documentation Style Guide"
|
||||
description: "Guidelines for writing Kilo Code documentation"
|
||||
---
|
||||
|
||||
# Documentation Style Guide
|
||||
|
||||
This guide covers writing, formatting, and structuring documentation for the Kilo Code docs site.
|
||||
|
||||
## Voice and Tone
|
||||
|
||||
Kilo Code documentation should be:
|
||||
|
||||
- **Clear and direct** - Cut unnecessary words. Prefer active voice.
|
||||
- **Helpful, not salesy** - Focus on what users can do, not just what's possible.
|
||||
- **Consistent** - Use the same terminology and phrasing across pages.
|
||||
- **Friendly but professional** - Write as a knowledgeable teammate explaining concepts.
|
||||
|
||||
### Do
|
||||
|
||||
- Write in the second person ("you")
|
||||
- Use present tense
|
||||
- Be specific: "Run `kilo run` to execute a task" not "You can run kilo run"
|
||||
|
||||
### Don't
|
||||
|
||||
- Use marketing fluff or hype language
|
||||
- Write in passive voice when active is clearer
|
||||
- Assume prior knowledge not explicitly stated
|
||||
|
||||
## Headings
|
||||
|
||||
- Use sentence case for heading text
|
||||
- Start with the most important word
|
||||
- One heading per section
|
||||
- Use heading levels logically (don't skip from H2 to H4)
|
||||
|
||||
```markdown
|
||||
## Installing Kilo Code
|
||||
|
||||
### VS Code Extension
|
||||
|
||||
### CLI
|
||||
```
|
||||
|
||||
## Procedures
|
||||
|
||||
Use numbered lists for step-by-step instructions. Each step should be a complete action.
|
||||
|
||||
```markdown
|
||||
1. Open VS Code
|
||||
2. Go to Extensions (Ctrl+Shift+X / Cmd+Shift+X)
|
||||
3. Search for "Kilo Code"
|
||||
4. Click the dropdown arrow next to **Install** and select **Install Pre-Release Version**
|
||||
```
|
||||
|
||||
### Procedural tips
|
||||
|
||||
- Include keyboard shortcuts in parentheses
|
||||
- Use present tense
|
||||
- Start each step with a verb
|
||||
- Don't number sub-steps; use nested lists instead
|
||||
|
||||
## Callouts
|
||||
|
||||
Use callouts to highlight important information. Choose the right type:
|
||||
|
||||
| Type | Use for |
|
||||
|---|---|
|
||||
| `note` | General information users should know |
|
||||
| `tip` | Helpful shortcuts or best practices |
|
||||
| `info` | Context or background information |
|
||||
| `warning` | Potential problems or important cautions |
|
||||
| `danger` | Critical warnings that could cause data loss |
|
||||
| `generic` | Content without a specific visual treatment |
|
||||
|
||||
```markdown
|
||||
{% callout type="tip" %}
|
||||
**The easiest way to configure Kilo is to ask the agent.** Just tell the agent what you want.
|
||||
{% /callout %}
|
||||
```
|
||||
|
||||
## Cross-References
|
||||
|
||||
- Use absolute paths starting from `/docs/` for internal links
|
||||
- Don't include `.md` extensions
|
||||
- Use descriptive link text, not "click here"
|
||||
|
||||
```markdown
|
||||
Good: [Quickstart Guide](/docs/getting-started/quickstart)
|
||||
|
||||
Bad: [Click here](/docs/getting-started/quickstart)
|
||||
```
|
||||
|
||||
## Code Examples
|
||||
|
||||
- Use fenced code blocks with language specified
|
||||
- Include comments in code where helpful
|
||||
- Show realistic, working examples
|
||||
- Use `kilo run` for CLI examples, not hypothetical commands
|
||||
|
||||
````markdown
|
||||
```bash
|
||||
kilo run "create a utils.py file with a function that adds two numbers"
|
||||
```
|
||||
````
|
||||
|
||||
### Code in prose
|
||||
|
||||
Use backticks for inline code, file references, and commands:
|
||||
|
||||
- `kilo.jsonc` for configuration files
|
||||
- `Ctrl+Shift+X` for keyboard shortcuts
|
||||
- `src/utils.ts` for file paths
|
||||
|
||||
## Markdoc Conventions
|
||||
|
||||
### Images
|
||||
|
||||
Use the Markdoc image tag format:
|
||||
|
||||
```markdown
|
||||
{% image src="/docs/img/kilo-provider/connected-accounts.png" alt="Connect account screen" width="800" caption="Connect account screen" /%}
|
||||
```
|
||||
|
||||
**Image path rules:**
|
||||
- Always include `/docs` prefix
|
||||
- Use generated screenshots from `packages/kilo-docs/public/img/screenshot-tests/` when available
|
||||
- Write descriptive alt text for accessibility
|
||||
|
||||
### Tables
|
||||
|
||||
Use compact markdown tables without padding:
|
||||
|
||||
```markdown
|
||||
| Command | What it runs |
|
||||
|---|---|
|
||||
| `kilo serve` | The prod CLI on `$PATH`. |
|
||||
```
|
||||
|
||||
### Tabs
|
||||
|
||||
Use tabs for platform-specific content:
|
||||
|
||||
```markdown
|
||||
{% tabs %}
|
||||
{% tab label="VS Code" %}
|
||||
|
||||
Content for VS Code
|
||||
|
||||
{% /tab %}
|
||||
{% tab label="CLI" %}
|
||||
|
||||
Content for CLI
|
||||
|
||||
{% /tab %}
|
||||
{% /tabs %}
|
||||
```
|
||||
|
||||
### Mermaid Diagrams
|
||||
|
||||
Use fenced `mermaid` blocks for architecture diagrams:
|
||||
|
||||
````markdown
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A --> B
|
||||
```
|
||||
````
|
||||
|
||||
## LLM-Generated Docs
|
||||
|
||||
This documentation site is maintained with AI assistance. When reviewing or editing:
|
||||
|
||||
- Verify technical accuracy manually
|
||||
- Ensure examples actually work
|
||||
- Check that terminology is consistent
|
||||
- Don't accept generated content without review
|
||||
|
||||
## Terminology
|
||||
|
||||
Use consistent terms throughout:
|
||||
|
||||
| Term | Use for |
|
||||
|---|---|
|
||||
| Kilo Code | The product name |
|
||||
| kilo CLI | The command-line interface |
|
||||
| VS Code extension | The VS Code extension |
|
||||
| JetBrains plugin | The JetBrains IDE plugin |
|
||||
| `kilo serve` | The local HTTP server |
|
||||
| `kilo run` | The headless execution command |
|
||||
| agent | The AI assistant |
|
||||
|
||||
## Navigation
|
||||
|
||||
- Add new pages to the appropriate nav file in `lib/nav/`
|
||||
- Update `lib/nav/index.ts` to export the new nav section
|
||||
- Navigation files are organized by section (e.g., `getting-started.ts`, `code-with-ai.ts`)
|
||||
|
||||
## Documentation Lifecycle
|
||||
|
||||
- Follow the branch naming convention: `docs/description-of-change`
|
||||
- For documentation-only changes, create branches with the `docs/` prefix
|
||||
- Update navigation when adding or removing pages
|
||||
- Add redirects in `previous-docs-redirects.js` when moving or removing pages
|
||||
@@ -10,7 +10,6 @@ export const AutomateNav: NavSection[] = [
|
||||
href: "/automate/code-reviews/overview",
|
||||
children: "Code Reviews",
|
||||
subLinks: [
|
||||
{ href: "/automate/code-reviews/overview", children: "Overview" },
|
||||
{ href: "/automate/code-reviews/github", children: "GitHub" },
|
||||
{ href: "/automate/code-reviews/gitlab", children: "GitLab" },
|
||||
],
|
||||
@@ -19,7 +18,6 @@ export const AutomateNav: NavSection[] = [
|
||||
href: "/automate/agent-manager",
|
||||
children: "Agent Manager",
|
||||
subLinks: [
|
||||
{ href: "/automate/agent-manager", children: "Reference" },
|
||||
{ href: "/automate/agent-manager-workflows", children: "Workflows" },
|
||||
],
|
||||
},
|
||||
@@ -42,7 +40,6 @@ export const AutomateNav: NavSection[] = [
|
||||
href: "/automate/mcp/overview",
|
||||
children: "MCP",
|
||||
subLinks: [
|
||||
{ href: "/automate/mcp/overview", children: "MCP Overview" },
|
||||
{
|
||||
href: "/automate/mcp/using-in-kilo-code",
|
||||
children: "Using MCP in Kilo Code",
|
||||
|
||||
@@ -30,6 +30,11 @@ export const CodeWithAiNav: NavSection[] = [
|
||||
},
|
||||
{ href: "/code-with-ai/platforms/cloud-agent", children: "Cloud Agent" },
|
||||
{ href: "/code-with-ai/platforms/mobile", children: "Mobile Apps" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Features",
|
||||
links: [
|
||||
{ href: "/code-with-ai/app-builder", children: "App Builder" },
|
||||
{
|
||||
href: "/code-with-ai/gastown",
|
||||
@@ -80,7 +85,6 @@ export const CodeWithAiNav: NavSection[] = [
|
||||
href: "/code-with-ai/agents/using-agents",
|
||||
children: "Agents",
|
||||
subLinks: [
|
||||
{ href: "/code-with-ai/agents/using-agents", children: "Using Agents" },
|
||||
{
|
||||
href: "/code-with-ai/agents/orchestrator-mode",
|
||||
children: "Orchestrator Mode",
|
||||
@@ -89,7 +93,6 @@ export const CodeWithAiNav: NavSection[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
title: "Productivity Tools",
|
||||
links: [
|
||||
@@ -113,11 +116,7 @@ export const CodeWithAiNav: NavSection[] = [
|
||||
href: "/code-with-ai/features/browser-use",
|
||||
children: "Agent Behavior",
|
||||
subLinks: [
|
||||
{ href: "/code-with-ai/features/browser-use", children: "Browser Use" },
|
||||
{
|
||||
href: "/code-with-ai/features/task-todo-list",
|
||||
children: "Task Todo List",
|
||||
},
|
||||
{ href: "/code-with-ai/features/task-todo-list", children: "Task Todo List" },
|
||||
{ href: "/code-with-ai/features/checkpoints", children: "Checkpoints" },
|
||||
{ href: "/code-with-ai/features/file-encoding", children: "File Encoding" },
|
||||
],
|
||||
|
||||
@@ -27,27 +27,26 @@ export const CollaborateNav: NavSection[] = [
|
||||
},
|
||||
{ href: "/collaborate/teams/billing", children: "Billing" },
|
||||
{ href: "/collaborate/teams/analytics", children: "Analytics" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "AI Adoption Dashboard",
|
||||
links: [
|
||||
{
|
||||
href: "/collaborate/adoption-dashboard/overview",
|
||||
children: "AI Adoption Dashboard",
|
||||
subLinks: [
|
||||
{
|
||||
href: "/collaborate/adoption-dashboard/overview",
|
||||
children: "Overview",
|
||||
},
|
||||
{
|
||||
href: "/collaborate/adoption-dashboard/understanding-your-score",
|
||||
children: "Understanding Your Score",
|
||||
},
|
||||
{
|
||||
href: "/collaborate/adoption-dashboard/improving-your-score",
|
||||
children: "Improving Your Score",
|
||||
},
|
||||
{
|
||||
href: "/collaborate/adoption-dashboard/for-team-leads",
|
||||
children: "For Team Leads",
|
||||
},
|
||||
],
|
||||
children: "Overview",
|
||||
},
|
||||
{
|
||||
href: "/collaborate/adoption-dashboard/understanding-your-score",
|
||||
children: "Understanding Your Score",
|
||||
},
|
||||
{
|
||||
href: "/collaborate/adoption-dashboard/improving-your-score",
|
||||
children: "Improving Your Score",
|
||||
},
|
||||
{
|
||||
href: "/collaborate/adoption-dashboard/for-team-leads",
|
||||
children: "For Team Leads",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -28,7 +28,10 @@ export const GettingStartedNav: NavSection[] = [
|
||||
],
|
||||
},
|
||||
{ href: "/getting-started/adding-credits", children: "Adding Credits" },
|
||||
{ href: "/getting-started/rate-limits-and-costs", children: "Cost Efficiency & Model Selection" },
|
||||
{
|
||||
href: "/getting-started/rate-limits-and-costs",
|
||||
children: "Cost Efficiency & Model Selection",
|
||||
},
|
||||
{ href: "/getting-started/cost-controls-and-usage-safeguards", children: "Cost Controls and Usage Safeguards" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -12,7 +12,6 @@ export const KiloClawNav: NavSection[] = [
|
||||
href: "/kiloclaw/control-ui/overview",
|
||||
children: "Control UI",
|
||||
subLinks: [
|
||||
{ href: "/kiloclaw/control-ui/overview", children: "Overview" },
|
||||
{ href: "/kiloclaw/control-ui/changing-models", children: "Changing Models" },
|
||||
{ href: "/kiloclaw/control-ui/exec-approvals", children: "Exec Approvals" },
|
||||
{ href: "/kiloclaw/control-ui/version-pinning", children: "Version Pinning" },
|
||||
@@ -22,7 +21,6 @@ export const KiloClawNav: NavSection[] = [
|
||||
href: "/kiloclaw/chat-platforms",
|
||||
children: "Chat Platforms",
|
||||
subLinks: [
|
||||
{ href: "/kiloclaw/chat-platforms", children: "Overview" },
|
||||
{ href: "/kiloclaw/chat-platforms/telegram", children: "Telegram" },
|
||||
{ href: "/kiloclaw/chat-platforms/discord", children: "Discord" },
|
||||
{ href: "/kiloclaw/chat-platforms/slack", children: "Slack" },
|
||||
@@ -32,7 +30,6 @@ export const KiloClawNav: NavSection[] = [
|
||||
href: "/kiloclaw/development-tools",
|
||||
children: "Integrations",
|
||||
subLinks: [
|
||||
{ href: "/kiloclaw/development-tools", children: "Overview" },
|
||||
{ href: "/kiloclaw/development-tools/github", children: "GitHub" },
|
||||
{ href: "/kiloclaw/development-tools/google", children: "Google Workspace" },
|
||||
{ href: "/kiloclaw/development-tools/linear", children: "Linear" },
|
||||
@@ -47,7 +44,6 @@ export const KiloClawNav: NavSection[] = [
|
||||
href: "/kiloclaw/triggers",
|
||||
children: "Triggers",
|
||||
subLinks: [
|
||||
{ href: "/kiloclaw/triggers", children: "Overview" },
|
||||
{ href: "/kiloclaw/triggers/webhooks", children: "Webhooks" },
|
||||
{ href: "/kiloclaw/triggers/scheduled", children: "Scheduled" },
|
||||
],
|
||||
|
||||
@@ -117,6 +117,7 @@ For detailed help on every command and subcommand, see the [CLI Command Referenc
|
||||
| `/status` | - | View status |
|
||||
| `/themes` | - | Switch theme |
|
||||
| `/help` | - | Show help |
|
||||
| `/reload` | - | Reload config, skills, agents, and commands from disk |
|
||||
| `/editor` | - | Open external editor |
|
||||
| `/exit` | `/quit`, `/q` | Exit the app |
|
||||
|
||||
|
||||
@@ -131,10 +131,10 @@ JetBrains Kotlin toolchain is Java 21. Gradle commands report missing or incompa
|
||||
| Typecheck | `./gradlew typecheck` |
|
||||
| Tests | `./gradlew test` |
|
||||
| Full plugin build | `bun run build` |
|
||||
| Gradle plugin assembly with prepared CLI binaries | `./gradlew buildPlugin` |
|
||||
| Gradle plugin assembly with pinned CLI download | `./gradlew buildPlugin` |
|
||||
| Split-mode sandbox | `./gradlew --no-configuration-cache runIdeSplitMode` |
|
||||
| Split backend sandbox | `./gradlew --no-configuration-cache runIdeBackend` |
|
||||
| Monolithic sandbox IDE | `./gradlew runIde` after `bun run build --prepare-cli` |
|
||||
| Monolithic sandbox IDE | `./gradlew runIde` |
|
||||
|
||||
Run `Plugin DevKit | Code | Frontend and Backend API Usage` inspection when moving code across split boundary.
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@ Skills are discovered when a session starts. The CLI scans all configured skill
|
||||
- In the **CLI**: Skills are loaded when you start a new session or run `kilo run`
|
||||
- In the **VS Code extension**: Skills are loaded when the extension connects to the CLI server
|
||||
|
||||
Skills are re-scanned at the start of each new session. To pick up newly added or modified skills, start a new session.
|
||||
Skills are re-scanned at the start of each new session. To pick up newly added or modified skills without starting a new session, use `/reload`.
|
||||
|
||||
{% /tab %}
|
||||
{% tab label="CLI" %}
|
||||
@@ -242,7 +242,7 @@ Skills are discovered when a session starts. The CLI scans all configured skill
|
||||
- In the **CLI**: Skills are loaded when you start a new session or run `kilo run`
|
||||
- In the **VS Code extension**: Skills are loaded when the extension connects to the CLI server
|
||||
|
||||
Skills are re-scanned at the start of each new session. To pick up newly added or modified skills, start a new session.
|
||||
Skills are re-scanned at the start of each new session. To pick up newly added or modified skills without starting a new session, use `/reload`.
|
||||
|
||||
{% /tab %}
|
||||
{% /tabs %}
|
||||
@@ -430,7 +430,7 @@ The new platform does not have a marketplace UI yet. You can find and share skil
|
||||
|
||||
1. **Verify frontmatter**: Ensure `name` and `description` are present in the YAML frontmatter. The `name` does not need to match the directory name but should be unique across all loaded skills.
|
||||
|
||||
2. **Start a new session**: Skills are scanned at session start. Begin a new session to pick up changes.
|
||||
2. **Reload or start a new session**: Use `/reload` to pick up changes without losing your current session, or start a new session.
|
||||
|
||||
3. **Check file location**: Ensure `SKILL.md` is directly inside the skill directory (e.g., `.kilo/skills/my-skill/SKILL.md`), not nested further.
|
||||
|
||||
@@ -441,7 +441,7 @@ The new platform does not have a marketplace UI yet. You can find and share skil
|
||||
|
||||
1. **Verify frontmatter**: Ensure `name` and `description` are present in the YAML frontmatter. The `name` does not need to match the directory name but should be unique across all loaded skills.
|
||||
|
||||
2. **Start a new session**: Skills are scanned at session start. Begin a new session to pick up changes.
|
||||
2. **Reload or start a new session**: Use `/reload` to pick up changes without losing your current session, or start a new session.
|
||||
|
||||
3. **Check file location**: Ensure `SKILL.md` is directly inside the skill directory (e.g., `.kilo/skills/my-skill/SKILL.md`), not nested further.
|
||||
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2f7edbc1bf6f10260626bf21406bb02e32dc17bf969c83e8b56251c50f755a1f
|
||||
size 7737
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:92cf06c01b19386f2041e378a08afdaf880fdc5c3e19132350fbda05684dbeec
|
||||
size 5775
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a817c2b87e1d7b28a7c28deaa8fd8d1e9c51aa2daabda1a2de4a0db0a33fcc17
|
||||
size 27768
|
||||
oid sha256:ea36a9604fbc773d2312289f46205221e607a927b0d302b1d9a5e6bdc1d80308
|
||||
size 28888
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f4dbd3be2cdf72a789c17076afa73ddeb2eacbe201ace8d8a2657eb95b23a413
|
||||
size 53851
|
||||
oid sha256:1ee158333ea6c4478f6c167eac878bdc5969fce47ba9f68a1e731fd9035e9e49
|
||||
size 52312
|
||||
|
||||
@@ -25,6 +25,8 @@ export async function fetchProfile(token: string): Promise<KilocodeProfile> {
|
||||
email?: string
|
||||
name?: string
|
||||
organizations?: Organization[]
|
||||
selectedOrganizationId?: string | null
|
||||
hasPersonalAccount?: boolean | null
|
||||
}
|
||||
// Backend returns { user: { email, name, ... }, organizations }
|
||||
// Transform to flat KilocodeProfile structure
|
||||
@@ -32,6 +34,8 @@ export async function fetchProfile(token: string): Promise<KilocodeProfile> {
|
||||
email: data.user?.email ?? data.email ?? "",
|
||||
name: data.user?.name ?? data.name,
|
||||
organizations: data.organizations,
|
||||
selectedOrganizationId: data.selectedOrganizationId ?? undefined,
|
||||
hasPersonalAccount: data.hasPersonalAccount ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +44,21 @@ export async function fetchProfile(token: string): Promise<KilocodeProfile> {
|
||||
*/
|
||||
export const getKiloProfile = fetchProfile
|
||||
|
||||
/**
|
||||
* Resolve the organization a fresh login should default to.
|
||||
*
|
||||
* Applied once at login time (not on every profile fetch): prefer the cloud's
|
||||
* validated `selectedOrganizationId`, and fall back to the first organization
|
||||
* when the account has no personal account to sit on. Returns `undefined` to
|
||||
* mean "personal account".
|
||||
*/
|
||||
export function defaultOrganizationId(profile: KilocodeProfile): string | undefined {
|
||||
const orgs = profile.organizations ?? []
|
||||
const selected = profile.selectedOrganizationId
|
||||
const valid = selected && orgs.some((org) => org.id === selected) ? selected : undefined
|
||||
return valid ?? (profile.hasPersonalAccount === false ? orgs[0]?.id : undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user balance from Kilo API
|
||||
* @param token - Authentication token
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execFile } from "child_process"
|
||||
import type { DeviceAuthInitiateResponse, DeviceAuthPollResponse } from "../types.js"
|
||||
import { poll } from "./polling.js"
|
||||
import { getKiloProfile, getKiloDefaultModel } from "../api/profile.js"
|
||||
import { getKiloProfile, getKiloDefaultModel, defaultOrganizationId } from "../api/profile.js"
|
||||
import { KILO_API_BASE, POLL_INTERVAL_MS } from "../api/constants.js"
|
||||
import type { AuthOuathResult } from "@kilocode/plugin"
|
||||
|
||||
@@ -127,13 +127,14 @@ export async function authenticateWithDeviceAuthTUI(inputs?: Record<string, stri
|
||||
|
||||
const token = result.token
|
||||
|
||||
// For TUI version, complete with personal account by default
|
||||
// Organization selection is handled by TUI after this callback completes
|
||||
// The TUI will fetch the profile separately and show organization dialog if needed
|
||||
const organizationId = undefined
|
||||
// Apply the cloud-selected organization as the login-time default.
|
||||
// After this, the stored accountId is authoritative; the user can switch
|
||||
// freely and profile fetches never re-derive it from the cloud.
|
||||
const profile = await getKiloProfile(token).catch(() => undefined)
|
||||
const organizationId = profile ? defaultOrganizationId(profile) : undefined
|
||||
|
||||
// Fetch default model
|
||||
const model = await getKiloDefaultModel(token, organizationId)
|
||||
await getKiloDefaultModel(token, organizationId)
|
||||
|
||||
// Return success with OAuth credentials
|
||||
return {
|
||||
@@ -142,6 +143,7 @@ export async function authenticateWithDeviceAuthTUI(inputs?: Record<string, stri
|
||||
refresh: token,
|
||||
access: token,
|
||||
expires: Date.now() + 365 * 24 * 60 * 60 * 1000, // 1 year
|
||||
...(organizationId && { accountId: organizationId }),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
fetchProfileWithBalance,
|
||||
fetchDefaultModel,
|
||||
getKiloProfile,
|
||||
defaultOrganizationId,
|
||||
getKiloBalance,
|
||||
getKiloDefaultModel,
|
||||
promptOrganizationSelection,
|
||||
|
||||
@@ -101,6 +101,8 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
|
||||
email: z.string(),
|
||||
name: z.string().optional(),
|
||||
organizations: z.array(Organization).optional(),
|
||||
selectedOrganizationId: z.string().optional(),
|
||||
hasPersonalAccount: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const Balance = z.object({
|
||||
|
||||
@@ -56,19 +56,25 @@ export function formatProfileInfo(
|
||||
export function getOrganizationOptions(
|
||||
organizations: Organization[],
|
||||
currentOrgId?: string,
|
||||
hasPersonalAccount = true,
|
||||
): Array<{
|
||||
title: string
|
||||
value: string | null
|
||||
description?: string
|
||||
category: string
|
||||
}> {
|
||||
const personal = hasPersonalAccount || organizations.length === 0
|
||||
return [
|
||||
{
|
||||
title: "Personal Account",
|
||||
value: null,
|
||||
description: !currentOrgId ? "→ (current)" : undefined,
|
||||
category: "Accounts",
|
||||
},
|
||||
...(personal
|
||||
? [
|
||||
{
|
||||
title: "Personal Account",
|
||||
value: null,
|
||||
description: !currentOrgId ? "→ (current)" : undefined,
|
||||
category: "Accounts",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...organizations.map((org) => ({
|
||||
title: org.name,
|
||||
value: org.id,
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface KilocodeProfile {
|
||||
email: string
|
||||
name?: string
|
||||
organizations?: Organization[]
|
||||
selectedOrganizationId?: string
|
||||
hasPersonalAccount?: boolean
|
||||
}
|
||||
|
||||
export interface KilocodeBalance {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { defaultOrganizationId } from "../../src/api/profile.js"
|
||||
import type { KilocodeProfile } from "../../src/types.js"
|
||||
|
||||
const profile = (input: Partial<KilocodeProfile> = {}): KilocodeProfile => ({
|
||||
email: "user@example.com",
|
||||
organizations: [{ id: "org_1", name: "Acme", role: "MEMBER" }],
|
||||
...input,
|
||||
})
|
||||
|
||||
describe("defaultOrganizationId", () => {
|
||||
test("defaults to the cloud selected organization", () => {
|
||||
expect(defaultOrganizationId(profile({ selectedOrganizationId: "org_1" }))).toBe("org_1")
|
||||
})
|
||||
|
||||
test("defaults to personal when there is no cloud selection", () => {
|
||||
expect(defaultOrganizationId(profile())).toBeUndefined()
|
||||
})
|
||||
|
||||
test("ignores a cloud selection that is not one of the user's organizations", () => {
|
||||
expect(defaultOrganizationId(profile({ selectedOrganizationId: "missing" }))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("falls back to the first organization when there is no personal account", () => {
|
||||
expect(
|
||||
defaultOrganizationId(
|
||||
profile({
|
||||
hasPersonalAccount: false,
|
||||
organizations: [
|
||||
{ id: "org_1", name: "Acme", role: "MEMBER" },
|
||||
{ id: "org_2", name: "Beta", role: "MEMBER" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe("org_1")
|
||||
})
|
||||
|
||||
test("prefers a valid cloud selection over the first-organization fallback", () => {
|
||||
expect(
|
||||
defaultOrganizationId(
|
||||
profile({
|
||||
selectedOrganizationId: "org_2",
|
||||
hasPersonalAccount: false,
|
||||
organizations: [
|
||||
{ id: "org_1", name: "Acme", role: "MEMBER" },
|
||||
{ id: "org_2", name: "Beta", role: "MEMBER" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe("org_2")
|
||||
})
|
||||
})
|
||||
@@ -16,3 +16,8 @@ bin/
|
||||
**/.kotlin/
|
||||
**/bin/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Downloaded CLI artifacts
|
||||
**/cli-cache/
|
||||
kilo-*.zip
|
||||
kilo-*.tar.gz
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
- `plugin.xml` `<content>` entries ↔ module XML descriptors (`kilo.jetbrains.{shared,frontend,backend}.xml`)
|
||||
- Service classes ↔ `<applicationService>`/`<projectService>` entries in the corresponding module XML
|
||||
- `script/build.ts` platform list ↔ `backend/build.gradle.kts` `requiredPlatforms` list
|
||||
- `packages/kilo-jetbrains/package.json` version ↔ GitHub CLI release tag consumed by the backend downloader
|
||||
|
||||
## IntelliJ Platform Source Lookup
|
||||
|
||||
@@ -153,7 +153,9 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
|
||||
|
||||
## CLI Integration
|
||||
|
||||
- CLI process spawning, extraction, and lifecycle belong in `backend`.
|
||||
- CLI process spawning, download, extraction, and lifecycle belong in `backend`.
|
||||
- The plugin does not bundle CLI binaries. At connect time the backend downloads the GitHub Release asset for the version pinned in `packages/kilo-jetbrains/package.json`; `backend` resources include `kilo.properties` with `cli.version` for split-mode RPC and runtime use.
|
||||
- The generated API client is produced from the pinned release binary by running `kilo generate` during the Gradle OpenAPI generation task.
|
||||
- For OS and environment checks, prefer IntelliJ Platform classes over raw JVM APIs such as `System.getProperty(...)` or `System.getenv(...)`.
|
||||
- Detect architecture with `com.intellij.util.system.CpuArch.CURRENT`, not `System.getProperty("os.arch")`.
|
||||
- Detect OS with `com.intellij.openapi.util.SystemInfo.isMac` / `isLinux` / `isWindows`.
|
||||
@@ -187,22 +189,21 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
|
||||
|
||||
## Build and Verification
|
||||
|
||||
- **Marketplace version build**: Use `script/build-version.sh <version>` from `packages/kilo-jetbrains/` to clean, prepare production CLI binaries, build, sign, and verify the JetBrains Marketplace plugin ZIP. Pass `--skip-verification` only when explicitly needed.
|
||||
- **Marketplace version build**: Use `script/build-version.sh <version>` from `packages/kilo-jetbrains/` to clean, build, sign, and verify the JetBrains Marketplace plugin ZIP. Pass `--skip-verification` only when explicitly needed.
|
||||
- **Test version build**: If the user asks for a JetBrains test build, still require a version and use `script/build-version.sh <version> --skip-signing --skip-verification` from `packages/kilo-jetbrains/` so no signing secrets are needed. Add `--skip-clean` only when the user wants a faster incremental test build.
|
||||
- **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).
|
||||
- **Typecheck**: `bun run typecheck` or `./gradlew typecheck` from `packages/kilo-jetbrains/` — compiles all Kotlin sources including the generated API client. A cold build downloads the pinned CLI release via `generateOpenApiSpec` and needs network access; Gradle-cached incremental runs skip the download. It does not bundle per-platform CLI binaries.
|
||||
- **Full build**: `bun run build` from `packages/kilo-jetbrains/` (runs Gradle `buildPlugin`).
|
||||
- **Gradle only**: `./gradlew buildPlugin` from `packages/kilo-jetbrains/`.
|
||||
- **Java checks**: Do not run `java -version` as a routine preflight. Gradle commands already fail clearly when Java is missing or incompatible; check Java only when diagnosing that failure mode.
|
||||
- **Via Turbo**: `bun turbo build --filter=@kilocode/kilo-jetbrains` from repo root.
|
||||
- **Run split mode**: `./gradlew --no-configuration-cache runIdeSplitMode` or the checked-in `Run IDE (Split Mode)` configuration — launches backend and frontend locally and prepares the local-platform CLI binary automatically. Emulate latency via the Split Mode widget (requires internal mode: `-Didea.is.internal=true`).
|
||||
- **Run split backend**: `./gradlew --no-configuration-cache runIdeBackend` — prepares the local-platform CLI binary automatically; if it exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting.
|
||||
- **Run in monolithic sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does NOT build CLI binaries.
|
||||
- **Run split mode**: `./gradlew --no-configuration-cache runIdeSplitMode` or the checked-in `Run IDE (Split Mode)` configuration — launches backend and frontend locally. Emulate latency via the Split Mode widget (requires internal mode: `-Didea.is.internal=true`).
|
||||
- **Run split backend**: `./gradlew --no-configuration-cache runIdeBackend` — if it exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting.
|
||||
- **Run in monolithic sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does not build or bundle CLI binaries; the backend downloads the pinned release at connect time.
|
||||
|
||||
### CLI/SDK Change Awareness
|
||||
|
||||
- JetBrains runtime behavior depends on the bundled CLI artifact under `backend/build/generated/cli/`; Gradle-only tasks and sandbox runs do not rebuild it.
|
||||
- If there are relevant changes outside `packages/kilo-jetbrains/` in the CLI (`packages/opencode/`) or SDK/API generation paths (`packages/sdk/js/`, server endpoints, OpenAPI outputs), warn the user that the JetBrains run may be using stale generated CLI or SDK artifacts.
|
||||
- Do not regenerate or rebuild those artifacts automatically just because such changes exist. Ask the user whether to refresh them first, typically with `bun run build --prepare-cli` from `packages/kilo-jetbrains/` for CLI artifacts and `./script/generate.ts` from the repo root for server/API SDK changes.
|
||||
- JetBrains runtime behavior depends on the downloaded CLI release pinned by `packages/kilo-jetbrains/package.json`; local `packages/opencode/` changes are not used unless published and pinned.
|
||||
- If there are relevant server/API changes outside `packages/kilo-jetbrains/`, warn the user that JetBrains may need a newly published/pinned CLI release and regenerated SDK artifacts.
|
||||
|
||||
## UI Guidelines
|
||||
|
||||
|
||||
@@ -152,6 +152,26 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [7.0.2-rc.2] - 2026-07-07
|
||||
|
||||
### Added
|
||||
- Show compact previews for collapsed reasoning blocks so long assistant reasoning stays readable without taking over the transcript.
|
||||
- Add clearer Kilo Core runtime information and diagnostics for release download failures.
|
||||
|
||||
### Fixed
|
||||
- Resolve the CLI executable using the user's shell environment so custom PATH setups work when sessions start from JetBrains.
|
||||
- Keep retry and offline status visible in the session footer while preserving transcript context.
|
||||
- Prevent oversized header popups by capping preview content.
|
||||
|
||||
### Changed
|
||||
- Download the required Kilo Core release at runtime and prune stale cached runtime binaries automatically.
|
||||
- Polish JetBrains chat spacing, prompt input behavior, question/todo layout, history scrolling, code block padding, and session background colors.
|
||||
|
||||
## [7.0.2-rc.1] - 2026-07-07
|
||||
|
||||
### Added
|
||||
- Download the pinned Kilo Core release at runtime instead of bundling every CLI binary in the JetBrains plugin, keeping the Marketplace package smaller while still verifying downloaded artifacts.
|
||||
|
||||
## [7.0.1] - 2026-07-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -10,7 +10,7 @@ To try the v7 Early Access Program plugin, follow the [JetBrains EAP installatio
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Bun** -- used to build CLI binaries and run build scripts
|
||||
- **Bun** -- used to run package build scripts
|
||||
- **JDK 21+** -- required by Gradle and the IntelliJ Platform SDK. Check with `java -version`. The preferred way to install is via [SDKMAN](https://sdkman.io/install):
|
||||
|
||||
```bash
|
||||
@@ -34,7 +34,7 @@ When working in a git worktree (e.g. via the Agent Manager), run `bun install` f
|
||||
bun install
|
||||
```
|
||||
|
||||
This installs Node dependencies required by the build scripts, including `script/build.ts` which prepares CLI binaries.
|
||||
This installs Node dependencies required by the build scripts.
|
||||
|
||||
---
|
||||
|
||||
@@ -52,7 +52,7 @@ From `packages/kilo-jetbrains/`:
|
||||
bun run build
|
||||
```
|
||||
|
||||
This builds the CLI binary for your current OS/arch only, copies it into the backend module resources, and runs `./gradlew buildPlugin`. The plugin archive is output to `build/distributions/`.
|
||||
This builds the plugin without bundling CLI binaries. The backend downloads the pinned Kilo CLI release for the host platform at connect time. The plugin archive is output to `build/distributions/`.
|
||||
|
||||
Or via Turbo from the repo root:
|
||||
|
||||
@@ -70,7 +70,7 @@ From `packages/kilo-jetbrains/`:
|
||||
bun run build:production
|
||||
```
|
||||
|
||||
This builds CLI binaries for all 6 desktop platforms (darwin-arm64, darwin-x64, linux-arm64, linux-x64, windows-x64, windows-arm64), copies them all into the backend jar, and fails if any are missing. Gradle also validates all platforms are present via `-Pproduction=true`.
|
||||
This builds the plugin without bundling CLI binaries. The backend downloads the pinned Kilo CLI release for the host platform at connect time.
|
||||
|
||||
The built plugin archive is at `build/distributions/kilo.jetbrains-<version>.zip`. This zip can be installed in any JetBrains IDE via **Settings > Plugins > Install Plugin from Disk**.
|
||||
|
||||
@@ -84,15 +84,15 @@ See [RELEASING.md](RELEASING.md) for the full release process, including how to
|
||||
|
||||
## Run the plugin
|
||||
|
||||
Use the checked-in `Run IDE (Split Mode)` run configuration (or `./gradlew --no-configuration-cache runIdeSplitMode` from `packages/kilo-jetbrains/`) to launch the backend and frontend halves of a local split-mode sandbox. The task prepares the local-platform CLI binary automatically when `backend/build/generated/cli/cli/` does not contain the expected binary.
|
||||
Use the checked-in `Run IDE (Split Mode)` run configuration (or `./gradlew --no-configuration-cache runIdeSplitMode` from `packages/kilo-jetbrains/`) to launch the backend and frontend halves of a local split-mode sandbox. The backend downloads the pinned CLI release on first connect.
|
||||
|
||||
Use `runIde` only when you need a monolithic sandboxed IntelliJ instance. `runIde` does not prepare the CLI binary automatically, so run `bun run build --prepare-cli` from `packages/kilo-jetbrains/` first to copy the local-platform binary into `backend/build/generated/cli/cli/`.
|
||||
Use `runIde` only when you need a monolithic sandboxed IntelliJ instance.
|
||||
|
||||
Production packaging still requires running `bun run build:production` so all platform binaries are present.
|
||||
Production packaging uses `bun run build:production` and still downloads the host CLI at runtime.
|
||||
|
||||
### Run the split backend
|
||||
|
||||
Use the checked-in `Run IDE (Backend)` run configuration (or `./gradlew --no-configuration-cache runIdeBackend`) to launch just the backend half of a split-mode session. It prepares the local-platform CLI binary automatically when `backend/build/generated/cli/cli/` does not contain the expected binary.
|
||||
Use the checked-in `Run IDE (Backend)` run configuration (or `./gradlew --no-configuration-cache runIdeBackend`) to launch just the backend half of a split-mode session.
|
||||
|
||||
If `Run IDE (Backend)` exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting the backend.
|
||||
|
||||
@@ -107,7 +107,6 @@ All properties below are passed with `-P` on the Gradle command line or in the r
|
||||
| `kilo.splitModeServerPort` | `0` | Backend split-mode server port. `0` or omitted lets the IntelliJ Platform Gradle Plugin pick a free port when the task runs. |
|
||||
| `kilo.dev.storage.isolated` | `false` | When `true`, CLI runs with `XDG_*_HOME` pointing to `.kilo-dev/` in the worktree root, fully isolating dev storage from your real Kilo installation. Enabled by default in the checked-in split-mode run configurations. |
|
||||
| `kilo.dev.worktree.root` | monorepo root | Worktree root used to resolve `.kilo-dev/`. Auto-detected from the Gradle project directory; override only when the auto-detection is wrong. |
|
||||
| `kilo.bun.path` | `bun` on `$PATH` | Absolute path to Bun. Set this when IntelliJ-launched Gradle cannot find Bun automatically. |
|
||||
|
||||
The checked-in IDE run configurations pass `--no-configuration-cache` because the IntelliJ Platform Gradle Plugin run-IDE tasks are not configuration-cache compatible in this setup.
|
||||
They also pass `--purge-old-log-directories` so stale sandbox logs do not hide the current backend and frontend `kilo-dev.log` files.
|
||||
@@ -195,9 +194,7 @@ For direct local packaging, run:
|
||||
bun run build
|
||||
```
|
||||
|
||||
This prepares the local CLI binary and then runs `./gradlew buildPlugin`.
|
||||
|
||||
If you run `./gradlew buildPlugin` directly, Gradle verifies CLI binaries are present but does not build them first. Run `bun run build --prepare-cli` beforehand if the binaries are missing.
|
||||
This runs `./gradlew buildPlugin`.
|
||||
|
||||
For production verification:
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ The published code comes from `jetbrains/v<version>`. Marketplace and GitHub rel
|
||||
|
||||
Maintainers can use the Kilo `release-jetbrains` skill to drive this process from a version request such as `next rc` or an explicit version. The skill resolves and confirms the version, dispatches and watches the prepare workflow, helps produce a filtered human-readable JetBrains/CLI changelog draft, commits the reviewed changelog to the release PR, and watches publishing after the PR is merged.
|
||||
|
||||
JetBrains plugin builds and runtime downloads use the Kilo Core version pinned in `packages/kilo-jetbrains/package.json`, so verify that pin points at a published `v<version>` release before creating the release tag.
|
||||
|
||||
The skill lives at `.kilo/skills/release-jetbrains/SKILL.md`. It does not move or recreate release tags, and merge permission is only required if the user explicitly asks the skill to merge the release PR automatically.
|
||||
|
||||
## Create Release Tag And PR
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import normalization.NormalizeOpenApiSpecTask
|
||||
import org.gradle.api.tasks.WriteProperties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.rpc)
|
||||
@@ -15,18 +16,35 @@ kotlin {
|
||||
val generatedApi = layout.buildDirectory.dir("generated/openapi/src/main/kotlin")
|
||||
val rawSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.raw.json")
|
||||
val generatedSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.json")
|
||||
val generatedProps = layout.buildDirectory.dir("generated/kilo-props")
|
||||
|
||||
val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text ->
|
||||
Regex("\"version\"\\s*:\\s*\"([^\"]+)\"").find(text)?.groupValues?.get(1)
|
||||
?: error("Could not read version from package.json")
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
resources.srcDir(layout.buildDirectory.dir("generated/cli"))
|
||||
resources.srcDir(generatedProps)
|
||||
kotlin.srcDir(generatedApi)
|
||||
}
|
||||
}
|
||||
|
||||
val writeKiloProperties by tasks.registering(WriteProperties::class) {
|
||||
description = "Write pinned Kilo CLI properties"
|
||||
val out = generatedProps.map { it.file("kilo.properties") }
|
||||
destinationFile.set(out)
|
||||
property("cli.version", pinnedCliVersion)
|
||||
}
|
||||
|
||||
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"))
|
||||
cliVersion.set(pinnedCliVersion)
|
||||
token.set(
|
||||
providers.environmentVariable("GH_TOKEN")
|
||||
.orElse(providers.environmentVariable("GITHUB_TOKEN"))
|
||||
)
|
||||
cacheDir.set(layout.buildDirectory.dir("cli-cache"))
|
||||
spec.set(rawSpec)
|
||||
}
|
||||
|
||||
@@ -83,49 +101,19 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
|
||||
}
|
||||
|
||||
tasks.named("compileKotlin") {
|
||||
dependsOn(fixGeneratedApi)
|
||||
dependsOn(fixGeneratedApi, writeKiloProperties)
|
||||
inputs.dir(generatedApi)
|
||||
}
|
||||
|
||||
tasks.named("processResources") {
|
||||
dependsOn(writeKiloProperties)
|
||||
}
|
||||
|
||||
tasks.named("compileTestKotlin") {
|
||||
dependsOn(fixGeneratedApi)
|
||||
inputs.dir(generatedApi)
|
||||
}
|
||||
|
||||
val cliDir = layout.buildDirectory.dir("generated/cli/cli")
|
||||
val production = providers.gradleProperty("production").map { it.toBoolean() }.orElse(false)
|
||||
|
||||
val prepareLocalCli by tasks.registering(PrepareLocalCliTask::class) {
|
||||
description = "Prepare the local-platform CLI binary for JetBrains backend runs"
|
||||
root.set(rootProject.layout.projectDirectory)
|
||||
dir.set(cliDir)
|
||||
bunPath.convention(
|
||||
providers.gradleProperty("kilo.bun.path")
|
||||
.orElse(providers.environmentVariable("BUN_EXE"))
|
||||
)
|
||||
}
|
||||
|
||||
val requiredPlatforms = listOf(
|
||||
"darwin-arm64",
|
||||
"darwin-x64",
|
||||
"linux-arm64",
|
||||
"linux-x64",
|
||||
"windows-x64",
|
||||
"windows-arm64",
|
||||
)
|
||||
|
||||
val prod = production
|
||||
val checkCli by tasks.registering(CheckCliTask::class) {
|
||||
description = "Verify CLI binaries exist before packaging"
|
||||
dir.set(cliDir)
|
||||
this.production.set(prod)
|
||||
platforms.set(requiredPlatforms)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
intellijIdea(libs.versions.intellij.platform)
|
||||
@@ -137,6 +125,7 @@ dependencies {
|
||||
implementation(project(":shared"))
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.sse)
|
||||
implementation(libs.commons.compress)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ import ai.kilocode.rpc.dto.ConfigDto
|
||||
*/
|
||||
sealed class KiloAppState {
|
||||
data object Disconnected : KiloAppState()
|
||||
data class Downloading(val percent: Int, val version: String, val platform: String) : KiloAppState()
|
||||
data object Connecting : KiloAppState()
|
||||
data class Loading(val progress: LoadProgress) : KiloAppState()
|
||||
data class MigrationRequired(val detection: LegacyMigrationDetection) : KiloAppState()
|
||||
|
||||
+7
-4
@@ -97,7 +97,7 @@ class KiloBackendAppService private constructor(
|
||||
private const val MAX_RETRIES = 3
|
||||
private const val RETRY_DELAY_MS = 1000L
|
||||
private const val APP_LOAD_TIMEOUT_MS = 30_000L
|
||||
private const val READY_TIMEOUT_MS = 5_000L
|
||||
private const val READY_TIMEOUT_MS = 120_000L
|
||||
|
||||
/** Test factory — no IntelliJ deps needed. */
|
||||
internal fun create(
|
||||
@@ -146,7 +146,7 @@ class KiloBackendAppService private constructor(
|
||||
suspend fun connect() {
|
||||
mutex.withLock {
|
||||
val current = _appState.value
|
||||
if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) return
|
||||
if (current is KiloAppState.Ready || current is KiloAppState.Downloading || current is KiloAppState.Connecting || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) return
|
||||
ensureWatcher()
|
||||
connection.connect()
|
||||
}
|
||||
@@ -179,6 +179,7 @@ class KiloBackendAppService private constructor(
|
||||
ensureWatcher()
|
||||
connection.connect()
|
||||
}
|
||||
is KiloAppState.Downloading,
|
||||
KiloAppState.Connecting,
|
||||
is KiloAppState.Loading -> Unit
|
||||
is KiloAppState.MigrationRequired -> {
|
||||
@@ -231,10 +232,11 @@ class KiloBackendAppService private constructor(
|
||||
when (_appState.value) {
|
||||
is KiloAppState.Ready -> return
|
||||
is KiloAppState.MigrationRequired -> throw IllegalStateException("Migration required")
|
||||
is KiloAppState.Downloading,
|
||||
is KiloAppState.Loading,
|
||||
KiloAppState.Connecting -> {
|
||||
val state = withTimeoutOrNull(timeoutMs) {
|
||||
appState.first { it !is KiloAppState.Loading && it !is KiloAppState.Connecting }
|
||||
appState.first { it !is KiloAppState.Downloading && it !is KiloAppState.Loading && it !is KiloAppState.Connecting }
|
||||
}
|
||||
when (state) {
|
||||
is KiloAppState.Ready -> return
|
||||
@@ -292,7 +294,7 @@ class KiloBackendAppService private constructor(
|
||||
private suspend fun reconnect() {
|
||||
mutex.withLock {
|
||||
val current = _appState.value
|
||||
if (current is KiloAppState.Ready || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) {
|
||||
if (current is KiloAppState.Ready || current is KiloAppState.Downloading || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) {
|
||||
log.info("reconnect: already ${current::class.simpleName} — skipping")
|
||||
return
|
||||
}
|
||||
@@ -307,6 +309,7 @@ class KiloBackendAppService private constructor(
|
||||
connection.state.collect { next ->
|
||||
when (next) {
|
||||
ConnectionState.Disconnected -> _appState.value = KiloAppState.Disconnected
|
||||
is ConnectionState.Downloading -> _appState.value = KiloAppState.Downloading(next.percent, next.version, next.platform)
|
||||
ConnectionState.Connecting -> _appState.value = KiloAppState.Connecting
|
||||
is ConnectionState.Connected -> {
|
||||
load()
|
||||
|
||||
+9
-4
@@ -1,8 +1,8 @@
|
||||
package ai.kilocode.backend.app
|
||||
|
||||
import ai.kilocode.backend.cli.CliServer
|
||||
import ai.kilocode.backend.cli.KiloBackendHttpClients
|
||||
import ai.kilocode.backend.cli.KiloCliDataParser
|
||||
import ai.kilocode.backend.cli.CliServer
|
||||
import ai.kilocode.log.ChatLogSummary
|
||||
import ai.kilocode.log.KiloLog
|
||||
import ai.kilocode.jetbrains.api.client.DefaultApi
|
||||
@@ -32,6 +32,7 @@ import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
sealed class ConnectionState {
|
||||
data object Disconnected : ConnectionState()
|
||||
data class Downloading(val percent: Int, val version: String, val platform: String) : ConnectionState()
|
||||
data object Connecting : ConnectionState()
|
||||
data class Connected(val port: Int, val password: String) : ConnectionState()
|
||||
data class Error(val message: String, val details: String? = null) : ConnectionState()
|
||||
@@ -144,7 +145,7 @@ class KiloConnectionService(
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the CLI process, re-extract the binary from JAR, and restart.
|
||||
* Kill the CLI process, re-download the binary, and restart.
|
||||
*
|
||||
* Called under [KiloBackendAppService]'s mutex.
|
||||
*/
|
||||
@@ -153,7 +154,7 @@ class KiloConnectionService(
|
||||
teardown()
|
||||
log.info("reinstall: teardown complete — setting forceExtract flag")
|
||||
server.forceExtract = true
|
||||
log.info("reinstall: spawning new CLI process (binary will be re-extracted)")
|
||||
log.info("reinstall: spawning new CLI process (binary will be re-downloaded)")
|
||||
open()
|
||||
log.info("reinstall: open() returned — CLI process started with fresh binary")
|
||||
}
|
||||
@@ -190,7 +191,10 @@ class KiloConnectionService(
|
||||
|
||||
setState(ConnectionState.Connecting)
|
||||
|
||||
val result = server.init()
|
||||
val result = server.init(
|
||||
onProgress = { item -> setState(ConnectionState.Downloading(item.percent, item.version, item.platform)) },
|
||||
onResolved = { setState(ConnectionState.Connecting) },
|
||||
)
|
||||
|
||||
if (result is CliServer.State.Error) {
|
||||
setState(ConnectionState.Error(result.message, result.details))
|
||||
@@ -198,6 +202,7 @@ class KiloConnectionService(
|
||||
}
|
||||
|
||||
val ready = result as CliServer.State.Ready
|
||||
setState(ConnectionState.Connecting)
|
||||
port = ready.port
|
||||
password = ready.password
|
||||
|
||||
|
||||
+13
-7
@@ -271,8 +271,8 @@ class KiloBackendSessionManager(
|
||||
title = s.title,
|
||||
version = s.version,
|
||||
time = SessionTimeDto(
|
||||
created = s.time.created.toDouble(),
|
||||
updated = s.time.updated.toDouble(),
|
||||
created = time(s.id, "created", s.time.created),
|
||||
updated = time(s.id, "updated", s.time.updated),
|
||||
archived = s.time.archived,
|
||||
),
|
||||
summary = s.summary?.let {
|
||||
@@ -292,15 +292,15 @@ class KiloBackendSessionManager(
|
||||
title = s.title,
|
||||
version = s.version,
|
||||
time = SessionTimeDto(
|
||||
created = s.time.created.toDouble(),
|
||||
updated = s.time.updated.toDouble(),
|
||||
created = time(s.id, "created", s.time.created),
|
||||
updated = time(s.id, "updated", s.time.updated),
|
||||
archived = s.time.archived,
|
||||
),
|
||||
summary = s.summary?.let {
|
||||
SessionSummaryDto(
|
||||
additions = it.additions.safeInt(),
|
||||
deletions = it.deletions.safeInt(),
|
||||
files = it.files.safeInt(),
|
||||
additions = it.additions?.safeInt() ?: 0,
|
||||
deletions = it.deletions?.safeInt() ?: 0,
|
||||
files = it.files?.safeInt() ?: 0,
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -315,6 +315,12 @@ class KiloBackendSessionManager(
|
||||
|
||||
private fun encode(value: String) = java.net.URLEncoder.encode(value, Charsets.UTF_8)
|
||||
|
||||
private fun time(id: String, field: String, value: Number?): Double {
|
||||
if (value != null) return value.toDouble()
|
||||
log.warn("Session $id missing $field timestamp; defaulting to 0.0")
|
||||
return 0.0
|
||||
}
|
||||
|
||||
private fun escape(value: String) = buildString {
|
||||
for (c in value) {
|
||||
when (c) {
|
||||
|
||||
+7
-1
@@ -13,8 +13,14 @@ interface CliServer {
|
||||
|
||||
var forceExtract: Boolean
|
||||
fun process(): Process?
|
||||
suspend fun init(): State
|
||||
suspend fun init(onProgress: (CliDownload) -> Unit = {}, onResolved: () -> Unit = {}): State
|
||||
fun exited(proc: Process)
|
||||
fun stop()
|
||||
fun dispose()
|
||||
}
|
||||
|
||||
data class CliDownload(
|
||||
val percent: Int,
|
||||
val version: String,
|
||||
val platform: String,
|
||||
)
|
||||
|
||||
+12
-67
@@ -5,8 +5,7 @@ import ai.kilocode.backend.dev.KiloDevMode
|
||||
import ai.kilocode.log.KiloLog
|
||||
import com.intellij.openapi.application.ApplicationInfo
|
||||
import com.intellij.openapi.application.PathManager
|
||||
import com.intellij.openapi.util.SystemInfo
|
||||
import com.intellij.util.system.CpuArch
|
||||
import com.intellij.util.EnvironmentUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -20,7 +19,7 @@ import java.util.concurrent.TimeUnit
|
||||
/**
|
||||
* Manages the Kilo CLI binary lifecycle.
|
||||
*
|
||||
* Extracts the bundled CLI from JAR resources into IntelliJ's system directory,
|
||||
* Downloads the pinned CLI into IntelliJ's system directory,
|
||||
* spawns `kilo serve --port 0`, and exposes the result as [State].
|
||||
*
|
||||
* Concurrency is handled by the owning [KiloBackendAppService] — all public
|
||||
@@ -50,9 +49,10 @@ class KiloBackendCliManager(
|
||||
|
||||
override fun process(): Process? = process
|
||||
|
||||
override suspend fun init(): CliServer.State {
|
||||
override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State {
|
||||
return try {
|
||||
val path = extractCli()
|
||||
val path = resolveCli(onProgress)
|
||||
onResolved()
|
||||
log.info("CLI binary path: ${path.absolutePath} (size=${path.length()} bytes)")
|
||||
withTimeout(STARTUP_TIMEOUT_MS) {
|
||||
spawn(path)
|
||||
@@ -84,55 +84,15 @@ class KiloBackendCliManager(
|
||||
cleanup(proc, "stop()")
|
||||
}
|
||||
|
||||
private fun extractCli(): File {
|
||||
val platform = platform()
|
||||
val exe = if (SystemInfo.isWindows) "kilo.exe" else "kilo"
|
||||
val target = File(PathManager.getSystemPath(), "kilo/bin/$exe")
|
||||
val worker = File(target.parentFile, "kilo-sandbox-mutation-worker.js")
|
||||
|
||||
if (forceExtract) {
|
||||
log.info("Force re-extracting CLI resources under ${target.parentFile.absolutePath}")
|
||||
if (target.exists()) target.delete()
|
||||
if (worker.exists()) worker.delete()
|
||||
forceExtract = false
|
||||
}
|
||||
|
||||
extractResource("cli/$platform/$exe", target, executable = true)
|
||||
if (worker.exists()) worker.delete()
|
||||
extractResource("cli/$platform/kilo-sandbox-mutation-worker.js", worker, executable = false)
|
||||
return target
|
||||
}
|
||||
|
||||
private fun extractResource(resource: String, target: File, executable: Boolean) {
|
||||
val loader = javaClass.classLoader
|
||||
val url = loader.getResource(resource)
|
||||
?: throw IllegalStateException("CLI resource not found in JAR resources at $resource")
|
||||
|
||||
val size = url.openConnection().contentLengthLong
|
||||
if (size >= 0 && target.exists() && target.length() == size) {
|
||||
log.info("CLI resource up-to-date at ${target.absolutePath}")
|
||||
if (executable && !SystemInfo.isWindows) {
|
||||
target.setExecutable(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.info("Extracting CLI resource to ${target.absolutePath}")
|
||||
target.parentFile.mkdirs()
|
||||
|
||||
url.openStream().use { input ->
|
||||
target.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
if (executable && !SystemInfo.isWindows) {
|
||||
target.setExecutable(true)
|
||||
}
|
||||
private suspend fun resolveCli(onProgress: (CliDownload) -> Unit): File {
|
||||
val force = forceExtract
|
||||
forceExtract = false
|
||||
if (force) log.info("Force re-downloading CLI ${KiloProps.cliVersion()}")
|
||||
return KiloCliDownloader(log = log).resolve(KiloProps.cliVersion(), force, onProgress)
|
||||
}
|
||||
|
||||
// Must be called from a background thread — devStorageEnv() performs blocking I/O (mkdirs).
|
||||
internal fun buildEnv(pwd: String, base: Map<String, String> = System.getenv()): Map<String, String> =
|
||||
internal fun buildEnv(pwd: String, base: Map<String, String> = EnvironmentUtil.getEnvironmentMap()): Map<String, String> =
|
||||
buildKiloCliEnv(pwd, base, log)
|
||||
|
||||
private suspend fun spawn(cli: File): CliServer.State =
|
||||
@@ -267,21 +227,6 @@ class KiloBackendCliManager(
|
||||
runCatching { proc.outputStream.close() }.onFailure { log.info("CLI stdin stream close skipped: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun platform(): String {
|
||||
val os = when {
|
||||
SystemInfo.isMac -> "darwin"
|
||||
SystemInfo.isLinux -> "linux"
|
||||
SystemInfo.isWindows -> "windows"
|
||||
else -> throw IllegalStateException("Unsupported OS: ${System.getProperty("os.name")}")
|
||||
}
|
||||
val arch = when (CpuArch.CURRENT) {
|
||||
CpuArch.ARM64 -> "arm64"
|
||||
CpuArch.X86_64 -> "x64"
|
||||
else -> throw IllegalStateException("Unsupported architecture: ${CpuArch.CURRENT}")
|
||||
}
|
||||
return "$os-$arch"
|
||||
}
|
||||
|
||||
private fun generatePassword(): String {
|
||||
val bytes = ByteArray(32)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
@@ -294,7 +239,7 @@ private const val DEFAULT_CONFIG = """{"permission":{"edit":"ask","bash":"ask"}}
|
||||
// Must be called from a background thread — devStorageEnv() performs blocking I/O (mkdirs).
|
||||
internal fun buildKiloCliEnv(
|
||||
pwd: String,
|
||||
base: Map<String, String> = System.getenv(),
|
||||
base: Map<String, String> = EnvironmentUtil.getEnvironmentMap(),
|
||||
log: KiloLog = KiloLog.create(KiloBackendCliManager::class.java),
|
||||
): Map<String, String> = buildMap {
|
||||
putAll(base)
|
||||
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
package ai.kilocode.backend.cli
|
||||
|
||||
import ai.kilocode.log.KiloLog
|
||||
import com.intellij.openapi.application.PathManager
|
||||
import com.intellij.openapi.util.SystemInfo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
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
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.security.MessageDigest
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.ZipInputStream
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class KiloCliDownloader(
|
||||
private val http: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.writeTimeout(120, TimeUnit.SECONDS)
|
||||
.build(),
|
||||
private val log: KiloLog = KiloLog.create(KiloCliDownloader::class.java),
|
||||
private val root: File = File(PathManager.getSystemPath(), "kilo/cli"),
|
||||
private val baseUrl: String = "https://github.com/Kilo-Org/kilocode/releases/download",
|
||||
private val api: String = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags",
|
||||
) {
|
||||
companion object {
|
||||
private val DIGEST = Regex("^sha256:[a-f0-9]{64}$")
|
||||
private val JSON = Json { ignoreUnknownKeys = true }
|
||||
private val LOCKS = ConcurrentHashMap<String, Any>()
|
||||
}
|
||||
|
||||
suspend fun resolve(version: String, force: Boolean = false, onProgress: (CliDownload) -> Unit = {}): File =
|
||||
withContext(Dispatchers.IO) {
|
||||
locked {
|
||||
val platform = KiloCliPlatform.current()
|
||||
val dir = File(File(root, version), platform)
|
||||
val exe = File(dir, "bin/${KiloCliPlatform.exe()}")
|
||||
val done = File(dir, ".complete")
|
||||
val ext = KiloCliPlatform.archive(platform)
|
||||
|
||||
if (!force) {
|
||||
cached(version, platform, exe, done)?.let { return@locked it }
|
||||
}
|
||||
|
||||
val digest = asset(version, platform, ext)
|
||||
val stage = stage(version, platform)
|
||||
try {
|
||||
val archive = File(stage, "kilo-$platform.$ext")
|
||||
val staged = File(stage, "bin/${KiloCliPlatform.exe()}")
|
||||
val complete = File(stage, ".complete")
|
||||
|
||||
log.info(
|
||||
"Kilo CLI $version for $platform is not cached; downloading new release into ${stage.absolutePath}"
|
||||
)
|
||||
onProgress(CliDownload(0, version, platform))
|
||||
download(version, platform, ext, archive, onProgress)
|
||||
verify(archive, digest)
|
||||
log.info(
|
||||
"Downloaded Kilo CLI $version for $platform to ${archive.absolutePath} (size=${archive.length()} bytes)"
|
||||
)
|
||||
extract(archive, stage)
|
||||
if (!staged.isFile) {
|
||||
throw IllegalStateException("Downloaded CLI archive did not contain bin/${KiloCliPlatform.exe()}")
|
||||
}
|
||||
if (!SystemInfo.isWindows) staged.setExecutable(true)
|
||||
if (archive.exists() && !archive.delete()) {
|
||||
log.warn("Failed to delete extracted Kilo CLI archive ${archive.absolutePath}")
|
||||
}
|
||||
complete.writeText("$digest\n")
|
||||
replace(dir, stage)
|
||||
onProgress(CliDownload(100, version, platform))
|
||||
prune(version)
|
||||
exe
|
||||
} finally {
|
||||
if (stage.exists() && !stage.deleteRecursively()) {
|
||||
log.warn("Failed to delete staged Kilo CLI download ${stage.absolutePath}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cached(version: String, platform: String, exe: File, done: File): File? {
|
||||
val digest = done.takeIf { it.isFile }?.readText()?.trim()
|
||||
if (!exe.isFile || digest == null || !digest.matches(DIGEST)) return null
|
||||
log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}")
|
||||
if (!SystemInfo.isWindows) exe.setExecutable(true)
|
||||
prune(version)
|
||||
return exe
|
||||
}
|
||||
|
||||
private fun <T> locked(block: () -> T): T {
|
||||
if (!root.isDirectory && !root.mkdirs()) {
|
||||
throw IllegalStateException("Failed to create Kilo CLI cache root ${root.absolutePath}")
|
||||
}
|
||||
val file = File(root, ".lock").canonicalFile
|
||||
val mutex = LOCKS.computeIfAbsent(file.absolutePath) { Any() }
|
||||
return synchronized(mutex) {
|
||||
RandomAccessFile(file, "rw").channel.use { channel ->
|
||||
channel.lock().use { block() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stage(version: String, platform: String): File {
|
||||
val tmp = File(root, ".tmp")
|
||||
val dir = File(tmp, "$version-$platform-${System.nanoTime()}")
|
||||
if (!dir.isDirectory && !dir.mkdirs()) {
|
||||
throw IllegalStateException("Failed to create Kilo CLI staging directory ${dir.absolutePath}")
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
private fun replace(dir: File, stage: File) {
|
||||
val parent = dir.parentFile
|
||||
if (!parent.isDirectory && !parent.mkdirs()) {
|
||||
throw IllegalStateException("Failed to create Kilo CLI cache directory ${parent.absolutePath}")
|
||||
}
|
||||
|
||||
val backup = File(parent, ".${dir.name}.backup-${System.nanoTime()}")
|
||||
if (dir.exists() && !dir.renameTo(backup)) {
|
||||
throw IllegalStateException("Failed to move existing Kilo CLI cache ${dir.absolutePath} aside")
|
||||
}
|
||||
if (stage.renameTo(dir)) {
|
||||
if (backup.exists() && !backup.deleteRecursively()) {
|
||||
log.warn("Failed to delete previous Kilo CLI cache ${backup.absolutePath}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (backup.exists() && !backup.renameTo(dir)) {
|
||||
log.warn("Failed to restore previous Kilo CLI cache ${backup.absolutePath} to ${dir.absolutePath}")
|
||||
}
|
||||
throw IllegalStateException("Failed to install Kilo CLI cache ${stage.absolutePath} to ${dir.absolutePath}")
|
||||
}
|
||||
|
||||
private fun fail(message: String): Nothing {
|
||||
log.warn(message)
|
||||
throw IllegalStateException(message)
|
||||
}
|
||||
|
||||
private fun asset(version: String, platform: String, ext: String): String {
|
||||
val name = "kilo-$platform.$ext"
|
||||
val url = "${api.trimEnd('/')}/v$version"
|
||||
log.info("Fetching Kilo CLI release metadata for $version from $url")
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
val info = rate(response)
|
||||
val body = runCatching { response.body?.string() }.getOrNull()?.take(500)
|
||||
val detail = if (body.isNullOrBlank()) "" else ": $body"
|
||||
if (limited(response)) {
|
||||
log.warn("GitHub API rate limit hit fetching Kilo CLI $version metadata from $url ($info)$detail")
|
||||
throw IllegalStateException(
|
||||
"GitHub API rate limit exceeded while resolving Kilo CLI $version ($info)$detail"
|
||||
)
|
||||
}
|
||||
log.warn("Failed to fetch Kilo CLI $version metadata from $url: HTTP ${response.code} ($info)$detail")
|
||||
throw IllegalStateException(
|
||||
"Failed to fetch Kilo CLI release metadata for $version: HTTP ${response.code} ($info)$detail"
|
||||
)
|
||||
}
|
||||
log.debug { "GitHub metadata OK for Kilo CLI $version (${rate(response)})" }
|
||||
val body = response.body?.string()
|
||||
?: throw IllegalStateException("Failed to fetch Kilo CLI release metadata for $version: empty response body")
|
||||
val assets = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray
|
||||
val entry = assets?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name }
|
||||
if (entry == null) {
|
||||
val names = assets?.mapNotNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull }?.joinToString(", ")
|
||||
fail("Kilo CLI release $version has no asset named $name (available assets: ${names ?: "none"})")
|
||||
}
|
||||
val digest = entry.jsonObject["digest"]?.jsonPrimitive?.contentOrNull
|
||||
if (digest == null) {
|
||||
fail("Kilo CLI release $version asset $name has no digest yet; GitHub has not published a SHA-256 checksum for it")
|
||||
}
|
||||
if (!digest.matches(DIGEST)) {
|
||||
fail("Kilo CLI release $version asset $name has a malformed digest '$digest'; expected sha256:<64 hex chars>")
|
||||
}
|
||||
return digest
|
||||
}
|
||||
}
|
||||
|
||||
private fun rate(response: Response): String {
|
||||
val reset = response.header("X-RateLimit-Reset")
|
||||
?.toLongOrNull()
|
||||
?.let { Instant.ofEpochSecond(it).toString() }
|
||||
return "limit=${response.header("X-RateLimit-Limit")} remaining=${response.header("X-RateLimit-Remaining")} " +
|
||||
"used=${response.header("X-RateLimit-Used")} reset=$reset retryAfter=${response.header("Retry-After")}"
|
||||
}
|
||||
|
||||
private fun limited(response: Response) =
|
||||
response.code == 429 || (response.code == 403 && response.header("X-RateLimit-Remaining") == "0")
|
||||
|
||||
private fun download(version: String, platform: String, ext: String, file: File, onProgress: (CliDownload) -> Unit) {
|
||||
val url = url(version, platform, ext)
|
||||
log.info("Downloading Kilo CLI $version for $platform from $url")
|
||||
val request = Request.Builder().url(url).build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Failed to download Kilo CLI $version for $platform: HTTP ${response.code}")
|
||||
}
|
||||
val body = response.body ?: throw IllegalStateException("Failed to download Kilo CLI $version: empty response body")
|
||||
val total = body.contentLength()
|
||||
var read = 0L
|
||||
var last = 0
|
||||
file.outputStream().use { output ->
|
||||
body.byteStream().use { input ->
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
output.write(buffer, 0, n)
|
||||
read += n
|
||||
if (total > 0) {
|
||||
val pct = ((read.toDouble() / total.toDouble()) * 100.0).roundToInt().coerceIn(0, 100)
|
||||
if (pct != last) {
|
||||
last = pct
|
||||
onProgress(CliDownload(pct, version, platform))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun verify(file: File, digest: String) {
|
||||
val actual = sum(file)
|
||||
if (actual == digest) return
|
||||
if (file.exists() && !file.delete()) log.warn("Failed to delete invalid Kilo CLI archive ${file.absolutePath}")
|
||||
throw IllegalStateException("Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual")
|
||||
}
|
||||
|
||||
private fun sum(file: File) = "sha256:${sha256(file)}"
|
||||
|
||||
private fun sha256(file: File): String {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().buffered().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
md.update(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
private fun extract(file: File, dir: File) {
|
||||
log.info("Extracting Kilo CLI archive ${file.absolutePath}")
|
||||
if (file.name.endsWith(".zip")) {
|
||||
ZipInputStream(file.inputStream().buffered()).use { zip ->
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
write(dir, entry.name, entry.isDirectory) { out -> zip.copyTo(out) }
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
TarArchiveInputStream(GzipCompressorInputStream(file.inputStream().buffered())).use { tar ->
|
||||
while (true) {
|
||||
val entry = tar.nextEntry ?: break
|
||||
write(dir, entry.name, entry.isDirectory) { out -> tar.copyTo(out) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun write(dir: File, name: String, directory: Boolean, copy: (java.io.OutputStream) -> Unit) {
|
||||
val path = if (name.startsWith("bin/")) name else "bin/$name"
|
||||
val target = File(dir, path).canonicalFile
|
||||
val base = dir.canonicalFile
|
||||
if (target != base && !target.path.startsWith(base.path + File.separator)) {
|
||||
throw IllegalStateException("Archive entry escapes target directory: $name")
|
||||
}
|
||||
if (directory) {
|
||||
target.mkdirs()
|
||||
return
|
||||
}
|
||||
target.parentFile.mkdirs()
|
||||
target.outputStream().use(copy)
|
||||
if (!SystemInfo.isWindows && (target.name == "kilo" || target.name == "bwrap")) {
|
||||
target.setExecutable(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every cached CLI version under [root] except [keep]. Each plugin update pins a new
|
||||
* CLI version, so without this old versions would accumulate in the IDE system directory
|
||||
* indefinitely. Runs only after the active version resolved successfully so a failed download
|
||||
* never wipes the last working copy.
|
||||
*/
|
||||
private fun prune(keep: String) {
|
||||
val entries = root.listFiles() ?: return
|
||||
for (entry in entries) {
|
||||
if (!entry.isDirectory || entry.name == keep || entry.name.startsWith(".")) continue
|
||||
log.info("Removing stale Kilo CLI version ${entry.absolutePath}")
|
||||
if (!entry.deleteRecursively()) {
|
||||
log.warn("Failed to remove stale Kilo CLI version ${entry.absolutePath}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun url(version: String, platform: String, ext: String) =
|
||||
"${baseUrl.trimEnd('/')}/v$version/kilo-$platform.$ext"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package ai.kilocode.backend.cli
|
||||
|
||||
import com.intellij.openapi.util.SystemInfo
|
||||
import com.intellij.util.system.CpuArch
|
||||
|
||||
internal object KiloCliPlatform {
|
||||
fun current(): String {
|
||||
val os = when {
|
||||
SystemInfo.isMac -> "darwin"
|
||||
SystemInfo.isLinux -> "linux"
|
||||
SystemInfo.isWindows -> "windows"
|
||||
else -> throw IllegalStateException("Unsupported OS: ${System.getProperty("os.name")}")
|
||||
}
|
||||
val arch = when (CpuArch.CURRENT) {
|
||||
CpuArch.ARM64 -> "arm64"
|
||||
CpuArch.X86_64 -> "x64"
|
||||
else -> throw IllegalStateException("Unsupported architecture: ${CpuArch.CURRENT}")
|
||||
}
|
||||
return "$os-$arch"
|
||||
}
|
||||
|
||||
fun exe(): String = if (SystemInfo.isWindows) "kilo.exe" else "kilo"
|
||||
|
||||
fun archive(platform: String = current()): String =
|
||||
if (platform.startsWith("linux-")) "tar.gz" else "zip"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ai.kilocode.backend.cli
|
||||
|
||||
import java.util.Properties
|
||||
|
||||
object KiloProps {
|
||||
private val props by lazy {
|
||||
val stream = KiloProps::class.java.classLoader.getResourceAsStream("kilo.properties")
|
||||
?: throw IllegalStateException("kilo.properties resource not found")
|
||||
stream.use {
|
||||
Properties().apply { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun cliVersion(): String = props.getProperty("cli.version")
|
||||
?: throw IllegalStateException("cli.version missing from kilo.properties")
|
||||
}
|
||||
+23
-4
@@ -9,6 +9,8 @@ import ai.kilocode.backend.app.ConfigWarning
|
||||
import ai.kilocode.backend.app.LoadError
|
||||
import ai.kilocode.backend.app.LoadProgress
|
||||
import ai.kilocode.backend.app.ProfileResult
|
||||
import ai.kilocode.backend.cli.KiloCliPlatform
|
||||
import ai.kilocode.backend.cli.KiloProps
|
||||
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
|
||||
import ai.kilocode.rpc.dto.ConfigPatchDto
|
||||
import ai.kilocode.rpc.KiloAppRpcApi
|
||||
@@ -51,6 +53,10 @@ class KiloAppRpcApiImpl : KiloAppRpcApi {
|
||||
|
||||
override suspend fun health(): HealthDto = app.health()
|
||||
|
||||
override suspend fun cliVersion(): String = KiloProps.cliVersion()
|
||||
|
||||
override suspend fun cliPlatform(): String = KiloCliPlatform.current()
|
||||
|
||||
override suspend fun retry() = app.retry()
|
||||
|
||||
override suspend fun restart() = app.restart()
|
||||
@@ -109,6 +115,12 @@ class KiloAppRpcApiImpl : KiloAppRpcApi {
|
||||
internal fun appStateDto(state: KiloAppState): KiloAppStateDto =
|
||||
when (state) {
|
||||
KiloAppState.Disconnected -> KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)
|
||||
is KiloAppState.Downloading -> KiloAppStateDto(
|
||||
status = KiloAppStatusDto.DOWNLOADING,
|
||||
downloadPercent = state.percent,
|
||||
downloadVersion = state.version,
|
||||
downloadPlatform = state.platform,
|
||||
)
|
||||
KiloAppState.Connecting -> KiloAppStateDto(KiloAppStatusDto.CONNECTING)
|
||||
is KiloAppState.Loading -> KiloAppStateDto(
|
||||
status = KiloAppStatusDto.LOADING,
|
||||
@@ -143,12 +155,19 @@ internal fun profileDto(p: KiloProfile200Response): ProfileDto = ProfileDto(
|
||||
organizations = p.profile.organizations.orEmpty().map { org ->
|
||||
ProfileOrganizationDto(id = org.id, name = org.name, role = org.role)
|
||||
},
|
||||
balance = p.balance?.let { ProfileBalanceDto(balance = it.balance) },
|
||||
// The pinned CLI release does not expose hasPersonalAccount yet, so default to
|
||||
// showing the personal account. Flip back to p.profile.hasPersonalAccount once a
|
||||
// CLI release ships the field.
|
||||
hasPersonalAccount = true,
|
||||
balance = p.balance?.balance?.let { ProfileBalanceDto(balance = it) },
|
||||
kiloPass = p.kiloPass?.let {
|
||||
val base = it.currentPeriodBaseCreditsUsd ?: return@let null
|
||||
val usage = it.currentPeriodUsageUsd ?: return@let null
|
||||
val bonus = it.currentPeriodBonusCreditsUsd ?: return@let null
|
||||
ProfileKiloPassDto(
|
||||
currentPeriodBaseCreditsUsd = it.currentPeriodBaseCreditsUsd,
|
||||
currentPeriodUsageUsd = it.currentPeriodUsageUsd,
|
||||
currentPeriodBonusCreditsUsd = it.currentPeriodBonusCreditsUsd,
|
||||
currentPeriodBaseCreditsUsd = base,
|
||||
currentPeriodUsageUsd = usage,
|
||||
currentPeriodBonusCreditsUsd = bonus,
|
||||
nextBillingAt = it.nextBillingAt,
|
||||
)
|
||||
},
|
||||
|
||||
+1
@@ -39,6 +39,7 @@ class KiloAppStateTest {
|
||||
@Test
|
||||
fun `KiloAppState sealed subtypes are distinct`() {
|
||||
assertIs<KiloAppState.Disconnected>(KiloAppState.Disconnected)
|
||||
assertIs<KiloAppState.Downloading>(KiloAppState.Downloading(42, "1.2.3", "darwin-arm64"))
|
||||
assertIs<KiloAppState.Connecting>(KiloAppState.Connecting)
|
||||
assertIs<KiloAppState.Loading>(KiloAppState.Loading(LoadProgress()))
|
||||
assertIs<KiloAppState.Error>(KiloAppState.Error("fail"))
|
||||
|
||||
+72
-2
@@ -3,18 +3,21 @@ package ai.kilocode.backend.app
|
||||
import ai.kilocode.backend.app.KiloAppState
|
||||
import ai.kilocode.backend.app.KiloBackendAppService
|
||||
import ai.kilocode.backend.cli.CliServer
|
||||
import ai.kilocode.backend.cli.CliDownload
|
||||
import ai.kilocode.backend.rpc.appStateDto
|
||||
import ai.kilocode.backend.testing.FakeCliServer
|
||||
import ai.kilocode.backend.testing.MockCliServer
|
||||
import ai.kilocode.backend.testing.TestLog
|
||||
import ai.kilocode.rpc.dto.AgentConfigPatchDto
|
||||
import ai.kilocode.rpc.dto.ConfigPatchDto
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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 kotlinx.coroutines.withTimeoutOrNull
|
||||
@@ -67,7 +70,8 @@ class KiloBackendAppServiceTest {
|
||||
|
||||
override fun process(): Process? = null
|
||||
|
||||
override suspend fun init(): CliServer.State {
|
||||
override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State {
|
||||
onResolved()
|
||||
if (starts.getAndIncrement() == 0) {
|
||||
val socket = ServerSocket(0)
|
||||
srv = socket
|
||||
@@ -102,6 +106,46 @@ class KiloBackendAppServiceTest {
|
||||
assertNotNull(ready.data.notifications)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `download progress maps to app state before ready`() = runBlocking {
|
||||
val resolved = CompletableDeferred<Unit>()
|
||||
val signal = CompletableDeferred<Unit>()
|
||||
val server = object : CliServer {
|
||||
override var forceExtract = false
|
||||
override fun process(): Process? = null
|
||||
override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State {
|
||||
onProgress(CliDownload(37, "1.2.3", "darwin-arm64"))
|
||||
resolved.await()
|
||||
onResolved()
|
||||
signal.await()
|
||||
return CliServer.State.Ready(mock.start(), mock.password)
|
||||
}
|
||||
override fun exited(proc: Process) {}
|
||||
override fun stop() {}
|
||||
override fun dispose() {}
|
||||
}
|
||||
val svc = KiloBackendAppService.create(scope, server, log)
|
||||
val job = scope.launch { svc.connect() }
|
||||
|
||||
val downloading = withTimeout(5_000) {
|
||||
svc.appState.first { it is KiloAppState.Downloading }
|
||||
}
|
||||
assertEquals(KiloAppState.Downloading(37, "1.2.3", "darwin-arm64"), downloading)
|
||||
val dto = appStateDto(downloading)
|
||||
assertEquals(37, dto.downloadPercent)
|
||||
assertEquals("1.2.3", dto.downloadVersion)
|
||||
assertEquals("darwin-arm64", dto.downloadPlatform)
|
||||
|
||||
resolved.complete(Unit)
|
||||
withTimeout(5_000) {
|
||||
svc.appState.first { it == KiloAppState.Connecting }
|
||||
}
|
||||
|
||||
signal.complete(Unit)
|
||||
ready(svc)
|
||||
job.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shutdown for unload clears runtime and disposes server once`() = runBlocking {
|
||||
val server = FakeCliServer(mock)
|
||||
@@ -389,7 +433,7 @@ class KiloBackendAppServiceTest {
|
||||
val failing = object : ai.kilocode.backend.cli.CliServer {
|
||||
override var forceExtract = false
|
||||
override fun process(): Process? = null
|
||||
override suspend fun init() = ai.kilocode.backend.cli.CliServer.State.Error(
|
||||
override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit) = ai.kilocode.backend.cli.CliServer.State.Error(
|
||||
message = "CLI startup failed",
|
||||
details = "stderr: missing dependency",
|
||||
)
|
||||
@@ -804,10 +848,36 @@ class KiloBackendAppServiceTest {
|
||||
assertEquals("alice@test.com", dto.profile?.email)
|
||||
assertEquals("Alice", dto.profile?.name)
|
||||
assertEquals("ADMIN", dto.profile?.organizations?.firstOrNull()?.role)
|
||||
// The pinned CLI does not expose hasPersonalAccount yet; the mapper defaults it to true.
|
||||
assertTrue(dto.profile?.hasPersonalAccount ?: false)
|
||||
assertEquals(42.5, dto.profile?.balance?.balance)
|
||||
assertEquals("org_1", dto.profile?.currentOrgId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ready dto hides unknown profile amounts`() = runBlocking {
|
||||
mock.profile = """{
|
||||
"profile":{"email":"alice@test.com","name":"Alice"},
|
||||
"balance":{"balance":null},
|
||||
"kiloPass":{
|
||||
"currentPeriodBaseCreditsUsd":null,
|
||||
"currentPeriodUsageUsd":null,
|
||||
"currentPeriodBonusCreditsUsd":null,
|
||||
"nextBillingAt":null
|
||||
},
|
||||
"currentOrgId":null
|
||||
}""".trimIndent()
|
||||
val svc = create()
|
||||
svc.connect()
|
||||
|
||||
ready(svc)
|
||||
|
||||
val dto = appStateDto(svc.appState.value)
|
||||
assertEquals("alice@test.com", dto.profile?.email)
|
||||
assertNull(dto.profile?.balance)
|
||||
assertNull(dto.profile?.kiloPass)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh profile updates ready dto profile`() = runBlocking {
|
||||
mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}"""
|
||||
|
||||
+42
-1
@@ -1,12 +1,14 @@
|
||||
package ai.kilocode.backend.app
|
||||
|
||||
import ai.kilocode.backend.cli.CliServer
|
||||
import ai.kilocode.backend.cli.CliDownload
|
||||
import ai.kilocode.backend.app.ConnectionState
|
||||
import ai.kilocode.backend.app.KiloConnectionService
|
||||
import ai.kilocode.backend.testing.FakeCliServer
|
||||
import ai.kilocode.backend.testing.MockCliServer
|
||||
import ai.kilocode.backend.testing.TestLog
|
||||
import ai.kilocode.log.KiloLog
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -78,6 +80,45 @@ class KiloConnectionServiceTest {
|
||||
assertTrue(svc.api != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connect reports downloading then connecting before ready`() = runBlocking {
|
||||
val resolved = CompletableDeferred<Unit>()
|
||||
val ready = CompletableDeferred<Unit>()
|
||||
val server = object : CliServer {
|
||||
override var forceExtract = false
|
||||
override fun process(): Process? = null
|
||||
override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State {
|
||||
onProgress(CliDownload(42, "1.2.3", "darwin-arm64"))
|
||||
resolved.await()
|
||||
onResolved()
|
||||
ready.await()
|
||||
return CliServer.State.Ready(mock.start(), mock.password)
|
||||
}
|
||||
override fun exited(proc: Process) {}
|
||||
override fun stop() {}
|
||||
override fun dispose() {}
|
||||
}
|
||||
val svc = KiloConnectionService(scope, server, {}, log)
|
||||
val job = scope.launch { svc.connect() }
|
||||
|
||||
val downloading = withTimeout(5_000) {
|
||||
svc.state.first { it is ConnectionState.Downloading }
|
||||
}
|
||||
assertEquals(ConnectionState.Downloading(42, "1.2.3", "darwin-arm64"), downloading)
|
||||
|
||||
resolved.complete(Unit)
|
||||
withTimeout(5_000) {
|
||||
svc.state.first { it == ConnectionState.Connecting }
|
||||
}
|
||||
|
||||
ready.complete(Unit)
|
||||
mock.awaitSseConnection()
|
||||
withTimeout(5_000) {
|
||||
svc.state.first { it is ConnectionState.Connected }
|
||||
}
|
||||
job.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SSE events are emitted`() = runBlocking {
|
||||
val svc = KiloConnectionService(scope, fake, {}, log)
|
||||
@@ -189,7 +230,7 @@ class KiloConnectionServiceTest {
|
||||
val failing = object : CliServer {
|
||||
override var forceExtract = false
|
||||
override fun process(): Process? = null
|
||||
override suspend fun init() = CliServer.State.Error("binary not found", "stderr line 1\nstderr line 2")
|
||||
override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit) = CliServer.State.Error("binary not found", "stderr line 1\nstderr line 2")
|
||||
override fun exited(proc: Process) {}
|
||||
override fun stop() {}
|
||||
override fun dispose() {}
|
||||
|
||||
+9
@@ -79,6 +79,15 @@ class KiloBackendCliManagerEnvTest {
|
||||
assertEquals(cfg, env["KILO_CONFIG_CONTENT"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isolation disabled - base PATH is preserved`() {
|
||||
val path = "/opt/homebrew/bin:/usr/bin"
|
||||
|
||||
val env = manager.buildEnv("pwd123", mapOf("PATH" to path))
|
||||
|
||||
assertEquals(path, env["PATH"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isolation disabled - no XDG storage overrides are injected`() {
|
||||
val env = manager.buildEnv("pwd123", emptyMap())
|
||||
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
package ai.kilocode.backend.cli
|
||||
|
||||
import ai.kilocode.backend.testing.TestLog
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.Buffer
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
|
||||
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class KiloCliDownloaderTest {
|
||||
@TempDir
|
||||
lateinit var dir: File
|
||||
|
||||
@Test
|
||||
fun `downloads extracts and caches pinned cli`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val bytes = archive()
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
val seen = mutableListOf<CliDownload>()
|
||||
val log = TestLog()
|
||||
val cli = KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3", onProgress = { seen.add(it) })
|
||||
|
||||
assertTrue(cli.isFile)
|
||||
assertEquals(File(File(dir, "1.2.3"), KiloCliPlatform.current()).absolutePath, cli.parentFile.parentFile.absolutePath)
|
||||
assertEquals("#!/bin/sh\n", cli.readText())
|
||||
assertTrue(File(cli.parentFile, "kilo-sandbox-mutation-worker.js").isFile)
|
||||
assertEquals("/api/v1.2.3", server.takeRequest().path)
|
||||
assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path)
|
||||
assertEquals(CliDownload(0, "1.2.3", KiloCliPlatform.current()), seen.first())
|
||||
assertTrue(seen.any { it.percent == 100 && it.version == "1.2.3" && it.platform == KiloCliPlatform.current() })
|
||||
assertTrue(
|
||||
log.messages.any {
|
||||
it.startsWith("INFO: Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} is not cached; downloading new release into ") &&
|
||||
it.contains("/.tmp/")
|
||||
}
|
||||
)
|
||||
|
||||
val cachedProgress = mutableListOf<CliDownload>()
|
||||
val cached = KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3", onProgress = { cachedProgress.add(it) })
|
||||
assertEquals(cli.absolutePath, cached.absolutePath)
|
||||
assertEquals(2, server.requestCount)
|
||||
assertTrue(cachedProgress.isEmpty())
|
||||
assertContains(log.messages, "INFO: Using cached Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} at ${cli.absolutePath}")
|
||||
|
||||
File(cli.parentFile.parentFile, ".complete").writeText("ok\n")
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
val stale = KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3")
|
||||
assertEquals(cli.absolutePath, stale.absolutePath)
|
||||
assertEquals(4, server.requestCount)
|
||||
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
val forced = KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3", force = true)
|
||||
assertEquals(cli.absolutePath, forced.absolutePath)
|
||||
assertEquals(6, server.requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prunes stale versions and removes the extracted archive`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val stale = File(File(dir, "0.0.1"), KiloCliPlatform.current())
|
||||
assertTrue(stale.mkdirs())
|
||||
File(stale, "leftover").writeText("old")
|
||||
|
||||
val bytes = archive()
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
val cli = KiloCliDownloader(
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3")
|
||||
|
||||
assertTrue(cli.isFile)
|
||||
assertFalse(File(dir, "0.0.1").exists())
|
||||
val archived = File(cli.parentFile.parentFile, "kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}")
|
||||
assertFalse(archived.exists())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forced resolve re-downloads and keeps only the active version`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val first = archive("#!/bin/old\n")
|
||||
val next = archive("#!/bin/new\n")
|
||||
server.enqueue(metadata(first))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(first)))
|
||||
server.enqueue(metadata(next))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(next)))
|
||||
val cli = KiloCliDownloader(
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
)
|
||||
val old = cli.resolve("1.2.3")
|
||||
assertEquals("#!/bin/old\n", old.readText())
|
||||
assertEquals(2, server.requestCount)
|
||||
|
||||
val forced = cli.resolve("1.2.3", force = true)
|
||||
assertTrue(forced.isFile)
|
||||
assertEquals("#!/bin/new\n", forced.readText())
|
||||
assertEquals(4, server.requestCount)
|
||||
assertEquals(listOf("1.2.3"), dir.listFiles()?.filter { it.isDirectory && !it.name.startsWith(".") }?.map { it.name })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forced resolve keeps the existing cli when download fails`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val bytes = archive("#!/bin/old\n")
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(503).setBody("unavailable"))
|
||||
|
||||
val cli = KiloCliDownloader(
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3")
|
||||
val ex = assertFailsWith<IllegalStateException> {
|
||||
KiloCliDownloader(
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3", force = true)
|
||||
}
|
||||
|
||||
assertContains(ex.message.orEmpty(), "Failed to download")
|
||||
assertTrue(cli.isFile)
|
||||
assertEquals("#!/bin/old\n", cli.readText())
|
||||
assertTrue(File(cli.parentFile.parentFile, ".complete").isFile)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects cli archive with mismatched digest`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val bytes = archive()
|
||||
server.enqueue(metadata("sha256:${sha256("different".toByteArray())}"))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
|
||||
val ex = assertFailsWith<IllegalStateException> {
|
||||
KiloCliDownloader(
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3")
|
||||
}
|
||||
|
||||
assertContains(ex.message.orEmpty(), "digest mismatch")
|
||||
assertFalse(File(File(File(dir, "1.2.3"), KiloCliPlatform.current()), ".complete").exists())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails clearly and logs when the release has no matching asset`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""{"assets":[{"name":"other.zip","digest":"sha256:${"a".repeat(64)}"}]}"""
|
||||
)
|
||||
)
|
||||
val log = TestLog()
|
||||
val ex = assertFailsWith<IllegalStateException> {
|
||||
KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3")
|
||||
}
|
||||
val name = "kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}"
|
||||
assertContains(ex.message.orEmpty(), "has no asset named $name")
|
||||
assertContains(ex.message.orEmpty(), "other.zip")
|
||||
assertTrue(log.messages.any { it.contains("has no asset named $name") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails clearly and logs when the asset has no digest`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""{"assets":[{"name":"kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}"}]}"""
|
||||
)
|
||||
)
|
||||
val log = TestLog()
|
||||
val ex = assertFailsWith<IllegalStateException> {
|
||||
KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3")
|
||||
}
|
||||
assertContains(ex.message.orEmpty(), "has no digest yet")
|
||||
assertTrue(log.messages.any { it.contains("has no digest yet") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports github api rate limits while resolving metadata`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val reset = 1_800_000_000L
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(403)
|
||||
.setHeader("X-RateLimit-Limit", "60")
|
||||
.setHeader("X-RateLimit-Remaining", "0")
|
||||
.setHeader("X-RateLimit-Used", "60")
|
||||
.setHeader("X-RateLimit-Reset", reset.toString())
|
||||
.setBody("API rate limit exceeded")
|
||||
)
|
||||
val log = TestLog()
|
||||
|
||||
val ex = assertFailsWith<IllegalStateException> {
|
||||
KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3")
|
||||
}
|
||||
|
||||
assertContains(ex.message.orEmpty(), "GitHub API rate limit exceeded")
|
||||
assertContains(ex.message.orEmpty(), "remaining=0")
|
||||
assertContains(ex.message.orEmpty(), "reset=2027-01-15T08:00:00Z")
|
||||
assertTrue(log.messages.any { it.contains("GitHub API rate limit hit") && it.contains("remaining=0") })
|
||||
assertEquals("/api/v1.2.3", server.takeRequest().path)
|
||||
assertEquals(1, server.requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
private fun archive(script: String = "#!/bin/sh\n"): ByteArray {
|
||||
val files = mapOf(
|
||||
"bin/${KiloCliPlatform.exe()}" to script.toByteArray(),
|
||||
"bin/kilo-sandbox-mutation-worker.js" to "worker\n".toByteArray(),
|
||||
)
|
||||
if (KiloCliPlatform.archive() == "zip") return zip(files)
|
||||
return tar(files)
|
||||
}
|
||||
|
||||
private fun metadata(bytes: ByteArray) = metadata("sha256:${sha256(bytes)}")
|
||||
|
||||
private fun metadata(digest: String) = MockResponse().setResponseCode(200).setBody(
|
||||
"""{"assets":[{"name":"kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}","digest":"$digest"}]}"""
|
||||
)
|
||||
|
||||
private fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256")
|
||||
.digest(bytes)
|
||||
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
|
||||
private fun zip(files: Map<String, ByteArray>): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
ZipOutputStream(out).use { zip ->
|
||||
files.forEach { entry ->
|
||||
zip.putNextEntry(ZipEntry(entry.key))
|
||||
zip.write(entry.value)
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private fun tar(files: Map<String, ByteArray>): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
GzipCompressorOutputStream(out).use { gzip ->
|
||||
TarArchiveOutputStream(gzip).use { tar ->
|
||||
files.forEach { entry ->
|
||||
val item = TarArchiveEntry(entry.key)
|
||||
item.size = entry.value.size.toLong()
|
||||
tar.putArchiveEntry(item)
|
||||
tar.write(entry.value)
|
||||
tar.closeArchiveEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -1,6 +1,7 @@
|
||||
package ai.kilocode.backend.testing
|
||||
|
||||
import ai.kilocode.backend.cli.CliServer
|
||||
import ai.kilocode.backend.cli.CliDownload
|
||||
|
||||
/**
|
||||
* Fake [CliServer] that delegates to a [MockCliServer] instead of
|
||||
@@ -20,8 +21,10 @@ class FakeCliServer(private val mock: MockCliServer) : CliServer {
|
||||
|
||||
override fun process(): Process? = null
|
||||
|
||||
override suspend fun init(): CliServer.State =
|
||||
CliServer.State.Ready(mock.start(), mock.password)
|
||||
override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State {
|
||||
onResolved()
|
||||
return CliServer.State.Ready(mock.start(), mock.password)
|
||||
}
|
||||
|
||||
override fun exited(proc: Process) {}
|
||||
|
||||
|
||||
+14
@@ -404,6 +404,20 @@ class KiloBackendWorkspaceTest {
|
||||
assertEquals("ses_1", result.sessions[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `workspace maps missing session timestamps to zero`() = runBlocking {
|
||||
mock.sessions = """[
|
||||
{"id":"ses_1","slug":"s","projectID":"p","directory":"/test/project","title":"T","version":"1","time":{"created":null,"updated":null}}
|
||||
]"""
|
||||
val app = setup()
|
||||
val ws = ready(app)
|
||||
loaded(ws)
|
||||
|
||||
val session = ws.sessions().sessions.single()
|
||||
assertEquals(0.0, session.time.created)
|
||||
assertEquals(0.0, session.time.updated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `workspace creates session in its directory`() = runBlocking {
|
||||
mock.sessionCreate = """{"id":"ses_new","slug":"n","projectID":"p","directory":"/test/project","title":"New","version":"1","time":{"created":1,"updated":1}}"""
|
||||
|
||||
@@ -8,6 +8,7 @@ repositories {
|
||||
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.commons.compress)
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import org.gradle.api.Project
|
||||
* `id("build-tasks")` resolves in `backend/build.gradle.kts`.
|
||||
*
|
||||
* The real value lives in the custom task classes this composite build
|
||||
* provides: [NormalizeOpenApiSpecTask], [FixGeneratedApiTask],
|
||||
* [PrepareLocalCliTask], and [CheckCliTask].
|
||||
* provides: [NormalizeOpenApiSpecTask], [FixGeneratedApiTask], and
|
||||
* [GenerateOpenApiSpecTask].
|
||||
*/
|
||||
class BuildTasksPlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) {}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.file.DirectoryProperty
|
||||
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
|
||||
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
|
||||
|
||||
@get:Input
|
||||
abstract val production: Property<Boolean>
|
||||
|
||||
@get:Input
|
||||
abstract val platforms: ListProperty<String>
|
||||
|
||||
@TaskAction
|
||||
fun run() {
|
||||
val resolved = dir.get().asFile
|
||||
if (!resolved.exists() || resolved.listFiles()?.isEmpty() != false) {
|
||||
throw GradleException(
|
||||
"CLI binaries not found at ${resolved.absolutePath}.\n" +
|
||||
"Run 'bun run build' from packages/kilo-jetbrains/ to build CLI and plugin together."
|
||||
)
|
||||
}
|
||||
if (production.get()) {
|
||||
val missing = platforms.get().filter { platform ->
|
||||
val d = File(resolved, platform)
|
||||
val exe = if (platform.startsWith("windows")) "kilo.exe" else "kilo"
|
||||
!File(d, exe).exists()
|
||||
}
|
||||
if (missing.isNotEmpty()) {
|
||||
throw GradleException(
|
||||
"Production build requires all platform CLI binaries.\n" +
|
||||
"Missing: ${missing.joinToString(", ")}\n" +
|
||||
"Run 'bun run build:production' to build all platforms."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,48 @@
|
||||
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
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
|
||||
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.provider.Property
|
||||
import org.gradle.api.tasks.Input
|
||||
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
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
import java.security.MessageDigest
|
||||
import java.time.Instant
|
||||
import java.util.zip.ZipInputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Generates the CLI OpenAPI spec from the pinned release binary.
|
||||
*/
|
||||
abstract class GenerateOpenApiSpecTask : DefaultTask() {
|
||||
companion object {
|
||||
private val DIGEST = Regex("^sha256:[a-f0-9]{64}$")
|
||||
private val JSON = Json { ignoreUnknownKeys = true }
|
||||
private const val API = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags"
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@get:Input
|
||||
abstract val cliVersion: Property<String>
|
||||
|
||||
/**
|
||||
* 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
|
||||
abstract val token: Property<String>
|
||||
|
||||
@get:Internal
|
||||
abstract val cacheDir: DirectoryProperty
|
||||
|
||||
/** Destination file for the generated openapi.json. */
|
||||
@get:OutputFile
|
||||
abstract val spec: RegularFileProperty
|
||||
|
||||
@@ -53,52 +51,206 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() {
|
||||
|
||||
@TaskAction
|
||||
fun run() {
|
||||
val kilo = resolve()
|
||||
val out = ByteArrayOutputStream()
|
||||
val err = ByteArrayOutputStream()
|
||||
val result = exec.exec {
|
||||
workingDir = opencodeDir.get().asFile
|
||||
commandLine(findBun(), "run", "--conditions=browser", "./src/index.ts", "generate")
|
||||
commandLine(kilo.absolutePath, "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)
|
||||
"kilo 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)}"
|
||||
"kilo 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
|
||||
private fun resolve(): File {
|
||||
val version = cliVersion.get()
|
||||
val platform = platform()
|
||||
val ext = if (platform.startsWith("linux-")) "tar.gz" else "zip"
|
||||
val dir = cacheDir.dir(version).map { it.dir(platform) }.get().asFile
|
||||
val exe = File(dir, "bin/${exe()}")
|
||||
val done = File(dir, ".complete")
|
||||
val cached = done.takeIf { it.isFile }?.readText()?.trim()
|
||||
val archive = File(dir, "kilo-$platform.$ext")
|
||||
if (exe.isFile && cached != null && cached.matches(DIGEST) && matches(archive, cached)) {
|
||||
if (!windows()) exe.setExecutable(true)
|
||||
return exe
|
||||
}
|
||||
return "bun"
|
||||
|
||||
val name = "kilo-$platform.$ext"
|
||||
val digest = asset(version, name)
|
||||
if (dir.exists() && !dir.deleteRecursively()) {
|
||||
throw GradleException("Failed to delete cached pinned Kilo CLI under ${dir.absolutePath}")
|
||||
}
|
||||
if (!dir.isDirectory && !dir.mkdirs()) {
|
||||
throw GradleException("Failed to create pinned Kilo CLI cache directory ${dir.absolutePath}")
|
||||
}
|
||||
download("https://github.com/Kilo-Org/kilocode/releases/download/v$version/kilo-$platform.$ext", archive)
|
||||
verify(archive, digest)
|
||||
extract(archive, dir)
|
||||
if (!exe.isFile) throw GradleException("Downloaded CLI archive did not contain bin/${exe()}")
|
||||
if (!windows()) exe.setExecutable(true)
|
||||
done.writeText("$digest\n")
|
||||
return exe
|
||||
}
|
||||
|
||||
private fun asset(version: String, name: String): String {
|
||||
val url = "$API/v$version"
|
||||
logger.lifecycle("Fetching pinned Kilo CLI release metadata from $url")
|
||||
val conn = URI(url).toURL().openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 30_000
|
||||
conn.readTimeout = 120_000
|
||||
conn.instanceFollowRedirects = true
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
token.getOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
|
||||
try {
|
||||
val code = conn.responseCode
|
||||
if (code !in 200..299) {
|
||||
val info = rate(conn)
|
||||
val body = runCatching { conn.errorStream?.bufferedReader()?.use { it.readText() } }
|
||||
.getOrNull()
|
||||
?.take(500)
|
||||
val detail = if (body.isNullOrBlank()) "" else ": $body"
|
||||
if (limited(conn, code)) {
|
||||
throw GradleException(
|
||||
"GitHub API rate limit exceeded while fetching pinned Kilo CLI release metadata ($info)$detail"
|
||||
)
|
||||
}
|
||||
throw GradleException("Failed to fetch pinned Kilo CLI release metadata: HTTP $code from $url ($info)$detail")
|
||||
}
|
||||
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
val digest = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray
|
||||
?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name }
|
||||
?.jsonObject?.get("digest")?.jsonPrimitive?.contentOrNull
|
||||
?: throw GradleException("Pinned Kilo CLI release $version did not include $name")
|
||||
if (!digest.matches(DIGEST)) throw GradleException("Pinned Kilo CLI release $version asset $name has invalid digest")
|
||||
return digest
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun rate(conn: HttpURLConnection): String {
|
||||
val reset = conn.getHeaderField("X-RateLimit-Reset")
|
||||
?.toLongOrNull()
|
||||
?.let { Instant.ofEpochSecond(it).toString() }
|
||||
return "limit=${conn.getHeaderField("X-RateLimit-Limit")} remaining=${conn.getHeaderField("X-RateLimit-Remaining")} " +
|
||||
"used=${conn.getHeaderField("X-RateLimit-Used")} reset=$reset retryAfter=${conn.getHeaderField("Retry-After")}"
|
||||
}
|
||||
|
||||
private fun limited(conn: HttpURLConnection, code: Int) =
|
||||
code == 429 || (code == 403 && conn.getHeaderField("X-RateLimit-Remaining") == "0")
|
||||
|
||||
private fun download(url: String, file: File) {
|
||||
logger.lifecycle("Downloading pinned Kilo CLI from $url")
|
||||
val conn = URI(url).toURL().openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 30_000
|
||||
conn.readTimeout = 120_000
|
||||
conn.instanceFollowRedirects = true
|
||||
try {
|
||||
val code = conn.responseCode
|
||||
if (code !in 200..299) throw GradleException("Failed to download pinned Kilo CLI: HTTP $code from $url")
|
||||
conn.inputStream.use { input ->
|
||||
file.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun verify(file: File, digest: String) {
|
||||
val actual = sum(file)
|
||||
if (actual == digest) return
|
||||
if (file.exists() && !file.delete()) logger.warn("Failed to delete invalid pinned Kilo CLI archive ${file.absolutePath}")
|
||||
throw GradleException("Pinned Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual")
|
||||
}
|
||||
|
||||
private fun matches(file: File, digest: String) = file.isFile && sum(file) == digest
|
||||
|
||||
private fun sum(file: File) = "sha256:${sha256(file)}"
|
||||
|
||||
private fun sha256(file: File): String {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().buffered().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
md.update(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
private fun extract(file: File, dir: File) {
|
||||
if (file.name.endsWith(".zip")) {
|
||||
ZipInputStream(file.inputStream().buffered()).use { zip ->
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
write(dir, entry.name, entry.isDirectory) { out -> zip.copyTo(out) }
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
TarArchiveInputStream(GzipCompressorInputStream(file.inputStream().buffered())).use { tar ->
|
||||
while (true) {
|
||||
val entry = tar.nextEntry ?: break
|
||||
write(dir, entry.name, entry.isDirectory) { out -> tar.copyTo(out) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun write(dir: File, name: String, directory: Boolean, copy: (java.io.OutputStream) -> Unit) {
|
||||
val path = if (name.startsWith("bin/")) name else "bin/$name"
|
||||
val target = File(dir, path).canonicalFile
|
||||
val base = dir.canonicalFile
|
||||
if (target != base && !target.path.startsWith(base.path + File.separator)) {
|
||||
throw GradleException("Archive entry escapes target directory: $name")
|
||||
}
|
||||
if (directory) {
|
||||
target.mkdirs()
|
||||
return
|
||||
}
|
||||
target.parentFile.mkdirs()
|
||||
target.outputStream().use(copy)
|
||||
if (!windows() && (target.name == "kilo" || target.name == "bwrap")) target.setExecutable(true)
|
||||
}
|
||||
|
||||
private fun platform(): String {
|
||||
val os = System.getProperty("os.name").lowercase()
|
||||
val name = when {
|
||||
os.contains("mac") || os.contains("darwin") -> "darwin"
|
||||
os.contains("linux") -> "linux"
|
||||
os.contains("windows") -> "windows"
|
||||
else -> throw GradleException("Unsupported OS: ${System.getProperty("os.name")}")
|
||||
}
|
||||
val arch = when (System.getProperty("os.arch").lowercase()) {
|
||||
"aarch64", "arm64" -> "arm64"
|
||||
"x86_64", "amd64" -> "x64"
|
||||
else -> throw GradleException("Unsupported architecture: ${System.getProperty("os.arch")}")
|
||||
}
|
||||
return "$name-$arch"
|
||||
}
|
||||
|
||||
private fun exe() = if (windows()) "kilo.exe" else "kilo"
|
||||
|
||||
private fun windows() = System.getProperty("os.name").lowercase().contains("windows")
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.file.DirectoryProperty
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.Internal
|
||||
import org.gradle.api.tasks.Optional
|
||||
import org.gradle.api.tasks.OutputDirectory
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import org.gradle.process.ExecOperations
|
||||
import org.gradle.work.DisableCachingByDefault
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Prepare the local-platform CLI binary for JetBrains backend runs.
|
||||
*
|
||||
* Runs `bun run build --prepare-cli` from the [root] project directory when
|
||||
* the expected current-platform binary is absent from [dir]. Skips
|
||||
* immediately when the binary already exists.
|
||||
*
|
||||
* Bun is found in the following priority order:
|
||||
* 1. Explicit `-Pkilo.bun.path=...` Gradle property (forwarded via [bunPath])
|
||||
* 2. `BUN_EXE` environment variable (absolute path to the bun executable)
|
||||
* 3. `BUN_INSTALL/bin/bun` (from `BUN_INSTALL` environment variable)
|
||||
* 4. Each entry on `PATH`
|
||||
* 5. Common install locations (`~/.bun/bin`, `/opt/homebrew/bin`, `/usr/local/bin`)
|
||||
*
|
||||
* After Bun finishes, the task verifies the expected binary exists so that Rosetta / architecture
|
||||
* mismatches surface with a clear error message.
|
||||
*/
|
||||
@DisableCachingByDefault(because = "Local developer bootstrap task that shells out to Bun")
|
||||
abstract class PrepareLocalCliTask : DefaultTask() {
|
||||
@get:Internal
|
||||
abstract val root: DirectoryProperty
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val dir: DirectoryProperty
|
||||
|
||||
@get:Input
|
||||
@get:Optional
|
||||
abstract val bunPath: Property<String>
|
||||
|
||||
@get:Inject
|
||||
abstract val exec: ExecOperations
|
||||
|
||||
@TaskAction
|
||||
fun run() {
|
||||
val expected = binary()
|
||||
if (expected.exists()) {
|
||||
logger.lifecycle("CLI binary already exists at ${expected.absolutePath}")
|
||||
return
|
||||
}
|
||||
|
||||
val bun = findBun()
|
||||
logger.lifecycle("Preparing local CLI binary with ${bun.absolutePath}")
|
||||
val result = exec.exec {
|
||||
workingDir = root.get().asFile
|
||||
executable = bun.absolutePath
|
||||
args("run", "build", "--prepare-cli")
|
||||
isIgnoreExitValue = true
|
||||
}
|
||||
|
||||
if (result.exitValue != 0) {
|
||||
throw GradleException("bun run build --prepare-cli failed with exit code ${result.exitValue}")
|
||||
}
|
||||
|
||||
if (!expected.exists()) {
|
||||
throw GradleException(
|
||||
"Expected CLI binary was not created at ${expected.absolutePath}. " +
|
||||
"This can happen if Bun and Gradle are running under different architectures."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun binary(): File {
|
||||
return File(File(dir.get().asFile, platform()), exe())
|
||||
}
|
||||
|
||||
private fun platform(): String {
|
||||
return "${os()}-${arch()}"
|
||||
}
|
||||
|
||||
private fun os(): String {
|
||||
val name = System.getProperty("os.name").lowercase()
|
||||
if ("mac" in name || "darwin" in name) return "darwin"
|
||||
if ("linux" in name) return "linux"
|
||||
if ("windows" in name) return "windows"
|
||||
throw GradleException("Unsupported OS for JetBrains CLI binary: $name")
|
||||
}
|
||||
|
||||
private fun arch(): String {
|
||||
return when (val value = System.getProperty("os.arch").lowercase()) {
|
||||
"aarch64", "arm64" -> "arm64"
|
||||
"x86_64", "amd64" -> "x64"
|
||||
else -> throw GradleException("Unsupported architecture for JetBrains CLI binary: $value")
|
||||
}
|
||||
}
|
||||
|
||||
private fun exe(): String {
|
||||
if (os() == "windows") return "kilo.exe"
|
||||
return "kilo"
|
||||
}
|
||||
|
||||
private fun findBun(): File {
|
||||
val configured = bunPath.orNull?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (configured != null) return requireBun(File(configured))
|
||||
|
||||
val names = if (os() == "windows") listOf("bun.exe", "bun.cmd", "bun.bat") else listOf("bun")
|
||||
val candidates = mutableListOf<File>()
|
||||
|
||||
System.getenv("BUN_EXE")?.trim()?.takeIf { it.isNotEmpty() }?.let { candidates.add(File(it)) }
|
||||
System.getenv("BUN_INSTALL")?.trim()?.takeIf { it.isNotEmpty() }?.let { install ->
|
||||
candidates.addAll(names.map { File(File(install, "bin"), it) })
|
||||
}
|
||||
System.getenv("PATH")?.split(File.pathSeparator)?.forEach { entry ->
|
||||
candidates.addAll(names.map { File(entry, it) })
|
||||
}
|
||||
|
||||
val home = System.getProperty("user.home")
|
||||
candidates.addAll(names.map { File(File(home, ".bun/bin"), it) })
|
||||
candidates.addAll(names.map { File("/opt/homebrew/bin", it) })
|
||||
candidates.addAll(names.map { File("/usr/local/bin", it) })
|
||||
|
||||
return candidates.firstOrNull(::usable)
|
||||
?: throw GradleException("Could not find Bun. Install Bun or pass -Pkilo.bun.path=/absolute/path/to/bun.")
|
||||
}
|
||||
|
||||
private fun requireBun(file: File): File {
|
||||
if (usable(file)) return file
|
||||
throw GradleException("Configured Bun executable is not usable: ${file.absolutePath}")
|
||||
}
|
||||
|
||||
private fun usable(file: File): Boolean {
|
||||
if (!file.isFile) return false
|
||||
if (os() == "windows") return true
|
||||
return file.canExecute()
|
||||
}
|
||||
}
|
||||
@@ -221,7 +221,6 @@ tasks {
|
||||
|
||||
runIdeBackend {
|
||||
splitModeServerPort.set(splitPort)
|
||||
dependsOn(":backend:prepareLocalCli")
|
||||
dependsOn(":backend:processResources")
|
||||
}
|
||||
|
||||
@@ -231,17 +230,12 @@ tasks {
|
||||
|
||||
runIdeSplitMode {
|
||||
splitModeServerPort.set(splitPort)
|
||||
dependsOn(":backend:prepareLocalCli")
|
||||
dependsOn(":backend:processResources")
|
||||
}
|
||||
}
|
||||
|
||||
project(":backend").tasks.named("processResources") {
|
||||
mustRunAfter(":backend:prepareLocalCli")
|
||||
}
|
||||
|
||||
// Compile-only typecheck: verifies Kotlin compiles (including generated API client)
|
||||
// without running processResources, CLI binary prep, or buildPlugin.
|
||||
// without running buildPlugin.
|
||||
tasks.register("typecheck") {
|
||||
dependsOn(
|
||||
":shared:compileKotlin",
|
||||
@@ -252,12 +246,6 @@ tasks.register("typecheck") {
|
||||
)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package ai.kilocode.client.actions
|
||||
|
||||
import ai.kilocode.client.app.KiloAppService
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
|
||||
class CoreInfoAction : AnAction(), DumbAware {
|
||||
override fun actionPerformed(e: AnActionEvent) = Unit
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val app = service<KiloAppService>()
|
||||
val info = app.core
|
||||
if (info == null) app.fetchCoreInfoAsync()
|
||||
e.presentation.text = info?.let {
|
||||
KiloBundle.message("action.Kilo.CoreInfo.text", it.version, it.platform)
|
||||
} ?: KiloBundle.message("action.Kilo.CoreInfo.loading")
|
||||
e.presentation.description = KiloBundle.message("action.Kilo.CoreInfo.description")
|
||||
e.presentation.isEnabled = false
|
||||
e.presentation.isVisible = true
|
||||
}
|
||||
|
||||
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
|
||||
}
|
||||
+71
-20
@@ -27,7 +27,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* App-level frontend service for Kilo CLI interaction.
|
||||
* App-level frontend service for Kilo Core interaction.
|
||||
*
|
||||
* Communicates with the backend via [KiloAppRpcApi]. All operations
|
||||
* are app-scoped — no project context is needed.
|
||||
@@ -47,10 +47,16 @@ class KiloAppService internal constructor(
|
||||
|
||||
private val started = AtomicBoolean(false)
|
||||
|
||||
/** CLI version string from the last successful health check, or null if unknown. */
|
||||
/** Core version and platform from the backend, or null if unknown. */
|
||||
@Volatile
|
||||
var version: String? = null
|
||||
private set
|
||||
private var info: CoreInfo? = null
|
||||
private val coreLock = Any()
|
||||
private val coreDone = mutableListOf<(CoreInfo?) -> Unit>()
|
||||
private var coreJob: Job? = null
|
||||
|
||||
val core: CoreInfo? get() = info
|
||||
|
||||
val version: String? get() = info?.version
|
||||
|
||||
internal val _state = MutableStateFlow(init)
|
||||
val state: StateFlow<KiloAppStateDto> = _state.asStateFlow()
|
||||
@@ -79,6 +85,9 @@ class KiloAppService internal constructor(
|
||||
}
|
||||
|
||||
private fun onState(state: KiloAppStateDto) {
|
||||
val version = state.downloadVersion
|
||||
val platform = state.downloadPlatform
|
||||
if (version != null && platform != null) info = CoreInfo(version, platform)
|
||||
_state.value = state
|
||||
if (state.status == KiloAppStatusDto.READY) refreshModelFavoritesAsync()
|
||||
}
|
||||
@@ -96,20 +105,20 @@ class KiloAppService internal constructor(
|
||||
call { retry() }
|
||||
}
|
||||
|
||||
/** Kill the CLI process and restart it. */
|
||||
/** Kill the Core process and restart it. */
|
||||
suspend fun restart() {
|
||||
LOG.info("restart: resetting state and sending RPC")
|
||||
started.set(false)
|
||||
version = null
|
||||
info = null
|
||||
call { restart() }
|
||||
LOG.info("restart: RPC returned — backend restart complete")
|
||||
}
|
||||
|
||||
/** Kill the CLI process, re-extract the binary, and restart. */
|
||||
/** Kill the Core process, re-download the binary, and restart. */
|
||||
suspend fun reinstall() {
|
||||
LOG.info("reinstall: resetting state and sending RPC")
|
||||
started.set(false)
|
||||
version = null
|
||||
info = null
|
||||
call { reinstall() }
|
||||
LOG.info("reinstall: RPC returned — backend reinstall complete")
|
||||
}
|
||||
@@ -131,18 +140,58 @@ class KiloAppService internal constructor(
|
||||
cs.launch { reinstall() }
|
||||
}
|
||||
|
||||
/** Fetch the CLI version and cache it. Call once after connection is established. */
|
||||
fun fetchVersionAsync() {
|
||||
cs.launch {
|
||||
LOG.info("fetchVersion: requesting health check")
|
||||
val dto = health()
|
||||
if (dto == null) {
|
||||
LOG.warn("fetchVersion: health check returned null — version not available")
|
||||
return@launch
|
||||
}
|
||||
version = dto.version
|
||||
LOG.info("fetchVersion: CLI version is ${dto.version}")
|
||||
suspend fun coreInfo(): CoreInfo? = try {
|
||||
val next = CoreInfo(
|
||||
version = call { cliVersion() },
|
||||
platform = call { cliPlatform() },
|
||||
)
|
||||
info = next
|
||||
next
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("core info failed", e)
|
||||
null
|
||||
}
|
||||
|
||||
/** Fetch the pinned Core version and platform and cache it. */
|
||||
fun fetchCoreInfoAsync(done: (CoreInfo?) -> Unit = {}) {
|
||||
val cached = info
|
||||
if (cached != null) {
|
||||
done(cached)
|
||||
return
|
||||
}
|
||||
|
||||
synchronized(coreLock) {
|
||||
val current = info
|
||||
if (current != null) {
|
||||
done(current)
|
||||
return
|
||||
}
|
||||
coreDone.add(done)
|
||||
if (coreJob != null) return
|
||||
coreJob = cs.launch {
|
||||
fetchCoreInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchCoreInfo() {
|
||||
LOG.info("fetchCoreInfo: requesting Core version and platform")
|
||||
val next = coreInfo()
|
||||
val list = synchronized(coreLock) {
|
||||
val items = coreDone.toList()
|
||||
coreDone.clear()
|
||||
coreJob = null
|
||||
items
|
||||
}
|
||||
list.forEach { it(next) }
|
||||
if (next != null) {
|
||||
LOG.info("fetchCoreInfo: Core version is ${next.version} (${next.platform})")
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch the pinned Core version and cache it. */
|
||||
fun fetchVersionAsync(done: (String?) -> Unit = {}) {
|
||||
fetchCoreInfoAsync { done(it?.version) }
|
||||
}
|
||||
|
||||
fun refreshModelFavoritesAsync() {
|
||||
@@ -298,7 +347,7 @@ class KiloAppService internal constructor(
|
||||
fun watch(fn: (KiloAppStateDto) -> Unit): Job {
|
||||
return cs.launch {
|
||||
state.collect { next ->
|
||||
if (next.status == KiloAppStatusDto.READY) fetchVersionAsync()
|
||||
if (next.status == KiloAppStatusDto.READY) fetchCoreInfoAsync()
|
||||
fn(next)
|
||||
}
|
||||
}
|
||||
@@ -313,6 +362,8 @@ class KiloAppService internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
data class CoreInfo(val version: String, val platform: String)
|
||||
|
||||
private fun summary(patch: ConfigPatchDto): String {
|
||||
val values = patch.values.keys.sorted().joinToString(",").ifEmpty { "none" }
|
||||
return "values=$values agents=${patch.agents.size}"
|
||||
|
||||
+4
-5
@@ -201,11 +201,11 @@ class SessionUi(
|
||||
Disposer.register(this, popup)
|
||||
buildUi()
|
||||
Disposer.register(this, selection)
|
||||
applyStyle(style)
|
||||
scroll.show(body(controller.model.state))
|
||||
bindUi()
|
||||
bindStyle()
|
||||
bindMigration()
|
||||
applyStyle(style)
|
||||
onStateChanged(controller.model.state)
|
||||
loaded?.let(::finishOpen)
|
||||
}
|
||||
@@ -307,9 +307,7 @@ class SessionUi(
|
||||
|
||||
sessionContent = JPanel(BorderLayout())
|
||||
|
||||
blankBody = JPanel(BorderLayout()).apply {
|
||||
isOpaque = false
|
||||
}
|
||||
blankBody = JPanel(BorderLayout())
|
||||
|
||||
load = LoadingPanel()
|
||||
progressBody = load
|
||||
@@ -605,8 +603,8 @@ class SessionUi(
|
||||
}
|
||||
|
||||
private fun body(state: SessionState): JPanel {
|
||||
if (state is SessionState.Retry || state is SessionState.Offline) return progressBody
|
||||
if (controller.model.showSession) return messageBody
|
||||
if (state is SessionState.Retry || state is SessionState.Offline) return progressBody
|
||||
if (state is SessionState.Loading) return progressBody
|
||||
return blankBody
|
||||
}
|
||||
@@ -786,6 +784,7 @@ class SessionUi(
|
||||
editorTheme = style.editorScheme
|
||||
colorTheme = UIManager.getLookAndFeel()
|
||||
background = style.editorBackground
|
||||
root.background = style.editorBackground
|
||||
root.content.background = style.editorBackground
|
||||
sessionContent.background = style.editorBackground
|
||||
blankBody.background = style.editorBackground
|
||||
|
||||
+10
-1
@@ -1094,7 +1094,8 @@ class SessionController(
|
||||
} else {
|
||||
snapshots.remove(key)
|
||||
}
|
||||
if (model.state is SessionState.Busy) {
|
||||
val s = model.state
|
||||
if (s is SessionState.Busy || s is SessionState.Retry || s is SessionState.Offline) {
|
||||
model.setState(SessionState.Busy(status()))
|
||||
}
|
||||
if (child != null) trackChild(key, child)
|
||||
@@ -1833,6 +1834,14 @@ class SessionController(
|
||||
)
|
||||
}
|
||||
|
||||
if (app.status == KiloAppStatusDto.DOWNLOADING) {
|
||||
return SessionControllerEvent.ConnectionChanged.ShowDownloading(
|
||||
app.downloadPercent ?: 0,
|
||||
app.downloadVersion,
|
||||
app.downloadPlatform,
|
||||
)
|
||||
}
|
||||
|
||||
if (workspace.status == KiloWorkspaceStatusDto.ERROR) {
|
||||
return SessionControllerEvent.ConnectionChanged.ShowError(
|
||||
KiloBundle.message("session.connection.error.workspace"),
|
||||
|
||||
+4
@@ -63,6 +63,10 @@ sealed class SessionControllerEvent {
|
||||
override fun toString() = "ConnectionChanged connecting"
|
||||
}
|
||||
|
||||
data class ShowDownloading(val percent: Int, val version: String? = null, val platform: String? = null) : ConnectionChanged() {
|
||||
override fun toString() = "ConnectionChanged downloading $percent"
|
||||
}
|
||||
|
||||
data class ShowError(val summary: String, val detail: String?, val source: String = "app") : ConnectionChanged() {
|
||||
override fun toString() = "ConnectionChanged error $source"
|
||||
}
|
||||
|
||||
+10
-6
@@ -13,7 +13,6 @@ import com.intellij.util.ui.EmptyIcon
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.FlowLayout
|
||||
import java.awt.Point
|
||||
import java.awt.Rectangle
|
||||
import java.awt.Component
|
||||
@@ -68,11 +67,11 @@ internal open class HistoryRenderer<T : HistoryItem>(
|
||||
verticalAlignment = SwingConstants.CENTER
|
||||
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
|
||||
}
|
||||
private val head = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
|
||||
add(title)
|
||||
add(badge)
|
||||
private val head = JPanel(BorderLayout(UiStyle.Gap.xs(), 0)).apply {
|
||||
add(title, BorderLayout.CENTER)
|
||||
add(badge, BorderLayout.EAST)
|
||||
}
|
||||
private val main = JPanel(BorderLayout()).apply {
|
||||
private val main = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply {
|
||||
add(head, BorderLayout.CENTER)
|
||||
add(time, BorderLayout.EAST)
|
||||
}
|
||||
@@ -86,7 +85,12 @@ internal open class HistoryRenderer<T : HistoryItem>(
|
||||
init {
|
||||
isOpaque = true
|
||||
top.isOpaque = true
|
||||
row.border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg())
|
||||
row.border = JBUI.Borders.empty(
|
||||
UiStyle.Gap.lg(),
|
||||
UiStyle.Gap.lg(),
|
||||
UiStyle.Gap.lg(),
|
||||
UiStyle.Gap.lg(),
|
||||
)
|
||||
UiStyle.Components.transparent(row, main, head, title, badge, time, del)
|
||||
wrap.setContent(row)
|
||||
add(top, BorderLayout.NORTH)
|
||||
|
||||
+16
-8
@@ -6,7 +6,9 @@ import ai.kilocode.client.session.ui.LoadingPanel
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.HoverIcon
|
||||
import ai.kilocode.client.ui.iconButton
|
||||
import ai.kilocode.client.ui.layout.HAlign
|
||||
import ai.kilocode.client.ui.layout.VAlign
|
||||
import ai.kilocode.client.ui.layout.align
|
||||
import ai.kilocode.client.util.UiTimerSource
|
||||
import ai.kilocode.client.util.UiTimers
|
||||
import com.intellij.icons.AllIcons
|
||||
@@ -50,6 +52,7 @@ import javax.swing.JComponent
|
||||
import javax.swing.JList
|
||||
import javax.swing.KeyStroke
|
||||
import javax.swing.ListSelectionModel
|
||||
import javax.swing.ScrollPaneConstants
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.event.DocumentEvent
|
||||
import javax.swing.event.ListDataEvent
|
||||
@@ -101,7 +104,7 @@ class HistoryPanel(
|
||||
|
||||
init {
|
||||
Disposer.register(parent, this)
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.lg())
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), 0)
|
||||
more.addActionListener { controller.loadMoreCloud() }
|
||||
bind(localList, controller.local)
|
||||
bind(cloudList, controller.cloud)
|
||||
@@ -185,7 +188,7 @@ class HistoryPanel(
|
||||
)
|
||||
}
|
||||
|
||||
private fun back(): BorderLayoutPanel {
|
||||
private fun back(): JComponent {
|
||||
val label = KiloBundle.message("history.back")
|
||||
val btn = HoverIcon().apply {
|
||||
icon = AllIcons.Actions.Back
|
||||
@@ -194,8 +197,7 @@ class HistoryPanel(
|
||||
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
addActionListener { nav() }
|
||||
}
|
||||
return BorderLayoutPanel().apply {
|
||||
add(btn, BorderLayout.WEST)
|
||||
return btn.align(HAlign.LEFT, VAlign.CENTER).apply {
|
||||
border = JBUI.Borders.emptyRight(UiStyle.Gap.lg())
|
||||
}
|
||||
}
|
||||
@@ -207,11 +209,13 @@ class HistoryPanel(
|
||||
if (list === cloudList) {
|
||||
add(repoOnly, BorderLayout.SOUTH)
|
||||
}
|
||||
border = JBUI.Borders.emptyRight(UiStyle.Gap.lg())
|
||||
}
|
||||
add(north, BorderLayout.NORTH)
|
||||
add(JBScrollPane(list).apply {
|
||||
border = JBUI.Borders.empty()
|
||||
viewportBorder = JBUI.Borders.empty()
|
||||
viewportBorder = JBUI.Borders.emptyRight(UiStyle.Gap.lg())
|
||||
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
}, BorderLayout.CENTER)
|
||||
footer?.let {
|
||||
add(Centerizer(it, Centerizer.TYPE.HORIZONTAL).apply {
|
||||
@@ -221,7 +225,7 @@ class HistoryPanel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun localList() = JBList(controller.local).apply {
|
||||
private fun localList() = HistoryList(controller.local).apply {
|
||||
selectionMode = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION
|
||||
isFocusable = true
|
||||
cellRenderer = LocalHistoryRenderer(controller.local, { snapshot.activity }, { snapshot.titles })
|
||||
@@ -249,7 +253,7 @@ class HistoryPanel(
|
||||
ScrollingUtil.installActions(this)
|
||||
}
|
||||
|
||||
private fun cloudList() = JBList(controller.cloud).apply {
|
||||
private fun cloudList() = HistoryList(controller.cloud).apply {
|
||||
selectionMode = ListSelectionModel.SINGLE_SELECTION
|
||||
isFocusable = true
|
||||
cellRenderer = CloudHistoryRenderer(controller.cloud) { snapshot.activity }
|
||||
@@ -548,6 +552,10 @@ class HistoryPanel(
|
||||
}
|
||||
}
|
||||
|
||||
private class HistoryList<T : HistoryItem>(model: HistoryModel<T>) : JBList<T>(model) {
|
||||
override fun getScrollableTracksViewportWidth() = true
|
||||
}
|
||||
|
||||
internal fun showingLoading() = !controller.local.loaded && !controller.cloud.loaded && (controller.local.loading || controller.cloud.loading)
|
||||
|
||||
private companion object {
|
||||
|
||||
+4
-4
@@ -5,7 +5,6 @@ import ai.kilocode.client.session.ui.SessionMessageListPanel
|
||||
import ai.kilocode.client.session.ui.SessionRootPanel
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.ui.components.JBLabel
|
||||
@@ -35,6 +34,8 @@ internal class SessionScroll(
|
||||
private const val FOLLOW_PASSES = 6
|
||||
}
|
||||
|
||||
private var style = SessionEditorStyle.current()
|
||||
|
||||
val component = JBScrollPane(body).apply {
|
||||
border = JBUI.Borders.empty()
|
||||
verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED
|
||||
@@ -46,7 +47,6 @@ internal class SessionScroll(
|
||||
val view: JComponent? get() = component.viewport.view as? JComponent
|
||||
var onScroll: (() -> Unit)? = null
|
||||
|
||||
private var style = SessionEditorStyle.current()
|
||||
private var tail = true
|
||||
private var auto = false
|
||||
private var opening = false
|
||||
@@ -202,8 +202,8 @@ internal class SessionScroll(
|
||||
@RequiresEdt
|
||||
fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
component.background = SessionUiStyle.Transcript.bgColor()
|
||||
component.viewport.background = SessionUiStyle.Transcript.bgColor()
|
||||
component.background = style.editorBackground
|
||||
component.viewport.background = style.editorBackground
|
||||
syncIcon()
|
||||
messages.applyStyle(style)
|
||||
val view = component.viewport.view
|
||||
|
||||
+18
@@ -120,6 +120,8 @@ class ConnectionPanel(
|
||||
|
||||
is SessionControllerEvent.ConnectionChanged.ShowConnecting -> showConnecting()
|
||||
|
||||
is SessionControllerEvent.ConnectionChanged.ShowDownloading -> showDownloading(event.percent, event.version, event.platform)
|
||||
|
||||
is SessionControllerEvent.ConnectionChanged.ShowError -> {
|
||||
showError(event.summary, event.detail)
|
||||
showPanel()
|
||||
@@ -145,6 +147,22 @@ class ConnectionPanel(
|
||||
showPanel()
|
||||
}
|
||||
|
||||
private fun showDownloading(percent: Int, version: String?, platform: String?) {
|
||||
label.foreground = UiStyle.Colors.weak()
|
||||
val pct = percent.coerceIn(0, 100)
|
||||
label.text = if (version != null && platform != null) {
|
||||
KiloBundle.message("session.connection.downloading.version", version, platform, pct)
|
||||
} else {
|
||||
KiloBundle.message("session.connection.downloading", pct)
|
||||
}
|
||||
detail = null
|
||||
expanded = false
|
||||
toggle.isVisible = false
|
||||
retry.isVisible = false
|
||||
renderDetails()
|
||||
showPanel()
|
||||
}
|
||||
|
||||
private fun showError(text: String, detail: String?) {
|
||||
label.foreground = UiStyle.Colors.errorLabelForeground()
|
||||
label.text = text
|
||||
|
||||
+35
-4
@@ -1,5 +1,6 @@
|
||||
package ai.kilocode.client.session.ui
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.model.SessionModel
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
@@ -20,6 +21,8 @@ import com.intellij.util.ui.JBUI
|
||||
*
|
||||
* Reacts to [SessionModelEvent.StateChanged]:
|
||||
* - [SessionState.Busy] → shows an animated spinner and [SessionState.Busy.text]
|
||||
* - [SessionState.Retry] → shows an animated spinner and retry detail
|
||||
* - [SessionState.Offline] → shows offline detail without a spinner
|
||||
* - Any other state -> hidden
|
||||
*
|
||||
* Owned by [SessionMessageListPanel], which always re-anchors it as the last child so it
|
||||
@@ -30,9 +33,12 @@ class ProgressPanel(
|
||||
parent: Disposable,
|
||||
) : Stack(StackAxis.HORIZONTAL, UiStyle.Gap.md()), SessionEditorStyleTarget {
|
||||
|
||||
private var style = SessionEditorStyle.current()
|
||||
private var state: SessionState = SessionState.Idle
|
||||
private val label = JBLabel().apply {
|
||||
foreground = UiStyle.Colors.weak()
|
||||
foreground = style.editorForeground
|
||||
}
|
||||
private val spinner = JBLabel(AnimatedIcon.Default())
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
@@ -45,7 +51,7 @@ class ProgressPanel(
|
||||
)
|
||||
applyStyle(SessionEditorStyle.current())
|
||||
|
||||
next(JBLabel(AnimatedIcon.Default()))
|
||||
next(spinner)
|
||||
next(label)
|
||||
|
||||
model.addListener(parent) { event ->
|
||||
@@ -56,22 +62,47 @@ class ProgressPanel(
|
||||
/** Exposed for test assertions. */
|
||||
fun labelText(): String = label.text
|
||||
|
||||
/** Exposed for test assertions. */
|
||||
fun labelForeground() = label.foreground
|
||||
|
||||
private fun onState(state: SessionState) {
|
||||
this.state = state
|
||||
when (state) {
|
||||
is SessionState.Busy -> {
|
||||
spinner.isVisible = true
|
||||
label.text = state.text
|
||||
label.foreground = UiStyle.Colors.weak()
|
||||
label.foreground = style.editorForeground
|
||||
isVisible = true
|
||||
}
|
||||
is SessionState.Retry -> {
|
||||
spinner.isVisible = true
|
||||
label.text = retryText(state)
|
||||
label.foreground = UiStyle.Colors.warningLabelForeground()
|
||||
isVisible = true
|
||||
}
|
||||
is SessionState.Offline -> {
|
||||
spinner.isVisible = false
|
||||
label.text = state.message.ifBlank { KiloBundle.message("session.status.offline") }
|
||||
label.foreground = UiStyle.Colors.errorLabelForeground()
|
||||
isVisible = true
|
||||
}
|
||||
is SessionState.Loading -> isVisible = false
|
||||
else -> isVisible = false
|
||||
}
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun retryText(state: SessionState.Retry): String {
|
||||
val base = state.message.ifBlank { KiloBundle.message("session.status.retry") }
|
||||
return if (state.attempt > 0) {
|
||||
KiloBundle.message("session.status.retry.attempt", base, state.attempt)
|
||||
} else base
|
||||
}
|
||||
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
label.font = style.regularFont
|
||||
if (state is SessionState.Busy) label.foreground = style.editorForeground
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
+2
-2
@@ -82,8 +82,8 @@ class SessionMessageListPanel(
|
||||
|
||||
init {
|
||||
isOpaque = true
|
||||
background = SessionUiStyle.Transcript.bgColor()
|
||||
Disposer.register(parent, this)
|
||||
applyStyle(style)
|
||||
|
||||
model.addListener(parent) { event ->
|
||||
when (event) {
|
||||
@@ -385,7 +385,7 @@ class SessionMessageListPanel(
|
||||
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
background = SessionUiStyle.Transcript.bgColor()
|
||||
background = style.editorBackground
|
||||
for (view in turnViews.values) view.applyStyle(style)
|
||||
question?.applyStyle(style)
|
||||
permission?.applyStyle(style)
|
||||
|
||||
+3
-2
@@ -124,7 +124,8 @@ internal class SessionAccountOverlay(
|
||||
var layout = false
|
||||
|
||||
val orgs = prof.organizations
|
||||
val next = listOf(AccountChoice(null, KiloBundle.message("profile.personalAccount"))) +
|
||||
val personal = prof.hasPersonalAccount
|
||||
val next = (if (personal) listOf(AccountChoice(null, KiloBundle.message("profile.personalAccount"))) else emptyList()) +
|
||||
orgs.map { org -> AccountChoice(org.id, org.name) }
|
||||
if (next != choices) {
|
||||
choices = next
|
||||
@@ -133,7 +134,7 @@ internal class SessionAccountOverlay(
|
||||
|
||||
if (currentOrgId != prof.currentOrgId) currentOrgId = prof.currentOrgId
|
||||
|
||||
val activeId = if (switching) target else prof.currentOrgId
|
||||
val activeId = if (switching) target else prof.currentOrgId ?: if (personal) null else orgs.firstOrNull()?.id
|
||||
val active = choices.firstOrNull { it.org == activeId } ?: choices.firstOrNull()
|
||||
val title = "${active?.title ?: " "} ▾"
|
||||
if (picker.text != title) {
|
||||
|
||||
+61
-2
@@ -1,8 +1,14 @@
|
||||
package ai.kilocode.client.session.ui.popup
|
||||
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Container
|
||||
import java.awt.Dimension
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JEditorPane
|
||||
import javax.swing.JPanel
|
||||
|
||||
class HeaderPopupRequest(
|
||||
val anchor: JComponent,
|
||||
@@ -11,7 +17,60 @@ class HeaderPopupRequest(
|
||||
)
|
||||
|
||||
class HeaderPopupBody(
|
||||
val component: JComponent,
|
||||
component: JComponent,
|
||||
val disposable: Disposable,
|
||||
val background: Color,
|
||||
)
|
||||
) {
|
||||
val component: JComponent = HeaderPopupPanel(component)
|
||||
}
|
||||
|
||||
private class HeaderPopupPanel(private val child: JComponent) : JPanel(BorderLayout()) {
|
||||
init {
|
||||
// Transparent so the balloon fill shows uniformly behind nested popup content.
|
||||
isOpaque = false
|
||||
add(child, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
val size = super.getPreferredSize()
|
||||
val cap = JBUI.scale(350)
|
||||
val width = size.width.takeIf { it > 0 }?.coerceAtMost(cap) ?: cap
|
||||
fit(child, width)
|
||||
val height = super.getPreferredSize().height.coerceAtMost(JBUI.scale(450))
|
||||
return Dimension(width, height)
|
||||
}
|
||||
|
||||
private fun fit(item: JComponent, width: Int) {
|
||||
if (width <= 0) return
|
||||
// JBHtmlPane derives wrapped preferred height from the current width, not just HTML content.
|
||||
item.setSize(width, Short.MAX_VALUE.toInt())
|
||||
layout(item, width)
|
||||
reset(item)
|
||||
}
|
||||
|
||||
private fun layout(item: Container, width: Int) {
|
||||
if (item is JEditorPane) {
|
||||
item.preferredSize = null
|
||||
item.setSize(width, Short.MAX_VALUE.toInt())
|
||||
item.preferredSize = Dimension(width, item.preferredSize.height)
|
||||
item.size = item.preferredSize
|
||||
return
|
||||
}
|
||||
item.doLayout()
|
||||
val insets = item.insets
|
||||
val inner = (width - insets.left - insets.right).coerceAtLeast(0)
|
||||
for (child in item.components) {
|
||||
val nested = child as? Container ?: continue
|
||||
val next = child.width.takeIf { it > 0 }?.coerceAtMost(inner) ?: inner
|
||||
layout(nested, next)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reset(item: Container) {
|
||||
item.invalidate()
|
||||
for (child in item.components) {
|
||||
val nested = child as? Container ?: continue
|
||||
reset(nested)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-4
@@ -42,6 +42,7 @@ import com.intellij.openapi.actionSystem.ex.ActionUtil
|
||||
import com.intellij.openapi.actionSystem.IdeActions
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
|
||||
import com.intellij.openapi.editor.SpellCheckingEditorCustomizationProvider
|
||||
import com.intellij.openapi.editor.colors.CodeInsightColors
|
||||
import com.intellij.openapi.editor.colors.TextAttributesKey
|
||||
import com.intellij.openapi.editor.event.CaretEvent
|
||||
@@ -133,6 +134,7 @@ class PromptPanel(
|
||||
var onAutoApproveToggle: (Boolean) -> Unit = {}
|
||||
var onFileDrag: (Boolean) -> Unit = {}
|
||||
private var style = SessionEditorStyle.current()
|
||||
private var focused = false
|
||||
private val shell = BorderLayoutPanel().apply {
|
||||
isOpaque = true
|
||||
border = JBUI.Borders.empty(
|
||||
@@ -170,13 +172,19 @@ class PromptPanel(
|
||||
style.applyTranscriptToEditor(ed)
|
||||
ed.setBorder(JBUI.Borders.empty())
|
||||
ed.scrollPane.border = JBUI.Borders.empty()
|
||||
ed.scrollPane.viewportBorder = JBUI.Borders.empty()
|
||||
ed.scrollPane.viewportBorder = JBUI.Borders.empty(
|
||||
0,
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET),
|
||||
0,
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET),
|
||||
)
|
||||
ed.backgroundColor = style.editorScheme.defaultBackground
|
||||
ed.scrollPane.background = style.editorScheme.defaultBackground
|
||||
ed.scrollPane.viewport.background = style.editorScheme.defaultBackground
|
||||
ed.settings.isUseSoftWraps = true
|
||||
ed.settings.isPaintSoftWraps = false
|
||||
ed.settings.isAdditionalPageAtBottom = false
|
||||
SpellCheckingEditorCustomizationProvider.getInstance().getDisabledCustomization()?.customize(ed)
|
||||
ed.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler { processPaste(it) })
|
||||
ed.scrollPane.verticalScrollBarPolicy =
|
||||
ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
|
||||
@@ -198,11 +206,11 @@ class PromptPanel(
|
||||
})
|
||||
ed.contentComponent.addFocusListener(object : FocusAdapter() {
|
||||
override fun focusGained(e: FocusEvent) {
|
||||
repaint()
|
||||
syncFocus(true)
|
||||
}
|
||||
|
||||
override fun focusLost(e: FocusEvent) {
|
||||
repaint()
|
||||
syncFocus(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -301,8 +309,27 @@ class PromptPanel(
|
||||
|
||||
override fun updateUI() {
|
||||
super.updateUI()
|
||||
syncBorder()
|
||||
}
|
||||
|
||||
private fun syncFocus(value: Boolean) {
|
||||
if (focused == value) {
|
||||
repaint()
|
||||
return
|
||||
}
|
||||
focused = value
|
||||
syncBorder()
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun syncBorder() {
|
||||
border = JBUI.Borders.compound(
|
||||
JBUI.Borders.customLineTop(SessionUiStyle.View.Prompt.separator()),
|
||||
if (focused) {
|
||||
JBUI.Borders.emptyTop(JBUI.scale(1))
|
||||
} else {
|
||||
JBUI.Borders.customLineTop(SessionUiStyle.View.Prompt.separator())
|
||||
},
|
||||
JBUI.Borders.empty(),
|
||||
)
|
||||
}
|
||||
|
||||
+10
-8
@@ -16,8 +16,13 @@ object SessionUiStyle {
|
||||
/** Geometry for the transcript list and its scroll behavior. */
|
||||
object SessionLayout {
|
||||
const val GAP = 3
|
||||
val InnerInsets = Insets(UiStyle.Gap.md(), UiStyle.Gap.md(), UiStyle.Gap.sm(), UiStyle.Gap.sm())
|
||||
const val TRANSCRIPT_SCROLLBAR_PADDING = 10
|
||||
val InnerInsets = Insets(
|
||||
UiStyle.Gap.md(),
|
||||
UiStyle.Gap.sm() + TRANSCRIPT_SCROLLBAR_PADDING,
|
||||
UiStyle.Gap.sm(),
|
||||
UiStyle.Gap.sm(),
|
||||
)
|
||||
const val USER_PROMPT_INDENT = 100
|
||||
const val SCROLL_INCREMENT = 48
|
||||
}
|
||||
@@ -73,6 +78,8 @@ object SessionUiStyle {
|
||||
const val CONTROL_GAP = 4
|
||||
const val SHELL_VERTICAL_PADDING = 6
|
||||
const val SHELL_HORIZONTAL_PADDING = 8
|
||||
// Horizontal editor inset intentionally matches vertical shell padding to balance text and chrome.
|
||||
const val EDITOR_HORIZONTAL_INSET = SHELL_VERTICAL_PADDING
|
||||
|
||||
fun separator(): Color = JBColor.namedColor(
|
||||
"EditorTabs.underTabsBorderColor",
|
||||
@@ -144,17 +151,12 @@ object SessionUiStyle {
|
||||
const val MIN_ROWS = 1
|
||||
const val BORDER_WIDTH = 1
|
||||
const val VIEWPORT_TOP_PADDING = 6
|
||||
const val VIEWPORT_HORIZONTAL_PADDING = 8
|
||||
const val VIEWPORT_HORIZONTAL_PADDING = Layout.HORIZONTAL_PADDING
|
||||
const val VIEWPORT_BOTTOM_PADDING = 6
|
||||
const val SCROLLBAR_HEIGHT = 12
|
||||
const val WIDTH_PADDING = 16
|
||||
|
||||
fun topPadding(): Int = VIEWPORT_TOP_PADDING
|
||||
}
|
||||
|
||||
object Popup {
|
||||
const val MAX_LINES = 15
|
||||
const val MAX_WIDTH = 520
|
||||
fun topPadding(): Int = VIEWPORT_TOP_PADDING + UiStyle.Gap.lg()
|
||||
}
|
||||
|
||||
/** Permission session-view command preview limits. */
|
||||
|
||||
+70
@@ -7,10 +7,13 @@ import ai.kilocode.client.session.SessionFileOpener
|
||||
import ai.kilocode.client.session.openSessionLink
|
||||
import ai.kilocode.client.session.model.Content
|
||||
import ai.kilocode.client.session.model.Reasoning
|
||||
import ai.kilocode.client.session.ui.popup.HeaderPopupBody
|
||||
import ai.kilocode.client.session.ui.popup.HeaderPopupRequest
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.selection.SessionSelection
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.base.SecondarySessionPartView
|
||||
import ai.kilocode.client.telemetry.Telemetry
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.md.MdView
|
||||
import ai.kilocode.client.ui.md.MdViewFactory
|
||||
@@ -63,6 +66,7 @@ class ReasoningView(
|
||||
private var done = reasoning.done
|
||||
private var registered = false
|
||||
private var following = false
|
||||
private var pinned = false
|
||||
|
||||
init {
|
||||
row.border = JBUI.Borders.empty(
|
||||
@@ -100,6 +104,7 @@ class ReasoningView(
|
||||
var changed = false
|
||||
val next = content.content.toString()
|
||||
val follow = tailVisible()
|
||||
val finishing = !done && content.done
|
||||
if (done != content.done) {
|
||||
done = content.done
|
||||
changed = true
|
||||
@@ -112,10 +117,30 @@ class ReasoningView(
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if (finishing && !pinned) {
|
||||
changed = collapse() || changed
|
||||
changed = releaseBody() || changed
|
||||
}
|
||||
changed = sync() || changed
|
||||
if (changed) refresh()
|
||||
}
|
||||
|
||||
/** Detaches and disposes the markdown body so its editors are released when reasoning finishes. */
|
||||
@RequiresEdt
|
||||
private fun releaseBody(): Boolean {
|
||||
if (!parts.bodyCreated()) return false
|
||||
val detached = discardBody()
|
||||
Disposer.dispose(parts.md(openFile, openUrl))
|
||||
parts.reset()
|
||||
registered = false
|
||||
return detached
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
override fun userToggled() {
|
||||
pinned = true
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
override fun appendDelta(delta: String) {
|
||||
if (delta.isEmpty()) return
|
||||
@@ -150,6 +175,15 @@ class ReasoningView(
|
||||
@RequiresEdt
|
||||
internal fun bodyScrollBottom() = parts.scrollOrNull?.verticalScrollBar?.let { it.maximum - it.visibleAmount } ?: 0
|
||||
|
||||
@RequiresEdt
|
||||
override fun headerPopup(): HeaderPopupRequest? {
|
||||
if (isExpanded()) return null
|
||||
val text = source.takeIf { it.isNotBlank() } ?: return null
|
||||
return HeaderPopupRequest(row, build = { buildPopupBody(text) }) {
|
||||
Telemetry.send("Header Popup Shown", mapOf("surface" to "session", "part" to "reasoning"))
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
@@ -235,6 +269,38 @@ class ReasoningView(
|
||||
Disposer.register(this, md)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun buildPopupBody(text: String): HeaderPopupBody {
|
||||
val md = MdViewFactory.create(style, null).apply {
|
||||
addLinkListener { openSessionLink(it, openFile, openUrl) }
|
||||
}
|
||||
md.applyStyle(style)
|
||||
md.font = style.smallEditorFont.deriveFont(Font.ITALIC)
|
||||
md.codeFont = style.editorFamily
|
||||
md.foreground = UiStyle.Colors.weak()
|
||||
md.background = style.editorBackground
|
||||
md.component.border = JBUI.Borders.empty()
|
||||
md.set(text)
|
||||
val panel = TrackPanel().apply {
|
||||
isOpaque = true
|
||||
background = style.editorBackground
|
||||
border = JBUI.Borders.empty(
|
||||
JBUI.scale(SessionUiStyle.View.Reasoning.BODY_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Reasoning.BODY_HORIZONTAL_PADDING),
|
||||
)
|
||||
add(md.component, BorderLayout.CENTER)
|
||||
}
|
||||
val scroll = JBScrollPane(panel).apply {
|
||||
border = JBUI.Borders.empty()
|
||||
isOpaque = true
|
||||
background = style.editorBackground
|
||||
viewport.background = style.editorBackground
|
||||
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
|
||||
}
|
||||
return HeaderPopupBody(scroll, md, style.editorBackground)
|
||||
}
|
||||
|
||||
private fun bodyMaxHeight(): Int {
|
||||
if (!parts.bodyCreated()) return 0
|
||||
val md = md
|
||||
@@ -280,6 +346,10 @@ class ReasoningParts(
|
||||
|
||||
fun bodyCreated() = body != null
|
||||
|
||||
fun reset() {
|
||||
body = null
|
||||
}
|
||||
|
||||
fun md(openFile: SessionFileOpener, openUrl: (String) -> Unit): MdView = body(openFile, openUrl).md
|
||||
|
||||
fun scroll(openFile: SessionFileOpener, openUrl: (String) -> Unit): JBScrollPane = body(openFile, openUrl).scroll
|
||||
|
||||
+12
@@ -67,10 +67,13 @@ abstract class AbstractSessionPartView(
|
||||
if (!expandable || !arrow.isVisible) return
|
||||
val changed = toggleLocal()
|
||||
if (!changed) return
|
||||
userToggled()
|
||||
syncArrow()
|
||||
refresh()
|
||||
}
|
||||
|
||||
protected open fun userToggled() {}
|
||||
|
||||
open fun expand(): Boolean {
|
||||
if (!expandable) return false
|
||||
if (isExpanded()) return false
|
||||
@@ -89,6 +92,15 @@ abstract class AbstractSessionPartView(
|
||||
|
||||
protected fun bodyComponent(): JComponent = body()
|
||||
|
||||
/** Detaches and forgets the cached body so the next expansion builds a fresh one. */
|
||||
protected fun discardBody(): Boolean {
|
||||
val item = body ?: return false
|
||||
val attached = item.parent === this
|
||||
if (attached) remove(item)
|
||||
body = null
|
||||
return attached
|
||||
}
|
||||
|
||||
private fun toggleLocal(): Boolean {
|
||||
val fn = resize ?: return toggleBody()
|
||||
val expanded = isExpanded()
|
||||
|
||||
+1
@@ -216,6 +216,7 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? =
|
||||
|
||||
val qText = makeText(q, UiStyle.Colors.weak(), false)
|
||||
qText.alignmentX = Component.LEFT_ALIGNMENT
|
||||
qText.border = JBUI.Borders.emptyBottom(UiStyle.Gap.xs())
|
||||
row.add(qText)
|
||||
|
||||
val joined = result.answers.getOrNull(i)?.joinToString(", ").orEmpty()
|
||||
|
||||
+2
@@ -490,6 +490,7 @@ class QuestionView(
|
||||
private fun buildCustomEditor(): SessionEditorTextField {
|
||||
val ed = SessionEditorTextField(project, selection = selection)
|
||||
ed.border = JBUI.Borders.empty()
|
||||
ed.setFontInheritedFromLAF(false)
|
||||
ed.setPlaceholder(KiloBundle.message("session.question.custom.placeholder"))
|
||||
ed.setShowPlaceholderWhenFocused(true)
|
||||
ed.setOneLineMode(false)
|
||||
@@ -502,6 +503,7 @@ class QuestionView(
|
||||
ex.scrollPane.background = style.editorScheme.defaultBackground
|
||||
ex.scrollPane.viewport.background = style.editorScheme.defaultBackground
|
||||
ex.settings.isUseSoftWraps = true
|
||||
ex.settings.isPaintSoftWraps = false
|
||||
ex.settings.isAdditionalPageAtBottom = false
|
||||
ex.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ class TodoListPanel(
|
||||
todos: List<TodoDto> = emptyList(),
|
||||
private var before: Int = 0,
|
||||
private var after: Int = 0,
|
||||
) : Stack(StackAxis.VERTICAL) {
|
||||
) : Stack(StackAxis.VERTICAL, JBUI.scale(SessionUiStyle.View.Layout.GAP)) {
|
||||
|
||||
private var items = todos
|
||||
private var style = SessionEditorStyle.current()
|
||||
|
||||
+2
-36
@@ -25,8 +25,6 @@ import com.intellij.ui.components.JBHtmlPane
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Container
|
||||
import java.awt.Dimension
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
@@ -196,7 +194,6 @@ class ShellToolView(
|
||||
MdCodeBlockFactory.default(
|
||||
MdCodeBlockOptions(
|
||||
border = MdCodeBlockBorder.None,
|
||||
maxLines = SessionUiStyle.View.Popup.MAX_LINES,
|
||||
verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
|
||||
editorOnly = true,
|
||||
),
|
||||
@@ -211,7 +208,7 @@ class ShellToolView(
|
||||
md.component.border = JBUI.Borders.empty()
|
||||
md.set(popupMd(formatCommand(cmd)))
|
||||
padPopup(md.component)
|
||||
return HeaderPopupBody(PopupPanel(md.component), md, style.editorBackground)
|
||||
return HeaderPopupBody(md.component, md, style.editorBackground)
|
||||
}
|
||||
|
||||
override fun dumpLabel() = "ShellToolView#$contentId(${labelText()})"
|
||||
@@ -237,37 +234,6 @@ private fun padPopup(root: JComponent) {
|
||||
|
||||
private fun grow(size: Dimension, pad: Int) = Dimension(size.width, size.height + pad)
|
||||
|
||||
private class PopupPanel(child: JComponent) : JPanel(BorderLayout()) {
|
||||
init {
|
||||
// Transparent so the balloon fill (editor background) shows uniformly behind the content.
|
||||
isOpaque = false
|
||||
add(child, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
// The markdown code block reports a preferred width of 0 so transcript layout can stretch it.
|
||||
// A balloon has no such constraint, so derive the natural content width and cap it instead.
|
||||
val size = super.getPreferredSize()
|
||||
val width = contentWidth(this).coerceAtMost(JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH))
|
||||
return Dimension(maxOf(width, size.width), size.height)
|
||||
}
|
||||
}
|
||||
|
||||
private fun contentWidth(root: Container): Int {
|
||||
var max = 0
|
||||
for (child in root.components) {
|
||||
if (child is JBScrollPane) {
|
||||
val view = child.viewport.view as? JComponent
|
||||
val content = view?.preferredSize?.width ?: 0
|
||||
val insets = child.insets
|
||||
val viewport = child.viewportBorder?.getBorderInsets(child) ?: JBUI.emptyInsets()
|
||||
max = maxOf(max, content + insets.left + insets.right + viewport.left + viewport.right)
|
||||
}
|
||||
if (child is Container) max = maxOf(max, contentWidth(child))
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
class ShellHolder(
|
||||
private val tool: Tool,
|
||||
private val selection: SessionSelection?,
|
||||
@@ -339,7 +305,7 @@ class ShellBody(
|
||||
private fun styleShell() {
|
||||
val root = md.component as? JPanel ?: return
|
||||
root.components.filterIsInstance<JBHtmlPane>().forEach {
|
||||
it.border = JBUI.Borders.emptyLeft(JBUI.scale(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING))
|
||||
it.border = JBUI.Borders.emptyLeft(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-4
@@ -331,12 +331,11 @@ internal class LoggedInProfileUi(
|
||||
@RequiresEdt
|
||||
private fun applyOrganizations(profile: ProfileDto) {
|
||||
val orgs = profile.organizations
|
||||
val keys: List<Pair<String?, String>> = listOf(null to KiloBundle.message("profile.personalAccount")) +
|
||||
val personal = profile.hasPersonalAccount
|
||||
val keys: List<Pair<String?, String>> = (if (personal) listOf(null to KiloBundle.message("profile.personalAccount")) else emptyList()) +
|
||||
orgs.map { it.id to it.name }
|
||||
|
||||
val target = profile.currentOrgId
|
||||
?.let { id -> orgs.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.plus(1) }
|
||||
?: 0
|
||||
val target = keys.indexOfFirst { it.first == profile.currentOrgId }.takeIf { it >= 0 } ?: 0
|
||||
|
||||
currentOrgId = profile.currentOrgId
|
||||
|
||||
|
||||
+1
-1
@@ -181,7 +181,7 @@ internal class LoggedOutProfileUi(
|
||||
}
|
||||
|
||||
private fun resolveMode(status: KiloAppStatusDto, login: LoginState): OutMode = when {
|
||||
status == KiloAppStatusDto.DISCONNECTED || status == KiloAppStatusDto.CONNECTING || status == KiloAppStatusDto.MIGRATION_REQUIRED -> OutMode.CONNECTING
|
||||
status == KiloAppStatusDto.DISCONNECTED || status == KiloAppStatusDto.DOWNLOADING || status == KiloAppStatusDto.CONNECTING || status == KiloAppStatusDto.MIGRATION_REQUIRED -> OutMode.CONNECTING
|
||||
status == KiloAppStatusDto.ERROR -> OutMode.APP_ERROR
|
||||
login is LoginState.Initiating -> OutMode.INITIATING
|
||||
login is LoginState.Pending -> OutMode.AUTH
|
||||
|
||||
+1
-1
@@ -163,7 +163,7 @@ internal class ProfileUi(
|
||||
val p = prof
|
||||
// When loading/connecting and already showing the logged-in card, stay on it to
|
||||
// avoid focus loss during reconnects, initial loads, and org switches.
|
||||
val transientLoad = s == KiloAppStatusDto.CONNECTING || s == KiloAppStatusDto.LOADING || s == KiloAppStatusDto.MIGRATION_REQUIRED
|
||||
val transientLoad = s == KiloAppStatusDto.DOWNLOADING || s == KiloAppStatusDto.CONNECTING || s == KiloAppStatusDto.LOADING || s == KiloAppStatusDto.MIGRATION_REQUIRED
|
||||
if (transientLoad && shown == Card.LOGGED_IN) return Card.LOGGED_IN
|
||||
return when {
|
||||
s == KiloAppStatusDto.DISCONNECTED || transientLoad -> Card.LOGGED_OUT
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user