Merge branch 'main' into docs-improve-onboarding-flow

This commit is contained in:
Emilie Lima Schario
2026-07-07 15:34:14 -04:00
committed by GitHub
251 changed files with 7208 additions and 1022 deletions
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix multiline bidirectional prompt input rendering and file mention arrow navigation.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve JetBrains session and code block padding alignment.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Allow Shift+Arrow selections in the prompt input to shrink back across file mentions.
+6
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Wait for microphone capture to start before showing voice input as recording.
+1 -9
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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:
+1
View File
@@ -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
View File
@@ -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="
}
}
+4
View File
@@ -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/`
+205
View File
@@ -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
-3
View File
@@ -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",
+6 -7
View File
@@ -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" },
],
+18 -19
View File
@@ -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" },
],
},
-4
View File
@@ -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.
+4 -4
View File
@@ -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.
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2f7edbc1bf6f10260626bf21406bb02e32dc17bf969c83e8b56251c50f755a1f
size 7737
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:92cf06c01b19386f2041e378a08afdaf880fdc5c3e19132350fbda05684dbeec
size 5775
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a817c2b87e1d7b28a7c28deaa8fd8d1e9c51aa2daabda1a2de4a0db0a33fcc17
size 27768
oid sha256:ea36a9604fbc773d2312289f46205221e607a927b0d302b1d9a5e6bdc1d80308
size 28888
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f4dbd3be2cdf72a789c17076afa73ddeb2eacbe201ace8d8a2657eb95b23a413
size 53851
oid sha256:1ee158333ea6c4478f6c167eac878bdc5969fce47ba9f68a1e731fd9035e9e49
size 52312
+19
View File
@@ -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 }),
}
},
}
+1
View File
@@ -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({
+12 -6
View File
@@ -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,
+2
View File
@@ -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")
})
})
+5
View File
@@ -16,3 +16,8 @@ bin/
**/.kotlin/
**/bin/
!gradle/wrapper/gradle-wrapper.jar
# Downloaded CLI artifacts
**/cli-cache/
kilo-*.zip
kilo-*.tar.gz
+13 -12
View File
@@ -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
+20
View File
@@ -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
+9 -12
View File
@@ -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:
+2
View File
@@ -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)
@@ -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()
@@ -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()
@@ -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
@@ -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) {
@@ -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,
)
@@ -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)
@@ -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"
}
@@ -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")
}
@@ -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,
)
},
@@ -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"))
@@ -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}"""
@@ -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() {}
@@ -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())
@@ -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()
}
}
@@ -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) {}
@@ -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()
}
}
+1 -13
View File
@@ -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 {
@@ -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
}
@@ -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}"
@@ -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
@@ -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"),
@@ -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"
}
@@ -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)
@@ -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 {
@@ -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
@@ -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
@@ -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()
}
@@ -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)
@@ -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) {
@@ -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)
}
}
}
@@ -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(),
)
}
@@ -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. */
@@ -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
@@ -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()
@@ -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()
@@ -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
}
@@ -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()
@@ -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)
}
}
@@ -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
@@ -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
@@ -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