Merge branch 'main' into fix/cli-prompt-loop-missing-finish-reason

This commit is contained in:
Christiaan Arnoldus
2026-05-07 14:28:55 +02:00
committed by GitHub
640 changed files with 27715 additions and 10548 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep Agent Manager branch, mode, and model selectors usable in the new worktree dialog.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show the autocomplete model selector with the same picker layout as other model selectors and save changes from the settings save bar.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Support copying, previewing, and exporting rendered Mermaid diagrams.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Make Agent Manager session tabs compact, status-aware, and easier to scan when many tabs are open.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add a searchable current-tabs menu to Agent Manager.
+6
View File
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@opencode-ai/ui": patch
---
Restore inline diff previews after session reloads while keeping large file contents stripped from VS Code webview messages.
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Support global and per-project codebase indexing enablement.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Remember the JetBrains session header metrics expansion preference and show file names in read/write timeline tooltips.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show collapsible session metadata, context usage, token usage, and compaction controls in the JetBrains session header.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show completed assistant steps in the JetBrains session timeline.
@@ -0,0 +1,8 @@
---
"kilo-code": minor
"@kilocode/cli": minor
"@kilocode/kilo-gateway": minor
"@kilocode/sdk": minor
---
Migrate KiloClaw chat to the new kilo-chat backend. Replaces the single-channel Stream Chat integration with a multi-conversation experience that matches the web UX at app.kilo.ai/claw/kilo-chat: conversation list, reactions, typing indicators, editing, and action approvals. The TUI continues to render a single chat view backed by the user's primary conversation.
+6
View File
@@ -0,0 +1,6 @@
---
"kilo-code": minor
"@kilocode/cli": minor
---
Rate assistant responses with thumbs up/down. Click the thumbs buttons next to the copy button on any assistant message, or press `<leader>=` / `<leader>-` in the terminal UI. Only shown when telemetry is enabled; feedback is sent to Kilo to help improve model and prompt quality.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Honor configured permission overrides in Ask and Plan modes, including persisted always-allow rules.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Support accepting command permission prompts with Enter and denying them with Escape.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Recover sessions when providers end a response with an error finish but no error details.
+7
View File
@@ -0,0 +1,7 @@
---
"kilo-code": patch
"@kilocode/cli": patch
"@kilocode/sdk": patch
---
Remove custom providers from settings when disconnecting them so they do not reappear after being disabled and re-enabled.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep Agent Manager model, mode, and reasoning effort selections independent per session and preserve them after restart.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Capture the active VS Code terminal when using `@terminal` in Agent Manager.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Unify the Changes panel into a single tab with a source dropdown to switch between workspace and current-session diffs. Show the session's additions and deletions on the sidebar changes badge, and warn in the panel when snapshots are disabled for the repository.
+21 -2
View File
@@ -96,6 +96,16 @@ jobs:
echo "cli_url=$URL" >> "$GITHUB_OUTPUT"
echo "::notice::Testing CLI v${VERSION} via asset API: $URL"
# Harbor's default agent-setup timeout is 360s. The hello-world container
# (FROM ubuntu:24.04) needs apt-get update + apt-get install + a NodeSource
# curl|bash + apt install nodejs before the CLI even downloads, and on
# the Blacksmith runners that occasionally pushes past 6 min when an apt
# mirror or NodeSource cdn is slow, killing the run with
# AgentSetupTimeoutError. Doubling the multiplier to 2 gives enough
# headroom for transient mirror/cdn slowness while still finishing well
# under timeout-minutes. Note: --timeout-multiplier scales every timeout
# uniformly (setup, agent, verifier, env build), but they're all upper
# limits so this is harmless.
- name: Run smoke test — hello-world
env:
KILO_CLI_URL: ${{ steps.cli.outputs.cli_url }}
@@ -104,7 +114,8 @@ jobs:
./scripts/run_eval.sh \
-m kilo/anthropic/claude-sonnet-4.6 \
-d hello-world \
--job-name smoke-test-hello-world
--job-name smoke-test-hello-world \
--timeout-multiplier 2
- name: Run smoke test — log-summary-date-ranges
env:
@@ -115,11 +126,18 @@ jobs:
-m kilo/anthropic/claude-sonnet-4.6 \
-d terminal-bench-sample \
-t "log-summary-date-ranges" \
--job-name smoke-test-log-summary
--job-name smoke-test-log-summary \
--timeout-multiplier 2
- name: Validate results
run: python3 scripts/validate_smoke_test.py jobs/smoke-test-*/
# Also upload the agent setup logs (stdout/stderr/return-code from the
# CLI install script) so we can see exactly which step stalls when the
# next AgentSetupTimeoutError happens. These come from the install
# script in kilo-bench, which uses `set -euo pipefail` (no `set -x`)
# and never echoes auth tokens or API keys, so the captured output is
# safe to upload.
- name: Upload results
if: always()
uses: actions/upload-artifact@v7 # kilocode_change
@@ -128,5 +146,6 @@ jobs:
path: |
jobs/smoke-test-*/**/result.json
jobs/smoke-test-*/**/trajectory.json
jobs/smoke-test-*/**/agent/setup/*.txt
retention-days: 30
if-no-files-found: warn
+8
View File
@@ -28,3 +28,11 @@ Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3
- In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior.
- Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types.
- Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first.
## Testing Patterns
- Use `testEffect(...)` from `packages/opencode/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations.
- Use `it.live(...)` for filesystem, git repositories, HTTP servers, sockets, child processes, locks, real time, and other live platform behavior.
- Run tests from package directories such as `packages/opencode`; never run package tests from the repo root.
- Prefer explicit test layers over ad hoc managed runtimes. Keep dependency provisioning visible in the test file.
- Use scoped fixtures and finalizers for resources that must be cleaned up, including temporary directories, flags, databases, fibers, servers, and global state.
+48 -25
View File
@@ -251,7 +251,6 @@
"quick-lru": "^7.0.0",
"simple-git": "3.35.2",
"solid-js": "^1.9.11",
"stream-chat": "9.38.0",
"uri-js": "^4.4.1",
"virtua": "catalog:",
"web-tree-sitter": "^0.24.7",
@@ -392,7 +391,6 @@
"semver": "^7.6.3",
"simple-git": "3.35.2",
"solid-js": "catalog:",
"stream-chat": "9.38.0",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
@@ -461,8 +459,8 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.1.105",
"@opentui/solid": ">=0.1.105",
"@opentui/core": ">=0.2.2",
"@opentui/solid": ">=0.2.2",
},
"optionalPeers": [
"@opentui/core",
@@ -580,7 +578,6 @@
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch",
},
"overrides": {
"@effect/platform-node-shared": "4.0.0-beta.46",
@@ -608,10 +605,12 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.1.105",
"@opentui/solid": "0.1.105",
"@opentui/core": "0.2.2",
"@opentui/solid": "0.2.2",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
@@ -1504,21 +1503,21 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="],
"@opentui/core": ["@opentui/core@0.2.2", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.2", "@opentui/core-darwin-x64": "0.2.2", "@opentui/core-linux-arm64": "0.2.2", "@opentui/core-linux-x64": "0.2.2", "@opentui/core-win32-arm64": "0.2.2", "@opentui/core-win32-x64": "0.2.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wxg1CD58SVrowu+WgbhZNi3UP/wWxPio2Kj2IeTjomoIE+6EXLxR8eCCxHYVuQUd9E4fknrKkY5HmiSsp6oPow=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="],
"@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="],
"@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2134,8 +2133,6 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="],
"@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="],
"@types/linkify-it": ["@types/linkify-it@3.0.5", "", {}, "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw=="],
@@ -2426,7 +2423,7 @@
"babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="],
"babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
@@ -2494,13 +2491,13 @@
"buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="],
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="],
@@ -3404,8 +3401,6 @@
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
"linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="],
"load-json-file": ["load-json-file@7.0.1", "", {}, "sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
@@ -4156,8 +4151,6 @@
"storybook-solidjs-vite": ["storybook-solidjs-vite@10.0.9", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.1", "@storybook/builder-vite": "^10.0.0", "@storybook/global": "^5.0.0", "vite-plugin-solid": "^2.11.8" }, "peerDependencies": { "solid-js": "^1.9.0", "storybook": "^0.0.0-0 || ^10.0.0", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-n6MwWCL9mK/qIaUutE9vhGB0X1I1hVnKin2NL+iVC5oXfAiuaABVZlr/1oEeEypsgCdyDOcbEbhJmDWmaqGpPw=="],
"stream-chat": ["stream-chat@9.38.0", "", { "dependencies": { "@types/jsonwebtoken": "^9.0.8", "@types/ws": "^8.5.14", "axios": "^1.12.2", "base64-js": "^1.5.1", "form-data": "^4.0.4", "isomorphic-ws": "^5.0.0", "jsonwebtoken": "^9.0.3", "linkifyjs": "^4.3.2", "ws": "^8.18.1" } }, "sha512-nyTFKHnhGfk1Op/xuZzPKzM9uNTy4TBma69+ApwGj/UtrK2pT6rSaU0Qy/oAqub+Bh7jR2/5vlV/8FWJ2BObFg=="],
"streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
@@ -4950,6 +4943,8 @@
"enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"esbuild-plugin-solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"eslint/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -5074,6 +5069,10 @@
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"opentui-spinner/@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="],
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="],
"ora/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
"ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
@@ -5188,6 +5187,8 @@
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"vite-plugin-solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"vitest/why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
@@ -5262,7 +5263,7 @@
"@kilocode/kilo-gateway/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.75", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="],
"@kilocode/kilo-gateway/@opentui/core/bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
"@kilocode/kilo-gateway/@opentui/core/bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"@kilocode/kilo-gateway/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
@@ -5600,6 +5601,26 @@
"opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="],
"opentui-spinner/@opentui/core/bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"opentui-spinner/@opentui/core/bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -5800,6 +5821,8 @@
"mocha/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"pkg-conf/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-BkFDOCEvivFrxKOvMjAIhj3QZwmb9vE/KvCZv5puw6E=",
"aarch64-linux": "sha256-sTkfQNjqCNCxmHbpQ8woA6azoiyD37e8Xy7UwB6eNsA=",
"aarch64-darwin": "sha256-lKuagsTHqij5C764DM3d+BOe4JKs2KV8XiwSlJItGMg=",
"x86_64-darwin": "sha256-g5gZMHLq8feSZHVaCOLGU6Tj2Qw56Zogif3pT9EIlz8="
"x86_64-linux": "sha256-ZInDUMIVu0aBcf0GufNsBEBdL8pQ6GvlQmQq18mFzso=",
"aarch64-linux": "sha256-I9oJ145BsbXUVTzaIWAZsG8YijmO/gUDO6DwinUwA3w=",
"aarch64-darwin": "sha256-tr8lRQvUMToux9JyiyTk6rYDFmPMm6LNai/IQ8Qk23c=",
"x86_64-darwin": "sha256-ffyoj7ubIzbUXhcm7/yOQBZA42BIt+fEBQs4khQqaSg="
}
}
+10 -7
View File
@@ -7,11 +7,13 @@
"packageManager": "bun@1.3.13",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev-setup": "bun run --cwd packages/opencode --conditions=browser src/index.ts dev-setup",
"dev:desktop": "bun --cwd packages/desktop-electron dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"typecheck": "bun turbo typecheck",
"postinstall": "bun run --cwd packages/opencode fix-node-pty && bun run script/setup-git.ts",
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'",
@@ -31,8 +33,8 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.1.105",
"@opentui/solid": "0.1.105",
"@opentui/core": "0.2.2",
"@opentui/solid": "0.2.2",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -74,6 +76,8 @@
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.12",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.10"
@@ -82,7 +86,6 @@
"devDependencies": {
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:",
"glob": "13.0.5",
@@ -93,6 +96,7 @@
"semver": "^7.6.0",
"sst": "3.18.10",
"turbo": "2.8.13",
"@types/bun": "catalog:",
"@changesets/changelog-github": "^0.5.1",
"@changesets/cli": "^2.27.10"
},
@@ -143,8 +147,7 @@
"patchedDependencies": {
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch"
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
},
"version": "7.2.42",
"peerDependencies": {}
+3 -1
View File
@@ -54,7 +54,9 @@ export const Flag = {
// Experimental
KILO_EXPERIMENTAL,
KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe(Config.withDefault(false)),
KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe(
Config.withDefault(false),
),
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe(
Config.withDefault(false),
),
+26 -11
View File
@@ -5,6 +5,7 @@ import os from "os"
import { Context, Effect, Layer } from "effect"
import { Flock } from "./util/flock"
import { markNoIndex } from "./kilocode/spotlight" // kilocode_change
import { Flag } from "./flag/flag"
const app = "kilo" // kilocode_change
// kilocode_change start
@@ -21,6 +22,7 @@ const cache = path.join(clean(xdgCache)!, app)
const config = path.join(clean(xdgConfig)!, app)
const state = path.join(clean(xdgState)!, app)
// kilocode_change end
const tmp = path.join(os.tmpdir(), app)
const paths = {
get home() {
@@ -32,6 +34,7 @@ const paths = {
cache,
config,
state,
tmp,
}
export const Path = paths
@@ -42,6 +45,7 @@ await Promise.all([
fs.mkdir(Path.data, { recursive: true }),
fs.mkdir(Path.config, { recursive: true }),
fs.mkdir(Path.state, { recursive: true }),
fs.mkdir(Path.tmp, { recursive: true }),
fs.mkdir(Path.log, { recursive: true }),
fs.mkdir(Path.bin, { recursive: true }),
])
@@ -58,23 +62,34 @@ export interface Interface {
readonly cache: string
readonly config: string
readonly state: string
readonly tmp: string
readonly bin: string
readonly log: string
}
export function make(input: Partial<Interface> = {}): Interface {
return {
home: Path.home,
data: Path.data,
cache: Path.cache,
config: Flag.KILO_CONFIG_DIR ?? Path.config,
state: Path.state,
tmp: Path.tmp,
bin: Path.bin,
log: Path.log,
...input,
}
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
return Service.of({
home: Path.home,
data: Path.data,
cache: Path.cache,
config: Path.config,
state: Path.state,
bin: Path.bin,
log: Path.log,
})
}),
Effect.sync(() => Service.of(make())),
)
export const layerWith = (input: Partial<Interface>) =>
Layer.effect(
Service,
Effect.sync(() => Service.of(make(input))),
)
export * as Global from "./global"
+6 -2
View File
@@ -120,13 +120,17 @@ export const layer = Layer.effect(
}
})()
if (yield* afs.existsSafe(dir)) {
if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) {
return resolveEntryPoint(name, path.join(dir, "node_modules", name))
}
const tree = yield* reify({ dir, add: [pkg] })
const first = tree.edgesOut.values().next().value?.to
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
if (!first) {
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name))
if (Option.isSome(result.entrypoint)) return result
return yield* new InstallFailedError({ add: [pkg], dir })
}
return resolveEntryPoint(first.name, first.path)
}, Effect.scoped)
@@ -18,20 +18,17 @@ function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
const msg: Msg = JSON.parse(process.argv[2]!)
const msg: Msg = JSON.parse(process.argv[2])
const testGlobal = Layer.succeed(
Global.Service,
Global.Service.of({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
}),
)
const testGlobal = Global.layerWith({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
})
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Global } from "@opencode-ai/core/global"
describe("global paths", () => {
test("tmp path is under the system temp directory", () => {
expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "opencode"))
expect(Global.make().tmp).toBe(Global.Path.tmp)
})
test("tmp path is created on module load", async () => {
expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true)
})
})
+35
View File
@@ -1,7 +1,12 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Option } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Npm } from "@opencode-ai/core/npm"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { tmpdir } from "./fixture/tmpdir"
const win = process.platform === "win32"
@@ -15,6 +20,14 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
}),
)
const npmLayer = (cache: string) =>
Npm.layer.pipe(
Layer.provide(EffectFlock.layer),
Layer.provide(AppFileSystem.layer),
Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })),
Layer.provide(NodeFileSystem.layer),
)
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
@@ -29,6 +42,28 @@ describe("Npm.sanitize", () => {
})
})
describe("Npm.add", () => {
test("reifies when package cache directory exists without the package installed", async () => {
await using tmp = await tmpdir()
await fs.mkdir(path.join(tmp.path, "fixture-provider"))
await writePackage(path.join(tmp.path, "fixture-provider"), {
name: "fixture-provider",
main: "index.js",
})
await Bun.write(path.join(tmp.path, "fixture-provider", "index.js"), "export const fixture = true\n")
const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}`
await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true })
const entry = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(spec)
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(Option.isSome(entry.entrypoint)).toBe(true)
})
})
describe("Npm.install", () => {
test("respects omit from project .npmrc", async () => {
await using tmp = await tmpdir()
+9 -12
View File
@@ -93,18 +93,15 @@ async function waitForFile(file: string, timeout = 3_000) {
// Test layer
// ---------------------------------------------------------------------------
const testGlobal = Layer.succeed(
Global.Service,
Global.Service.of({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
}),
)
const testGlobal = Global.layerWith({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
})
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
+1 -8
View File
@@ -3,13 +3,6 @@
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUncheckedIndexedAccess": false,
"types": ["bun"],
"plugins": [
{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
}
]
"types": ["bun"]
}
}
@@ -80,6 +80,7 @@ export const CodeWithAiNav: NavSection[] = [
children: "Git Commit Generation",
},
{ href: "/code-with-ai/features/speech-to-text", children: "Voice Transcription" },
{ href: "/code-with-ai/features/message-feedback", children: "Message Feedback" },
{
href: "/code-with-ai/features/browser-use",
children: "Agent Behavior",
+2
View File
@@ -43,4 +43,6 @@ exclude = [
# Consistently times out in CI
'^https?://opncd\.ai',
'^https?://zod\.dev/v4/changelog',
# OpenAI docs return 404 to plain GET link checks but resolve in browsers
'^https?://platform\.openai\.com/docs/api-reference/responses/create',
]
@@ -0,0 +1,76 @@
---
title: "Message Feedback"
description: "Rate assistant responses with thumbs up/down to help Kilo improve"
---
# Message Feedback
You can give a thumbs up or thumbs down to any response Kilo gives you. It's a quick way to tell us when something worked well, or when it didn't.
{% callout type="info" %}
Feedback is only available when telemetry is on. If you turn telemetry off, the buttons no longer display. See [Turning it off](#turning-it-off) below.
{% /callout %}
## How to use it
{% tabs %}
{% tab label="VSCode" %}
When Kilo finishes a response, you'll see thumbs-up and thumbs-down buttons next to the **Copy response** button. Click one to send your rating. Click the same button again to take it back, or click the other one to change your mind.
If you reload the window or switch to a different session, the feedback status will be reset.
{% /tab %}
{% tab label="CLI" %}
In the terminal, two keybinds rate the most recent assistant message:
| Action | Keybind |
|---|---|
| Helpful | `<leader>=` |
| Not helpful | `<leader>-` |
`<leader>` defaults to `Ctrl+X`, so you press `Ctrl+X` and then `=` for thumbs-up, or `Ctrl+X` and then `-` for thumbs-down.
{% /tab %}
{% /tabs %}
## Why it matters
Your feedback is the most direct way to tell us what's working and what isn't. We use it to tune the prompts behind Kilo's behavior, pick better default models, and find and fix problems faster than we could on our own.
## What we send
When you rate a response, here's what gets sent:
- That you rated a response thumbs up or thumbs down.
- The provider and model ID that produced the response (for example, `anthropic` and `claude-sonnet-4-5`).
- For responses that came through Kilo Gateway, the request ID so we can match the rating back to the session.
No further information is included in the rating feedback. For more information on telemetry, see [PRIVACY.md](https://github.com/Kilo-Org/kilocode/blob/main/PRIVACY.md).
## Turning it off
Feedback is part of telemetry, so turning telemetry off will cause the feedback buttons to no longer appear.
{% tabs %}
{% tab label="VSCode" %}
Open Settings (`Cmd+,` on Mac, `Ctrl+,` on Windows/Linux), search for **`telemetry.telemetryLevel`**, and set it to **Off**. The feedback buttons will no longer appear. Set it back to **All** to bring them back. (For more on what this setting controls, see [VS Code's telemetry docs](https://code.visualstudio.com/docs/getstarted/telemetry).)
{% /tab %}
{% tab label="CLI" %}
Set `KILO_TELEMETRY_LEVEL=off` in your environment before starting Kilo:
```bash
KILO_TELEMETRY_LEVEL=off kilo
```
With telemetry off, pressing the feedback keybinds shows a message saying feedback is disabled, and nothing is sent.
{% /tab %}
{% /tabs %}
@@ -598,7 +598,7 @@ Options:
--path directory path to generate the agent file [string]
--description what the agent should do [string]
--mode agent mode [string] [choices: "all", "primary", "subagent"]
--permissions, --tools comma-separated list of permissions to allow (default: all). Available: "bash, read, edit, glob, grep, webfetch, task, todowrite, websearch, codesearch, lsp, skill" [string]
--permissions, --tools comma-separated list of permissions to allow (default: all). Available: "bash, read, edit, glob, grep, webfetch, task, todowrite, websearch, lsp, skill" [string]
-m, --model model to use in the format of provider/model [string]
```
@@ -24,7 +24,7 @@ Add a thumbs-up / thumbs-down widget next to the existing copy button on every a
| Surface | Approach |
|---|---|
| VS Code extension | Thumbs buttons inline next to the copy button |
| TUI | Keybinds (`<leader>+` / `<leader>-`) on the last assistant message |
| TUI | Keybinds (`<leader>=` / `<leader>-`) on the last assistant message |
### Telemetry Payload
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee8d8e04b09b58973a1ff66d5a3d8648ba590f88312cf1f37412c39ccdac0480
size 3973
oid sha256:483e73147bed34050ee35e60607c33c04d7e726b8f746921ad27e582cb5b19ee
size 2886
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f0e9ea1be8fbb44f05bed7960d1ba13f0d4c5e904e4025e4ab4743c4991e93c8
size 4696
oid sha256:3b4687ed34fe06adb7e72797798b1f3602c225e9a36223842ea1c66eebea4601
size 4794
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:66a7ecedac685e17c442bb036d7392320e0642ef43da77ed8dc23be29bc7988e
size 2935
oid sha256:980c9baf0edafa113956157a34eb00495aff55293b10fb4cbb86a8cb5b28e8c0
size 2865
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0d29bb3a9356cf8bec6bb0a9d6e4d0b59d147c1fa8adee7140c81a061eea407d
size 6930
oid sha256:067a5c757dfa32d27955dd634b8dea52edef2141111a5eecee0161df36c0ed8b
size 7061
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:483c04840175e7678f0cfd1eba377ec199239404acd567fd7778367f4d2cb46c
size 21219
oid sha256:a1ca383c2a5fa9d6f477430d7c63c7330e13e2667bf717906d3c29c215f163c5
size 21564
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f2130db1ea14a5e8fbf879e96529198592a785b8460d29e12746175e102d50d5
size 53617
oid sha256:a67058f86869ec7457058feec03f880b7a3ac31c00b87ae6db5a55d977295856
size 57421
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bdcac95e7969d021ebce19001f37f86b8ab670bd79e39086385c9419617294df
size 27177
@@ -12,6 +12,24 @@ export const DEFAULT_KILO_API_URL = "https://api.kilo.ai"
/** Base URL for Kilo API - can be overridden by KILO_API_URL env var */
export const KILO_API_BASE = process.env[ENV_KILO_API_URL] || DEFAULT_KILO_API_URL
/** Environment variable for custom Kilo Chat URL */
export const KILO_CHAT_URL_ENV = "KILO_CHAT_URL"
/** Default Kilo Chat URL (REST endpoint for messages, conversations, etc.) */
export const KILO_DEFAULT_CHAT_URL = "https://chat.kiloapps.io"
/** Base URL for Kilo Chat - can be overridden by KILO_CHAT_URL env var */
export const KILO_CHAT_URL = process.env[KILO_CHAT_URL_ENV] || KILO_DEFAULT_CHAT_URL
/** Environment variable for custom Event Service URL */
export const KILO_EVENT_SERVICE_URL_ENV = "EVENT_SERVICE_URL"
/** Default Event Service URL (WebSocket endpoint for kilo-chat events) */
export const KILO_DEFAULT_EVENT_SERVICE_URL = "wss://events.kiloapps.io"
/** Base URL for Event Service - can be overridden by EVENT_SERVICE_URL env var */
export const KILO_EVENT_SERVICE_URL = process.env[KILO_EVENT_SERVICE_URL_ENV] || KILO_DEFAULT_EVENT_SERVICE_URL
/** Default base URL for OpenRouter-compatible endpoint */
export const KILO_OPENROUTER_BASE = `${KILO_API_BASE}/api/openrouter`
+55 -36
View File
@@ -8,7 +8,13 @@
import { fetchProfile, fetchBalance } from "../api/profile.js"
import { fetchKilocodeNotifications, KilocodeNotificationSchema } from "../api/notifications.js"
import { fetchOrganizationModes, clearModesCache } from "../api/modes.js"
import { KILO_API_BASE, HEADER_FEATURE, HEADER_ORGANIZATIONID } from "../api/constants.js"
import {
KILO_API_BASE,
KILO_CHAT_URL,
KILO_EVENT_SERVICE_URL,
HEADER_FEATURE,
HEADER_ORGANIZATIONID,
} from "../api/constants.js"
import { buildKiloHeaders } from "../headers.js"
import type { ImportDeps, DrizzleDb } from "../cloud-sessions.js"
import { fetchCloudSession, fetchCloudSessionForImport, importSessionToDb } from "../cloud-sessions.js"
@@ -32,7 +38,7 @@ interface KiloRoutesDeps extends ImportDeps {
Auth: Auth
ModelCache: ModelCache
z: Z
Instance: ImportDeps["Instance"] & { disposeAll(): Promise<void> }
InstanceStore: { disposeAllInstances(): Promise<void> }
}
const FIM_TIMEOUT_MS = 30_000
@@ -79,6 +85,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
SessionCreatedEvent,
Identifier,
ModelCache,
InstanceStore,
} = deps
const Organization = z.object({
@@ -210,7 +217,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
ModelCache.clear("kilo")
clearModesCache()
await Instance.disposeAll()
await InstanceStore.disposeAllInstances()
return c.json(true)
},
@@ -524,8 +531,24 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
"application/json": {
schema: resolver(
z.object({
// `recovering` and `restoring` are transitional states the
// worker reports while it brings an instance back online
// after an unexpected stop or a snapshot restore — see
// cloud `services/kiloclaw/src/index.ts` and the
// `PlatformStatusResponse` type in
// cloud/apps/web/src/lib/kiloclaw/types.ts. Keeping them in
// the enum so the SDK types stay accurate.
status: z
.enum(["provisioned", "starting", "restarting", "running", "stopped", "destroying"])
.enum([
"provisioned",
"starting",
"restarting",
"recovering",
"running",
"stopped",
"destroying",
"restoring",
])
.nullable(),
sandboxId: z.string().optional(),
flyRegion: z.string().optional(),
@@ -536,6 +559,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
channelCount: z.number().optional(),
secretCount: z.number().optional(),
userId: z.string().optional(),
botName: z.string().nullable().optional(),
}),
),
},
@@ -578,57 +602,52 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
"/claw/chat-credentials",
describeRoute({
summary: "Get KiloClaw chat credentials",
description: "Fetch Stream Chat credentials for the user's KiloClaw instance",
description:
"Returns the bearer token and endpoint URLs the client uses to talk to the Kilo Chat worker " +
"and the Event Service. The bearer is the user's existing long-lived Kilo JWT — kilo-chat and " +
"event-service both verify it directly with NEXTAUTH_SECRET, so no separate token mint is needed.",
operationId: "kilo.claw.chatCredentials",
responses: {
200: {
description: "Stream Chat credentials or null",
description: "Kilo Chat credentials or null",
content: {
"application/json": {
schema: resolver(
z
.object({
apiKey: z.string(),
userId: z.string(),
userToken: z.string(),
channelId: z.string(),
token: z.string(),
expiresAt: z.string(),
kiloChatUrl: z.string(),
eventServiceUrl: z.string(),
})
.nullable(),
),
},
},
},
...errors(401, 502),
...errors(401),
},
}),
async (c: any) => {
try {
const auth = await Auth.get("kilo")
if (!auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401)
const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined
if (!token) return c.json({ error: "No valid token found" }, 401)
const auth = await Auth.get("kilo")
if (!auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401)
const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined
if (!token) return c.json({ error: "No valid token found" }, 401)
const organizationId = auth.type === "oauth" ? auth.accountId : undefined
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
}
if (organizationId) {
headers[HEADER_ORGANIZATIONID] = organizationId
}
// For OAuth, expires is a millisecond epoch we already track. For
// API tokens we don't have a verified expiry locally — the JWT is
// signed by the cloud and validated by kilo-chat/event-service on
// every request. Use a far-future placeholder so the client cache
// doesn't refetch unnecessarily; on 401 the client clears the
// cache and prompts re-auth.
const expiresAtMs = auth.type === "oauth" ? auth.expires : Date.now() + 365 * 24 * 60 * 60 * 1000
const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/chat-credentials`, { headers })
if (!response.ok) {
const text = await response.text()
return c.json({ error: `KiloClaw request failed: ${response.status} ${text}` }, response.status as any)
}
return c.json(await response.json())
} catch (err: any) {
console.error("[Kilo Gateway] claw/chat-credentials: error", err?.message ?? err)
return c.json({ error: "Failed to reach KiloClaw" }, 502)
}
return c.json({
token,
expiresAt: new Date(expiresAtMs).toISOString(),
kiloChatUrl: KILO_CHAT_URL,
eventServiceUrl: KILO_EVENT_SERVICE_URL,
})
},
)
.get(
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"أوقف لقطات Kilo لهذا المشروع. ستفقد ميزة التراجع/الإعادة لتعديلات Kilo، لكن git سيستمر في تتبع كل شيء.",
"ui.messagePart.openInDiffViewer": "فتح في عارض الفروقات",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "كان هذا مفيدًا",
"ui.message.feedback.notHelpful": "لم يكن هذا مفيدًا",
"ui.message.feedback.clearRating": "مسح التقييم",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Desligue os snapshots do Kilo para este projeto. Você perde desfazer/refazer das mudanças feitas pelo Kilo, mas o git continua rastreando tudo.",
"ui.messagePart.openInDiffViewer": "Abrir no Visualizador de Diferenças",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Isso foi útil",
"ui.message.feedback.notHelpful": "Isso não foi útil",
"ui.message.feedback.clearRating": "Limpar avaliação",
}
+5
View File
@@ -97,4 +97,9 @@ export const dict = {
"Isključi Kilo snapshotove za ovaj projekat. Izgubićete poništi/vrati za izmjene koje napravi Kilo, ali git i dalje prati sve.",
"ui.messagePart.openInDiffViewer": "Otvori u pregledniku razlika",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Ovo je bilo korisno",
"ui.message.feedback.notHelpful": "Ovo nije bilo korisno",
"ui.message.feedback.clearRating": "Obriši ocjenu",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Slå Kilos snapshots fra for dette projekt. Du mister fortryd/gentag for Kilo-ændringer, men git sporer stadig alt.",
"ui.messagePart.openInDiffViewer": "Åbn i diff-viser",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Dette var nyttigt",
"ui.message.feedback.notHelpful": "Dette var ikke nyttigt",
"ui.message.feedback.clearRating": "Ryd bedømmelse",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Kilo-Snapshots für dieses Projekt ausschalten. Rückgängig/Wiederherstellen für Kilo-Änderungen ist nicht mehr möglich, aber git verfolgt weiterhin alles.",
"ui.messagePart.openInDiffViewer": "Im Diff-Viewer öffnen",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Das war hilfreich",
"ui.message.feedback.notHelpful": "Das war nicht hilfreich",
"ui.message.feedback.clearRating": "Bewertung löschen",
}
+5
View File
@@ -99,4 +99,9 @@ export const dict = {
// Edit-tool header: hover-revealed action opening the diff in a full tab.
"ui.messagePart.openInDiffViewer": "Open in Diff Viewer",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "This was helpful",
"ui.message.feedback.notHelpful": "This wasn't helpful",
"ui.message.feedback.clearRating": "Clear rating",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Apaga las instantáneas de Kilo para este proyecto. Perderás deshacer/rehacer de los cambios de Kilo, pero git seguirá rastreando todo.",
"ui.messagePart.openInDiffViewer": "Abrir en el visor de diferencias",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Esto fue útil",
"ui.message.feedback.notHelpful": "Esto no fue útil",
"ui.message.feedback.clearRating": "Borrar valoración",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Désactivez les instantanés Kilo pour ce projet. Vous perdez l'annulation/restauration des modifications faites par Kilo, mais git continue de tout suivre.",
"ui.messagePart.openInDiffViewer": "Ouvrir dans la visionneuse de différences",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "C'était utile",
"ui.message.feedback.notHelpful": "Ce n'était pas utile",
"ui.message.feedback.clearRating": "Effacer la notation",
}
+5
View File
@@ -90,4 +90,9 @@ export const dict = {
"このプロジェクトでは Kilo のスナップショットを無効にします。Kilo による変更の取り消し/やり直しはできなくなりますが、git は引き続きすべてを追跡します。",
"ui.messagePart.openInDiffViewer": "差分ビューアで開く",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "役に立ちました",
"ui.message.feedback.notHelpful": "役に立ちませんでした",
"ui.message.feedback.clearRating": "評価をクリア",
}
+5
View File
@@ -90,4 +90,9 @@ export const dict = {
"이 프로젝트의 Kilo 스냅샷을 끕니다. Kilo 변경에 대한 실행 취소/다시 실행은 사용할 수 없지만 git은 여전히 모든 것을 추적합니다.",
"ui.messagePart.openInDiffViewer": "Diff 뷰어에서 열기",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "도움이 됐어요",
"ui.message.feedback.notHelpful": "도움이 안 됐어요",
"ui.message.feedback.clearRating": "평가 지우기",
}
+5
View File
@@ -94,4 +94,9 @@ export const dict = {
"Zet Kilo-snapshots uit voor dit project. Je verliest ongedaan maken/opnieuw doen van Kilo-wijzigingen, maar git blijft alles volgen.",
"ui.messagePart.openInDiffViewer": "Openen in diff-viewer",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Dit was nuttig",
"ui.message.feedback.notHelpful": "Dit was niet nuttig",
"ui.message.feedback.clearRating": "Beoordeling wissen",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Slå av Kilos snapshots for dette prosjektet. Du mister angre/gjør om for Kilo-endringer, men git fortsetter å spore alt.",
"ui.messagePart.openInDiffViewer": "Åpne i diff-viser",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Dette var nyttig",
"ui.message.feedback.notHelpful": "Dette var ikke nyttig",
"ui.message.feedback.clearRating": "Fjern vurdering",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Wyłącz snapshoty Kilo dla tego projektu. Stracisz cofanie/przywracanie zmian Kilo, ale git nadal śledzi wszystko.",
"ui.messagePart.openInDiffViewer": "Otwórz w podglądzie różnic",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "To było pomocne",
"ui.message.feedback.notHelpful": "To nie było pomocne",
"ui.message.feedback.clearRating": "Wyczyść ocenę",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Выключите снимки Kilo для этого проекта. Вы потеряете отмену/повтор изменений Kilo, но git по-прежнему отслеживает всё.",
"ui.messagePart.openInDiffViewer": "Открыть в просмотрщике различий",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Это было полезно",
"ui.message.feedback.notHelpful": "Это было бесполезно",
"ui.message.feedback.clearRating": "Очистить оценку",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"ปิดสแน็ปช็อตของ Kilo สำหรับโปรเจกต์นี้ คุณจะสูญเสียการยกเลิก/ทำซ้ำสำหรับการเปลี่ยนแปลงของ Kilo แต่ git ยังคงติดตามทุกอย่าง",
"ui.messagePart.openInDiffViewer": "เปิดในตัวดูความแตกต่าง",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "สิ่งนี้มีประโยชน์",
"ui.message.feedback.notHelpful": "สิ่งนี้ไม่มีประโยชน์",
"ui.message.feedback.clearRating": "ล้างการให้คะแนน",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Bu proje için Kilo anlık görüntülerini kapat. Kilo değişiklikleri için geri alma/yeniden yapmayı kaybedersiniz, ancak git her şeyi izlemeye devam eder.",
"ui.messagePart.openInDiffViewer": "Fark görüntüleyicide aç",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Bu yardımcı oldu",
"ui.message.feedback.notHelpful": "Bu yardımcı olmadı",
"ui.message.feedback.clearRating": "Değerlendirmeyi temizle",
}
+5
View File
@@ -92,4 +92,9 @@ export const dict = {
"Вимкніть знімки Kilo для цього проєкту. Ви втратите скасування/повторення для змін Kilo, але git продовжить відстежувати все.",
"ui.messagePart.openInDiffViewer": "Відкрити у переглядачі відмінностей",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Це було корисно",
"ui.message.feedback.notHelpful": "Це не було корисно",
"ui.message.feedback.clearRating": "Очистити оцінку",
}
+5
View File
@@ -87,4 +87,9 @@ export const dict = {
"关闭本项目的 Kilo 快照。你将失去对 Kilo 更改的撤销/重做,但 git 仍会追踪所有内容。",
"ui.messagePart.openInDiffViewer": "在差异查看器中打开",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "这有帮助",
"ui.message.feedback.notHelpful": "这没有帮助",
"ui.message.feedback.clearRating": "清除评分",
}
+5
View File
@@ -87,4 +87,9 @@ export const dict = {
"關閉本專案的 Kilo 快照。你將失去對 Kilo 變更的撤銷/重做,但 git 仍會追蹤所有內容。",
"ui.messagePart.openInDiffViewer": "在差異檢視器中開啟",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "這有幫助",
"ui.message.feedback.notHelpful": "這沒有幫助",
"ui.message.feedback.clearRating": "清除評分",
}
@@ -6,6 +6,7 @@ import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
@@ -49,6 +50,7 @@ class KiloBackendChatManager(
"session.turn.close",
"session.error",
"session.status",
"session.updated",
"session.idle",
"session.compacted",
"session.diff",
@@ -156,6 +158,37 @@ class KiloBackendChatManager(
}
}
// ------ compact ------
fun compact(id: String, dir: String, model: ModelSelectionDto) {
log.info("${ChatLogSummary.sid(id)} kind=compact ${ChatLogSummary.dir(dir)} model=${model.providerID}/${model.modelID} op=summarize")
val http = requireClient()
val url = requireBase()
val body = KiloCliDataParser.buildSummarizeJson(model)
val request = Request.Builder()
.url("$url/session/$id/summarize?directory=${encode(dir)}")
.post(body.toRequestBody(JSON_TYPE))
.build()
try {
http.newCall(request).execute().use { response ->
val code = response.code
if (!response.isSuccessful) {
val raw = response.body?.string()
log.warn("summarize failed: HTTP $code")
raw?.let { log.debug { "${ChatLogSummary.sid(id)} kind=compact op=summarize error=${ChatLogSummary.body(it)}" } }
throw RuntimeException("summarize failed: HTTP $code")
}
log.debug { "${ChatLogSummary.sid(id)} kind=compact op=summarize ok=true code=$code" }
}
} catch (e: RuntimeException) {
throw e
} catch (e: Exception) {
log.warn("${ChatLogSummary.sid(id)} kind=compact op=summarize dir=${ChatLogSummary.dir(dir)} failed message=${e.message}", e)
throw RuntimeException("summarize HTTP call failed: ${e.message}", e)
}
}
// ------ messages ------
fun messages(id: String, dir: String): List<MessageWithPartsDto> {
@@ -174,6 +174,13 @@ object KiloCliDataParser {
ChatEventDto.SessionStatusChanged(sid, dto)
}
"session.updated" -> {
val info = props["info"]?.jsonObject ?: return null
val dto = parseSessionObject(info)
val sid = props.str("sessionID") ?: dto.id.takeIf { it.isNotBlank() } ?: return null
ChatEventDto.SessionUpdated(sid, dto)
}
"session.idle" -> {
val sid = props.str("sessionID") ?: return null
ChatEventDto.SessionIdle(sid)
@@ -316,6 +323,12 @@ object KiloCliDataParser {
return sb.toString()
}
/**
* Build the JSON body for `POST /session/{id}/summarize`.
*/
fun buildSummarizeJson(model: ModelSelectionDto): String =
"""{"providerID":${escape(model.providerID)},"modelID":${escape(model.modelID)}}"""
/**
* Build the partial JSON body for `PATCH /global/config`.
*/
@@ -363,22 +376,14 @@ object KiloCliDataParser {
modelID = obj.str("modelID"),
parentID = obj.str("parentID"),
cost = obj.num("cost"),
tokens = tokens?.let {
val cache = it["cache"]?.jsonObject
TokensDto(
input = it.long("input") ?: 0,
output = it.long("output") ?: 0,
reasoning = it.long("reasoning") ?: 0,
cacheRead = cache?.long("read") ?: 0,
cacheWrite = cache?.long("write") ?: 0,
)
},
tokens = tokens?.let(::parseTokens),
error = error?.let { parseError(it) },
)
}
internal fun parsePart(obj: JsonObject): PartDto {
val state = obj["state"]?.jsonObject
val tokens = obj["tokens"]?.jsonObject
val top = obj.map("metadata")
val meta = state.map("metadata") + top
return PartDto(
@@ -396,6 +401,20 @@ object KiloCliDataParser {
output = state?.str("output"),
error = state?.str("error"),
time = obj.time("time") ?: state.time("time"),
reason = obj.str("reason"),
cost = obj.num("cost"),
tokens = tokens?.let(::parseTokens),
)
}
private fun parseTokens(obj: JsonObject): TokensDto {
val cache = obj["cache"]?.jsonObject
return TokensDto(
input = obj.long("input") ?: 0,
output = obj.long("output") ?: 0,
reasoning = obj.long("reasoning") ?: 0,
cacheRead = cache?.long("read") ?: 0,
cacheWrite = cache?.long("write") ?: 0,
)
}
@@ -11,6 +11,7 @@ import ai.kilocode.rpc.KiloSessionRpcApi
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
@@ -88,6 +89,9 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
override suspend fun abort(id: String, directory: String) =
chat.abort(id, directory)
override suspend fun compact(id: String, directory: String, model: ModelSelectionDto) =
chat.compact(id, directory, model)
override suspend fun messages(id: String, directory: String): List<MessageWithPartsDto> =
chat.messages(id, directory)
@@ -108,6 +112,7 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
is ChatEventDto.QuestionReplied -> event.sessionID
is ChatEventDto.QuestionRejected -> event.sessionID
is ChatEventDto.SessionStatusChanged -> event.sessionID
is ChatEventDto.SessionUpdated -> event.sessionID
is ChatEventDto.SessionIdle -> event.sessionID
is ChatEventDto.SessionCompacted -> event.sessionID
is ChatEventDto.SessionDiffChanged -> event.sessionID
@@ -24,6 +24,7 @@ import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.LoadErrorDto
import ai.kilocode.rpc.dto.ModelDto
import ai.kilocode.rpc.dto.ModelLimitDto
import ai.kilocode.rpc.dto.ProviderDto
import ai.kilocode.rpc.dto.ProvidersDto
import ai.kilocode.rpc.dto.SkillDto
@@ -142,6 +143,7 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
status = m.status,
recommendedIndex = m.recommendedIndex,
variants = m.variants,
limit = m.limit?.let { ModelLimitDto(it.context, it.input, it.output) },
)
private fun agents(d: AgentData) = AgentsDto(
@@ -16,6 +16,7 @@ import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@@ -295,6 +296,7 @@ class KiloBackendWorkspace(
private fun model(id: String, obj: JsonObject): ModelInfo {
val cap = obj["capabilities"]?.jsonObject
val limit = obj["limit"]?.jsonObject
return ModelInfo(
id = obj.str("id") ?: id,
name = obj.str("name") ?: id,
@@ -306,6 +308,13 @@ class KiloBackendWorkspace(
status = obj.str("status"),
recommendedIndex = obj.num("recommendedIndex"),
variants = variants(obj),
limit = limit?.let {
ModelLimitInfo(
context = it.long("context") ?: 0,
input = it.long("input"),
output = it.long("output") ?: 0,
)
},
)
}
@@ -361,3 +370,4 @@ private fun encode(value: String) = java.net.URLEncoder.encode(value, Charsets.U
private fun JsonObject.str(key: String) = this[key]?.jsonPrimitive?.contentOrNull
private fun JsonObject?.bool(key: String) = this?.get(key)?.jsonPrimitive?.booleanOrNull ?: false
private fun JsonObject.num(key: String) = this[key]?.jsonPrimitive?.doubleOrNull
private fun JsonObject.long(key: String) = this[key]?.jsonPrimitive?.longOrNull
@@ -57,6 +57,13 @@ data class ModelInfo(
val status: String?,
val recommendedIndex: Double?,
val variants: List<String>,
val limit: ModelLimitInfo?,
)
data class ModelLimitInfo(
val context: Long = 0,
val input: Long? = null,
val output: Long = 0,
)
data class AgentData(
@@ -0,0 +1,42 @@
package ai.kilocode.backend.app
import ai.kilocode.backend.testing.MockCliServer
import ai.kilocode.backend.testing.TestLog
import ai.kilocode.rpc.dto.ModelSelectionDto
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import okhttp3.OkHttpClient
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class KiloBackendChatManagerTest {
private val mock = MockCliServer()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@AfterTest
fun tearDown() {
scope.cancel()
mock.close()
}
@Test
fun `compact posts summarize request with selected model`() {
val port = mock.start()
val chat = KiloBackendChatManager(scope, TestLog())
chat.start(OkHttpClient(), port, MutableSharedFlow())
chat.compact("ses_abc", "/test/project", ModelSelectionDto("anthropic", "claude-4"))
assertEquals(1, mock.requestCount("/session/ses_abc/summarize"))
assertNotNull(mock.lastSummarizePath)
assertTrue(mock.lastSummarizePath!!.startsWith("/session/ses_abc/summarize?directory="))
assertEquals("""{"providerID":"anthropic","modelID":"claude-4"}""", mock.lastSummarizeBody)
}
}
@@ -188,7 +188,7 @@ class GeneratedApiModelSerializationTest {
assertEquals(true, obj.native)
assertEquals(false, obj.hidden)
assertEquals("#FF5733", obj.color)
assertEquals(5, obj.steps)
assertEquals(5.0, obj.steps)
}
@Test
@@ -451,6 +451,32 @@ class KiloCliDataParserTest {
assertEquals("Read file.txt", part.title)
}
@Test
fun `parseMessages - step finish part with tokens`() {
val raw = """[{
"info": { "id": "m1", "sessionID": "s1", "role": "assistant", "time": { "created": 1.0 } },
"parts": [{
"id": "p1",
"sessionID": "s1",
"messageID": "m1",
"type": "step-finish",
"reason": "stop",
"cost": 0.005,
"tokens": { "input": 100, "output": 50, "reasoning": 10, "cache": { "read": 20, "write": 5 } }
}]
}]"""
val part = KiloCliDataParser.parseMessages(raw)[0].parts[0]
assertEquals("step-finish", part.type)
assertEquals("stop", part.reason)
assertEquals(0.005, part.cost)
assertEquals(100L, part.tokens?.input)
assertEquals(50L, part.tokens?.output)
assertEquals(10L, part.tokens?.reasoning)
assertEquals(20L, part.tokens?.cacheRead)
assertEquals(5L, part.tokens?.cacheWrite)
}
@Test
fun `parseMessages - malformed JSON returns empty`() {
assertEquals(emptyList(), KiloCliDataParser.parseMessages("not json"))
@@ -619,6 +645,13 @@ class KiloCliDataParserTest {
assertTrue(result.contains("""line1\nline2\t\"quoted\""""))
}
@Test
fun `buildSummarizeJson - writes provider and model`() {
val result = KiloCliDataParser.buildSummarizeJson(ModelSelectionDto("anthropic", "claude-4"))
assertEquals("""{"providerID":"anthropic","modelID":"claude-4"}""", result)
}
// ================================================================
// buildConfigPartial
// ================================================================
@@ -866,6 +899,32 @@ class KiloCliDataParserTest {
assertTrue(result is ChatEventDto.SessionCompacted)
}
@Test
fun `parseChatEvent - session updated`() {
val data = globalEvent("""
"type": "session.updated",
"properties": {
"sessionID": "ses_1",
"info": {
"id": "ses_1",
"projectID": "proj_1",
"directory": "/tmp/project",
"title": "Updated title",
"version": "1",
"time": { "created": 1.0, "updated": 2.0 },
"summary": { "additions": 3, "deletions": 1, "files": 2 }
}
}
""")
val result = KiloCliDataParser.parseChatEvent("session.updated", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.SessionUpdated)
assertEquals("ses_1", result.sessionID)
assertEquals("Updated title", result.session.title)
assertEquals(2, result.session.summary?.files)
}
@Test
fun `parseChatEvent - session diff`() {
val data = globalEvent("""
@@ -171,7 +171,7 @@ class ProjectModelSerializationTest {
assertEquals(true, obj.native)
assertEquals(false, obj.hidden)
assertEquals("#FF5733", obj.color)
assertEquals(5, obj.steps)
assertEquals(5.0, obj.steps)
}
@Test
@@ -55,12 +55,16 @@ class MockCliServer : AutoCloseable {
@Volatile var recentSessions = "[]"
@Volatile var sessionCreate = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"New Session","version":"1.0.0","time":{"created":1000,"updated":1000}}"""
@Volatile var sessionStatuses = "{}"
@Volatile var summarizeResponse = "true"
@Volatile var sessionsStatus = 200
@Volatile var recentSessionsStatus = 200
@Volatile var sessionCreateStatus = 200
@Volatile var sessionGetStatus = 200
@Volatile var sessionDeleteStatus = 200
@Volatile var sessionStatusesStatus = 200
@Volatile var summarizeStatus = 200
@Volatile var lastSummarizePath: String? = null
@Volatile var lastSummarizeBody: String? = null
/** Configurable delay for all endpoint responses (ms). 0 = no delay. */
@Volatile var responseDelay: Long = 0
@@ -173,11 +177,16 @@ class MockCliServer : AutoCloseable {
val method = parts[0]
val path = parts[1]
// Read all headers
var len = 0
while (true) {
val header = input.readLine()
if (header.isNullOrBlank()) break
val parts = header.split(":", limit = 2)
if (parts.size == 2 && parts[0].equals("Content-Length", ignoreCase = true)) {
len = parts[1].trim().toIntOrNull() ?: 0
}
}
val body = if (len > 0) CharArray(len).also { input.read(it, 0, len) }.concatToString() else ""
val output = BufferedWriter(OutputStreamWriter(socket.getOutputStream()))
val bare = path.substringBefore("?")
@@ -218,6 +227,11 @@ class MockCliServer : AutoCloseable {
respond(output, sessionGetStatus, sessionCreate)
bare.matches(Regex("/session/ses_[^/]+")) && method == "DELETE" ->
respond(output, sessionDeleteStatus, "true")
bare.matches(Regex("/session/ses_[^/]+/summarize")) && method == "POST" -> {
lastSummarizePath = path
lastSummarizeBody = body
respond(output, summarizeStatus, summarizeResponse)
}
else -> respond(output, 404, """{"error":"Not found"}""")
}
} catch (_: SocketException) {
@@ -297,6 +297,9 @@ class KiloBackendWorkspaceTest {
assertTrue(model.toolCall)
assertEquals(2.0, model.recommendedIndex)
assertEquals(listOf("low", "medium", "high"), model.variants)
assertEquals(200000L, model.limit?.context)
assertEquals(100000L, model.limit?.input)
assertEquals(16000L, model.limit?.output)
}
@Test
@@ -513,7 +516,7 @@ class KiloBackendWorkspaceTest {
"interleaved": false
},
"cost": {"input": 0, "output": 0, "cache": {"read": 0, "write": 0}},
"limit": {"context": 200000, "output": 16000},
"limit": {"context": 200000, "input": 100000, "output": 16000},
"status": "active",
"recommendedIndex": 2,
"variants": {"high": {}, "low": {}, "medium": {}},
@@ -7,6 +7,7 @@ import ai.kilocode.rpc.KiloSessionRpcApi
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
@@ -87,6 +88,10 @@ class KiloSessionService internal constructor(
suspend fun recent(dir: String, limit: Int): List<SessionDto> =
call { recent(dir, limit) }.sessions
/** Get a single session. */
suspend fun get(id: String, dir: String): SessionDto =
call { get(id, dir) }
/** Create a new session. Caller awaits the result. */
suspend fun create(dir: String): SessionDto {
LOG.info("create: dir=$dir")
@@ -138,6 +143,11 @@ class KiloSessionService internal constructor(
call { abort(id, dir) }
}
/** Summarize/compact a session. */
suspend fun compact(id: String, dir: String, model: ModelSelectionDto) {
call { compact(id, dir, model) }
}
/** Load message history for a session. */
suspend fun messages(id: String, dir: String): List<MessageWithPartsDto> =
call { messages(id, dir) }
@@ -15,8 +15,8 @@ import javax.swing.JPanel
class SessionSidePanelManager(
private val project: Project,
private val root: Workspace,
private val create: (Project, Workspace, SessionManager, String?, Boolean) -> SessionUi = { project, workspace, manager, id, loading ->
service<SessionUiFactory>().create(project, workspace, manager, id, loading)
private val create: (Project, Workspace, SessionManager, String?, Boolean, SessionDto?) -> SessionUi = { project, workspace, manager, id, loading, session ->
service<SessionUiFactory>().create(project, workspace, manager, id, loading, session)
},
private val resolve: (String) -> Workspace = { dir -> service<KiloWorkspaceService>().workspace(dir) },
) : SessionManager, Disposable {
@@ -37,13 +37,13 @@ class SessionSidePanelManager(
val active = current
if (active?.blank == true) return
register(active)
show(create(project, root, this, null, active == null))
show(create(project, root, this, null, active == null, null))
}
override fun openSession(session: SessionDto) {
register(current)
val ui = opened.getOrPut(session.id) {
create(project, resolve(session.directory), this, session.id, false).also {
create(project, resolve(session.directory), this, session.id, false, session).also {
all.add(it)
}
}
@@ -16,6 +16,7 @@ import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.ui.QuestionPanel
import ai.kilocode.client.session.ui.SessionRootPanel
import ai.kilocode.client.session.ui.SessionMessageListPanel
import ai.kilocode.client.session.ui.header.SessionHeaderPanel
import ai.kilocode.client.session.ui.SessionStyle
import ai.kilocode.client.session.ui.SessionStyleTarget
import ai.kilocode.client.session.update.EVENT_FLUSH_MS
@@ -56,6 +57,7 @@ class SessionUi private constructor(
displayMs: Long,
open: (SessionDto) -> Unit,
private val loading: Boolean,
session: SessionDto? = null,
) : JPanel(BorderLayout()), Disposable, SessionStyleTarget {
constructor(
@@ -67,7 +69,8 @@ class SessionUi private constructor(
id: String? = null,
displayMs: Long = SessionController.DISPLAY_DELAY_MS,
open: (SessionDto) -> Unit = {},
) : this(project, workspace, sessions, app, cs, id, displayMs, open, id == null)
session: SessionDto? = null,
) : this(project, workspace, sessions, app, cs, session?.id ?: id, displayMs, open, id == null, session)
internal constructor(
project: Project,
@@ -79,7 +82,8 @@ class SessionUi private constructor(
displayMs: Long = SessionController.DISPLAY_DELAY_MS,
loading: Boolean,
open: (SessionDto) -> Unit = {},
) : this(project, workspace, sessions, app, cs, id, displayMs, open, loading)
session: SessionDto? = null,
) : this(project, workspace, sessions, app, cs, id, displayMs, open, loading, session)
companion object {
private val LOG = KiloLog.create(SessionUi::class.java)
@@ -101,6 +105,7 @@ class SessionUi private constructor(
flushMs = flushMs,
condense = Registry.`is`("kilo.session.condense", true),
displayMs = displayMs,
session = session,
open = open,
beforeUpdate = { if (opening) false else scroll.atBottom() },
afterUpdate = { if (!opening) scroll.followBottom(it) },
@@ -118,6 +123,8 @@ class SessionUi private constructor(
private lateinit var messageBody: SessionMessageListPanel
private lateinit var header: SessionHeaderPanel
internal lateinit var scroll: SessionScroll
private lateinit var question: QuestionPanel
@@ -174,6 +181,7 @@ class SessionUi private constructor(
), BorderLayout.CENTER)
}
messageBody = SessionMessageListPanel(controller.model, this)
header = SessionHeaderPanel(controller, this)
scroll = SessionScroll(root, sessionContent, messageBody, blankBody)
question = QuestionPanel(controller)
@@ -186,6 +194,7 @@ class SessionUi private constructor(
onAbort = { controller.abort() },
)
sessionContent.add(header, BorderLayout.NORTH)
sessionContent.add(scroll.component, BorderLayout.CENTER)
root.content.add(sessionContent, BorderLayout.CENTER)
// Dock panels stay in normal flow so each visible state takes layout space
@@ -278,6 +287,8 @@ class SessionUi private constructor(
is SessionModelEvent.ContentRemoved,
is SessionModelEvent.DiffUpdated,
is SessionModelEvent.TodosUpdated,
is SessionModelEvent.SessionUpdated,
is SessionModelEvent.HeaderUpdated,
is SessionModelEvent.Compacted,
is SessionModelEvent.Cleared -> Unit
}
@@ -372,6 +383,7 @@ class SessionUi private constructor(
override fun applyStyle(style: SessionStyle) {
this.style = style
loadingLabel.font = style.uiFont
header.applyStyle(style)
prompt.applyStyle(style)
scroll.applyStyle(style)
refresh()
@@ -3,6 +3,7 @@ package ai.kilocode.client.session
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.Workspace
import ai.kilocode.rpc.dto.SessionDto
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
@@ -20,14 +21,16 @@ class SessionUiFactory(
manager: SessionManager,
id: String? = null,
loading: Boolean = id == null,
session: SessionDto? = null,
): SessionUi = SessionUi(
project = project,
workspace = workspace,
sessions = project.service<KiloSessionService>(),
app = service<KiloAppService>(),
cs = scope(),
id = id,
id = session?.id ?: id,
loading = loading,
session = session,
open = manager::openSession,
)
@@ -2,6 +2,47 @@ package ai.kilocode.client.session.model
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.PartTimeDto
import ai.kilocode.rpc.dto.TodoDto
import ai.kilocode.rpc.dto.TokensDto
data class SessionHeaderSnapshot(
val visible: Boolean,
val title: String,
val cost: Double?,
val context: ContextUsage?,
val tokens: TokensDto?,
val timeline: List<TimelineItem>,
val todos: TodoSummary,
val canCompact: Boolean,
)
data class ContextUsage(
val tokens: Long,
val percentage: Int?,
val limit: Long?,
val output: Long?,
)
data class TimelineItem(
val id: String,
val part: Content,
val title: String,
val weight: Int,
val durationMs: Long?,
val active: Boolean,
)
data class TodoSummary(
val total: Int,
val completed: Int,
val items: List<TodoDto>,
)
data class ModelLimitItem(
val context: Long = 0,
val input: Long? = null,
val output: Long = 0,
)
/** A single message with its typed contents. */
class Message(
@@ -25,7 +66,7 @@ class Reasoning(id: String) : Content(id) {
}
/** Tool invocation with lifecycle state. */
class Tool(id: String, val name: String) : Content(id) {
class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) {
var state: ToolExecState = ToolExecState.PENDING
var title: String? = null
var input: Map<String, String> = emptyMap()
@@ -38,6 +79,13 @@ class Tool(id: String, val name: String) : Content(id) {
/** Context compaction marker. */
class Compaction(id: String) : Content(id)
/** Assistant step completion marker used by the session header timeline. */
class StepFinish(id: String) : Content(id) {
var reason: String? = null
var cost: Double? = null
var tokens: TokensDto? = null
}
/**
* Generic fallback for part types not yet given a dedicated class.
* Preserves the [type] string so unknown content is not silently dropped.
@@ -46,6 +94,17 @@ class Generic(id: String, val type: String) : Content(id)
enum class ToolExecState { PENDING, RUNNING, COMPLETED, ERROR }
enum class ToolKind { READ, WRITE, GENERIC }
private val READ_TOOLS = setOf("read", "glob", "grep", "find", "ls", "diagnostics", "warpgrep")
private val WRITE_TOOLS = setOf("edit", "write", "patch", "multi_edit", "multiedit", "apply_patch")
fun toolKind(name: String?): ToolKind = when (name?.lowercase()) {
in READ_TOOLS -> ToolKind.READ
in WRITE_TOOLS -> ToolKind.WRITE
else -> ToolKind.GENERIC
}
data class ToolCallRef(
val messageId: String,
val callId: String,
@@ -8,9 +8,12 @@ import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.TodoDto
import ai.kilocode.rpc.dto.TokensDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import kotlin.math.roundToInt
/**
* Pure session model — single source of truth for session content and runtime state.
@@ -32,7 +35,7 @@ class SessionModel {
companion object {
/** Part types that are internal server markers and must never be stored or rendered. */
val SILENT_PART_TYPES = setOf("step-start", "step-finish")
val SILENT_PART_TYPES = setOf("step-start")
}
private val entries = LinkedHashMap<String, Message>()
@@ -55,6 +58,12 @@ class SessionModel {
var state: SessionState = SessionState.Idle
private set
var session: SessionDto? = null
private set
var header: SessionHeaderSnapshot = emptyHeader()
private set
var diff: List<DiffFileDto> = emptyList()
private set
@@ -95,12 +104,14 @@ class SessionModel {
val updated = Message(dto).also { it.parts.putAll(existing.parts) }
entries[dto.id] = updated
fire(SessionModelEvent.MessageUpdated(updated))
updateHeader()
return false
}
val msg = Message(dto)
entries[dto.id] = msg
fire(SessionModelEvent.MessageAdded(msg))
regroup()
updateHeader()
return true
}
@@ -111,6 +122,7 @@ class SessionModel {
entries[dto.id] = msg
fire(SessionModelEvent.MessageAdded(msg))
regroup()
updateHeader()
return msg
}
@@ -118,12 +130,14 @@ class SessionModel {
if (entries.remove(id) == null) return
fire(SessionModelEvent.MessageRemoved(id))
regroup()
updateHeader()
}
fun removeContent(messageId: String, contentId: String) {
val msg = entries[messageId] ?: return
if (msg.parts.remove(contentId) == null) return
fire(SessionModelEvent.ContentRemoved(messageId, contentId))
updateHeader()
}
fun updateContent(messageId: String, dto: PartDto) {
@@ -137,6 +151,7 @@ class SessionModel {
val content = fromDto(dto)
msg.parts[dto.id] = content
fire(SessionModelEvent.ContentAdded(messageId, content))
updateHeader()
}
fun appendDelta(messageId: String, contentId: String, delta: String) {
@@ -156,11 +171,20 @@ class SessionModel {
fire(SessionModelEvent.ContentAdded(messageId, content))
}
fire(SessionModelEvent.ContentDelta(messageId, contentId, delta))
updateHeader()
}
fun setState(state: SessionState) {
this.state = state
fire(SessionModelEvent.StateChanged(state))
updateHeader()
}
fun setSession(session: SessionDto) {
if (this.session == session) return
this.session = session
fire(SessionModelEvent.SessionUpdated(session))
updateHeader()
}
fun setDiff(diff: List<DiffFileDto>) {
@@ -171,15 +195,22 @@ class SessionModel {
fun setTodos(todos: List<TodoDto>) {
this.todos = todos
fire(SessionModelEvent.TodosUpdated(todos))
updateHeader()
}
fun markCompacted() {
compactionCount++
fire(SessionModelEvent.Compacted(compactionCount))
updateHeader()
}
fun refreshHeader() {
updateHeader()
}
fun loadHistory(history: List<MessageWithPartsDto>) {
entries.clear()
session = null
state = SessionState.Idle
diff = emptyList()
todos = emptyList()
@@ -195,16 +226,19 @@ class SessionModel {
}
rebuildTurnsSilently()
fire(SessionModelEvent.HistoryLoaded)
updateHeader()
}
fun clear() {
entries.clear()
turnEntries.clear()
session = null
state = SessionState.Idle
diff = emptyList()
todos = emptyList()
compactionCount = 0
fire(SessionModelEvent.Cleared)
updateHeader()
}
// ------ turn grouping ------
@@ -315,6 +349,7 @@ class SessionModel {
existing.done = dto.time?.end != null || dto.time == null
}
is Tool -> {
existing.kind = toolKind(dto.tool)
existing.state = parseToolState(dto.state)
existing.title = dto.title
existing.input = dto.input
@@ -324,9 +359,15 @@ class SessionModel {
existing.time = dto.time
}
is Compaction -> return
is StepFinish -> {
existing.reason = dto.reason
existing.cost = dto.cost
existing.tokens = dto.tokens
}
is Generic -> return
}
fire(SessionModelEvent.ContentUpdated(messageId, existing))
updateHeader()
}
private fun fromDto(dto: PartDto, text: CharSequence? = null): Content {
@@ -339,7 +380,7 @@ class SessionModel {
if (content != null && content.isNotEmpty()) this.content.append(content)
done = dto.time?.end != null || dto.time == null
}
"tool" -> Tool(dto.id, dto.tool ?: "unknown").apply {
"tool" -> Tool(dto.id, dto.tool ?: "unknown", toolKind(dto.tool)).apply {
state = parseToolState(dto.state)
title = dto.title
input = dto.input
@@ -349,6 +390,11 @@ class SessionModel {
time = dto.time
}
"compaction" -> Compaction(dto.id)
"step-finish" -> StepFinish(dto.id).apply {
reason = dto.reason
cost = dto.cost
tokens = dto.tokens
}
else -> Generic(dto.id, dto.type)
}
}
@@ -357,6 +403,61 @@ class SessionModel {
for (l in listeners) l.onEvent(event)
}
private fun updateHeader() {
val next = buildHeader()
if (next == header) return
header = next
fire(SessionModelEvent.HeaderUpdated(next))
}
private fun buildHeader(): SessionHeaderSnapshot {
val items = messages().toList()
if (items.isEmpty()) return emptyHeader()
val last = items.asReversed()
.firstOrNull { it.info.role == "assistant" && (it.info.tokens?.total()?.let { total -> total > 0 } == true) }
val tokens = last?.info?.tokens
val limit = model?.let(::item)?.limit
val total = tokens?.total() ?: 0
val context = if (tokens == null || total == 0L) null else ContextUsage(
tokens = total,
percentage = limit?.context?.takeIf { it > 0 }?.let { (total.toDouble() / it.toDouble() * 100).roundToInt() },
limit = limit?.context?.takeIf { it > 0 },
output = limit?.output?.takeIf { it > 0 },
)
val cost = items
.filter { it.info.role == "assistant" }
.sumOf { it.info.cost ?: 0.0 }
.takeIf { it > 0.0 }
val done = todos.count { it.status == "completed" }
return SessionHeaderSnapshot(
visible = items.isNotEmpty(),
title = session?.title?.takeIf { it.isNotBlank() } ?: "New Session",
cost = cost,
context = context,
tokens = tokens,
timeline = timeline(items),
todos = TodoSummary(todos.size, done, todos),
canCompact = !state.isBusy() && model?.let(::parseModelKey) != null,
)
}
private fun timeline(items: List<Message>): List<TimelineItem> = items
.filter { it.info.role == "assistant" }
.flatMap { msg ->
msg.parts.values.map { part ->
TimelineItem(
id = "${msg.info.id}/${part.id}",
part = part,
title = part.timelineTitle(),
weight = part.weight().coerceIn(1, 10),
durationMs = (part as? Tool)?.time?.durationMs(),
active = (part as? Tool)?.state == ToolExecState.RUNNING || part is Reasoning && !part.done,
)
}
}
private fun item(key: String): ModelItem? = models.firstOrNull { it.key == key }
// ------ string representations ------
/**
@@ -435,10 +536,87 @@ data class ModelItem(
val recommendedIndex: Double?,
val free: Boolean,
val variants: List<String>,
val limit: ModelLimitItem?,
) {
val key: String get() = "$provider/$id"
}
private fun emptyHeader() = SessionHeaderSnapshot(
visible = false,
title = "New Session",
cost = null,
context = null,
tokens = null,
timeline = emptyList(),
todos = TodoSummary(0, 0, emptyList()),
canCompact = false,
)
private fun TokensDto.total(): Long = listOf(input, output, reasoning, cacheRead, cacheWrite).fold(0L) { sum, value ->
if (value <= 0) return@fold sum
if (Long.MAX_VALUE - sum < value) return@fold Long.MAX_VALUE
sum + value
}
private fun TokensDto.stepWeight(): Int = (input.coerceIn(0L, 10L) + output.coerceIn(0L, 10L) + reasoning.coerceIn(0L, 10L))
.coerceIn(1L, 10L)
.toInt()
private fun parseModelKey(value: String): Pair<String, String>? {
val slash = value.indexOf('/')
if (slash <= 0 || slash >= value.length - 1) return null
return value.substring(0, slash) to value.substring(slash + 1)
}
private fun Content.timelineTitle(): String = when (this) {
is Text -> "Text"
is Reasoning -> "Reasoning"
is Tool -> fileActionTitle() ?: title?.takeIf { it.isNotBlank() } ?: name
is Compaction -> "Compaction"
is StepFinish -> "Step finish"
is Generic -> type
}
private fun Tool.fileActionTitle(): String? {
val verb = when (kind) {
ToolKind.READ -> "Read"
ToolKind.WRITE -> "Write"
ToolKind.GENERIC -> return null
}
val path = listOf("filePath", "path", "file")
.asSequence()
.mapNotNull {
input[it]?.takeIf { value -> value.isNotBlank() }
?: metadata[it]?.takeIf { value -> value.isNotBlank() }
}
.firstOrNull()
?: return null
return "$verb ${tail(path).ifBlank { path }}"
}
private fun tail(path: String): String {
val value = path.trimEnd('/', '\\')
val index = maxOf(value.lastIndexOf('/'), value.lastIndexOf('\\'))
if (index < 0) return value
return value.substring(index + 1)
}
private fun Content.weight(): Int = when (this) {
is Text -> content.length / 200 + 1
is Reasoning -> content.length / 200 + 1
is Tool -> listOf(input.size, output?.length?.div(400) ?: 0, error?.length?.div(200) ?: 0).sum() + 1
is Compaction -> 2
is StepFinish -> tokens?.stepWeight() ?: 1
is Generic -> 1
}
private fun ai.kilocode.rpc.dto.PartTimeDto.durationMs(): Long? {
val start = start ?: return null
val end = end ?: return null
if (end < start) return null
return ((end - start) * 1000).toLong()
}
private fun renderMessage(msg: Message): List<String> {
val out = mutableListOf<String>()
out.add("${msg.info.role}#${msg.info.id}")
@@ -454,6 +632,7 @@ private fun renderMessage(msg: Message): List<String> {
}
is Tool -> out.add(renderTool(part))
is Compaction -> out.add("compaction#${part.id}")
is StepFinish -> out.add("step-finish#${part.id}")
is Generic -> out.add("${part.type}#${part.id}")
}
}
@@ -1,6 +1,7 @@
package ai.kilocode.client.session.model
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.TodoDto
/**
@@ -47,6 +48,12 @@ sealed class SessionModelEvent {
data class TodosUpdated(val todos: List<TodoDto>) : SessionModelEvent() {
override fun toString() = "TodosUpdated count=${todos.size}"
}
data class SessionUpdated(val session: SessionDto) : SessionModelEvent() {
override fun toString() = "SessionUpdated ${session.id}"
}
data class HeaderUpdated(val header: SessionHeaderSnapshot) : SessionModelEvent() {
override fun toString() = "HeaderUpdated visible=${header.visible}"
}
data class Compacted(val count: Int) : SessionModelEvent() {
override fun toString() = "Compacted count=$count"
}
@@ -87,6 +87,8 @@ class SessionMessageListPanel(
is SessionModelEvent.StateChanged,
is SessionModelEvent.DiffUpdated,
is SessionModelEvent.TodosUpdated,
is SessionModelEvent.SessionUpdated,
is SessionModelEvent.HeaderUpdated,
is SessionModelEvent.Compacted -> Unit
}
}
@@ -5,6 +5,7 @@ import com.intellij.openapi.editor.colors.EditorColorsScheme
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import java.awt.Color
import java.awt.Font
import kotlin.math.roundToInt
@@ -13,6 +14,8 @@ data class SessionStyle(
val editorScheme: EditorColorsScheme,
val editorFamily: String,
val editorSize: Int,
val editorForeground: Color,
val editorBackground: Color,
val transcriptFont: Font,
val smallEditorFont: Font,
val boldEditorFont: Font,
@@ -44,6 +47,8 @@ data class SessionStyle(
editorScheme = scheme,
editorFamily = family,
editorSize = size,
editorForeground = scheme.defaultForeground,
editorBackground = scheme.defaultBackground,
transcriptFont = Font(family, Font.PLAIN, size),
smallEditorFont = Font(family, Font.PLAIN, small),
boldEditorFont = Font(family, Font.BOLD, size),
@@ -0,0 +1,150 @@
package ai.kilocode.client.session.ui.header
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.ContextUsage
import ai.kilocode.client.session.ui.SessionStyle
import ai.kilocode.client.ui.UiStyle
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Dimension
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
import javax.swing.JComponent
import javax.swing.JPanel
internal class ContextBar : JPanel(BorderLayout(UiStyle.Gap.inline(), 0)) {
private val used = JBLabel()
private val limit = JBLabel()
private val meter = Meter()
init {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Space.SM, 0, 0, 0)
add(used, BorderLayout.WEST)
add(meter, BorderLayout.CENTER)
add(limit, BorderLayout.EAST)
}
fun setUsage(value: ContextUsage?) {
val data = data(value)
meter.data = data
isVisible = data != null
used.text = data?.used?.let(::num).orEmpty()
limit.text = data?.limit?.let(::num).orEmpty()
toolTipText = data?.tip()
meter.toolTipText = toolTipText
revalidate()
repaint()
}
fun applyStyle(style: SessionStyle) {
background = style.editorBackground
foreground = style.editorForeground
meter.background = style.editorBackground
used.font = style.smallUiFont
used.foreground = style.editorForeground
limit.font = style.smallUiFont
limit.foreground = style.editorForeground
}
fun foregrounds() = listOf(used.foreground, limit.foreground)
fun used(): Long? = meter.data?.used
fun reserved(): Long? = meter.data?.reserved
fun available(): Long? = meter.data?.available
fun limit(): Long? = meter.data?.limit
fun trackColor(): Color = meter.trackColor()
fun usedColor(): Color = meter.data?.let(meter::usedColor) ?: meter.usedColor()
fun reservedColor(): Color = meter.reservedColor()
private fun data(value: ContextUsage?): ContextData? {
val ctx = value ?: return null
val max = ctx.limit?.takeIf { it > 0 } ?: return null
if (ctx.tokens <= 0) return null
val used = ctx.tokens.coerceAtMost(max)
val output = ctx.output?.takeIf { it > 0 } ?: 0L
val reserved = output.coerceAtMost(max - used)
val available = (max - used - reserved).coerceAtLeast(0)
return ContextData(used, reserved, available, max, output)
}
override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height)
}
private data class ContextData(
val used: Long,
val reserved: Long,
val available: Long,
val limit: Long,
val output: Long,
) {
fun tip(): String {
val lines = mutableListOf(KiloBundle.message("session.header.context.used", num(used), num(limit)))
if (output > 0) lines.add(KiloBundle.message("session.header.context.reserved", num(output)))
if (available > 0) lines.add(KiloBundle.message("session.header.context.available", num(available)))
return lines.joinToString("\n")
}
}
private class Meter : JComponent() {
var data: ContextData? = null
init {
isOpaque = false
preferredSize = JBUI.size(80, 4)
minimumSize = JBUI.size(24, 4)
}
override fun paintComponent(g: Graphics) {
val data = data ?: return
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
val h = JBUI.scale(4).coerceAtMost(height).coerceAtLeast(1)
val y = (height - h) / 2
val arc = JBUI.scale(4)
g2.color = trackColor()
g2.fillRoundRect(0, y, width, h, arc, arc)
val used = segment(data.used, data.limit)
val reserved = segment(data.reserved, data.limit)
fill(g2, 0, y, used, h, usedColor(data), arc)
fill(g2, used, y, reserved, h, reservedColor(), arc)
} finally {
g2.dispose()
}
}
private fun segment(value: Long, limit: Long): Int {
if (value <= 0 || limit <= 0 || width <= 0) return 0
return ((value.toDouble() / limit.toDouble()) * width).toInt().coerceIn(0, width)
}
private fun fill(g: Graphics2D, x: Int, y: Int, w: Int, h: Int, color: Color, arc: Int) {
if (w <= 0) return
g.color = color
g.fillRoundRect(x, y, w, h, arc, arc)
}
fun trackColor(): Color = shade(0.14f)
fun usedColor(): Color = shade(0.45f)
fun usedColor(data: ContextData): Color = usedColor()
fun reservedColor(): Color = shade(0.28f)
private fun shade(alpha: Float): Color {
val base = background ?: UiStyle.Colors.panel()
val grey = if (UiStyle.Colors.bright(base)) Color.BLACK else Color.WHITE
return UiStyle.Colors.blend(base, grey, alpha)
}
}
@@ -0,0 +1,48 @@
package ai.kilocode.client.session.ui.header
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.ContextUsage
import com.intellij.ui.components.JBLabel
internal fun set(label: JBLabel, value: String?) {
val text = value.orEmpty()
if (label.text != text) label.text = text
val show = text.isNotEmpty()
if (label.isVisible != show) label.isVisible = show
}
internal fun money(value: Double?): String? {
val cost = value ?: return null
if (cost < 0.01) return "\$%.4f".format(cost)
if (cost < 1.0) return "\$%.2f".format(cost)
return "\$%.2f".format(cost)
}
internal fun contextText(value: ContextUsage?): String? {
val ctx = value ?: return null
val pct = ctx.percentage
if (pct != null) return "$pct%"
if (ctx.tokens > 0) return num(ctx.tokens)
return null
}
internal fun contextTip(value: ContextUsage?): String? {
val ctx = value ?: return null
val pct = ctx.percentage
if (pct != null) return KiloBundle.message("session.header.context.tooltip.percent", num(ctx.tokens), pct)
if (ctx.tokens > 0) return KiloBundle.message("session.header.context.tooltip.tokens", num(ctx.tokens))
return null
}
internal fun todo(done: Int, total: Int): String? {
if (total <= 0) return null
if (done >= total) return KiloBundle.message("session.header.todos.done", total)
return KiloBundle.message("session.header.todos.progress", done, total)
}
internal fun num(value: Long): String {
val abs = kotlin.math.abs(value)
if (abs < 1_000) return value.toString()
if (abs < 1_000_000) return "%.1fK".format(value / 1_000.0)
return "%.1fM".format(value / 1_000_000.0)
}
@@ -0,0 +1,27 @@
package ai.kilocode.client.session.ui.header
import java.awt.Component
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.geom.AffineTransform
import javax.swing.Icon
internal class RotatedIcon(private val base: Icon) : Icon {
override fun getIconWidth(): Int = base.iconWidth
override fun getIconHeight(): Int = base.iconHeight
override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) {
val g2 = g.create() as Graphics2D
try {
val tx = AffineTransform()
tx.translate((x + iconWidth / 2.0), (y + iconHeight / 2.0))
tx.rotate(Math.PI)
tx.translate((-iconWidth / 2.0), (-iconHeight / 2.0))
g2.transform(tx)
base.paintIcon(c, g2, 0, 0)
} finally {
g2.dispose()
}
}
}
@@ -0,0 +1,455 @@
package ai.kilocode.client.session.ui.header
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.SessionHeaderSnapshot
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.ui.SessionStyle
import ai.kilocode.client.session.ui.SessionStyleTarget
import ai.kilocode.client.session.update.SessionController
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.TokensDto
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Dimension
import java.awt.FlowLayout
import java.awt.Point
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.event.MouseMotionAdapter
import java.awt.event.MouseWheelEvent
import javax.swing.Box
import javax.swing.BoxLayout
import javax.swing.Icon
import javax.swing.JPanel
import javax.swing.JViewport
import javax.swing.SwingUtilities
class SessionHeaderPanel(
private val controller: SessionController,
parent: Disposable,
) : BorderLayoutPanel(), SessionStyleTarget {
companion object {
private val COMPRESS_ICON: Icon = IconLoader.getIcon("/icons/compress.svg", SessionHeaderPanel::class.java)
private val CHEVRON_ICON: Icon = IconLoader.getIcon("/icons/chevron-down.svg", SessionHeaderPanel::class.java)
private val CHEVRON_UP_ICON: Icon = RotatedIcon(CHEVRON_ICON)
private val UP_ICON: Icon = IconLoader.getIcon("/icons/arrow-up.svg", SessionHeaderPanel::class.java)
private val DOWN_ICON: Icon = IconLoader.getIcon("/icons/arrow-down-to-line.svg", SessionHeaderPanel::class.java)
private const val TOUCH_BEGIN = 2
private const val TOUCH_UPDATE = 3
private const val TOUCH_END = 4
internal const val EXPANDED_KEY = "kilo.session.header.expanded"
}
private val title = JBLabel()
private val cost = JBLabel()
private val context = JBLabel()
private val todos = JBLabel()
private val compact = UiStyle.Buttons.HoverIcon().apply {
icon = COMPRESS_ICON
toolTipText = KiloBundle.message("session.header.compact.description")
accessibleContext.accessibleName = KiloBundle.message("session.header.compact")
addActionListener { controller.compact() }
}
private val expand = UiStyle.Buttons.HoverIcon().apply {
icon = CHEVRON_ICON
toolTipText = KiloBundle.message("session.header.expand")
accessibleContext.accessibleName = KiloBundle.message("session.header.expand")
addActionListener { toggle() }
}
private val timeline = TimelinePanel()
private val viewport = JViewport().apply {
isOpaque = false
view = timeline
}
private var press: Point? = null
private var origin = Point()
private var rest = 0.0
private val bar = ContextBar()
private val tokenTitle = JBLabel(KiloBundle.message("session.header.tokens"))
private val input = JBLabel().apply {
icon = UP_ICON
iconTextGap = UiStyle.Gap.xs()
}
private val output = JBLabel().apply {
icon = DOWN_ICON
iconTextGap = UiStyle.Gap.xs()
}
private val cacheRead = JBLabel().apply {
icon = DOWN_ICON
iconTextGap = UiStyle.Gap.xs()
}
private val cacheWrite = JBLabel().apply {
icon = UP_ICON
iconTextGap = UiStyle.Gap.xs()
}
private val top = BorderLayoutPanel()
private val right = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.inline(), 0)).apply {
isOpaque = false
add(cost)
add(context)
add(compact)
add(expand)
}
private val tokens = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Space.SM, 0, 0, 0)
add(tokenTitle)
add(Box.createHorizontalStrut(UiStyle.Gap.inline()))
add(input)
add(Box.createHorizontalStrut(UiStyle.Gap.small()))
add(output)
add(Box.createHorizontalStrut(UiStyle.Gap.small()))
add(cacheRead)
add(Box.createHorizontalStrut(UiStyle.Gap.small()))
add(cacheWrite)
}
private val todoRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.inline(), 0)).apply {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Space.SM, 0, 0, 0)
add(todos)
}
private val body = JPanel().apply {
isOpaque = false
layout = BoxLayout(this, BoxLayout.Y_AXIS)
border = JBUI.Borders.empty(UiStyle.Space.SM, 0, 0, 0)
add(viewport)
add(tokens)
add(bar)
add(todoRow)
}
private var style = SessionStyle.current()
init {
isOpaque = true
updateUI()
top.add(title, BorderLayout.CENTER)
top.add(right, BorderLayout.EAST)
add(top, BorderLayout.NORTH)
timeline.addMouseListener(object : MouseAdapter() {
override fun mousePressed(event: MouseEvent) {
press = event.point
origin = viewport.viewPosition
}
override fun mouseReleased(event: MouseEvent) {
press = null
}
})
timeline.addMouseMotionListener(object : MouseMotionAdapter() {
override fun mouseDragged(event: MouseEvent) {
drag(event)
}
})
timeline.addMouseWheelListener { scroll(it) }
viewport.addMouseWheelListener { scroll(it) }
controller.model.addListener(parent) { event ->
when (event) {
is SessionModelEvent.HeaderUpdated -> update(event.header)
is SessionModelEvent.MessageAdded,
is SessionModelEvent.MessageUpdated,
is SessionModelEvent.MessageRemoved,
is SessionModelEvent.ContentAdded,
is SessionModelEvent.ContentUpdated,
is SessionModelEvent.ContentRemoved,
is SessionModelEvent.ContentDelta,
is SessionModelEvent.StateChanged,
is SessionModelEvent.DiffUpdated,
is SessionModelEvent.TodosUpdated,
is SessionModelEvent.SessionUpdated,
is SessionModelEvent.Compacted,
is SessionModelEvent.HistoryLoaded,
is SessionModelEvent.Cleared,
is SessionModelEvent.TurnAdded,
is SessionModelEvent.TurnUpdated,
is SessionModelEvent.TurnRemoved -> Unit
}
}
applyStyle(style)
syncExpanded(expanded())
update(controller.model.header)
}
override fun updateUI() {
super.updateUI()
border = JBUI.Borders.compound(
JBUI.Borders.customLine(JBUI.CurrentTheme.ToolWindow.borderColor(), 1, 0, 1, 0),
JBUI.Borders.empty(UiStyle.Space.LG, UiStyle.Space.PAD, UiStyle.Space.SM, UiStyle.Space.PAD),
)
}
fun update(header: SessionHeaderSnapshot) {
val before = isVisible
title.text = header.title
title.toolTipText = header.title
title.accessibleContext.accessibleName = header.title
isVisible = header.visible
if (!header.visible) {
syncExpanded(false)
if (before) refresh()
return
}
syncExpanded(expanded())
set(cost, money(header.cost))
set(context, contextText(header.context))
context.toolTipText = contextTip(header.context)
setTokens(header.tokens)
set(todos, todo(header.todos.completed, header.todos.total))
todoRow.isVisible = todos.isVisible
compact.isEnabled = header.canCompact
val appended = timeline.setItems(header.timeline)
sizeTimeline()
if (viewport.isVisible != timeline.isVisible) viewport.isVisible = timeline.isVisible
if (appended) SwingUtilities.invokeLater { endTimeline() }
bar.setUsage(header.context)
refresh()
}
override fun applyStyle(style: SessionStyle) {
this.style = style
background = style.editorBackground
foreground = style.editorForeground
top.background = style.editorBackground
right.background = style.editorBackground
tokens.background = style.editorBackground
todoRow.background = style.editorBackground
body.background = style.editorBackground
viewport.background = style.editorBackground
title.font = style.boldUiFont
title.foreground = style.editorForeground
cost.font = style.uiFont
cost.foreground = style.editorForeground
context.font = style.uiFont
context.foreground = style.editorForeground
todos.font = style.smallUiFont
todos.foreground = style.editorForeground
tokenTitle.font = style.smallUiFont
tokenTitle.foreground = style.editorForeground
input.font = style.smallUiFont
input.foreground = style.editorForeground
output.font = style.smallUiFont
output.foreground = style.editorForeground
cacheRead.font = style.smallUiFont
cacheRead.foreground = style.editorForeground
cacheWrite.font = style.smallUiFont
cacheWrite.foreground = style.editorForeground
bar.applyStyle(style)
refresh()
}
internal fun titleText(): String = title.text
internal fun costText(): String = cost.text
internal fun contextText(): String = context.text
internal fun foregrounds() = listOf(title, cost, context, todos, tokenTitle, input, output, cacheWrite, cacheRead)
.map { it.foreground }
internal fun tokenText(): String = listOf(tokenTitle, input, output, cacheWrite, cacheRead)
.filter { it.isVisible }
.joinToString(" ") { it.text }
internal fun tokenTip(): String = tokens.toolTipText
internal fun inputTokenText(): String = input.text
internal fun outputTokenText(): String = output.text
internal fun cacheReadText(): String = cacheRead.text
internal fun cacheWriteText(): String = cacheWrite.text
internal fun todoText(): String = todos.text
internal fun todoVisible() = todoRow.isVisible && todos.isVisible
internal fun compactButton() = compact
internal fun expandButton() = expand
internal fun isExpanded() = body.parent === this
internal fun bodyPanel() = body
internal fun bodyComponents() = body.components.toList()
internal fun timelineViewport(): JViewport = viewport
internal fun tokenPanel() = tokens
internal fun timelinePanel(): Component = timeline
internal fun timelineToolTip() = timeline.toolTipText
internal fun timelineHover() = timeline.hovered()
internal fun contextBar(): Component = bar
internal fun contextBarVisible() = bar.isVisible
internal fun contextBarUsed() = bar.used()
internal fun contextBarReserved() = bar.reserved()
internal fun contextBarAvailable() = bar.available()
internal fun contextBarLimit() = bar.limit()
internal fun contextBarForegrounds() = bar.foregrounds()
internal fun contextBarTip() = bar.toolTipText
internal fun contextBarTrackColor() = bar.trackColor()
internal fun contextBarUsedColor() = bar.usedColor()
internal fun contextBarReservedColor() = bar.reservedColor()
internal fun timelineCount() = timeline.count()
internal fun timelineParts() = timeline.parts()
internal fun timelineActive(index: Int) = timeline.active(index)
internal fun timelinePreferredSize() = timeline.preferredSize
internal fun timelineBarHeight(index: Int) = timeline.barHeight(index)
internal fun timelineBarWidth() = timeline.barWidth()
internal fun timelineViewportPreferredSize() = viewport.preferredSize
internal fun expandTip() = expand.toolTipText
private fun setTokens(value: TokensDto?) {
val tk = value
val sent = tk?.input ?: 0L
val received = (tk?.output ?: 0L) + (tk?.reasoning ?: 0L)
val write = tk?.cacheWrite ?: 0L
val read = tk?.cacheRead ?: 0L
val total = listOf(sent, received, write, read).fold(0L) { sum, value ->
if (value <= 0) return@fold sum
if (Long.MAX_VALUE - sum < value) return@fold Long.MAX_VALUE
sum + value
}
tokenTitle.text = KiloBundle.message("session.header.tokens")
tokens.toolTipText = KiloBundle.message("session.header.tokens.description")
tokenTitle.isVisible = total > 0
set(input, if (sent > 0) num(sent) else null)
set(output, if (received > 0) num(received) else null)
set(cacheWrite, if (write > 0) KiloBundle.message("session.header.cache.write", num(write)) else null)
set(cacheRead, if (read > 0) KiloBundle.message("session.header.cache.read", num(read)) else null)
tokens.isVisible = total > 0
}
private fun toggle() {
val next = !isExpanded()
syncExpanded(next)
PropertiesComponent.getInstance().setValue(EXPANDED_KEY, next.toString())
refresh()
}
private fun syncExpanded(expanded: Boolean) {
if (expanded) expand() else collapse()
}
private fun expand(): Boolean {
if (isExpanded()) return false
add(body, BorderLayout.CENTER)
setExpand(true)
return true
}
private fun collapse(): Boolean {
val attached = body.parent === this
if (!attached) return false
remove(body)
setExpand(false)
return attached
}
private fun setExpand(expanded: Boolean) {
val key = if (expanded) "session.header.collapse" else "session.header.expand"
expand.icon = if (expanded) CHEVRON_UP_ICON else CHEVRON_ICON
expand.toolTipText = KiloBundle.message(key)
expand.accessibleContext.accessibleName = KiloBundle.message(key)
}
private fun expanded() = PropertiesComponent.getInstance().getBoolean(EXPANDED_KEY, true)
private fun sizeTimeline() {
val size = timeline.preferredSize
viewport.preferredSize = Dimension(0, size.height)
viewport.minimumSize = viewport.preferredSize
viewport.maximumSize = Dimension(Int.MAX_VALUE, viewport.preferredSize.height)
}
private fun drag(event: MouseEvent) {
val start = press ?: return
val max = limit()
val x = (origin.x - (event.x - start.x)).coerceIn(0, max)
move(x)
}
private fun endTimeline() {
move(limit())
}
private fun scroll(event: MouseWheelEvent) {
if (event.scrollType == TOUCH_BEGIN || event.scrollType == TOUCH_END) {
rest = 0.0
event.consume()
return
}
val delta = delta(event)
if (delta == 0.0) return
rest += delta
val x = rest.toInt()
if (x == 0) return
if (!move(viewport.viewPosition.x + x)) {
rest = 0.0
return
}
rest -= x
event.consume()
}
private fun delta(event: MouseWheelEvent): Double {
if (!event.preciseWheelRotation.isFinite()) return 0.0
if (event.scrollType == TOUCH_UPDATE) return event.preciseWheelRotation * event.scrollAmount
if (event.scrollType == MouseWheelEvent.WHEEL_BLOCK_SCROLL) return event.preciseWheelRotation * viewport.extentSize.width
return event.preciseWheelRotation * event.scrollAmount * timeline.barWidth()
}
private fun move(x: Int): Boolean {
val next = x.coerceIn(0, limit())
if (viewport.viewPosition.x == next) return false
viewport.setViewPosition(Point(next, 0))
return true
}
private fun limit(): Int {
val wide = timeline.preferredSize.width.coerceAtLeast(timeline.width)
return (wide - viewport.extentSize.width).coerceAtLeast(0)
}
private fun refresh() {
revalidate()
repaint()
}
}
@@ -0,0 +1,151 @@
package ai.kilocode.client.session.ui.header
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Compaction
import ai.kilocode.client.session.model.Reasoning
import ai.kilocode.client.session.model.StepFinish
import ai.kilocode.client.session.model.Text
import ai.kilocode.client.session.model.TimelineItem
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.ToolKind
import ai.kilocode.client.ui.UiStyle
import com.intellij.util.ui.JBUI
import java.awt.Color
import java.awt.Dimension
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.event.MouseMotionAdapter
import kotlin.math.roundToInt
import javax.swing.JPanel
internal class TimelinePanel : JPanel() {
companion object {
private const val WIDTH = 12
private const val MIN = 8
private const val PAD = 4
private const val GAP = 2
}
private var items: List<TimelineItem> = emptyList()
private var heights: List<Int> = emptyList()
private var hover = -1
init {
isOpaque = false
addMouseMotionListener(object : MouseMotionAdapter() {
override fun mouseMoved(event: MouseEvent) {
val idx = index(event)
toolTipText = items.getOrNull(idx)?.title
if (hover == idx) return
hover = idx
repaint()
}
})
addMouseListener(object : MouseAdapter() {
override fun mouseExited(event: MouseEvent) {
toolTipText = null
if (hover == -1) return
hover = -1
repaint()
}
})
}
fun setItems(items: List<TimelineItem>): Boolean {
val appended = items.size > this.items.size
val max = items.maxOfOrNull { it.weight }?.coerceAtLeast(1) ?: 1
this.items = items
heights = items.map { height(it.weight, max) }
val show = items.isNotEmpty()
if (isVisible != show) isVisible = show
revalidate()
repaint()
return appended
}
override fun paintComponent(g: Graphics) {
super.paintComponent(g)
val g2 = g.create() as Graphics2D
val w = JBUI.scale(WIDTH)
val gap = JBUI.scale(GAP)
val grow = JBUI.scale(1)
val tall = height.takeIf { it > 0 } ?: preferredSize.height
try {
for (idx in items.indices) {
val over = idx == hover
val h = heights[idx] + if (over) grow else 0
val wide = w + if (over) grow * 2 else 0
val x = grow + idx * (w + gap) - if (over) grow else 0
val y = (tall - h).coerceAtLeast(0)
g2.color = color(items[idx])
g2.fillRect(x, y, wide, h)
}
} finally {
g2.dispose()
}
}
private fun index(event: MouseEvent): Int {
val w = JBUI.scale(WIDTH)
val gap = JBUI.scale(GAP)
val grow = JBUI.scale(1)
val tall = height.takeIf { it > 0 } ?: preferredSize.height
for (idx in items.indices) {
val h = heights[idx]
val x = grow + idx * (w + gap)
val y = (tall - h).coerceAtLeast(0)
val inside = event.x >= x && event.x < x + w && event.y >= y && event.y < y + h
if (inside) return idx
}
return -1
}
override fun getPreferredSize(): Dimension = Dimension(width(), height())
override fun getMinimumSize(): Dimension = Dimension(0, height())
override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, height())
fun count() = items.size
fun parts(): List<Content> = items.map { it.part }
fun active(index: Int) = items[index].active
fun barHeight(index: Int) = heights[index]
fun barWidth() = JBUI.scale(WIDTH + GAP)
fun hovered() = hover
private fun height(weight: Int, max: Int): Int {
val fill = MIN + (weight.toDouble() / max.toDouble()) * (MIN * 3 - MIN - PAD)
return JBUI.scale(fill.roundToInt())
}
private fun height(): Int {
val max = heights.maxOrNull() ?: return 0
return max + JBUI.scale(PAD)
}
private fun width(): Int {
if (items.isEmpty()) return 0
return items.size * JBUI.scale(WIDTH) + (items.size - 1) * JBUI.scale(GAP) + JBUI.scale(2)
}
private fun color(item: TimelineItem): Color {
val part = item.part
if (part is Tool && part.state == ToolExecState.ERROR) return UiStyle.Colors.timelineError
if (part is Text) return UiStyle.Colors.timelineText
if (part is Reasoning) return UiStyle.Colors.timelineText
if (part is Compaction) return UiStyle.Colors.timelineStep
if (part is StepFinish) return UiStyle.Colors.timelineSuccess
if (part !is Tool) return UiStyle.Colors.timelineStep
if (part.kind == ToolKind.READ) return UiStyle.Colors.timelineRead
if (part.kind == ToolKind.WRITE) return UiStyle.Colors.timelineWrite
return UiStyle.Colors.timelineTool
}
}
@@ -5,6 +5,7 @@ import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.AgentItem
import ai.kilocode.client.session.model.ModelLimitItem
import ai.kilocode.client.session.model.ModelItem
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionModelEvent
@@ -68,6 +69,7 @@ class SessionController(
private val flushMs: Long = EVENT_FLUSH_MS,
private val condense: Boolean = true,
private val displayMs: Long = DISPLAY_DELAY_MS,
session: SessionDto? = null,
private val open: (SessionDto) -> Unit = {},
private val beforeUpdate: () -> Boolean = { false },
private val afterUpdate: (Boolean) -> Unit = {},
@@ -87,7 +89,8 @@ class SessionController(
val model = SessionModel()
private val listeners = mutableListOf<SessionControllerListener>()
private var sessionId: String? = id
private var sessionId: String? = session?.id ?: id
private val initialSession = session
private val directory: String get() = workspace.directory
private val updates = SessionUpdateQueue(
parent,
@@ -151,6 +154,9 @@ class SessionController(
val session = sessions.create(directory)
runEdt {
sessionId = session.id
updateModel {
model.setSession(session)
}
}
val meta = if (LOG.isDebugEnabled) ChatLogSummary.dir(directory) else "kind=session"
LOG.info("${ChatLogSummary.sid(session.id)} kind=session $meta created=true")
@@ -185,6 +191,29 @@ class SessionController(
}
}
fun compact() {
assertEdt()
val id = sessionId ?: return
if (model.state.isBusy()) return
if (model.isEmpty()) return
val parsed = model.model?.let(::parseModel) ?: return
val sel = ModelSelectionDto(parsed.first, parsed.second)
LOG.debug { "${ChatLogSummary.sid(id)} kind=compact model=${sel.providerID}/${sel.modelID}" }
cs.launch {
try {
sessions.compact(id, directory, sel)
LOG.debug { "${ChatLogSummary.sid(id)} kind=compact ok=true" }
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(id)} kind=compact dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
edt {
updateModel {
model.setState(SessionState.Error(e.message ?: KiloBundle.message("session.error.compact")))
}
}
}
}
}
fun retryConnection() {
assertEdt()
LOG.debug {
@@ -296,6 +325,7 @@ class SessionController(
}
init {
initialSession?.let { model.setSession(it) }
if (sessionId != null) {
loadHistory()
subscribeEvents()
@@ -356,6 +386,7 @@ class SessionController(
info.recommendedIndex,
info.free,
info.variants,
info.limit?.let { ModelLimitItem(it.context, it.input, it.output) },
)
}
}
@@ -365,6 +396,7 @@ class SessionController(
this@SessionController.model.agent = state.agents?.default
}
syncModelSelection()
model.refreshHeader()
}
if (state.status == KiloWorkspaceStatusDto.READY) {
@@ -388,11 +420,13 @@ class SessionController(
if (!model.showSession) setControllerViewState(SessionControllerEvent.ViewChanged.ShowProgress)
}
try {
val session = initialSession ?: runCatching { sessions.get(id, directory) }.getOrNull()
val items = sessions.messages(id, directory)
LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(items)}" }
runEdt {
updateModel {
this@SessionController.model.loadHistory(items)
if (session != null) this@SessionController.model.setSession(session)
}
}
recoverPending(id)
@@ -604,6 +638,8 @@ class SessionController(
model.setState(state)
}
is ChatEventDto.SessionUpdated -> model.setSession(event.session)
is ChatEventDto.SessionIdle -> {
// Treat session.idle as an explicit signal to return to Idle.
// Only apply if we're not in a more specific non-terminal state.
@@ -685,6 +721,7 @@ class SessionController(
model.variants = item?.variants ?: emptyList()
val saved = key?.let { app.models.value.variant[it] }
model.variant = saved?.takeIf { it in model.variants } ?: model.variants.firstOrNull()
model.refreshHeader()
}
private fun item(key: String): ModelItem? = model.models.firstOrNull { it.key == key }
@@ -943,6 +980,7 @@ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (eve
is ChatEventDto.QuestionReplied -> event.sessionID == id
is ChatEventDto.QuestionRejected -> event.sessionID == id
is ChatEventDto.SessionStatusChanged -> event.sessionID == id
is ChatEventDto.SessionUpdated -> event.sessionID == id
is ChatEventDto.SessionIdle -> event.sessionID == id
is ChatEventDto.SessionCompacted -> event.sessionID == id
is ChatEventDto.SessionDiffChanged -> event.sessionID == id
@@ -2,6 +2,7 @@ package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Message
import ai.kilocode.client.session.model.StepFinish
import ai.kilocode.client.session.ui.SessionView
import ai.kilocode.client.session.ui.SessionStyle
import ai.kilocode.client.session.ui.SessionStyleTarget
@@ -42,6 +43,7 @@ class MessageView(
// Populate content that already exists (e.g. after loadHistory)
for ((_, content) in msg.parts) {
if (content is StepFinish) continue
val view = ViewFactory.create(content)
view.applyStyle(style)
parts[content.id] = view
@@ -51,6 +53,7 @@ class MessageView(
/** Add or update the renderer for [content]. */
fun upsertPart(content: Content) {
if (content is StepFinish) return
val existing = parts[content.id]
if (existing != null) {
existing.update(content)
@@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Compaction
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Generic
import ai.kilocode.client.session.model.Reasoning
import ai.kilocode.client.session.model.StepFinish
import ai.kilocode.client.session.model.Text
import ai.kilocode.client.session.model.Tool
@@ -21,6 +22,7 @@ object ViewFactory {
is Reasoning -> ReasoningView(content)
is Tool -> ToolView(content)
is Compaction -> CompactionView(content)
is StepFinish -> error("step-finish is timeline-only")
is Generic -> GenericView(content)
}
}
@@ -51,6 +51,14 @@ object UiStyle {
internal const val BORDER_DELTA = 64
internal const val HOVER_ALPHA = 0.35f
val timelineRead: Color = JBColor(Color(0x37, 0x94, 0xff), Color(0x37, 0x94, 0xff))
val timelineWrite: Color = JBColor(Color(0x00, 0x7f, 0xd4), Color(0x00, 0x7f, 0xd4))
val timelineTool: Color = JBColor(Color(0x00, 0x7a, 0xcc), Color(0x00, 0x7a, 0xcc))
val timelineSuccess: Color = JBColor.namedColor("Label.successForeground", UIUtil.getLabelSuccessForeground())
val timelineError: Color = JBColor(Color(0xf4, 0x87, 0x71), Color(0xf4, 0x87, 0x71))
val timelineText: Color = JBColor(Color(0x9d, 0x9d, 0x9d), Color(0x9d, 0x9d, 0x9d))
val timelineStep: Color = JBColor(Color(0x4d, 0x4d, 0x4d), Color(0x4d, 0x4d, 0x4d))
fun bg(): Color = UIUtil.getPanelBackground()
fun fg(): Color = UIUtil.getLabelForeground()
@@ -69,6 +77,8 @@ object UiStyle {
fun header(): Color = panel()
fun headerBar(): Color = JBUI.CurrentTheme.ToolWindow.headerBackground(false)
/** Local hover color for collapsible transcript card headers. */
fun headerHover(): Color = panelHover()
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.2083 11.6667L10 16.875L4.79167 11.6667M10 16.25V3.125" stroke="#6C707E" stroke-width="1.25" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.2083 11.6667L10 16.875L4.79167 11.6667M10 16.25V3.125" stroke="#CED0D6" stroke-width="1.25" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99991 2.24121L16.0921 8.33343L15.2083 9.21731L10.6249 4.63397V17.5001H9.37492V4.63398L4.7916 9.21731L3.90771 8.33343L9.99991 2.24121Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 309 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99991 2.24121L16.0921 8.33343L15.2083 9.21731L10.6249 4.63397V17.5001H9.37492V4.63398L4.7916 9.21731L3.90771 8.33343L9.99991 2.24121Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 309 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="#6C707E" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 206 B

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